diff --git a/.github/workflows/changeset-release-pr.test.mjs b/.github/workflows/changeset-release-pr.test.mjs index 8887fc5..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:)/, @@ -50,3 +57,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..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: @@ -135,8 +138,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 +171,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 +185,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..97d83f1 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,13 @@ jobs: ``` The caller must provide `changeset`, `changeset:version`, and a `.changeset/config.json`. +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 `sync-cargo-inherited-lock` enabled, the shared workflow validates the incoming