Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions .github/workflows/node-github-release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: node-github-release

on:
workflow_run:
workflows: [node-release]
types: [completed]
workflow_dispatch:
inputs:
tag:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,11 +148,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" ||
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/node-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
196 changes: 189 additions & 7 deletions sdk/typescript/tests-ts/release-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,60 @@ 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,
);
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", () => {
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,
Expand All @@ -2064,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,
Expand All @@ -2077,6 +2132,139 @@ 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,
},
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",
},
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",
},
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,
Expand Down Expand Up @@ -2108,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,
});
Expand Down Expand Up @@ -2174,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,
});
Expand Down Expand Up @@ -2217,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,
});
Expand Down
Loading