diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 20869a889..ab71a7cbe 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -1,8 +1,8 @@ name: GitHub Draft Release v2 on: - # Use the base repository's trusted workflow so release PRs from forks can - # create an auditable Draft Release without executing untrusted fork code. + # Use the base repository's trusted workflow and admit only its canonical + # release branches in the job guard below. pull_request_target: types: [closed] branches: [main] @@ -34,17 +34,19 @@ permissions: contents: write pull-requests: read -concurrency: - group: draft-release-v2-${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.event.pull_request.head.ref }} - cancel-in-progress: false - jobs: release: if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - (startsWith(github.event.pull_request.head.ref, 'v') || - startsWith(github.event.pull_request.head.ref, 'release/v'))) + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/v')) + concurrency: + # Tag and Draft Release mutations are repository-global. Queue automatic + # and manual recovery runs so they cannot mutate assets concurrently. + group: draft-release-v2 + cancel-in-progress: false + queue: max runs-on: ubuntu-latest environment: release env: @@ -67,11 +69,11 @@ jobs: create_draft="false" manual_target_sha_supplied="false" if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - if [[ ! "$PR_HEAD_REF" =~ ^(release/)?v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then - echo "Release branch must match vX.Y.Z or release/vX.Y.Z" >&2 + if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then + echo "Release branch must match release/vX.Y.Z" >&2 exit 1 fi - version="${BASH_REMATCH[2]}" + version="${BASH_REMATCH[1]}" target_sha="$PR_MERGE_SHA" preflight_level="full" create_draft="true" diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index ec4f8eb07..e2492b490 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -233,7 +233,7 @@ describe("GitHub Draft Release v2 workflow", () => { } }); - it("creates Draft Releases from merged vX.Y.Z PRs and keeps manual fallback", () => { + it("creates Draft Releases only from merged release/vX.Y.Z PRs and keeps manual fallback", () => { expect(draftWorkflow.on.pull_request_target).toEqual({ types: ["closed"], branches: ["main"], @@ -247,17 +247,22 @@ describe("GitHub Draft Release v2 workflow", () => { "full", ]); expect(draftWorkflow.on.workflow_dispatch.inputs.create_draft.default).toBe(false); - expect(draftJob.if).toContain("github.event.pull_request.merged == true"); - expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'v')"); - expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); + const normalizedJobCondition = String(draftJob.if).replace(/\s+/g, " ").trim(); + expect(normalizedJobCondition).toBe( + "github.event_name == 'workflow_dispatch' || " + + "(github.event.pull_request.merged == true && " + + "github.event.pull_request.head.repo.full_name == github.repository && " + + "startsWith(github.event.pull_request.head.ref, 'release/v'))", + ); const resolve = draftScript("Resolve and validate release"); expect(resolve).toContain('if [[ "$EVENT_NAME" == "pull_request_target" ]]'); expect(resolve).toContain( - "^(release/)?v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", + "^release/v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", ); - expect(resolve).toContain("vX.Y.Z or release/vX.Y.Z"); - expect(resolve).toContain('version="${BASH_REMATCH[2]}"'); + expect(resolve).toContain("Release branch must match release/vX.Y.Z"); + expect(resolve).not.toContain("vX.Y.Z or release/vX.Y.Z"); + expect(resolve).toContain('version="${BASH_REMATCH[1]}"'); expect(resolve).toContain('target_sha="$PR_MERGE_SHA"'); expect(resolve).toContain('preflight_level="full"'); expect(resolve).toContain('create_draft="true"'); @@ -497,15 +502,18 @@ describe("GitHub Draft Release v2 workflow", () => { expect(create).toContain("trap - EXIT"); }); - it("uses the release environment, minimal permissions, and per-version concurrency", () => { + it("uses the release environment, minimal permissions, and global non-cancelling concurrency", () => { expect(draftWorkflow.permissions).toEqual({ contents: "write", "pull-requests": "read", }); expect(draftJob.environment).toBe("release"); - expect(draftWorkflow.concurrency["cancel-in-progress"]).toBe(false); - expect(draftWorkflow.concurrency.group).toContain("inputs.version"); - expect(draftWorkflow.concurrency.group).toContain("pull_request.head.ref"); + expect(draftWorkflow.concurrency).toBeUndefined(); + expect(draftJob.concurrency).toEqual({ + group: "draft-release-v2", + "cancel-in-progress": false, + queue: "max", + }); }); it("records the manual Publish boundary in the workflow summary", () => {