From a0c0face0cf504c0f23b65bfcd324552002c9879 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 1 Sep 2026 18:28:51 +0200 Subject: [PATCH] chore(ci): migrate the release workflow to changesets/action v2 Dependabot #1035 moved the tag alone. v2 renamed every input and output, so the tag on its own leaves five silent regressions behind. - `version`, `title` and `commit` are gone. Unknown `with:` keys only warn, so the action would have fallen back to a bare `changeset version` and dropped the `npm install --package-lock-only` half of `version-packages`, drifting package-lock.json off package.json. - v2 no longer reads the GITHUB_TOKEN environment variable. The token is an input now; without it the action authenticates as `github.token`, whose pushes start no workflows, so the Version Packages PR would arrive with no checks. CI_TOKEN exists for exactly that reason. - v2 pushes through the GitHub API by default, which would have made the CI_TOKEN auth header installed one step earlier dead weight. `push-with-git-cli: true` keeps the existing credential path. - `hasChangesets` is `has-changesets`. The stale name reads as empty, so the five stable-promotion guards comparing it to 'false' would never fire: a `workflow_dispatch` promotion would go green having skipped the GHCR write checks, the tag and the draft release. @changesets/cli is already ^3.0.1, which is what v2 requires. release-metadata.test.mjs pinned the old input name; it now pins the new one, plus the output name against the action major that publishes it, so the tag and the expressions reading it cannot drift apart again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017J8AuNpMQpExYq5qG5iQe9 --- .github/workflows/release.yml | 44 ++++++++++++++++++++--------------- web/release-metadata.test.mjs | 11 ++++++++- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b951b146f..add640a41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,26 +115,32 @@ jobs: - name: Create or update Version Packages PR id: changesets - uses: changesets/action@v1 + uses: changesets/action@v2 with: + # v2 takes the token as an input. It no longer reads a + # GITHUB_TOKEN environment variable, and it ignores the + # credentials checkout leaves behind, so passing CI_TOKEN here is + # the only way the action authenticates as anything but + # `github.token`. + # + # CI_TOKEN should be a fine-grained PAT scoped to THIS repo with + # `contents: write` + `pull-requests: write` only — nothing here + # needs broader scope, and a narrow token bounds the blast radius. + github-token: ${{ secrets.CI_TOKEN }} + # v2 pushes through the GitHub API by default. Keep the Git CLI: + # the push then uses the single local auth header installed by the + # prior step, which is the same CI_TOKEN, and a PAT push starts the + # Version PR's checks. A push made with `github.token` starts none. + push-with-git-cli: true # `version-packages` runs `changeset version` to bump package.json # and rewrite CHANGELOG.md, then synchronizes package-lock.json's # root package metadata with the generated version. - # We deliberately do NOT pass `publish:` — see the header + # We deliberately do NOT pass `publish-script:` — see the header # comment for the rationale. The publish step below does # the tag + GitHub Release manually, idempotently. - version: npm run version-packages - title: "chore(release): version packages" - commit: "chore(release): version packages" - env: - # changesets/action uses this token for its GitHub API calls. Its - # git push uses the single local auth header installed by the prior - # step; checkout does not persist a second credential. - # - # CI_TOKEN should be a fine-grained PAT scoped to THIS repo with - # `contents: write` + `pull-requests: write` only — nothing here - # needs broader scope, and a narrow token bounds the blast radius. - GITHUB_TOKEN: ${{ secrets.CI_TOKEN }} + version-script: npm run version-packages + pr-title: "chore(release): version packages" + commit-message: "chore(release): version packages" - name: Restore default release credential if: always() @@ -152,11 +158,11 @@ jobs: fi - name: Set up Docker Buildx for stable promotion validation - if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' + if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.has-changesets == 'false' uses: docker/setup-buildx-action@v4 - name: Require the compatibility package credential - if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' + if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.has-changesets == 'false' env: LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }} run: | @@ -166,14 +172,14 @@ jobs: fi - name: Verify canonical package writes before stable release state - if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' + if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.has-changesets == 'false' env: GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: bash scripts/check-ghcr-write-access.sh srcfl/ftw srcfl/ftw-updater - name: Verify compatibility package writes before stable release state - if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' + if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.has-changesets == 'false' env: GHCR_USERNAME: frahlg GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }} @@ -183,7 +189,7 @@ jobs: id: publish # Stable is an explicit promotion after real beta validation. Pushes # still maintain the Version Packages PR but never publish stable. - if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.hasChangesets == 'false' + if: github.event_name == 'workflow_dispatch' && steps.changesets.outputs.has-changesets == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_BETA: ${{ inputs.source_beta }} diff --git a/web/release-metadata.test.mjs b/web/release-metadata.test.mjs index bf105a9c9..9435ff7e2 100644 --- a/web/release-metadata.test.mjs +++ b/web/release-metadata.test.mjs @@ -89,7 +89,16 @@ describe("release metadata", () => { packageJSON.scripts?.["version-packages"] || "", /changeset version.+npm install --package-lock-only/, ); - assert.match(releaseWorkflow, /version:\s+npm run version-packages/); + assert.match(releaseWorkflow, /version-script:\s+npm run version-packages/); + }); + + it("reads the changesets output name its action major publishes", () => { + // changesets/action v2 renamed every input and output to kebab-case. + // A stale `hasChangesets` reads as empty, so the stable promotion + // steps guarded by it would skip in silence and release nothing. + assert.match(releaseWorkflow, /uses: changesets\/action@v2\b/); + assert.doesNotMatch(releaseWorkflow, /outputs\.hasChangesets/); + assert.match(releaseWorkflow, /outputs\.has-changesets/); }); it("publishes the state schema in beta and stable release notes", () => {