From 62cb2b50b7f56feb08c87f1bc8ddcbf2869921e6 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Fri, 7 Aug 2026 04:10:53 +0000 Subject: [PATCH 1/2] fix: publish GitHub release notes after npm releases --- .github/workflows/node-github-release.yml | 29 ++- .github/workflows/node-release.yml | 23 +++ .../tests-ts/release-automation.test.ts | 189 ++++++++++++++++++ 3 files changed, 236 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index df48d264..dc7a92bd 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -156,11 +156,30 @@ jobs: exit 1 fi - run_fields="$( - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$release_run" \ - --jq '[.name, .status, .conclusion, .head_sha, .head_branch] | @tsv' - )" - IFS=$'\t' read -r run_name run_status run_conclusion run_sha run_tag <<< "$run_fields" + for ((attempt = 1; attempt <= 30; attempt++)); do + run_fields="$( + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$release_run" \ + --jq '[.name, .status, (.conclusion // "pending"), .head_sha, .head_branch] | @tsv' + )" + IFS=$'\t' read -r run_name run_status run_conclusion run_sha run_tag <<< "$run_fields" + + if [[ "$run_name" != "node-release" || + "$run_sha" != "$release_sha" || + "$run_tag" != "$release_tag" ]]; then + echo "The release run must successfully publish the exact tagged commit." >&2 + exit 1 + fi + + if [[ "$run_status" == "completed" ]]; then + break + fi + + if (( attempt == 30 )); then + echo "Timed out waiting for protected npm release $release_run to complete." >&2 + exit 1 + fi + sleep 2 + done if [[ "$run_name" != "node-release" || "$run_status" != "completed" || diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index e0a8b098..84ef6889 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -401,3 +401,26 @@ jobs: - name: Publish to npm using trusted publishing if: needs.verify.outputs.mode == 'publish' && github.ref_name != 'npm-v0.1.0' run: npm publish ./dist/*.tgz --provenance --access public --tag latest + + dispatch-github-release: + if: github.repository == 'openai/codex-security' + name: dispatch GitHub release + needs: publish + runs-on: ubuntu-latest + permissions: + actions: write + + steps: + - name: Dispatch the verified GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + gh workflow run node-github-release.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref main \ + -f "tag=$RELEASE_TAG" \ + -f "run_id=$RELEASE_RUN_ID" diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index cf5afe6d..f52e0563 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2055,6 +2055,56 @@ describe("GitHub release workflow safeguards", () => { ); }); + test("dispatches GitHub releases after publishing with isolated permissions", () => { + expect(protectedReleaseWorkflow).toContain( + [ + " dispatch-github-release:", + " if: github.repository == 'openai/codex-security'", + " name: dispatch GitHub release", + " needs: publish", + " runs-on: ubuntu-latest", + " permissions:", + " actions: write", + ].join("\n"), + ); + expect(protectedReleaseWorkflow).toMatch( + / publish:\n[\s\S]*? permissions:\n contents: read\n id-token: write\n/u, + ); + }); + + test("dispatches the exact protected run and release tag from trusted main", () => { + const script = workflowStepShell( + protectedReleaseWorkflow, + "Dispatch the verified GitHub release", + ); + const mock = "gh() { printf '%s\\n' \"$@\"; }"; + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: releaseRepository, + RELEASE_RUN_ID: releaseRun, + RELEASE_TAG: "npm-v0.1.2", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "workflow", + "run", + "node-github-release.yml", + "--repo", + releaseRepository, + "--ref", + "main", + "-f", + "tag=npm-v0.1.2", + "-f", + `run_id=${releaseRun}`, + ]); + }); + 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, @@ -2077,6 +2127,145 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each(["queued", "in_progress"])( + "waits for a %s protected npm release to finish before publishing notes", + (pendingStatus) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Resolve the successful protected release", + ); + const root = mkdtempSync(join(tmpdir(), "codex-security-release-wait-")); + const state = join(root, "release-state"); + const sleeps = join(root, "release-sleeps"); + const mocks = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ' "repos/openai/codex-security/git/ref/tags/npm-v0.1.2")', + ` printf '%s\\t%s\\n' 'commit' '${releaseCommit}'`, + " ;;", + ` "repos/openai/codex-security/actions/runs/${releaseRun}")`, + ' if [[ -f "$MOCK_RUN_STATE" ]]; then', + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'completed' 'success' '${releaseCommit}' 'npm-v0.1.2'`, + " else", + ' touch "$MOCK_RUN_STATE"', + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' '${pendingStatus}' 'pending' '${releaseCommit}' 'npm-v0.1.2'`, + " fi", + " ;;", + " *) return 65 ;;", + " esac", + "}", + `sleep() { printf '%s\\n' "$1" >> "${sleeps}"; }`, + ].join("\n"); + + try { + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: releaseRepository, + INPUT_RUN_ID: releaseRun, + INPUT_TAG: "npm-v0.1.2", + MOCK_RUN_STATE: state, + TRIGGER_RUN_ID: "", + TRIGGER_TAG: "", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(readFileSync(sleeps, "utf8")).toBe("2\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test("rejects a pending npm release for a different tagged commit", () => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Resolve the successful protected release", + ); + const differentCommit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const mocks = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ' "repos/openai/codex-security/git/ref/tags/npm-v0.1.2")', + ` printf '%s\\t%s\\n' 'commit' '${releaseCommit}'`, + " ;;", + ` "repos/openai/codex-security/actions/runs/${releaseRun}")`, + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'in_progress' 'pending' '${differentCommit}' 'npm-v0.1.2'`, + " ;;", + " *) return 65 ;;", + " esac", + "}", + "sleep() { return 99; }", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: releaseRepository, + INPUT_RUN_ID: releaseRun, + INPUT_TAG: "npm-v0.1.2", + TRIGGER_RUN_ID: "", + TRIGGER_TAG: "", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "The release run must successfully publish the exact tagged commit.", + ); + }); + + test("times out safely if a protected npm release never completes", () => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Resolve the successful protected release", + ); + const mocks = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ' "repos/openai/codex-security/git/ref/tags/npm-v0.1.2")', + ` printf '%s\\t%s\\n' 'commit' '${releaseCommit}'`, + " ;;", + ` "repos/openai/codex-security/actions/runs/${releaseRun}")`, + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'in_progress' 'pending' '${releaseCommit}' 'npm-v0.1.2'`, + " ;;", + " *) return 65 ;;", + " esac", + "}", + "sleep() { :; }", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: releaseRepository, + INPUT_RUN_ID: releaseRun, + INPUT_TAG: "npm-v0.1.2", + TRIGGER_RUN_ID: "", + TRIGGER_TAG: "", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Timed out waiting for protected npm release ${releaseRun} to complete.`, + ); + }); + test("rejects a release-shaped branch before resolving its commit", () => { const script = workflowStepShell( githubReleaseWorkflow, From 9953863e61d22efebb13eb2c0f96956e791795a5 Mon Sep 17 00:00:00 2001 From: Kyle Brown Date: Fri, 7 Aug 2026 10:32:59 -0700 Subject: [PATCH 2/2] cleanup: remove unused GitHub release trigger --- .github/workflows/node-github-release.yml | 14 +++----------- .../tests-ts/release-automation.test.ts | 19 ++++++------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index dc7a92bd..d8552b35 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -1,9 +1,6 @@ name: node-github-release on: - workflow_run: - workflows: [node-release] - types: [completed] workflow_dispatch: inputs: tag: @@ -26,10 +23,7 @@ jobs: release: if: >- github.repository == 'openai/codex-security' && - ((github.event_name == 'workflow_dispatch' && - github.ref == 'refs/heads/main') || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success')) + github.ref == 'refs/heads/main' name: publish verified GitHub release runs-on: ubuntu-latest permissions: @@ -86,13 +80,11 @@ jobs: GH_TOKEN: ${{ github.token }} INPUT_TAG: ${{ inputs.tag }} INPUT_RUN_ID: ${{ inputs.run_id }} - TRIGGER_TAG: ${{ github.event.workflow_run.head_branch }} - TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} run: | set -euo pipefail - release_tag="${INPUT_TAG:-${TRIGGER_TAG:-}}" - release_run="${INPUT_RUN_ID:-${TRIGGER_RUN_ID:-}}" + release_tag="${INPUT_TAG:-}" + release_run="${INPUT_RUN_ID:-}" if [[ ! "$release_tag" =~ ^npm-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "GitHub releases require an existing stable npm-vX.Y.Z tag." >&2 diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index f52e0563..7c4a4b6a 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2070,6 +2070,10 @@ describe("GitHub release workflow safeguards", () => { expect(protectedReleaseWorkflow).toMatch( / publish:\n[\s\S]*? permissions:\n contents: read\n id-token: write\n/u, ); + expect(githubReleaseWorkflow).not.toContain("workflow_run:"); + expect(githubReleaseWorkflow).not.toContain("github.event.workflow_run"); + expect(githubReleaseWorkflow).not.toContain("TRIGGER_TAG"); + expect(githubReleaseWorkflow).not.toContain("TRIGGER_RUN_ID"); }); test("dispatches the exact protected run and release tag from trusted main", () => { @@ -2114,7 +2118,8 @@ describe("GitHub release workflow safeguards", () => { ); }); - test("runs manually dispatched GitHub backfills from trusted main", () => { + test("runs manually dispatched GitHub releases from trusted main", () => { + expect(githubReleaseWorkflow).toContain("workflow_dispatch:"); expect(githubReleaseWorkflow).toContain("github.ref == 'refs/heads/main'"); expect(githubReleaseWorkflow).toMatch( /- name: Checkout release automation\n(?:[^\n]*\n)*?\s+ref: refs\/heads\/main/u, @@ -2169,8 +2174,6 @@ describe("GitHub release workflow safeguards", () => { INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", MOCK_RUN_STATE: state, - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, }); @@ -2213,8 +2216,6 @@ describe("GitHub release workflow safeguards", () => { GITHUB_REPOSITORY: releaseRepository, INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, }); @@ -2254,8 +2255,6 @@ describe("GitHub release workflow safeguards", () => { GITHUB_REPOSITORY: releaseRepository, INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, }); @@ -2297,8 +2296,6 @@ describe("GitHub release workflow safeguards", () => { GITHUB_REPOSITORY: "openai/codex-security", INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, }); @@ -2363,8 +2360,6 @@ describe("GitHub release workflow safeguards", () => { INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", MOCK_TAG_TYPE: tagType, - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, }); @@ -2406,8 +2401,6 @@ describe("GitHub release workflow safeguards", () => { GITHUB_REPOSITORY: "openai/codex-security", INPUT_RUN_ID: runId, INPUT_TAG: "npm-v0.1.2", - TRIGGER_RUN_ID: "", - TRIGGER_TAG: "", }, timeout: 10_000, });