From 160cd049c7ba6d83335aa863b5f794ca78d1db23 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 11 Aug 2026 02:49:41 +0200 Subject: [PATCH 1/2] fix: prevent duplicate changeset release PRs --- .../workflows/changeset-release-pr.test.mjs | 42 ++++++++++ .github/workflows/changeset-release-pr.yml | 78 ++++++++++++++++++- README.md | 6 ++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changeset-release-pr.test.mjs b/.github/workflows/changeset-release-pr.test.mjs index 8887fc5..2e22f54 100644 --- a/.github/workflows/changeset-release-pr.test.mjs +++ b/.github/workflows/changeset-release-pr.test.mjs @@ -50,3 +50,45 @@ test("tool installation precedes the write-capable release credential", () => { const token = indexOf("name: Mint version PR token"); assert.ok(preparation < token); }); + +test("stale source revisions cannot mint credentials or mutate release PRs", () => { + const freshness = indexOf("name: Check release source is current"); + const token = indexOf("name: Mint version PR token"); + const release = indexOf("name: Create or update version packages PR"); + + assert.ok(freshness < token); + assert.ok(freshness < release); + assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match( + workflow, + /git\/ref\/heads\/\$GITHUB_REF_NAME[\s\S]+?if \[\[ "\$current_sha" == "\$GITHUB_SHA" \]\]/, + ); + assert.match( + workflow, + /if: steps\.source\.outputs\.current == 'true'\n {8}name: Mint version PR token/, + ); + assert.match( + workflow, + /if: steps\.source\.outputs\.current == 'true'\n {8}name: Create or update version packages PR/, + ); +}); + +test("a current no-op run removes the stale release PR and branch", () => { + const cleanup = workflow.match( + / {6}- if: >-\n {10}steps\.source\.outputs\.current == 'true'[\s\S]+?(?=\n {6}- |\n\S|$)/, + )?.[0]; + + assert.ok(cleanup, "missing stale release cleanup step"); + assert.match( + cleanup, + /steps\.changesets\.outputs\.pullRequestNumber == ''/, + ); + assert.match(cleanup, /name: Remove stale version packages PR/); + assert.match(cleanup, /current_sha="\$\(\n {12}gh api/); + assert.match(cleanup, /if \[\[ "\$current_sha" != "\$GITHUB_SHA" \]\]/); + assert.match(cleanup, /-f base="\$BASE_BRANCH"/); + assert.match(cleanup, /-f head="\$owner:\$RELEASE_BRANCH"/); + assert.match(cleanup, /-f per_page=1/); + assert.match(cleanup, /-f state=closed/); + assert.match(cleanup, /git\/refs\/heads\/\$RELEASE_BRANCH/); +}); diff --git a/.github/workflows/changeset-release-pr.yml b/.github/workflows/changeset-release-pr.yml index 577a79e..7c4a722 100644 --- a/.github/workflows/changeset-release-pr.yml +++ b/.github/workflows/changeset-release-pr.yml @@ -135,8 +135,29 @@ jobs: chmod 0700 "$version_command" printf 'path=%s\n' "$version_command" >> "$GITHUB_OUTPUT" + - name: Check release source is current + id: source + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + current_sha="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$GITHUB_REF_NAME" \ + --jq '.object.sha' + )" + if [[ "$current_sha" == "$GITHUB_SHA" ]]; then + echo "current=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping version PR update because $GITHUB_SHA is no longer the head of $GITHUB_REF_NAME." + # Install hooks run before a write-capable credential exists in the job. - - name: Mint version PR token + - if: steps.source.outputs.current == 'true' + name: Mint version PR token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -147,7 +168,9 @@ jobs: permission-contents: write permission-pull-requests: write - - name: Create or update version packages PR + - if: steps.source.outputs.current == 'true' + name: Create or update version packages PR + id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: bash ${{ steps.version-command.outputs.path }} @@ -159,3 +182,54 @@ jobs: CARGO_SYNC_SCRIPT: ${{ steps.cargo-sync.outputs.script }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} SYNC_CARGO_INHERITED_LOCK: ${{ inputs.sync-cargo-inherited-lock }} + + - if: >- + steps.source.outputs.current == 'true' && + steps.changesets.outputs.pullRequestNumber == '' + name: Remove stale version packages PR + shell: bash + env: + BASE_BRANCH: ${{ github.ref_name }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_BRANCH: changeset-release/${{ github.ref_name }} + run: | + set -euo pipefail + current_sha="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE_BRANCH" \ + --jq '.object.sha' + )" + if [[ "$current_sha" != "$GITHUB_SHA" ]]; then + echo "::notice::Skipping stale version PR cleanup because $GITHUB_SHA is no longer the head of $BASE_BRANCH." + exit 0 + fi + + owner="${GITHUB_REPOSITORY%%/*}" + pr_number="$( + gh api \ + --method GET \ + "repos/$GITHUB_REPOSITORY/pulls" \ + -f base="$BASE_BRANCH" \ + -f head="$owner:$RELEASE_BRANCH" \ + -f per_page=1 \ + -f state=open \ + --jq '.[0].number // empty' + )" + if [[ -n "$pr_number" ]]; then + gh api \ + --method PATCH \ + "repos/$GITHUB_REPOSITORY/pulls/$pr_number" \ + -f state=closed \ + --silent + echo "::notice::Closed stale version PR #$pr_number." + fi + + if gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$RELEASE_BRANCH" \ + --silent >/dev/null 2>&1; then + gh api \ + --method DELETE \ + "repos/$GITHUB_REPOSITORY/git/refs/heads/$RELEASE_BRANCH" \ + --silent + echo "::notice::Deleted stale branch $RELEASE_BRANCH." + fi diff --git a/README.md b/README.md index ecd7aaf..463dc7d 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,12 @@ jobs: ``` The caller must provide `changeset`, `changeset:version`, and a `.changeset/config.json`. +Configure the caller's push workflow with one concurrency group per target branch and +`cancel-in-progress: true`. The shared workflow skips write-capable steps when its +trigger commit is no longer the branch head. A current no-op run closes any stale +version PR and deletes its generated branch, so interrupted runs converge without a +duplicate release PR. + For hybrid repositories, `changeset:version` must synchronize the selected package version into every npm, Cargo manifest, Python, and central `VERSION` surface. With `sync-cargo-inherited-lock` enabled, the shared workflow validates the incoming From 213975ba15129a91f7ace0b54f460a30ec903425 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 11 Aug 2026 06:37:27 +0200 Subject: [PATCH 2/2] fix: serialize shared changeset runs --- .github/workflows/changeset-release-pr.test.mjs | 7 +++++++ .github/workflows/changeset-release-pr.yml | 3 +++ README.md | 11 ++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/changeset-release-pr.test.mjs b/.github/workflows/changeset-release-pr.test.mjs index 2e22f54..f044830 100644 --- a/.github/workflows/changeset-release-pr.test.mjs +++ b/.github/workflows/changeset-release-pr.test.mjs @@ -13,6 +13,13 @@ const indexOf = (text) => { return index; }; +test("version PR mutations use latest-run branch concurrency", () => { + assert.match( + workflow, + /concurrency:\n {6}group: changeset-release-pr-\$\{\{ github\.ref \}\}\n {6}cancel-in-progress: true/, + ); +}); + test("Rust Wasm preparation is explicit and disabled by default", () => { const input = workflow.match( / {6}prepare-rust-wasm:\n[\s\S]+?(?= {4}secrets:)/, diff --git a/.github/workflows/changeset-release-pr.yml b/.github/workflows/changeset-release-pr.yml index 7c4a722..77a89d5 100644 --- a/.github/workflows/changeset-release-pr.yml +++ b/.github/workflows/changeset-release-pr.yml @@ -58,6 +58,9 @@ jobs: name: Maintain version packages PR runs-on: ubuntu-latest timeout-minutes: 15 + concurrency: + group: changeset-release-pr-${{ github.ref }} + cancel-in-progress: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/README.md b/README.md index 463dc7d..97d83f1 100644 --- a/README.md +++ b/README.md @@ -252,11 +252,12 @@ jobs: ``` The caller must provide `changeset`, `changeset:version`, and a `.changeset/config.json`. -Configure the caller's push workflow with one concurrency group per target branch and -`cancel-in-progress: true`. The shared workflow skips write-capable steps when its -trigger commit is no longer the branch head. A current no-op run closes any stale -version PR and deletes its generated branch, so interrupted runs converge without a -duplicate release PR. +The shared workflow serializes each target branch with latest-run concurrency and +skips write-capable steps when its trigger commit is no longer the branch head. A +current no-op run closes any stale version PR and deletes its generated branch, so +interrupted runs converge without a duplicate release PR. Callers may use matching +workflow-level concurrency to cancel obsolete setup work before the reusable job +starts. For hybrid repositories, `changeset:version` must synchronize the selected package version into every npm, Cargo manifest, Python, and central `VERSION` surface. With