diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index d8552b35..7972c46a 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -1,4 +1,5 @@ name: node-github-release +run-name: GitHub release ${{ inputs.tag }} on: workflow_dispatch: @@ -16,8 +17,8 @@ permissions: contents: read concurrency: - group: node-github-release - queue: max + group: node-github-release-${{ inputs.tag }} + cancel-in-progress: false jobs: release: @@ -392,6 +393,47 @@ jobs: printf 'make-latest=%s\n' "$make_latest" } >> "$GITHUB_OUTPUT" + - name: Wait for older GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + for attempt in {1..120}; do + waiting= + active_runs="$( + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/workflows/node-github-release.yml/runs?per_page=100" \ + --jq '.workflow_runs[] | select(.status != "completed") | [.id, (.display_title // ""), .run_number] | @tsv' + )" + while IFS=$'\t' read -r run_id run_name run_number; do + [[ -n "$run_id" && "$run_id" != "$GITHUB_RUN_ID" ]] || continue + if [[ "$run_name" == "GitHub release npm-v"* ]]; then + candidate="${run_name#GitHub release npm-v}" + node sdk/typescript/scripts/release-automation.mjs \ + require-increase "$RELEASE_VERSION" "$candidate" \ + >/dev/null 2>&1 || continue + elif [[ ! "$run_number" =~ ^[0-9]+$ || + "$run_number" -ge "$GITHUB_RUN_NUMBER" ]]; then + continue + fi + waiting="$run_id" + break + done <<< "$active_runs" + + if [[ -z "$waiting" ]]; then + echo "No older GitHub release is still running." + break + fi + if [[ "$attempt" == 120 ]]; then + echo "Timed out waiting for older GitHub release run $waiting." >&2 + exit 1 + fi + sleep 15 + done + - name: Publish GitHub Release and generated notes shell: bash env: diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index 6edec563..f289171d 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -11,8 +11,8 @@ permissions: contents: read concurrency: - group: node-release-cut - queue: max + group: node-release-cut-${{ github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: false jobs: cut: diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 84ef6889..d2c4269f 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }} - queue: max + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false jobs: verify: @@ -218,6 +218,7 @@ jobs: permissions: contents: read id-token: write + actions: read steps: - name: Checkout release verification source @@ -331,6 +332,44 @@ jobs: "$GITHUB_SHA" \ "$GITHUB_REPOSITORY" + - name: Wait for older npm releases + if: needs.verify.outputs.mode == 'publish' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + + for attempt in {1..120}; do + waiting= + active_runs="$( + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/workflows/node-release.yml/runs?per_page=100" \ + --jq '.workflow_runs[] | select(.status != "completed") | [.id, (.head_branch // "")] | @tsv' + )" + while IFS=$'\t' read -r run_id ref_name; do + [[ -n "$run_id" && "$run_id" != "$GITHUB_RUN_ID" ]] || continue + [[ "$ref_name" == npm-v* ]] || continue + candidate="${ref_name#npm-v}" + if node sdk/typescript/scripts/release-automation.mjs \ + require-increase "$RELEASE_VERSION" "$candidate" >/dev/null 2>&1; then + waiting="$run_id" + break + fi + done <<< "$active_runs" + + if [[ -z "$waiting" ]]; then + echo "No older npm release is still running." + break + fi + if [[ "$attempt" == 120 ]]; then + echo "Timed out waiting for older npm release run $waiting." >&2 + exit 1 + fi + sleep 15 + done + - name: Revalidate protected release tag if: needs.verify.outputs.mode == 'publish' shell: bash diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 7c4a4b6a..b33b0642 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2046,13 +2046,125 @@ describe("GitHub release workflow safeguards", () => { ); }); - test("durably queues every release-cut and protected publishing run", () => { - expect(releaseCutWorkflow).toMatch( - /concurrency:\s*\n\s+group: node-release-cut\s*\n\s+queue: max/u, + test("keeps distinct release commits and tags in separate concurrency groups", () => { + expect(releaseCutWorkflow).toContain( + "group: node-release-cut-${{ github.event.workflow_run.head_sha || github.sha }}", ); - expect(protectedReleaseWorkflow).toMatch( - /concurrency:\s*\n\s+group: \$\{\{ github\.workflow \}\}\s*\n\s+queue: max/u, + expect(protectedReleaseWorkflow).toContain( + "group: ${{ github.workflow }}-${{ github.ref }}", + ); + expect(releaseCutWorkflow).toContain("cancel-in-progress: false"); + expect(protectedReleaseWorkflow).toContain("cancel-in-progress: false"); + expect(releaseCutWorkflow).not.toMatch(/^\s+queue:/mu); + expect(protectedReleaseWorkflow).not.toMatch(/^\s+queue:/mu); + }); + + test("waits for older npm publishers without dropping pending release tags", () => { + const ordering = workflowStepShell( + protectedReleaseWorkflow, + "Wait for older npm releases", + ); + expect(ordering).toContain( + "actions/workflows/node-release.yml/runs?per_page=100", + ); + expect(ordering).toContain( + 'require-increase "$RELEASE_VERSION" "$candidate"', + ); + expect(ordering).toContain("sleep 15"); + expect(protectedReleaseWorkflow).toContain(" actions: read"); + expect( + protectedReleaseWorkflow.indexOf("- name: Wait for older npm releases"), + ).toBeLessThan( + protectedReleaseWorkflow.indexOf( + "- name: Revalidate protected release tag", + ), + ); + expect( + protectedReleaseWorkflow.indexOf( + "- name: Revalidate protected release tag", + ), + ).toBeLessThan( + protectedReleaseWorkflow.indexOf("- name: Publish initial npm release"), ); + + const failedLookup = spawnSync( + "bash", + ["-c", `gh() { return 17; }\n${ordering}`], + { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + GITHUB_RUN_ID: "1", + RELEASE_VERSION: "0.2.0", + }, + }, + ); + expect(failedLookup.status).toBe(17); + expect(failedLookup.stdout).not.toContain("No older npm release"); + }); + + test("publishes GitHub releases in version order without dropping pending tags", () => { + expect(githubReleaseWorkflow).toContain( + "run-name: GitHub release ${{ inputs.tag }}", + ); + const ordering = workflowStepShell( + githubReleaseWorkflow, + "Wait for older GitHub releases", + ); + expect(ordering).toContain( + "actions/workflows/node-github-release.yml/runs?per_page=100", + ); + expect(ordering).toContain( + 'require-increase "$RELEASE_VERSION" "$candidate"', + ); + + const root = mkdtempSync(join(tmpdir(), "codex-security-github-order-")); + const mocks = [ + "gh() {", + ' if [[ ! -e "$RELEASE_RUN_CHECKED" ]]; then', + ' touch "$RELEASE_RUN_CHECKED"', + " printf '7\\tGitHub release npm-v0.1.0\\t7\\n'", + " fi", + "}", + 'sleep() { printf "waited %s\\n" "$1"; }', + ].join("\n"); + try { + const ordered = spawnSync("bash", ["-c", `${mocks}\n${ordering}`], { + encoding: "utf8", + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + GITHUB_RUN_ID: "8", + GITHUB_RUN_NUMBER: "8", + RELEASE_RUN_CHECKED: join(root, "checked"), + RELEASE_VERSION: "0.2.0", + }, + }); + expect(ordered.status).toBe(0); + expect(ordered.stdout).toContain("waited 15"); + expect(ordered.stdout).toContain("No older GitHub release"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + + const failedLookup = spawnSync( + "bash", + ["-c", `gh() { return 17; }\n${ordering}`], + { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + GITHUB_RUN_ID: "8", + GITHUB_RUN_NUMBER: "8", + RELEASE_VERSION: "0.2.0", + }, + }, + ); + expect(failedLookup.status).toBe(17); + expect(failedLookup.stdout).not.toContain("No older GitHub release"); }); test("dispatches GitHub releases after publishing with isolated permissions", () => { @@ -2109,13 +2221,12 @@ describe("GitHub release workflow safeguards", () => { ]); }); - test("serializes every GitHub release and historical backfill", () => { - expect(githubReleaseWorkflow).toMatch( - /concurrency:\s*\n\s+group: node-github-release\s*\n\s+queue: max/u, - ); - expect(githubReleaseWorkflow).not.toContain( - "group: node-github-release-${{", + test("preserves GitHub releases and historical backfills for distinct tags", () => { + expect(githubReleaseWorkflow).toContain( + "group: node-github-release-${{ inputs.tag }}", ); + expect(githubReleaseWorkflow).toContain("cancel-in-progress: false"); + expect(githubReleaseWorkflow).not.toMatch(/^\s+queue:/mu); }); test("runs manually dispatched GitHub releases from trusted main", () => {