Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 27 additions & 51 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,68 +102,44 @@ jobs:
working-directory: ./cli
run: pnpm audit --audit-level moderate

# Promotions to `production` MUST pin the stable version with a `Release-As:`
# footer on a commit. Without it release-please derives the stable version
# from the most recent tag reachable from `production` — and because a
# promotion is a merge commit, every beta tag is reachable, so it picks up a
# `-beta` version. That is exactly how 5.6.0 ended up with `production`
# carrying 5.6.0-beta.1 in package.json and a release PR that could not
# publish. 5.5.0 got a pin (`chore: pin the 5.5.0 promotion`) and came out
# correct; 5.6.0's was abandoned and did not.
# Anything merging into `production` must carry a plain X.Y.Z version in
# package.json. This is the exact failure that stalled 5.6.0: the promotion
# merge dragged dev's `5.6.0-beta.1` across, and release-please's node updater
# takes package.json as the current version — its own log read
# `updating from 5.6.0-beta.1 to 5.6.0-beta.1` — so the stable Release PR
# proposed a prerelease AS the stable release, and production sat unpublishable.
#
# It has to be on a NORMAL commit, not the merge commit — release-please's
# commit splitting is unreliable on merges.
promotion-pin:
# npm-publish.yml asserts the same thing, but only reaches it once the Release
# PR has merged and the tag and GitHub Release already exist. Catching it on the
# PR is the difference between a red check and a half-published release.
#
# This also passes on release-please's own Release PR, which sets the stable
# version — so it does not need an exemption.
production-version:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming this job orphans the required status check on the production ruleset.

This renames the job key promotion-pinproduction-version. GitHub matches required status checks by name, and the in-file comment notes this gate "is required by the production ruleset":

# version — so it does not need an exemption.
production-version:
if: github.event_name == 'pull_request' && github.base_ref == 'production'

Because the check reports under a new name after this merges, one of two things happens unless the ruleset is updated in lockstep:

  • If the ruleset still requires promotion-pin, that context is never reported again — every promotion PR sits on "Expected — waiting for status" and becomes unmergeable.
  • If nobody adds production-version to the required-check list, the new gate becomes advisory only — a promotion carrying a -beta version could be merged despite the check, which defeats the job's entire purpose.

Please update the production branch ruleset's required-status-check list from promotion-pin to production-version when this merges. (Not fixable in the diff — it's a repo setting — so no suggestion block.)

if: github.event_name == 'pull_request' && github.base_ref == 'production'
runs-on: ubuntu-latest
steps:
# Default checkout on a pull_request is the merge ref, so this is the version
# production would actually end up with, not the head branch's in isolation.
- uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Require a stable Release-As pin
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: package.json version must be a stable release
run: |
set -euo pipefail
git fetch --quiet origin dev

# Scope matters more than the pattern here. A promotion merges the whole
# of `dev`, whose history carries a `Release-As:` footer from every past
# promotion (5.0.0, 5.1.0, 5.1.1, 5.2.0, 5.3.0, 5.3.1 …). Scanning
# `base..head` therefore always finds one and passes vacuously — checked
# against the real #176, which it waved through. Only commits unique to
# this promotion branch count: everything on `production` and everything
# on `dev` is excluded. `--no-merges` because release-please's commit
# splitting is unreliable on merge commits, so a pin has to sit on an
# ordinary one.
#
# NB `^ref` not `--not ref`: --not is a TOGGLE over everything that
# follows, so `--not A --not B` excludes A and re-includes B.
MESSAGES=$(git log --no-merges --format=%B "$HEAD_SHA" "^$BASE_SHA" "^origin/dev")

if echo "$MESSAGES" | grep -qE '^Release-As:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+[[:space:]]*$'; then
echo "Found $(echo "$MESSAGES" | grep -oE '^Release-As:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
VERSION=$(node -p "require('./package.json').version")
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "package.json is $VERSION"
exit 0
fi

if echo "$MESSAGES" | grep -qE '^Release-As:'; then
echo "::error::This promotion pins a PRERELEASE version. The stable line must be pinned to a plain X.Y.Z."
echo "$MESSAGES" | grep -E '^Release-As:' >&2
exit 1
fi

echo "::error::No 'Release-As: X.Y.Z' footer on any non-merge commit unique to this promotion."
echo "::error::production's package.json version would become '$VERSION', which is not a plain X.Y.Z."
{
echo "Add one as its own commit on the promotion branch:"
echo " git commit --allow-empty -m 'chore: pin the X.Y.Z promotion' -m 'Release-As: X.Y.Z'"
echo "A promotion merge brings dev's version across. If dev is mid-beta, reset it"
echo "on the promotion branch to production's last stable before opening the PR:"
echo
echo " npm pkg set version=\$(git show origin/production:package.json | node -p \"JSON.parse(require('fs').readFileSync(0,'utf8')).version\")"
echo
echo "Do not skip it on the grounds that the conventional commits since the last"
echo "stable already imply the right bump. They do not: a promotion is a merge, so"
echo "every beta tag becomes reachable from production, and release-please picks the"
echo "newest reachable tag as its base. That is how the 5.6.0 promotion — which"
echo "reasoned exactly that way — produced a 'chore(production): release 5.6.0-beta.1'"
echo "release PR and left production carrying a prerelease in package.json."
echo "release-please rewrites it from .release-please-manifest.json when the Release"
echo "PR lands; the point is that it must never be a prerelease in the meantime,"
echo "because release-please reads it as the current version when computing the next."
} >&2
exit 1
14 changes: 4 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,12 @@ Betas have **no release-please track and no manifest**. `release-beta.yml` deriv

**Promoting beta -> stable** is a maintainer opening a PR from `dev` into `production` and merging it with a **merge commit** — that push to `production` is what triggers the stable Release PR. Use a merge commit, never squash or rebase: the merge is what makes `production` a descendant of `dev`, so the two branches stay reconcilable and the next promotion's diff is only the commits since the last one. A squash or rebase promotion rewrites the commits, leaves the histories permanently divergent, and forces the next promotion to be reconstructed by hand — that is exactly what the pre-5.5.0 promotions did.

### Every promotion must carry a `Release-As:` pin
### Keep `production`'s version stable through a promotion

Put the stable version on its own commit in the promotion branch:
When resolving the promotion's `package.json` conflict, **keep `production`'s `version` line, not `dev`'s** — and if `dev` is mid-beta, reset it to production's last stable on the promotion branch.

```
git commit --allow-empty -m "chore: pin the X.Y.Z promotion" -m "Release-As: X.Y.Z"
```
release-please's node strategy reads `package.json` as the *current* version when computing the next one. Its manifest is what anchors the last release (`Found release for path ., v5.5.0`), but the updater still reads the file — and when the 5.6.0 promotion carried `5.6.0-beta.1` across, its log read `updating from 5.6.0-beta.1 to 5.6.0-beta.1` and the stable Release PR proposed **a prerelease as the stable release**. `production` was then stuck: `npm-publish.yml`'s version guard correctly refuses to publish a `-beta` to `latest`, so nothing could ship until the version was reset by hand. The `production-version` check in `cli-ci.yml` now catches this on the PR instead of after a tag exists.

It must be an ordinary commit, not the merge commit — release-please's commit splitting is unreliable on merges. The `promotion-pin` check in `cli-ci.yml` enforces this.

**Do not skip it on the grounds that the conventional commits since the last stable already imply the right bump.** They don't. A promotion is a merge, so every beta tag becomes reachable from `production`, and release-please takes the newest reachable tag as its base — which is a `-beta` one. The 5.6.0 promotion reasoned exactly that way, skipped the pin, and produced a `chore(production): release 5.6.0-beta.1` release PR while leaving `production` carrying `5.6.0-beta.1` in `package.json` against a stable manifest still reading `5.5.0`. `npm-publish.yml`'s version guard then refuses to publish, which is the intended backstop, not the fix.

Also **keep `production`'s `version` line** when resolving the promotion's `package.json` conflict, not `dev`'s. Taking `dev`'s puts a prerelease on the stable branch for as long as the Release PR is open; release-please rewrites it from the manifest when that PR lands, but if the PR stalls — as 5.6.0's did — the stable branch sits on a `-beta`.
This is self-correcting from 5.6.0 onward: nothing writes `dev`'s `package.json` any more (`release-please.yml` only triggers on `production`, and `release-beta.yml` stamps the version in CI without committing it), so the back-merge after each stable release leaves `dev` holding the last stable version. A `Release-As:` footer is only needed when the computed bump is genuinely wrong — put it on an ordinary commit, never the merge commit.

Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR and the back-merge PR trigger the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured.
Loading