From a142328cd8f5c809cdbc6db9f53ae5c241643eec Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:20:36 +0100 Subject: [PATCH] ci: guard production's version instead of requiring a Release-As pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promotion-pin was guarding the wrong thing and blocked release-please's own Release PR, which has no pin by design (#185). The 5.6.0 failure was not tag reachability: release-please anchored correctly on v5.5.0 from the manifest, then read production's package.json as the current version and logged `updating from 5.6.0-beta.1 to 5.6.0-beta.1`. The prerelease came in on the promotion merge, not from a tag. So assert what actually matters — a PR into production must leave package.json on a plain X.Y.Z. npm-publish.yml checks the same thing, but only after the tag and release exist. --- .github/workflows/cli-ci.yml | 78 +++++++++++++----------------------- CLAUDE.md | 14 ++----- 2 files changed, 31 insertions(+), 61 deletions(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 3ec9ff9..fac5213 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -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: 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 diff --git a/CLAUDE.md b/CLAUDE.md index 0bf72db..3c483c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.