From 474965a0780edf123dd6e8ed88eb67eeecd823c9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 13:12:44 -0700 Subject: [PATCH 01/20] feat: automate verified npm and GitHub releases --- .github/release.yml | 17 ++ .github/workflows/node-github-release.yml | 207 ++++++++++++++++ .github/workflows/node-release-cut.yml | 108 +++++++++ .github/workflows/node-release-labels.yml | 65 +++++ .github/workflows/node-release.yml | 1 + sdk/typescript/scripts/release-automation.mjs | 127 ++++++++++ .../tests-ts/release-automation.test.ts | 223 ++++++++++++++++++ 7 files changed, 748 insertions(+) create mode 100644 .github/release.yml create mode 100644 .github/workflows/node-github-release.yml create mode 100644 .github/workflows/node-release-cut.yml create mode 100644 .github/workflows/node-release-labels.yml create mode 100644 sdk/typescript/scripts/release-automation.mjs create mode 100644 sdk/typescript/tests-ts/release-automation.test.ts diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..0fe32119 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,17 @@ +changelog: + exclude: + labels: + - skip-release-notes + categories: + - title: Features + labels: + - enhancement + - title: Fixes + labels: + - bug + - title: Documentation + labels: + - documentation + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml new file mode 100644 index 00000000..f22df39c --- /dev/null +++ b/.github/workflows/node-github-release.yml @@ -0,0 +1,207 @@ +name: node-github-release + +on: + workflow_run: + workflows: [node-release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Existing stable npm release tag + required: true + type: string + run_id: + description: Successful node-release run to use for backfill + required: false + type: string + +permissions: + contents: read + +concurrency: + group: node-github-release-${{ inputs.tag || github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: false + +jobs: + release: + if: >- + github.repository == 'openai/codex-security' && + (github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success') + name: publish verified GitHub release + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + + steps: + - name: Checkout release automation + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Resolve the successful protected release + id: release + shell: bash + env: + 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:-}}" + + 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 + exit 1 + fi + + release_sha="$( + gh api "repos/$GITHUB_REPOSITORY/commits/$release_tag" \ + --jq '.sha' + )" + + if [[ -z "$release_run" ]]; then + release_run="$( + gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow node-release.yml \ + --commit "$release_sha" \ + --limit 20 \ + --json databaseId,status,conclusion \ + --jq '.[] | select(.status == "completed" and .conclusion == "success") | .databaseId' | + head -n 1 + )" + fi + + if [[ ! "$release_run" =~ ^[0-9]+$ ]]; then + echo "No successful protected npm release exists for $release_tag." >&2 + 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" + + if [[ "$run_name" != "node-release" || + "$run_status" != "completed" || + "$run_conclusion" != "success" || + "$run_sha" != "$release_sha" || + "$run_tag" != "$release_tag" ]]; then + echo "The release run must successfully publish the exact tagged commit." >&2 + exit 1 + fi + + { + printf 'tag=%s\n' "$release_tag" + printf 'version=%s\n' "${release_tag#npm-v}" + printf 'sha=%s\n' "$release_sha" + printf 'run-id=%s\n' "$release_run" + } >> "$GITHUB_OUTPUT" + + - name: Download the verified npm release artifact + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ steps.release.outputs.run-id }} + run: | + set -euo pipefail + gh run download "$RELEASE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name npm-release-tarball \ + --dir dist + + - name: Verify the public npm package and provenance + id: artifact + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + run: | + set -euo pipefail + shopt -s nullglob + + archives=(dist/*.tgz) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one verified npm release tarball." >&2 + exit 1 + fi + archive="${archives[0]}" + + metadata="$( + npm view "@openai/codex-security@$RELEASE_VERSION" \ + name version gitHead dist.integrity dist.attestations \ + --json \ + --registry=https://registry.npmjs.org/ + )" + + printf '%s\n' "$metadata" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-publication "$archive" "$RELEASE_VERSION" "$RELEASE_SHA" + + printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub Release and generated notes + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_ARCHIVE: ${{ steps.artifact.outputs.archive }} + run: | + set -euo pipefail + + if existing_release="$( + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ + 2>/dev/null + )"; then + printf '%s\n' "$existing_release" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-github-release "$RELEASE_ARCHIVE" "$RELEASE_TAG" + echo "The verified GitHub Release already exists." + exit 0 + fi + + previous_tag="" + if git rev-parse "$RELEASE_SHA^" >/dev/null 2>&1; then + while IFS= read -r candidate; do + if [[ "$candidate" =~ ^npm-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + previous_tag="$candidate" + break + fi + done < <( + git tag --merged "$RELEASE_SHA^" \ + --list 'npm-v*' \ + --sort=-version:refname + ) + fi + + release_args=( + "$RELEASE_TAG" + "$RELEASE_ARCHIVE" + --repo "$GITHUB_REPOSITORY" + --title "Codex Security $RELEASE_VERSION" + --verify-tag + --generate-notes + --latest + ) + if [[ -n "$previous_tag" ]]; then + release_args+=(--notes-start-tag "$previous_tag") + fi + + gh release create "${release_args[@]}" diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml new file mode 100644 index 00000000..8e9ae25e --- /dev/null +++ b/.github/workflows/node-release-cut.yml @@ -0,0 +1,108 @@ +name: node-release-cut + +on: + push: + branches: [main] + paths: + - sdk/typescript/package.json + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: node-release-cut + cancel-in-progress: false + +jobs: + cut: + if: github.repository == 'openai/codex-security' + name: cut release tag + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve the stable package version + id: release + shell: bash + env: + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + + version="$(node sdk/typescript/scripts/release-automation.mjs version sdk/typescript/package.json)" + + if [[ "$GITHUB_EVENT_NAME" == "push" && "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + if previous_manifest="$(git show "$BEFORE_SHA:sdk/typescript/package.json" 2>/dev/null)"; then + previous_version="$( + printf '%s\n' "$previous_manifest" | + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const manifest = JSON.parse(readFileSync(0, "utf8")); + if (typeof manifest.version !== "string") { + throw new Error("The previous package version is missing."); + } + process.stdout.write(manifest.version); + ' + )" + if [[ "$version" == "$previous_version" ]]; then + echo "Package version is unchanged; no release is needed." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + fi + + { + printf 'changed=true\n' + printf 'version=%s\n' "$version" + printf 'tag=npm-v%s\n' "$version" + } >> "$GITHUB_OUTPUT" + + - name: Create the exact merged release tag + if: steps.release.outputs.changed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + + existing_sha="$( + gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '.object.sha' 2>/dev/null || true + )" + + if [[ -n "$existing_sha" ]]; then + if [[ "$existing_sha" != "$GITHUB_SHA" ]]; then + echo "Release tag $RELEASE_TAG already points to a different commit." >&2 + exit 1 + fi + echo "Release tag $RELEASE_TAG already points to $GITHUB_SHA." + exit 0 + fi + + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f "ref=refs/tags/$RELEASE_TAG" \ + -f "sha=$GITHUB_SHA" \ + --silent + + - name: Dispatch the protected npm release + if: steps.release.outputs.changed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh workflow run node-release.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$RELEASE_TAG" diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml new file mode 100644 index 00000000..7bae2c01 --- /dev/null +++ b/.github/workflows/node-release-labels.yml @@ -0,0 +1,65 @@ +name: node-release-labels + +on: + pull_request_target: + branches: [main] + types: [opened, edited, reopened] + +permissions: + contents: read + +jobs: + categorize: + if: github.repository == 'openai/codex-security' + name: categorize release notes + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Categorize pull request without checking out its code + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + case "$PR_TITLE" in + feat:* | feat\(*\):*) + label="enhancement" + ;; + fix:* | fix\(*\):*) + label="bug" + ;; + docs:* | docs\(*\):*) + label="documentation" + ;; + release:* | release\(*\):* | test:* | test\(*\):*) + label="skip-release-notes" + ;; + *) + echo "No automatic release-note category applies." + exit 0 + ;; + esac + + if [[ "$label" == "skip-release-notes" ]]; then + if ! gh api \ + "repos/$GITHUB_REPOSITORY/labels/skip-release-notes" \ + --silent >/dev/null 2>&1; then + gh api --method POST "repos/$GITHUB_REPOSITORY/labels" \ + -f name=skip-release-notes \ + -f color=ededed \ + -f description="Omit internal changes from generated release notes" \ + --silent + fi + fi + + gh api --method POST \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" \ + -f "labels[]=$label" \ + --silent diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 828b1994..b0b0e9cd 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -4,6 +4,7 @@ on: push: tags: - "npm-v*" + workflow_dispatch: concurrency: group: ${{ github.workflow }} diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs new file mode 100644 index 00000000..fa16103a --- /dev/null +++ b/sdk/typescript/scripts/release-automation.mjs @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { pathToFileURL } from "node:url"; +import { assertExpectedGitHead } from "./package-provenance.mjs"; + +const packageName = "@openai/codex-security"; +const stableVersion = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; +const provenancePredicate = "https://slsa.dev/provenance/v1"; + +export function releaseVersion(packageJson) { + if (packageJson?.name !== packageName) { + throw new Error("Release package must be @openai/codex-security."); + } + if ( + typeof packageJson.version !== "string" || + !stableVersion.test(packageJson.version) + ) { + throw new Error("Release package must have a stable X.Y.Z version."); + } + return packageJson.version; +} + +export function verifyPublishedRelease(metadata, archive, expected) { + const version = releaseVersion(metadata); + if (version !== expected.version) { + throw new Error("Published npm package must match the release version."); + } + + assertExpectedGitHead(metadata, expected.gitHead); + + const integrity = metadata["dist.integrity"] ?? metadata.dist?.integrity; + const expectedIntegrity = + "sha512-" + createHash("sha512").update(archive).digest("base64"); + if (integrity !== expectedIntegrity) { + throw new Error( + "Published npm integrity must match the verified release artifact.", + ); + } + + const attestations = + metadata["dist.attestations"] ?? metadata.dist?.attestations; + if (attestations?.provenance?.predicateType !== provenancePredicate) { + throw new Error("Published npm package must have SLSA v1 provenance."); + } + + return { + version, + gitHead: expected.gitHead, + integrity: expectedIntegrity, + sha256: createHash("sha256").update(archive).digest("hex"), + }; +} + +export function verifyGitHubRelease(release, archive, expectedTag, assetName) { + if (release?.tag_name !== expectedTag) { + throw new Error("Existing GitHub Release must match the release tag."); + } + if (release.draft !== false || release.prerelease !== false) { + throw new Error("Existing GitHub Release must be published and stable."); + } + + const expectedDigest = + "sha256:" + createHash("sha256").update(archive).digest("hex"); + const asset = Array.isArray(release.assets) + ? release.assets.find((candidate) => candidate.name === assetName) + : undefined; + if (asset?.digest !== expectedDigest) { + throw new Error( + "Existing GitHub Release asset must match the verified npm artifact.", + ); + } + + return { + tag: expectedTag, + asset: assetName, + digest: expectedDigest, + }; +} + +function main() { + const command = process.argv[2]; + + if (command === "version" && process.argv.length === 4) { + const packageJson = JSON.parse(readFileSync(process.argv[3], "utf8")); + console.log(releaseVersion(packageJson)); + return; + } + + if (command === "verify-publication" && process.argv.length === 6) { + const metadata = JSON.parse(readFileSync(0, "utf8")); + const archive = readFileSync(process.argv[3]); + const verified = verifyPublishedRelease(metadata, archive, { + version: process.argv[4], + gitHead: process.argv[5], + }); + console.log(JSON.stringify(verified)); + return; + } + + if (command === "verify-github-release" && process.argv.length === 5) { + const release = JSON.parse(readFileSync(0, "utf8")); + const archivePath = process.argv[3]; + const archive = readFileSync(archivePath); + const verified = verifyGitHubRelease( + release, + archive, + process.argv[4], + basename(archivePath), + ); + console.log(JSON.stringify(verified)); + return; + } + + throw new Error( + "Usage: release-automation.mjs version , " + + "verify-publication , or " + + "verify-github-release .", + ); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(); +} diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts new file mode 100644 index 00000000..34404689 --- /dev/null +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -0,0 +1,223 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; + +type ReleaseMetadata = Record; + +type ReleaseAutomation = { + releaseVersion: (packageJson: ReleaseMetadata) => string; + verifyPublishedRelease: ( + metadata: ReleaseMetadata, + archive: Uint8Array, + expected: { version: string; gitHead: string }, + ) => { + version: string; + gitHead: string; + integrity: string; + sha256: string; + }; + verifyGitHubRelease: ( + release: ReleaseMetadata, + archive: Uint8Array, + expectedTag: string, + assetName: string, + ) => { tag: string; asset: string; digest: string }; +}; + +const { releaseVersion, verifyPublishedRelease, verifyGitHubRelease } = + (await import( + new URL("../scripts/release-automation.mjs", import.meta.url).href + )) as ReleaseAutomation; + +const releaseCommit = "1e03c89ad22d2df5ae65b146be1483b3608572a9"; +const archive = Buffer.from("verified codex security release artifact"); +const integrity = + "sha512-" + createHash("sha512").update(archive).digest("base64"); +const digest = "sha256:" + createHash("sha256").update(archive).digest("hex"); + +function publishedMetadata(): ReleaseMetadata { + return { + name: "@openai/codex-security", + version: "0.1.2", + gitHead: releaseCommit, + "dist.integrity": integrity, + "dist.attestations": { + provenance: { + predicateType: "https://slsa.dev/provenance/v1", + }, + }, + }; +} + +function githubRelease(): ReleaseMetadata { + return { + tag_name: "npm-v0.1.2", + draft: false, + prerelease: false, + assets: [{ name: "openai-codex-security-0.1.2.tgz", digest }], + }; +} + +describe("stable npm release versions", () => { + test("accepts the official stable package and version", () => { + expect( + releaseVersion({ + name: "@openai/codex-security", + version: "0.1.2", + }), + ).toBe("0.1.2"); + }); + + test("rejects another package", () => { + expect(() => + releaseVersion({ name: "codex-security", version: "0.1.2" }), + ).toThrow("Release package must be @openai/codex-security."); + }); + + test.each(["0.1.2-beta.1", "01.1.2", "0.1", "latest", ""])( + "rejects unstable or malformed version %s", + (version) => { + expect(() => + releaseVersion({ name: "@openai/codex-security", version }), + ).toThrow("Release package must have a stable X.Y.Z version."); + }, + ); +}); + +describe("published npm release verification", () => { + test("verifies source commit, tarball integrity, and SLSA provenance", () => { + expect( + verifyPublishedRelease(publishedMetadata(), archive, { + version: "0.1.2", + gitHead: releaseCommit, + }), + ).toEqual({ + version: "0.1.2", + gitHead: releaseCommit, + integrity, + sha256: digest.slice("sha256:".length), + }); + }); + + test("supports nested public npm metadata", () => { + expect( + verifyPublishedRelease( + { + name: "@openai/codex-security", + version: "0.1.2", + gitHead: releaseCommit, + dist: { + integrity, + attestations: { + provenance: { + predicateType: "https://slsa.dev/provenance/v1", + }, + }, + }, + }, + archive, + { version: "0.1.2", gitHead: releaseCommit }, + ).integrity, + ).toBe(integrity); + }); + + test("rejects a different published version", () => { + expect(() => + verifyPublishedRelease(publishedMetadata(), archive, { + version: "0.1.3", + gitHead: releaseCommit, + }), + ).toThrow("Published npm package must match the release version."); + }); + + test("rejects a missing or mismatched release commit", () => { + expect(() => + verifyPublishedRelease( + { ...publishedMetadata(), gitHead: undefined }, + archive, + { version: "0.1.2", gitHead: releaseCommit }, + ), + ).toThrow("npm package gitHead must match release commit"); + }); + + test("rejects a tarball that differs from the published npm artifact", () => { + expect(() => + verifyPublishedRelease( + publishedMetadata(), + Buffer.from("different release artifact"), + { version: "0.1.2", gitHead: releaseCommit }, + ), + ).toThrow( + "Published npm integrity must match the verified release artifact.", + ); + }); + + test("rejects missing or unexpected provenance", () => { + expect(() => + verifyPublishedRelease( + { ...publishedMetadata(), "dist.attestations": undefined }, + archive, + { version: "0.1.2", gitHead: releaseCommit }, + ), + ).toThrow("Published npm package must have SLSA v1 provenance."); + }); +}); + +describe("idempotent GitHub release verification", () => { + test("accepts the already-published exact verified release asset", () => { + expect( + verifyGitHubRelease( + githubRelease(), + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toEqual({ + tag: "npm-v0.1.2", + asset: "openai-codex-security-0.1.2.tgz", + digest, + }); + }); + + test("rejects a GitHub release for another tag", () => { + expect(() => + verifyGitHubRelease( + { ...githubRelease(), tag_name: "npm-v0.1.1" }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toThrow("Existing GitHub Release must match the release tag."); + }); + + test("rejects a draft or prerelease", () => { + expect(() => + verifyGitHubRelease( + { ...githubRelease(), draft: true }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toThrow("Existing GitHub Release must be published and stable."); + }); + + test("rejects a missing or different GitHub release artifact", () => { + expect(() => + verifyGitHubRelease( + { + ...githubRelease(), + assets: [ + { + name: "openai-codex-security-0.1.2.tgz", + digest: "sha256:incorrect", + }, + ], + }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toThrow( + "Existing GitHub Release asset must match the verified npm artifact.", + ); + }); +}); From 335411fc07552c9147baad3d99f898617c7b0659 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 13:32:05 -0700 Subject: [PATCH 02/20] fix: harden release backfills and changelog labels --- .github/workflows/node-github-release.yml | 1 - .github/workflows/node-release-labels.yml | 58 +++++++++++++------ .../tests-ts/release-automation.test.ts | 35 +++++++++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index f22df39c..9e30af62 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -198,7 +198,6 @@ jobs: --title "Codex Security $RELEASE_VERSION" --verify-tag --generate-notes - --latest ) if [[ -n "$previous_tag" ]]; then release_args+=(--notes-start-tag "$previous_tag") diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index 7bae2c01..e1cf85d0 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -25,27 +25,49 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_PREVIOUS_TITLE: ${{ github.event.changes.title.from }} run: | set -euo pipefail - case "$PR_TITLE" in - feat:* | feat\(*\):*) - label="enhancement" - ;; - fix:* | fix\(*\):*) - label="bug" - ;; - docs:* | docs\(*\):*) - label="documentation" - ;; - release:* | release\(*\):* | test:* | test\(*\):*) - label="skip-release-notes" - ;; - *) - echo "No automatic release-note category applies." - exit 0 - ;; - esac + release_note_label() { + case "$1" in + feat:* | feat\(*\):*) + printf '%s\n' enhancement + ;; + fix:* | fix\(*\):*) + printf '%s\n' bug + ;; + docs:* | docs\(*\):*) + printf '%s\n' documentation + ;; + release:* | release\(*\):* | test:* | test\(*\):*) + printf '%s\n' skip-release-notes + ;; + esac + } + + label="$(release_note_label "$PR_TITLE")" + previous_label="$(release_note_label "$PR_PREVIOUS_TITLE")" + + if [[ -n "$previous_label" && "$previous_label" != "$label" ]]; then + current_labels="$( + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" \ + --jq '.[].name' + )" + while IFS= read -r current_label; do + if [[ "$current_label" == "$previous_label" ]]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/$previous_label" \ + --silent + break + fi + done <<< "$current_labels" + fi + + if [[ -z "$label" ]]; then + echo "No automatic release-note category applies." + exit 0 + fi if [[ "$label" == "skip-release-notes" ]]; then if ! gh api \ diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 34404689..32855f5a 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; type ReleaseMetadata = Record; @@ -33,6 +34,20 @@ const archive = Buffer.from("verified codex security release artifact"); const integrity = "sha512-" + createHash("sha512").update(archive).digest("base64"); const digest = "sha256:" + createHash("sha256").update(archive).digest("hex"); +const githubReleaseWorkflow = readFileSync( + new URL( + "../../../.github/workflows/node-github-release.yml", + import.meta.url, + ), + "utf8", +); +const releaseLabelsWorkflow = readFileSync( + new URL( + "../../../.github/workflows/node-release-labels.yml", + import.meta.url, + ), + "utf8", +); function publishedMetadata(): ReleaseMetadata { return { @@ -221,3 +236,23 @@ describe("idempotent GitHub release verification", () => { ); }); }); + +describe("GitHub release workflow safeguards", () => { + test("does not force a historical backfill to become the latest release", () => { + expect(githubReleaseWorkflow).toContain("--generate-notes"); + expect(githubReleaseWorkflow).not.toMatch(/^\s*--latest(?:=true)?\s*$/mu); + }); + + test("reconciles the previous category after a pull request title changes", () => { + expect(releaseLabelsWorkflow).toContain( + "PR_PREVIOUS_TITLE: ${{ github.event.changes.title.from }}", + ); + expect(releaseLabelsWorkflow).toContain( + 'previous_label="$(release_note_label "$PR_PREVIOUS_TITLE")"', + ); + expect(releaseLabelsWorkflow).toContain( + 'if [[ -n "$previous_label" && "$previous_label" != "$label" ]]; then', + ); + expect(releaseLabelsWorkflow).toContain("gh api --method DELETE"); + }); +}); From 35413aafa3c7fbf0fa6a792bab2e82a437b84a34 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 14:02:36 -0700 Subject: [PATCH 03/20] fix: address all release automation review feedback --- .github/workflows/node-github-release.yml | 126 +++- .github/workflows/node-release-cut.yml | 12 + .github/workflows/node-release-labels.yml | 45 +- .github/workflows/node-release.yml | 17 +- sdk/typescript/scripts/release-automation.mjs | 333 ++++++++++- .../tests-ts/release-automation.test.ts | 566 +++++++++++++++++- 6 files changed, 1037 insertions(+), 62 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 9e30af62..0229fb4e 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -81,8 +81,7 @@ jobs: --commit "$release_sha" \ --limit 20 \ --json databaseId,status,conclusion \ - --jq '.[] | select(.status == "completed" and .conclusion == "success") | .databaseId' | - head -n 1 + --jq '[.[] | select(.status == "completed" and .conclusion == "success")] | first | .databaseId // empty' )" fi @@ -113,24 +112,33 @@ jobs: printf 'run-id=%s\n' "$release_run" } >> "$GITHUB_OUTPUT" - - name: Download the verified npm release artifact + - name: Download the verified or durable npm release artifact shell: bash env: GH_TOKEN: ${{ github.token }} RELEASE_RUN_ID: ${{ steps.release.outputs.run-id }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail - gh run download "$RELEASE_RUN_ID" \ + mkdir -p dist + if ! gh run download "$RELEASE_RUN_ID" \ --repo "$GITHUB_REPOSITORY" \ --name npm-release-tarball \ - --dir dist + --dir dist; then + echo "Release artifact expired; retrieving the public npm tarball." + npm pack "@openai/codex-security@$RELEASE_VERSION" \ + --ignore-scripts \ + --pack-destination dist \ + --registry=https://registry.npmjs.org/ + fi - - name: Verify the public npm package and provenance + - name: Verify the public npm package and signed provenance id: artifact shell: bash env: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_RUN_ID: ${{ steps.release.outputs.run-id }} run: | set -euo pipefail shopt -s nullglob @@ -153,8 +161,93 @@ jobs: node sdk/typescript/scripts/release-automation.mjs \ verify-publication "$archive" "$RELEASE_VERSION" "$RELEASE_SHA" + consumer="$(mktemp -d)" + trap 'rm -rf "$consumer"' EXIT + npm install \ + --prefix "$consumer" \ + --ignore-scripts \ + --omit=optional \ + --no-audit \ + --no-fund \ + --registry=https://registry.npmjs.org/ \ + "@openai/codex-security@$RELEASE_VERSION" + npm audit signatures \ + --prefix "$consumer" \ + --registry=https://registry.npmjs.org/ \ + --json \ + --include-attestations | + node sdk/typescript/scripts/release-automation.mjs \ + verify-provenance \ + "$archive" \ + "$RELEASE_VERSION" \ + "$RELEASE_SHA" \ + "$GITHUB_REPOSITORY" \ + "$RELEASE_RUN_ID" + printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT" + - name: Resolve published release history + id: history + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + run: | + set -euo pipefail + + published_versions="$( + npm view @openai/codex-security versions \ + --json \ + --registry=https://registry.npmjs.org/ + )" + published_github_tags="$( + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' + )" + reachable_tags="" + if git rev-parse "$RELEASE_SHA^" >/dev/null 2>&1; then + reachable_tags="$( + git tag --merged "$RELEASE_SHA^" \ + --list 'npm-v*' \ + --sort=-version:refname + )" + fi + + history="$( + CODEX_SECURITY_PUBLISHED_NPM_VERSIONS="$published_versions" \ + CODEX_SECURITY_PUBLISHED_GITHUB_TAGS="$published_github_tags" \ + CODEX_SECURITY_REACHABLE_RELEASE_TAGS="$reachable_tags" \ + node sdk/typescript/scripts/release-automation.mjs \ + release-history "$RELEASE_TAG" + )" + + previous_tag="$( + printf '%s\n' "$history" | + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const history = JSON.parse(readFileSync(0, "utf8")); + process.stdout.write(history.previousTag ?? ""); + ' + )" + make_latest="$( + printf '%s\n' "$history" | + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const history = JSON.parse(readFileSync(0, "utf8")); + if (typeof history.makeLatest !== "boolean") { + throw new Error("Release latest status must be explicit."); + } + process.stdout.write(String(history.makeLatest)); + ' + )" + + { + printf 'previous-tag=%s\n' "$previous_tag" + printf 'make-latest=%s\n' "$make_latest" + } >> "$GITHUB_OUTPUT" + - name: Publish GitHub Release and generated notes shell: bash env: @@ -163,6 +256,8 @@ jobs: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_SHA: ${{ steps.release.outputs.sha }} RELEASE_ARCHIVE: ${{ steps.artifact.outputs.archive }} + PREVIOUS_TAG: ${{ steps.history.outputs.previous-tag }} + MAKE_LATEST: ${{ steps.history.outputs.make-latest }} run: | set -euo pipefail @@ -177,20 +272,6 @@ jobs: exit 0 fi - previous_tag="" - if git rev-parse "$RELEASE_SHA^" >/dev/null 2>&1; then - while IFS= read -r candidate; do - if [[ "$candidate" =~ ^npm-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - previous_tag="$candidate" - break - fi - done < <( - git tag --merged "$RELEASE_SHA^" \ - --list 'npm-v*' \ - --sort=-version:refname - ) - fi - release_args=( "$RELEASE_TAG" "$RELEASE_ARCHIVE" @@ -198,9 +279,10 @@ jobs: --title "Codex Security $RELEASE_VERSION" --verify-tag --generate-notes + --latest="$MAKE_LATEST" ) - if [[ -n "$previous_tag" ]]; then - release_args+=(--notes-start-tag "$previous_tag") + if [[ -n "$PREVIOUS_TAG" ]]; then + release_args+=(--notes-start-tag "$PREVIOUS_TAG") fi gh release create "${release_args[@]}" diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index 8e9ae25e..94b1eeea 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -38,6 +38,16 @@ jobs: run: | set -euo pipefail + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Release tags can only be cut from main." >&2 + exit 1 + fi + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Release tags can only be cut from commits on main." >&2 + exit 1 + fi + version="$(node sdk/typescript/scripts/release-automation.mjs version sdk/typescript/package.json)" if [[ "$GITHUB_EVENT_NAME" == "push" && "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ ]]; then @@ -58,6 +68,8 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi + node sdk/typescript/scripts/release-automation.mjs \ + require-increase "$version" "$previous_version" >/dev/null fi fi diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index e1cf85d0..f197cd02 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: node-release-labels-${{ github.event.pull_request.number }} + cancel-in-progress: false + jobs: categorize: if: github.repository == 'openai/codex-security' @@ -24,8 +28,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_PREVIOUS_TITLE: ${{ github.event.changes.title.from }} run: | set -euo pipefail @@ -43,26 +45,33 @@ jobs: release:* | release\(*\):* | test:* | test\(*\):*) printf '%s\n' skip-release-notes ;; + *) + return 0 + ;; esac } - label="$(release_note_label "$PR_TITLE")" - previous_label="$(release_note_label "$PR_PREVIOUS_TITLE")" + current_title="$( + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER" \ + --jq '.title' + )" + label="$(release_note_label "$current_title")" + current_labels="$( + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" \ + --jq '.[].name' + )" - if [[ -n "$previous_label" && "$previous_label" != "$label" ]]; then - current_labels="$( - gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels" \ - --jq '.[].name' - )" - while IFS= read -r current_label; do - if [[ "$current_label" == "$previous_label" ]]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/$previous_label" \ - --silent - break - fi - done <<< "$current_labels" - fi + while IFS= read -r current_label; do + case "$current_label" in + enhancement | bug | documentation | skip-release-notes) + if [[ "$current_label" != "$label" ]]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/$current_label" \ + --silent + fi + ;; + esac + done <<< "$current_labels" if [[ -z "$label" ]]; then echo "No automatic release-note category applies." diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index b0b0e9cd..65fb0b64 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -78,16 +78,13 @@ jobs: shell: bash run: | set -euo pipefail - if [[ ! "$GITHUB_REF_NAME" =~ ^npm-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "npm release tags must identify a stable release, such as npm-v0.1.0." >&2 - exit 1 - fi - version="${GITHUB_REF_NAME#npm-v}" - package_version="$(node -p 'require("./sdk/typescript/package.json").version')" - if [[ "$version" != "$package_version" ]]; then - echo "npm release tag $version must match package version $package_version." >&2 - exit 1 - fi + version="$( + node sdk/typescript/scripts/release-automation.mjs release-tag \ + "$GITHUB_REF_TYPE" \ + "$GITHUB_REF" \ + "$GITHUB_REF_NAME" \ + sdk/typescript/package.json + )" git fetch origin main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "npm release tags must point to a commit on main." >&2 diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index fa16103a..9fa9e423 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -7,6 +7,19 @@ import { assertExpectedGitHead } from "./package-provenance.mjs"; const packageName = "@openai/codex-security"; const stableVersion = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; const provenancePredicate = "https://slsa.dev/provenance/v1"; +const publicNpmRegistry = "https://registry.npmjs.org/"; + +function stableReleaseTagVersion(tag) { + if (typeof tag !== "string" || !tag.startsWith("npm-v")) { + throw new Error("Release tags must identify a stable npm-vX.Y.Z version."); + } + + const version = tag.slice("npm-v".length); + if (!stableVersion.test(version)) { + throw new Error("Release tags must identify a stable npm-vX.Y.Z version."); + } + return version; +} export function releaseVersion(packageJson) { if (packageJson?.name !== packageName) { @@ -21,6 +34,108 @@ export function releaseVersion(packageJson) { return packageJson.version; } +export function releaseTagVersion(refType, ref, refName, packageJson) { + if (refType !== "tag" || ref !== `refs/tags/${refName}`) { + throw new Error("npm releases must be dispatched from a real Git tag."); + } + + const tagVersion = stableReleaseTagVersion(refName); + const packageVersion = releaseVersion(packageJson); + if (tagVersion !== packageVersion) { + throw new Error("npm release tag must match the stable package version."); + } + + return packageVersion; +} + +export function compareReleaseVersions(left, right) { + if (!stableVersion.test(left) || !stableVersion.test(right)) { + throw new Error("Release versions must use stable X.Y.Z versions."); + } + + const leftParts = left.split(".").map(BigInt); + const rightParts = right.split(".").map(BigInt); + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] > rightParts[index]) return 1; + if (leftParts[index] < rightParts[index]) return -1; + } + return 0; +} + +export function requireReleaseIncrease(version, previousVersion) { + if (compareReleaseVersions(version, previousVersion) <= 0) { + throw new Error( + "Release version must be greater than the previous stable version.", + ); + } + return version; +} + +export function releaseHistory(tag, history) { + const version = stableReleaseTagVersion(tag); + if ( + !Array.isArray(history?.registryVersions) || + !Array.isArray(history.githubReleaseTags) || + !Array.isArray(history.reachableTags) + ) { + throw new Error( + "Release history must contain published and reachable tags.", + ); + } + + const publishedVersions = new Set( + history.registryVersions.filter( + (candidate) => + typeof candidate === "string" && stableVersion.test(candidate), + ), + ); + const publishedGitHubTags = new Set( + history.githubReleaseTags.filter( + (candidate) => + typeof candidate === "string" && + candidate.startsWith("npm-v") && + stableVersion.test(candidate.slice("npm-v".length)), + ), + ); + + let previousTag = null; + for (const candidate of history.reachableTags) { + if ( + typeof candidate !== "string" || + !candidate.startsWith("npm-v") || + !stableVersion.test(candidate.slice("npm-v".length)) + ) { + continue; + } + + const candidateVersion = candidate.slice("npm-v".length); + if ( + compareReleaseVersions(candidateVersion, version) >= 0 || + (!publishedVersions.has(candidateVersion) && + !publishedGitHubTags.has(candidate)) + ) { + continue; + } + + if ( + previousTag === null || + compareReleaseVersions( + candidateVersion, + previousTag.slice("npm-v".length), + ) > 0 + ) { + previousTag = candidate; + } + } + + const makeLatest = Array.from(publishedGitHubTags).every( + (candidate) => + compareReleaseVersions(version, candidate.slice("npm-v".length)) > 0, + ); + + return { previousTag, makeLatest }; +} + export function verifyPublishedRelease(metadata, archive, expected) { const version = releaseVersion(metadata); if (version !== expected.version) { @@ -52,6 +167,156 @@ export function verifyPublishedRelease(metadata, archive, expected) { }; } +export function verifySignatureAudit(report, archive, expected) { + if ( + !Array.isArray(report?.invalid) || + !Array.isArray(report.missing) || + !Array.isArray(report.verified) || + report.invalid.length !== 0 || + report.missing.length !== 0 + ) { + throw new Error("npm registry signatures and attestations must verify."); + } + + const version = releaseVersion({ + name: packageName, + version: expected.version, + }); + const verified = report.verified.find( + (candidate) => + candidate?.name === packageName && candidate.version === version, + ); + if (verified === undefined) { + throw new Error( + "The published package must have a cryptographically verified attestation.", + ); + } + + let registry; + try { + registry = new URL(verified.registry).href; + } catch { + throw new Error( + "Verified provenance must come from the public npm registry.", + ); + } + if (registry !== publicNpmRegistry) { + throw new Error( + "Verified provenance must come from the public npm registry.", + ); + } + + if ( + verified.attestations?.provenance?.predicateType !== provenancePredicate + ) { + throw new Error("The verified npm package must have SLSA v1 provenance."); + } + + const provenance = verified.attestationBundles?.find( + (candidate) => candidate?.predicateType === provenancePredicate, + ); + const encodedStatement = provenance?.bundle?.dsseEnvelope?.payload; + if (typeof encodedStatement !== "string") { + throw new Error("The verified SLSA provenance bundle is missing."); + } + + let statement; + try { + statement = JSON.parse( + Buffer.from(encodedStatement, "base64").toString("utf8"), + ); + } catch { + throw new Error("The verified SLSA provenance statement is invalid."); + } + if ( + statement?._type !== "https://in-toto.io/Statement/v1" || + statement.predicateType !== provenancePredicate + ) { + throw new Error("The verified SLSA provenance statement is invalid."); + } + + const sha512 = createHash("sha512").update(archive).digest("hex"); + const expectedSubject = `pkg:npm/%40openai/codex-security@${version}`; + if ( + !Array.isArray(statement.subject) || + !statement.subject.some( + (subject) => + subject?.name === expectedSubject && subject.digest?.sha512 === sha512, + ) + ) { + throw new Error( + "Verified SLSA provenance must identify the exact published tarball.", + ); + } + + if ( + typeof expected.repository !== "string" || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(expected.repository) + ) { + throw new Error("Verified provenance requires an exact GitHub repository."); + } + const repository = `https://github.com/${expected.repository}`; + const releaseRef = `refs/tags/npm-v${version}`; + const build = statement.predicate?.buildDefinition; + const workflow = build?.externalParameters?.workflow; + if ( + workflow?.repository !== repository || + workflow.ref !== releaseRef || + workflow.path !== ".github/workflows/node-release.yml" + ) { + throw new Error( + "Verified SLSA provenance must identify the protected release workflow.", + ); + } + + const sourceUri = `git+${repository}@${releaseRef}`; + const source = build.resolvedDependencies?.find( + (dependency) => dependency?.uri === sourceUri, + ); + if (source === undefined) { + throw new Error( + "Verified SLSA provenance must identify the exact release source.", + ); + } + assertExpectedGitHead( + { gitHead: source.digest?.gitCommit }, + expected.gitHead, + ); + + const runId = String(expected.runId); + if (!/^[1-9][0-9]*$/u.test(runId)) { + throw new Error( + "Verified provenance requires a valid release workflow run.", + ); + } + const invocation = statement.predicate?.runDetails?.metadata?.invocationId; + if ( + typeof invocation !== "string" || + !invocation.startsWith(`${repository}/actions/runs/${runId}/attempts/`) + ) { + throw new Error( + "Verified SLSA provenance must identify the successful release run.", + ); + } + + if ( + statement.predicate?.runDetails?.builder?.id !== + "https://github.com/actions/runner/github-hosted" + ) { + throw new Error( + "Verified SLSA provenance must use a GitHub-hosted release runner.", + ); + } + + return { + version, + gitHead: expected.gitHead, + repository: expected.repository, + runId, + sha512, + }; +} + export function verifyGitHubRelease(release, archive, expectedTag, assetName) { if (release?.tag_name !== expectedTag) { throw new Error("Existing GitHub Release must match the release tag."); @@ -87,6 +352,50 @@ function main() { return; } + if (command === "release-tag" && process.argv.length === 7) { + const packageJson = JSON.parse(readFileSync(process.argv[6], "utf8")); + console.log( + releaseTagVersion( + process.argv[3], + process.argv[4], + process.argv[5], + packageJson, + ), + ); + return; + } + + if (command === "require-increase" && process.argv.length === 5) { + console.log(requireReleaseIncrease(process.argv[3], process.argv[4])); + return; + } + + if (command === "release-history" && process.argv.length === 4) { + const registryVersions = JSON.parse( + process.env.CODEX_SECURITY_PUBLISHED_NPM_VERSIONS ?? "[]", + ); + const githubReleaseTags = ( + process.env.CODEX_SECURITY_PUBLISHED_GITHUB_TAGS ?? "" + ) + .split("\n") + .filter(Boolean); + const reachableTags = ( + process.env.CODEX_SECURITY_REACHABLE_RELEASE_TAGS ?? "" + ) + .split("\n") + .filter(Boolean); + console.log( + JSON.stringify( + releaseHistory(process.argv[3], { + registryVersions, + githubReleaseTags, + reachableTags, + }), + ), + ); + return; + } + if (command === "verify-publication" && process.argv.length === 6) { const metadata = JSON.parse(readFileSync(0, "utf8")); const archive = readFileSync(process.argv[3]); @@ -98,6 +407,19 @@ function main() { return; } + if (command === "verify-provenance" && process.argv.length === 8) { + const report = JSON.parse(readFileSync(0, "utf8")); + const archive = readFileSync(process.argv[3]); + const verified = verifySignatureAudit(report, archive, { + version: process.argv[4], + gitHead: process.argv[5], + repository: process.argv[6], + runId: process.argv[7], + }); + console.log(JSON.stringify(verified)); + return; + } + if (command === "verify-github-release" && process.argv.length === 5) { const release = JSON.parse(readFileSync(0, "utf8")); const archivePath = process.argv[3]; @@ -114,8 +436,15 @@ function main() { throw new Error( "Usage: release-automation.mjs version , " + - "verify-publication , or " + - "verify-github-release .", + "release-tag , " + + "require-increase , " + + "release-history , " + + "verify-publication " + + "(package metadata JSON from stdin), " + + "verify-provenance " + + "(signature audit JSON from stdin), or " + + "verify-github-release " + + "(GitHub release JSON from stdin).", ); } diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 32855f5a..5ad98fe7 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1,11 +1,29 @@ +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, expect, test } from "bun:test"; type ReleaseMetadata = Record; type ReleaseAutomation = { releaseVersion: (packageJson: ReleaseMetadata) => string; + releaseTagVersion: ( + refType: string, + ref: string, + refName: string, + packageJson: ReleaseMetadata, + ) => string; + compareReleaseVersions: (left: string, right: string) => -1 | 0 | 1; + requireReleaseIncrease: (version: string, previousVersion: string) => string; + releaseHistory: ( + tag: string, + history: { + registryVersions: string[]; + githubReleaseTags: string[]; + reachableTags: string[]; + }, + ) => { previousTag: string | null; makeLatest: boolean }; verifyPublishedRelease: ( metadata: ReleaseMetadata, archive: Uint8Array, @@ -16,6 +34,22 @@ type ReleaseAutomation = { integrity: string; sha256: string; }; + verifySignatureAudit: ( + report: ReleaseMetadata, + archive: Uint8Array, + expected: { + version: string; + gitHead: string; + repository: string; + runId: string; + }, + ) => { + version: string; + gitHead: string; + repository: string; + runId: string; + sha512: string; + }; verifyGitHubRelease: ( release: ReleaseMetadata, archive: Uint8Array, @@ -24,16 +58,37 @@ type ReleaseAutomation = { ) => { tag: string; asset: string; digest: string }; }; -const { releaseVersion, verifyPublishedRelease, verifyGitHubRelease } = - (await import( - new URL("../scripts/release-automation.mjs", import.meta.url).href - )) as ReleaseAutomation; +const automationScript = new URL( + "../scripts/release-automation.mjs", + import.meta.url, +); +const { + releaseVersion, + releaseTagVersion, + compareReleaseVersions, + requireReleaseIncrease, + releaseHistory, + verifyPublishedRelease, + verifySignatureAudit, + verifyGitHubRelease, +} = (await import(automationScript.href)) as ReleaseAutomation; const releaseCommit = "1e03c89ad22d2df5ae65b146be1483b3608572a9"; +const releaseRun = "30481596229"; +const releaseRepository = "openai/codex-security"; const archive = Buffer.from("verified codex security release artifact"); const integrity = "sha512-" + createHash("sha512").update(archive).digest("base64"); +const sha512 = createHash("sha512").update(archive).digest("hex"); const digest = "sha256:" + createHash("sha256").update(archive).digest("hex"); +const protectedReleaseWorkflow = readFileSync( + new URL("../../../.github/workflows/node-release.yml", import.meta.url), + "utf8", +); +const releaseCutWorkflow = readFileSync( + new URL("../../../.github/workflows/node-release-cut.yml", import.meta.url), + "utf8", +); const githubReleaseWorkflow = readFileSync( new URL( "../../../.github/workflows/node-github-release.yml", @@ -72,6 +127,118 @@ function githubRelease(): ReleaseMetadata { }; } +type SignatureAuditFixture = { + name?: string; + version?: string; + registry?: string; + invalid?: unknown[]; + missing?: unknown[]; + includeVerified?: boolean; + includeProvenance?: boolean; + includeBundle?: boolean; + subjectName?: string; + subjectDigest?: string; + repository?: string; + workflowPath?: string; + workflowRef?: string; + sourceCommit?: string; + runId?: string; + builder?: string; +}; + +function signatureAudit(options: SignatureAuditFixture = {}): ReleaseMetadata { + const version = options.version ?? "0.1.2"; + const repository = options.repository ?? releaseRepository; + const releaseRef = options.workflowRef ?? `refs/tags/npm-v${version}`; + const statement = { + _type: "https://in-toto.io/Statement/v1", + subject: [ + { + name: + options.subjectName ?? `pkg:npm/%40openai/codex-security@${version}`, + digest: { sha512: options.subjectDigest ?? sha512 }, + }, + ], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { + buildDefinition: { + externalParameters: { + workflow: { + repository: `https://github.com/${repository}`, + ref: releaseRef, + path: options.workflowPath ?? ".github/workflows/node-release.yml", + }, + }, + resolvedDependencies: [ + { + uri: `git+https://github.com/${repository}@${releaseRef}`, + digest: { gitCommit: options.sourceCommit ?? releaseCommit }, + }, + ], + }, + runDetails: { + builder: { + id: + options.builder ?? + "https://github.com/actions/runner/github-hosted", + }, + metadata: { + invocationId: + `https://github.com/${repository}/actions/runs/` + + `${options.runId ?? releaseRun}/attempts/1`, + }, + }, + }, + }; + + return { + invalid: options.invalid ?? [], + missing: options.missing ?? [], + verified: + options.includeVerified === false + ? [] + : [ + { + name: options.name ?? "@openai/codex-security", + version, + registry: options.registry ?? "https://registry.npmjs.org/", + attestations: + options.includeProvenance === false + ? {} + : { + provenance: { + predicateType: "https://slsa.dev/provenance/v1", + }, + }, + attestationBundles: + options.includeBundle === false + ? [] + : [ + { + predicateType: "https://slsa.dev/provenance/v1", + bundle: { + dsseEnvelope: { + payload: Buffer.from( + JSON.stringify(statement), + ).toString("base64"), + }, + }, + }, + ], + }, + ], + }; +} + +function signatureExpected() { + return { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + }; +} + describe("stable npm release versions", () => { test("accepts the official stable package and version", () => { expect( @@ -98,6 +265,164 @@ describe("stable npm release versions", () => { ); }); +describe("protected Git release refs", () => { + const packageJson = { + name: "@openai/codex-security", + version: "0.1.2", + }; + + test("accepts the exact stable Git tag and package", () => { + expect( + releaseTagVersion( + "tag", + "refs/tags/npm-v0.1.2", + "npm-v0.1.2", + packageJson, + ), + ).toBe("0.1.2"); + }); + + test("rejects a release-shaped branch", () => { + expect(() => + releaseTagVersion( + "branch", + "refs/heads/npm-v0.1.2", + "npm-v0.1.2", + packageJson, + ), + ).toThrow("npm releases must be dispatched from a real Git tag."); + }); + + test("rejects a mismatched full Git ref", () => { + expect(() => + releaseTagVersion( + "tag", + "refs/tags/npm-v0.1.3", + "npm-v0.1.2", + packageJson, + ), + ).toThrow("npm releases must be dispatched from a real Git tag."); + }); + + test("rejects an unstable release tag", () => { + expect(() => + releaseTagVersion( + "tag", + "refs/tags/npm-v0.1.2-beta.1", + "npm-v0.1.2-beta.1", + packageJson, + ), + ).toThrow("Release tags must identify a stable npm-vX.Y.Z version."); + }); + + test("rejects a Git tag for a different package version", () => { + expect(() => + releaseTagVersion( + "tag", + "refs/tags/npm-v0.1.3", + "npm-v0.1.3", + packageJson, + ), + ).toThrow("npm release tag must match the stable package version."); + }); +}); + +describe("monotonic stable release versions", () => { + test("compares semantic version components numerically", () => { + expect(compareReleaseVersions("0.1.10", "0.1.9")).toBe(1); + expect(compareReleaseVersions("1.0.0", "0.99.99")).toBe(1); + expect(compareReleaseVersions("0.1.2", "0.1.2")).toBe(0); + expect(compareReleaseVersions("0.1.2", "0.1.3")).toBe(-1); + }); + + test("compares components without unsafe JavaScript number rounding", () => { + expect( + compareReleaseVersions("9007199254740993.0.0", "9007199254740992.0.0"), + ).toBe(1); + }); + + test("accepts a strictly increasing release", () => { + expect(requireReleaseIncrease("0.1.3", "0.1.2")).toBe("0.1.3"); + }); + + test("rejects a downgrade or repeated release", () => { + expect(() => requireReleaseIncrease("0.1.1", "0.1.2")).toThrow( + "Release version must be greater than the previous stable version.", + ); + expect(() => requireReleaseIncrease("0.1.2", "0.1.2")).toThrow( + "Release version must be greater than the previous stable version.", + ); + }); + + test("rejects malformed and prerelease versions", () => { + expect(() => compareReleaseVersions("0.1.3-beta.1", "0.1.2")).toThrow( + "Release versions must use stable X.Y.Z versions.", + ); + }); +}); + +describe("published GitHub and npm release history", () => { + test("marks the first verified GitHub release as latest", () => { + expect( + releaseHistory("npm-v0.1.2", { + registryVersions: ["0.1.0", "0.1.1", "0.1.2"], + githubReleaseTags: [], + reachableTags: ["npm-v0.1.1", "npm-v0.1.0"], + }), + ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: true }); + }); + + test("never marks a historical backfill as latest", () => { + expect( + releaseHistory("npm-v0.1.2", { + registryVersions: ["0.1.1", "0.1.2", "0.1.3"], + githubReleaseTags: ["npm-v0.1.3"], + reachableTags: ["npm-v0.1.1"], + }), + ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: false }); + }); + + test("starts notes from the newest actually published version", () => { + expect( + releaseHistory("npm-v0.1.4", { + registryVersions: ["0.1.1", "0.1.2", "0.1.4"], + githubReleaseTags: ["npm-v0.1.2"], + reachableTags: ["npm-v0.1.3", "npm-v0.1.2", "npm-v0.1.1"], + }), + ).toEqual({ previousTag: "npm-v0.1.2", makeLatest: true }); + }); + + test("compares published versions numerically rather than lexically", () => { + expect( + releaseHistory("npm-v0.1.11", { + registryVersions: ["0.1.9", "0.1.10", "0.1.11"], + githubReleaseTags: ["npm-v0.1.9", "npm-v0.1.10"], + reachableTags: ["npm-v0.1.9", "npm-v0.1.10"], + }), + ).toEqual({ previousTag: "npm-v0.1.10", makeLatest: true }); + }); + + test("ignores unrelated, unstable, and unreachable release tags", () => { + expect( + releaseHistory("npm-v0.1.2", { + registryVersions: ["0.1.1", "0.1.2", "0.1.3-beta.1"], + githubReleaseTags: ["container-v99.0.0", "npm-v0.1.3-beta.1"], + reachableTags: ["container-v99.0.0", "npm-v0.1.3-beta.1", "npm-v0.1.1"], + }), + ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: true }); + }); + + test("rejects invalid release history inputs", () => { + expect(() => + releaseHistory("0.1.2", { + registryVersions: [], + githubReleaseTags: [], + reachableTags: [], + }), + ).toThrow("Release tags must identify a stable npm-vX.Y.Z version."); + }); +}); + describe("published npm release verification", () => { test("verifies source commit, tarball integrity, and SLSA provenance", () => { expect( @@ -177,6 +502,168 @@ describe("published npm release verification", () => { }); }); +describe("cryptographically verified npm provenance", () => { + test("binds the verified bundle to the exact archive, source, and run", () => { + expect( + verifySignatureAudit(signatureAudit(), archive, signatureExpected()), + ).toEqual({ + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + sha512, + }); + }); + + test("rejects invalid or missing registry signatures", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ invalid: [{ name: "@openai/codex-security" }] }), + archive, + signatureExpected(), + ), + ).toThrow("npm registry signatures and attestations must verify."); + expect(() => + verifySignatureAudit( + signatureAudit({ missing: [{ name: "@openai/codex-security" }] }), + archive, + signatureExpected(), + ), + ).toThrow("npm registry signatures and attestations must verify."); + }); + + test("requires the exact published package in the verified audit", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ includeVerified: false }), + archive, + signatureExpected(), + ), + ).toThrow( + "The published package must have a cryptographically verified attestation.", + ); + expect(() => + verifySignatureAudit( + signatureAudit({ name: "@openai/codex" }), + archive, + signatureExpected(), + ), + ).toThrow( + "The published package must have a cryptographically verified attestation.", + ); + }); + + test("requires the signed public npm registry and SLSA bundle", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ registry: "https://registry.example/" }), + archive, + signatureExpected(), + ), + ).toThrow("Verified provenance must come from the public npm registry."); + expect(() => + verifySignatureAudit( + signatureAudit({ includeProvenance: false }), + archive, + signatureExpected(), + ), + ).toThrow("The verified npm package must have SLSA v1 provenance."); + expect(() => + verifySignatureAudit( + signatureAudit({ includeBundle: false }), + archive, + signatureExpected(), + ), + ).toThrow("The verified SLSA provenance bundle is missing."); + }); + + test("rejects provenance for a different package subject or archive", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ subjectName: "pkg:npm/another-package@0.1.2" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the exact published tarball.", + ); + expect(() => + verifySignatureAudit( + signatureAudit({ subjectDigest: "incorrect" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the exact published tarball.", + ); + }); + + test("rejects another repository, workflow, or release tag", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ repository: "attacker/codex-security" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the protected release workflow.", + ); + expect(() => + verifySignatureAudit( + signatureAudit({ + workflowPath: ".github/workflows/untrusted-release.yml", + }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the protected release workflow.", + ); + expect(() => + verifySignatureAudit( + signatureAudit({ workflowRef: "refs/heads/npm-v0.1.2" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the protected release workflow.", + ); + }); + + test("rejects a source commit outside the protected release", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + sourceCommit: "0000000000000000000000000000000000000000", + }), + archive, + signatureExpected(), + ), + ).toThrow("npm package gitHead must match release commit"); + }); + + test("rejects provenance from another release run or builder", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ runId: "12345" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the successful release run.", + ); + expect(() => + verifySignatureAudit( + signatureAudit({ builder: "https://example.com/untrusted-runner" }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must use a GitHub-hosted release runner.", + ); + }); +}); + describe("idempotent GitHub release verification", () => { test("accepts the already-published exact verified release asset", () => { expect( @@ -238,21 +725,80 @@ describe("idempotent GitHub release verification", () => { }); describe("GitHub release workflow safeguards", () => { - test("does not force a historical backfill to become the latest release", () => { + test("requires a real tag for protected npm publication", () => { + expect(protectedReleaseWorkflow).toContain("release-tag"); + expect(protectedReleaseWorkflow).toContain('"$GITHUB_REF_TYPE"'); + expect(protectedReleaseWorkflow).toContain('"$GITHUB_REF"'); + }); + + test("restricts release cuts to main and increasing stable versions", () => { + expect(releaseCutWorkflow).toContain( + 'if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then', + ); + expect(releaseCutWorkflow).toContain( + 'git merge-base --is-ancestor "$GITHUB_SHA" origin/main', + ); + expect(releaseCutWorkflow).toContain("require-increase"); + }); + + test("explicitly prevents historical releases becoming latest", () => { expect(githubReleaseWorkflow).toContain("--generate-notes"); - expect(githubReleaseWorkflow).not.toMatch(/^\s*--latest(?:=true)?\s*$/mu); + expect(githubReleaseWorkflow).toContain('--latest="$MAKE_LATEST"'); + expect(githubReleaseWorkflow).toContain("release-history"); + }); + + test("generates notes only from a previously published release", () => { + expect(githubReleaseWorkflow).toContain( + "npm view @openai/codex-security versions", + ); + expect(githubReleaseWorkflow).toContain( + "steps.history.outputs.previous-tag", + ); + expect(githubReleaseWorkflow).toContain( + 'release_args+=(--notes-start-tag "$PREVIOUS_TAG")', + ); }); - test("reconciles the previous category after a pull request title changes", () => { + test("recovers a public npm tarball after release artifacts expire", () => { + expect(githubReleaseWorkflow).toContain("if ! gh run download"); + expect(githubReleaseWorkflow).toContain( + 'npm pack "@openai/codex-security@$RELEASE_VERSION"', + ); + expect(githubReleaseWorkflow).toContain("--ignore-scripts"); + }); + + test("cryptographically verifies the exact npm provenance bundle", () => { + expect(githubReleaseWorkflow).toContain("npm audit signatures"); + expect(githubReleaseWorkflow).toContain("--include-attestations"); + expect(githubReleaseWorkflow).toContain("verify-provenance"); + }); + + test("serializes label reconciliation and reads the current PR title", () => { + expect(releaseLabelsWorkflow).toContain( + "group: node-release-labels-${{ github.event.pull_request.number }}", + ); expect(releaseLabelsWorkflow).toContain( - "PR_PREVIOUS_TITLE: ${{ github.event.changes.title.from }}", + 'gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER"', ); expect(releaseLabelsWorkflow).toContain( - 'previous_label="$(release_note_label "$PR_PREVIOUS_TITLE")"', + 'label="$(release_note_label "$current_title")"', ); expect(releaseLabelsWorkflow).toContain( - 'if [[ -n "$previous_label" && "$previous_label" != "$label" ]]; then', + "enhancement | bug | documentation | skip-release-notes)", ); expect(releaseLabelsWorkflow).toContain("gh api --method DELETE"); + expect(releaseLabelsWorkflow).toContain("return 0"); + }); + + test("documents JSON stdin for every verification command", () => { + const result = spawnSync( + "node", + [fileURLToPath(automationScript), "unknown"], + { encoding: "utf8", timeout: 10_000 }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("package metadata JSON from stdin"); + expect(result.stderr).toContain("signature audit JSON from stdin"); + expect(result.stderr).toContain("GitHub release JSON from stdin"); }); }); From bf19d80ab7256ac43074eedeed3737832aef999c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 14:37:28 -0700 Subject: [PATCH 04/20] fix: harden release queues and signing verification --- .github/workflows/node-github-release.yml | 15 +- .github/workflows/node-release-cut.yml | 11 +- .github/workflows/node-release-labels.yml | 8 +- .github/workflows/node-release.yml | 2 +- sdk/typescript/scripts/release-automation.mjs | 270 +++++++++++- .../tests-ts/release-automation.test.ts | 416 ++++++++++++++++++ 6 files changed, 710 insertions(+), 12 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 0229fb4e..1d9d66cb 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -19,8 +19,8 @@ permissions: contents: read concurrency: - group: node-github-release-${{ inputs.tag || github.event.workflow_run.head_branch || github.run_id }} - cancel-in-progress: false + group: node-github-release + queue: max jobs: release: @@ -44,10 +44,19 @@ jobs: - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: "24" + node-version: "24.15.0" registry-url: "https://registry.npmjs.org" package-manager-cache: false + - name: Verify bundled provenance-capable npm + shell: bash + run: | + set -euo pipefail + if [[ "$(npm --version)" != "11.12.1" ]]; then + echo "npm 11.12.1 is required to verify Sigstore attestations." >&2 + exit 1 + fi + - name: Resolve the successful protected release id: release shell: bash diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index 94b1eeea..ce350cc3 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -12,7 +12,7 @@ permissions: concurrency: group: node-release-cut - cancel-in-progress: false + queue: max jobs: cut: @@ -73,6 +73,15 @@ jobs: fi fi + published_versions="$( + npm view @openai/codex-security versions \ + --json \ + --registry=https://registry.npmjs.org/ + )" + printf '%s\n' "$published_versions" | + node sdk/typescript/scripts/release-automation.mjs \ + require-published-increase "$version" >/dev/null + { printf 'changed=true\n' printf 'version=%s\n' "$version" diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index f197cd02..e3298ec7 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -82,11 +82,15 @@ jobs: if ! gh api \ "repos/$GITHUB_REPOSITORY/labels/skip-release-notes" \ --silent >/dev/null 2>&1; then - gh api --method POST "repos/$GITHUB_REPOSITORY/labels" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/labels" \ -f name=skip-release-notes \ -f color=ededed \ -f description="Omit internal changes from generated release notes" \ - --silent + --silent >/dev/null 2>&1; then + gh api \ + "repos/$GITHUB_REPOSITORY/labels/skip-release-notes" \ + --silent >/dev/null + fi fi fi diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 65fb0b64..d886a2c5 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -8,7 +8,7 @@ on: concurrency: group: ${{ github.workflow }} - cancel-in-progress: false + queue: max jobs: verify: diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 9fa9e423..9a441024 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { pathToFileURL } from "node:url"; @@ -8,6 +8,8 @@ const packageName = "@openai/codex-security"; const stableVersion = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; const provenancePredicate = "https://slsa.dev/provenance/v1"; const publicNpmRegistry = "https://registry.npmjs.org/"; +const githubActionsOidcIssuer = "https://token.actions.githubusercontent.com"; +const fulcioExtensionPrefix = Buffer.from("2b0601040183bf3001", "hex"); function stableReleaseTagVersion(tag) { if (typeof tag !== "string" || !tag.startsWith("npm-v")) { @@ -71,6 +73,245 @@ export function requireReleaseIncrease(version, previousVersion) { return version; } +export function requirePublishedReleaseIncrease(version, publishedVersions) { + releaseVersion({ name: packageName, version }); + if (!Array.isArray(publishedVersions)) { + throw new Error("Published npm release versions must be an array."); + } + + for (const publishedVersion of publishedVersions) { + if ( + typeof publishedVersion === "string" && + stableVersion.test(publishedVersion) && + compareReleaseVersions(version, publishedVersion) <= 0 + ) { + throw new Error( + "Release version must be greater than every published stable version.", + ); + } + } + + return version; +} + +function invalidSigningCertificate() { + return new Error("The verified Fulcio signing certificate is invalid."); +} + +function derElement(bytes, offset, limit = bytes.length) { + if (!Number.isSafeInteger(offset) || offset < 0 || offset + 2 > limit) { + throw invalidSigningCertificate(); + } + + let cursor = offset; + const tag = bytes[cursor++]; + if ((tag & 0x1f) === 0x1f) { + throw invalidSigningCertificate(); + } + + let length = bytes[cursor++]; + if ((length & 0x80) !== 0) { + const count = length & 0x7f; + if ( + count === 0 || + count > 4 || + cursor + count > limit || + bytes[cursor] === 0 + ) { + throw invalidSigningCertificate(); + } + + length = 0; + for (let index = 0; index < count; index += 1) { + length = length * 256 + bytes[cursor++]; + } + if (length < 128) { + throw invalidSigningCertificate(); + } + } + + if (length > limit - cursor) { + throw invalidSigningCertificate(); + } + + return { tag, start: cursor, end: cursor + length }; +} + +function derChildren(bytes, element) { + const children = []; + let cursor = element.start; + while (cursor < element.end) { + const child = derElement(bytes, cursor, element.end); + children.push(child); + cursor = child.end; + } + if (cursor !== element.end) { + throw invalidSigningCertificate(); + } + return children; +} + +function fulcioCertificateExtensions(bytes) { + const root = derElement(bytes, 0); + if (root.tag !== 0x30 || root.end !== bytes.length) { + throw invalidSigningCertificate(); + } + + const [tbsCertificate] = derChildren(bytes, root); + if (tbsCertificate?.tag !== 0x30) { + throw invalidSigningCertificate(); + } + + const wrappers = derChildren(bytes, tbsCertificate).filter( + (element) => element.tag === 0xa3, + ); + if (wrappers.length !== 1) { + throw invalidSigningCertificate(); + } + + const [extensionSequence, unexpected] = derChildren(bytes, wrappers[0]); + if (extensionSequence?.tag !== 0x30 || unexpected !== undefined) { + throw invalidSigningCertificate(); + } + + const extensions = new Map(); + for (const extension of derChildren(bytes, extensionSequence)) { + if (extension.tag !== 0x30) { + throw invalidSigningCertificate(); + } + + const fields = derChildren(bytes, extension); + if ( + (fields.length !== 2 && fields.length !== 3) || + fields[0].tag !== 0x06 || + (fields.length === 3 && fields[1].tag !== 0x01) || + fields.at(-1).tag !== 0x04 + ) { + throw invalidSigningCertificate(); + } + + const oid = bytes.subarray(fields[0].start, fields[0].end); + if ( + oid.length !== fulcioExtensionPrefix.length + 1 || + !oid + .subarray(0, fulcioExtensionPrefix.length) + .equals(fulcioExtensionPrefix) + ) { + continue; + } + + const identifier = oid.at(-1); + if (extensions.has(identifier)) { + throw invalidSigningCertificate(); + } + + const field = fields.at(-1); + const value = bytes.subarray(field.start, field.end); + if (identifier >= 8) { + const text = derElement(value, 0); + if (text.tag !== 0x0c || text.end !== value.length) { + throw invalidSigningCertificate(); + } + extensions.set( + identifier, + value.subarray(text.start, text.end).toString("utf8"), + ); + } else { + extensions.set(identifier, value.toString("utf8")); + } + } + + return extensions; +} + +function verifySigningCertificate(bundle, expected) { + const material = bundle?.verificationMaterial; + const encoded = + material?.certificate?.rawBytes ?? + material?.x509CertificateChain?.certificates?.[0]?.rawBytes; + + let certificate; + let extensions; + try { + if (typeof encoded !== "string" || encoded.length === 0) { + throw invalidSigningCertificate(); + } + const raw = Buffer.from(encoded, "base64"); + if (raw.toString("base64") !== encoded) { + throw invalidSigningCertificate(); + } + certificate = new X509Certificate(raw); + extensions = fulcioCertificateExtensions(certificate.raw); + } catch { + throw invalidSigningCertificate(); + } + + const issuerV1 = extensions.get(1); + const issuerV2 = extensions.get(8); + if ( + (issuerV1 === undefined && issuerV2 === undefined) || + (issuerV1 !== undefined && issuerV1 !== githubActionsOidcIssuer) || + (issuerV2 !== undefined && issuerV2 !== githubActionsOidcIssuer) + ) { + throw new Error( + "The Fulcio certificate must use the GitHub Actions OIDC issuer.", + ); + } + + const workflowIdentity = + `${expected.repository}/.github/workflows/node-release.yml@` + + expected.releaseRef; + if ( + certificate.subjectAltName !== `URI:${workflowIdentity}` || + extensions.get(9) !== workflowIdentity || + extensions.get(18) !== workflowIdentity || + extensions.get(12) !== expected.repository || + extensions.get(14) !== expected.releaseRef + ) { + throw new Error( + "The Fulcio certificate must identify the protected release workflow.", + ); + } + + if ( + extensions.get(10) !== expected.gitHead || + extensions.get(13) !== expected.gitHead || + extensions.get(19) !== expected.gitHead + ) { + throw new Error( + "The Fulcio certificate must identify the exact release commit.", + ); + } + + if (extensions.get(11) !== "github-hosted") { + throw new Error( + "The Fulcio certificate must identify a GitHub-hosted release runner.", + ); + } + + const runPrefix = `${expected.repository}/actions/runs/${expected.runId}/attempts/`; + const certificateRun = extensions.get(21); + if ( + typeof certificateRun !== "string" || + !certificateRun.startsWith(runPrefix) || + !/^[1-9][0-9]*$/u.test(certificateRun.slice(runPrefix.length)) + ) { + throw new Error( + "The Fulcio certificate must identify the exact release run.", + ); + } + + if ( + extensions.get(23) !== "npm" || + extensions.get(24) !== + `repo:${expected.repository.slice("https://github.com/".length)}:environment:npm` + ) { + throw new Error( + "The Fulcio certificate must identify the protected npm environment.", + ); + } +} + export function releaseHistory(tag, history) { const version = stableReleaseTagVersion(tag); if ( @@ -212,9 +453,11 @@ export function verifySignatureAudit(report, archive, expected) { throw new Error("The verified npm package must have SLSA v1 provenance."); } - const provenance = verified.attestationBundles?.find( - (candidate) => candidate?.predicateType === provenancePredicate, - ); + const provenance = Array.isArray(verified.attestationBundles) + ? verified.attestationBundles.find( + (candidate) => candidate?.predicateType === provenancePredicate, + ) + : undefined; const encodedStatement = provenance?.bundle?.dsseEnvelope?.payload; if (typeof encodedStatement !== "string") { throw new Error("The verified SLSA provenance bundle is missing."); @@ -308,6 +551,13 @@ export function verifySignatureAudit(report, archive, expected) { ); } + verifySigningCertificate(provenance.bundle, { + repository, + releaseRef, + gitHead: expected.gitHead, + runId, + }); + return { version, gitHead: expected.gitHead, @@ -328,7 +578,7 @@ export function verifyGitHubRelease(release, archive, expectedTag, assetName) { const expectedDigest = "sha256:" + createHash("sha256").update(archive).digest("hex"); const asset = Array.isArray(release.assets) - ? release.assets.find((candidate) => candidate.name === assetName) + ? release.assets.find((candidate) => candidate?.name === assetName) : undefined; if (asset?.digest !== expectedDigest) { throw new Error( @@ -370,6 +620,14 @@ function main() { return; } + if (command === "require-published-increase" && process.argv.length === 4) { + const publishedVersions = JSON.parse(readFileSync(0, "utf8")); + console.log( + requirePublishedReleaseIncrease(process.argv[3], publishedVersions), + ); + return; + } + if (command === "release-history" && process.argv.length === 4) { const registryVersions = JSON.parse( process.env.CODEX_SECURITY_PUBLISHED_NPM_VERSIONS ?? "[]", @@ -438,6 +696,8 @@ function main() { "Usage: release-automation.mjs version , " + "release-tag , " + "require-increase , " + + "require-published-increase " + + "(published npm versions JSON from stdin), " + "release-history , " + "verify-publication " + "(package metadata JSON from stdin), " + diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 5ad98fe7..afc8f671 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -16,6 +16,10 @@ type ReleaseAutomation = { ) => string; compareReleaseVersions: (left: string, right: string) => -1 | 0 | 1; requireReleaseIncrease: (version: string, previousVersion: string) => string; + requirePublishedReleaseIncrease: ( + version: string, + publishedVersions: unknown, + ) => string; releaseHistory: ( tag: string, history: { @@ -67,6 +71,7 @@ const { releaseTagVersion, compareReleaseVersions, requireReleaseIncrease, + requirePublishedReleaseIncrease, releaseHistory, verifyPublishedRelease, verifySignatureAudit, @@ -76,6 +81,36 @@ const { const releaseCommit = "1e03c89ad22d2df5ae65b146be1483b3608572a9"; const releaseRun = "30481596229"; const releaseRepository = "openai/codex-security"; +const releaseSigningCertificate = + "MIIHOjCCBr+gAwIBAgIUDDD6xE6tccKRAzn6GcB6Ajvw2+swCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3Rv" + + "cmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzI5MTg1MTA1WhcNMjYwNzI5MTkw" + + "MTA1WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkin3vstve90HTjDjvA07JK3um96wz1+wu9IbeAOgqPZW" + + "7Ijzx++FAEBWmRp9dJiLOa3xlHM1ZEqjZc3FlbBQrqOCBd4wggXaMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK" + + "BggrBgEFBQcDAzAdBgNVHQ4EFgQUn/ffAFOuPTzNV/Vezq93CcIME+wwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjp" + + "KFWixi4YZD8wbgYDVR0RAQH/BGQwYoZgaHR0cHM6Ly9naXRodWIuY29tL29wZW5haS9jb2RleC1zZWN1cml0eS8u" + + "Z2l0aHViL3dvcmtmbG93cy9ub2RlLXJlbGVhc2UueW1sQHJlZnMvdGFncy9ucG0tdjAuMS4yMDkGCisGAQQBg78w" + + "AQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wEgYKKwYBBAGDvzABAgQEcHVz" + + "aDA2BgorBgEEAYO/MAEDBCgxZTAzYzg5YWQyMmQyZGY1YWU2NWIxNDZiZTE0ODNiMzYwODU3MmE5MBoGCisGAQQB" + + "g78wAQQEDG5vZGUtcmVsZWFzZTAjBgorBgEEAYO/MAEFBBVvcGVuYWkvY29kZXgtc2VjdXJpdHkwIgYKKwYBBAGD" + + "vzABBgQUcmVmcy90YWdzL25wbS12MC4xLjIwOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMu" + + "Z2l0aHVidXNlcmNvbnRlbnQuY29tMHAGCisGAQQBg78wAQkEYgxgaHR0cHM6Ly9naXRodWIuY29tL29wZW5haS9j" + + "b2RleC1zZWN1cml0eS8uZ2l0aHViL3dvcmtmbG93cy9ub2RlLXJlbGVhc2UueW1sQHJlZnMvdGFncy9ucG0tdjAu" + + "MS4yMDgGCisGAQQBg78wAQoEKgwoMWUwM2M4OWFkMjJkMmRmNWFlNjViMTQ2YmUxNDgzYjM2MDg1NzJhOTAdBgor" + + "BgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwOAYKKwYBBAGDvzABDAQqDChodHRwczovL2dpdGh1Yi5jb20vb3Bl" + + "bmFpL2NvZGV4LXNlY3VyaXR5MDgGCisGAQQBg78wAQ0EKgwoMWUwM2M4OWFkMjJkMmRmNWFlNjViMTQ2YmUxNDgz" + + "YjM2MDg1NzJhOTAkBgorBgEEAYO/MAEOBBYMFHJlZnMvdGFncy9ucG0tdjAuMS4yMBoGCisGAQQBg78wAQ8EDAwK" + + "MTI5OTc2OTIyMDApBgorBgEEAYO/MAEQBBsMGWh0dHBzOi8vZ2l0aHViLmNvbS9vcGVuYWkwGAYKKwYBBAGDvzAB" + + "EQQKDAgxNDk1NzA4MjBwBgorBgEEAYO/MAESBGIMYGh0dHBzOi8vZ2l0aHViLmNvbS9vcGVuYWkvY29kZXgtc2Vj" + + "dXJpdHkvLmdpdGh1Yi93b3JrZmxvd3Mvbm9kZS1yZWxlYXNlLnltbEByZWZzL3RhZ3MvbnBtLXYwLjEuMjA4Bgor" + + "BgEEAYO/MAETBCoMKDFlMDNjODlhZDIyZDJkZjVhZTY1YjE0NmJlMTQ4M2IzNjA4NTcyYTkwFAYKKwYBBAGDvzAB" + + "FAQGDARwdXNoMFwGCisGAQQBg78wARUETgxMaHR0cHM6Ly9naXRodWIuY29tL29wZW5haS9jb2RleC1zZWN1cml0" + + "eS9hY3Rpb25zL3J1bnMvMzA0ODE1OTYyMjkvYXR0ZW1wdHMvMTAWBgorBgEEAYO/MAEWBAgMBnB1YmxpYzATBgor" + + "BgEEAYO/MAEXBAUMA25wbTA6BgorBgEEAYO/MAEYBCwMKnJlcG86b3BlbmFpL2NvZGV4LXNlY3VyaXR5OmVudmly" + + "b25tZW50Om5wbTCBigYKKwYBBAHWeQIEAgR8BHoAeAB2AN09MGrGxxEyYxkeHJlnNwKiSl643jyt/4eKcoAvKe6O" + + "AAABn683UUoAAAQDAEcwRQIhAIByLL09iV3Tt78+V79VHOAcTGiwBe8ZQJO2YfWKr5CcAiAYJQpfxgVSyerx1dTC" + + "SivSEzQt/ABuKvEHEFavAb+qCzAKBggqhkjOPQQDAwNpADBmAjEA09chNJjy7FhYVY6n7ioITfLzDBs9oaHuGFrD" + + "HXYnMKbVXVlkt2fVM8WjllMQitjhAjEA2m9qBcOod9M8uMCw76eVJk3YloyAhcTDZfMTtMWaM5DpNG4v/vEZ+MIt" + + "zIiMHK/0"; const archive = Buffer.from("verified codex security release artifact"); const integrity = "sha512-" + createHash("sha512").update(archive).digest("base64"); @@ -136,6 +171,8 @@ type SignatureAuditFixture = { includeVerified?: boolean; includeProvenance?: boolean; includeBundle?: boolean; + signingCertificate?: string | null; + certificateLocation?: "certificate" | "chain"; subjectName?: string; subjectDigest?: string; repository?: string; @@ -150,6 +187,18 @@ function signatureAudit(options: SignatureAuditFixture = {}): ReleaseMetadata { const version = options.version ?? "0.1.2"; const repository = options.repository ?? releaseRepository; const releaseRef = options.workflowRef ?? `refs/tags/npm-v${version}`; + const signingCertificate = + options.signingCertificate === undefined + ? releaseSigningCertificate + : options.signingCertificate; + const verificationMaterial = + options.certificateLocation === "chain" + ? { + x509CertificateChain: { + certificates: [{ rawBytes: signingCertificate }], + }, + } + : { certificate: { rawBytes: signingCertificate } }; const statement = { _type: "https://in-toto.io/Statement/v1", subject: [ @@ -217,6 +266,7 @@ function signatureAudit(options: SignatureAuditFixture = {}): ReleaseMetadata { { predicateType: "https://slsa.dev/provenance/v1", bundle: { + verificationMaterial, dsseEnvelope: { payload: Buffer.from( JSON.stringify(statement), @@ -230,6 +280,34 @@ function signatureAudit(options: SignatureAuditFixture = {}): ReleaseMetadata { }; } +function signingCertificateWithReplacement( + original: string, + replacement: string, +): string { + const needle = Buffer.from(original); + const substitute = Buffer.from(replacement); + if (needle.length !== substitute.length) { + throw new Error( + "Signing certificate replacements must preserve DER lengths.", + ); + } + + const certificate = Buffer.from(releaseSigningCertificate, "base64"); + let offset = certificate.indexOf(needle); + if (offset === -1) { + throw new Error( + "Signing certificate does not contain the requested value.", + ); + } + + while (offset !== -1) { + substitute.copy(certificate, offset); + offset = certificate.indexOf(needle, offset + substitute.length); + } + + return certificate.toString("base64"); +} + function signatureExpected() { return { version: "0.1.2", @@ -239,6 +317,31 @@ function signatureExpected() { }; } +function workflowStepShell(workflow: string, stepName: string): string { + const stepMarker = ` - name: ${stepName}\n`; + const stepStart = workflow.indexOf(stepMarker); + if (stepStart === -1) { + throw new Error(`Workflow step is missing: ${stepName}`); + } + + const nextStep = workflow.indexOf("\n - name: ", stepStart + 1); + const step = workflow.slice( + stepStart, + nextStep === -1 ? undefined : nextStep, + ); + const runMarker = " run: |\n"; + const runStart = step.indexOf(runMarker); + if (runStart === -1) { + throw new Error(`Workflow step has no shell script: ${stepName}`); + } + + return step + .slice(runStart + runMarker.length) + .split("\n") + .map((line) => (line.startsWith(" ") ? line.slice(10) : line)) + .join("\n"); +} + describe("stable npm release versions", () => { test("accepts the official stable package and version", () => { expect( @@ -359,6 +462,40 @@ describe("monotonic stable release versions", () => { "Release versions must use stable X.Y.Z versions.", ); }); + + test("requires every release to exceed every published stable version", () => { + expect( + requirePublishedReleaseIncrease("0.1.11", [ + "0.1.9", + "0.1.10", + "0.1.3-beta.1", + ]), + ).toBe("0.1.11"); + }); + + test("rejects a manual release below the highest published version", () => { + expect(() => + requirePublishedReleaseIncrease("1.9.9", ["1.9.8", "2.0.0"]), + ).toThrow( + "Release version must be greater than every published stable version.", + ); + }); + + test("rejects a previously published release version", () => { + expect(() => + requirePublishedReleaseIncrease("0.1.2", ["0.1.0", "0.1.2"]), + ).toThrow( + "Release version must be greater than every published stable version.", + ); + }); + + test("fails safely when published release history is malformed", () => { + for (const publishedVersions of [null, {}, "0.1.2"]) { + expect(() => + requirePublishedReleaseIncrease("0.1.3", publishedVersions), + ).toThrow("Published npm release versions must be an array."); + } + }); }); describe("published GitHub and npm release history", () => { @@ -515,6 +652,115 @@ describe("cryptographically verified npm provenance", () => { }); }); + test("accepts the real Fulcio certificate in the legacy chain format", () => { + expect( + verifySignatureAudit( + signatureAudit({ certificateLocation: "chain" }), + archive, + signatureExpected(), + ), + ).toEqual({ + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + sha512, + }); + }); + + test("rejects missing and malformed Fulcio signing certificates", () => { + for (const signingCertificate of [null, "not-base64"]) { + expect(() => + verifySignatureAudit( + signatureAudit({ signingCertificate }), + archive, + signatureExpected(), + ), + ).toThrow("The verified Fulcio signing certificate is invalid."); + } + }); + + test("rejects a certificate from another OIDC issuer", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: signingCertificateWithReplacement( + "token.actions.githubusercontent.com", + "token.actions.githabusercontent.com", + ), + }), + archive, + signatureExpected(), + ), + ).toThrow( + "The Fulcio certificate must use the GitHub Actions OIDC issuer.", + ); + }); + + test("rejects a certificate for another GitHub Actions workflow", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: signingCertificateWithReplacement( + "node-release.yml", + "fake-release.yml", + ), + }), + archive, + signatureExpected(), + ), + ).toThrow( + "The Fulcio certificate must identify the protected release workflow.", + ); + }); + + test("rejects a certificate for a different release commit", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: signingCertificateWithReplacement( + releaseCommit, + "2e03c89ad22d2df5ae65b146be1483b3608572a9", + ), + }), + archive, + signatureExpected(), + ), + ).toThrow("The Fulcio certificate must identify the exact release commit."); + }); + + test("rejects a certificate for another release run", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: signingCertificateWithReplacement( + releaseRun, + "30481596228", + ), + }), + archive, + signatureExpected(), + ), + ).toThrow("The Fulcio certificate must identify the exact release run."); + }); + + test("rejects a certificate outside the protected npm environment", () => { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: signingCertificateWithReplacement( + "environment:npm", + "environment:dev", + ), + }), + archive, + signatureExpected(), + ), + ).toThrow( + "The Fulcio certificate must identify the protected npm environment.", + ); + }); + test("rejects invalid or missing registry signatures", () => { expect(() => verifySignatureAudit( @@ -577,6 +823,24 @@ describe("cryptographically verified npm provenance", () => { ).toThrow("The verified SLSA provenance bundle is missing."); }); + test("rejects malformed attestation bundle collections safely", () => { + const report = signatureAudit(); + const [verified] = report["verified"] as ReleaseMetadata[]; + + for (const attestationBundles of [null, {}, "invalid", 42, [null]]) { + expect(() => + verifySignatureAudit( + { + ...report, + verified: [{ ...verified, attestationBundles }], + }, + archive, + signatureExpected(), + ), + ).toThrow("The verified SLSA provenance bundle is missing."); + } + }); + test("rejects provenance for a different package subject or archive", () => { expect(() => verifySignatureAudit( @@ -722,6 +986,41 @@ describe("idempotent GitHub release verification", () => { "Existing GitHub Release asset must match the verified npm artifact.", ); }); + + test("rejects malformed GitHub release assets safely", () => { + for (const assets of [null, {}, [null]]) { + expect(() => + verifyGitHubRelease( + { ...githubRelease(), assets }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toThrow( + "Existing GitHub Release asset must match the verified npm artifact.", + ); + } + }); + + test("ignores malformed assets before the exact verified release asset", () => { + const release = githubRelease(); + + expect( + verifyGitHubRelease( + { + ...release, + assets: [null, ...(release["assets"] as ReleaseMetadata[])], + }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toEqual({ + tag: "npm-v0.1.2", + asset: "openai-codex-security-0.1.2.tgz", + digest, + }); + }); }); describe("GitHub release workflow safeguards", () => { @@ -739,6 +1038,57 @@ describe("GitHub release workflow safeguards", () => { 'git merge-base --is-ancestor "$GITHUB_SHA" origin/main', ); expect(releaseCutWorkflow).toContain("require-increase"); + expect(releaseCutWorkflow).toContain("require-published-increase"); + expect(releaseCutWorkflow).toContain( + "npm view @openai/codex-security versions", + ); + }); + + test("executes the manual release cut against all published versions", () => { + const script = workflowStepShell( + releaseCutWorkflow, + "Resolve the stable package version", + ); + const mocks = [ + "git() { return 0; }", + "npm() { printf '%s\\n' '[\"0.1.1\",\"2.0.0\"]'; }", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf8", + env: { + ...process.env, + BEFORE_SHA: "", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_OUTPUT: "/dev/null", + GITHUB_REF: "refs/heads/main", + GITHUB_SHA: releaseCommit, + }, + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Release version must be greater than every published stable version.", + ); + }); + + 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, + ); + expect(protectedReleaseWorkflow).toMatch( + /concurrency:\s*\n\s+group: \$\{\{ github\.workflow \}\}\s*\n\s+queue: max/u, + ); + }); + + 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("explicitly prevents historical releases becoming latest", () => { @@ -768,6 +1118,10 @@ describe("GitHub release workflow safeguards", () => { }); test("cryptographically verifies the exact npm provenance bundle", () => { + expect(githubReleaseWorkflow).toContain('node-version: "24.15.0"'); + expect(githubReleaseWorkflow).toContain( + 'if [[ "$(npm --version)" != "11.12.1" ]]; then', + ); expect(githubReleaseWorkflow).toContain("npm audit signatures"); expect(githubReleaseWorkflow).toContain("--include-attestations"); expect(githubReleaseWorkflow).toContain("verify-provenance"); @@ -790,6 +1144,67 @@ describe("GitHub release workflow safeguards", () => { expect(releaseLabelsWorkflow).toContain("return 0"); }); + test("recovers when another PR concurrently creates the skip label", () => { + expect(releaseLabelsWorkflow).toContain( + 'if ! gh api --method POST "repos/$GITHUB_REPOSITORY/labels"', + ); + expect(releaseLabelsWorkflow).toContain( + '"repos/$GITHUB_REPOSITORY/labels/skip-release-notes"', + ); + }); + + test("executes and recovers from concurrent skip-label creation", () => { + const script = workflowStepShell( + releaseLabelsWorkflow, + "Categorize pull request without checking out its code", + ); + const mock = [ + "skip_label_exists=0", + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' local endpoint="$1"', + ' case "$method $endpoint" in', + ' "GET repos/test/codex-security/issues/17")', + " printf '%s\\n' 'release: automate published notes'", + " ;;", + ' "GET repos/test/codex-security/issues/17/labels")', + " return 0", + " ;;", + ' "GET repos/test/codex-security/labels/skip-release-notes")', + ' [[ "$skip_label_exists" == 1 ]]', + " ;;", + ' "POST repos/test/codex-security/labels")', + " skip_label_exists=1", + " return 1", + " ;;", + ' "POST repos/test/codex-security/issues/17/labels")', + ' if [[ "$skip_label_exists" != 1 ]]; then return 65; fi', + " printf '%s\\n' 'applied skip-release-notes'", + " ;;", + " *) return 66 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + PR_NUMBER: "17", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("applied skip-release-notes"); + }); + test("documents JSON stdin for every verification command", () => { const result = spawnSync( "node", @@ -797,6 +1212,7 @@ describe("GitHub release workflow safeguards", () => { { encoding: "utf8", timeout: 10_000 }, ); expect(result.status).toBe(1); + expect(result.stderr).toContain("published npm versions JSON from stdin"); expect(result.stderr).toContain("package metadata JSON from stdin"); expect(result.stderr).toContain("signature audit JSON from stdin"); expect(result.stderr).toContain("GitHub release JSON from stdin"); From 65c0722e40dce450ebdcda46deae03adc4fb3ef7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 14:49:30 -0700 Subject: [PATCH 05/20] fix: enforce protected release tag and version checks --- .github/workflows/node-github-release.yml | 7 ++ .github/workflows/node-release.yml | 6 ++ .../tests-ts/release-automation.test.ts | 85 +++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 1d9d66cb..05b5c4d7 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -77,6 +77,13 @@ jobs: exit 1 fi + if ! gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" \ + --silent >/dev/null 2>&1; then + echo "GitHub releases require an existing stable npm-vX.Y.Z tag." >&2 + exit 1 + fi + release_sha="$( gh api "repos/$GITHUB_REPOSITORY/commits/$release_tag" \ --jq '.sha' diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index d886a2c5..1b3c618d 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -90,6 +90,12 @@ jobs: echo "npm release tags must point to a commit on main." >&2 exit 1 fi + published_versions="$( + sfw npm view @openai/codex-security versions --json + )" + printf '%s\n' "$published_versions" | + node sdk/typescript/scripts/release-automation.mjs \ + require-published-increase "$version" >/dev/null echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install dependencies diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index afc8f671..2e2654e1 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1073,6 +1073,42 @@ describe("GitHub release workflow safeguards", () => { ); }); + test("enforces increasing versions inside the protected npm publisher", () => { + expect(protectedReleaseWorkflow).toContain( + "sfw npm view @openai/codex-security versions", + ); + expect(protectedReleaseWorkflow).toContain("require-published-increase"); + }); + + test("rejects manually publishing a tag older than npm latest", () => { + const script = workflowStepShell( + protectedReleaseWorkflow, + "Validate release tag", + ); + const mocks = [ + "git() { return 0; }", + "sfw() { printf '%s\\n' '[\"0.1.1\",\"2.0.0\"]'; }", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REF: "refs/tags/npm-v0.1.2", + GITHUB_REF_NAME: "npm-v0.1.2", + GITHUB_REF_TYPE: "tag", + GITHUB_SHA: releaseCommit, + }, + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Release version must be greater than every published stable version.", + ); + }); + 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, @@ -1091,6 +1127,55 @@ describe("GitHub release workflow safeguards", () => { ); }); + test("requires an actual Git tag for GitHub releases", () => { + expect(githubReleaseWorkflow).toContain( + '"repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag"', + ); + }); + + test("rejects a release-shaped branch before resolving its commit", () => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Resolve the successful protected release", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ' "repos/openai/codex-security/git/ref/tags/npm-v0.1.2")', + " return 1", + " ;;", + ' "repos/openai/codex-security/commits/npm-v0.1.2")', + ` printf '%s\\n' '${releaseCommit}'`, + " ;;", + ` "repos/openai/codex-security/actions/runs/${releaseRun}")`, + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'completed' 'success' '${releaseCommit}' 'npm-v0.1.2'`, + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: "openai/codex-security", + 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( + "GitHub releases require an existing stable npm-vX.Y.Z tag.", + ); + }); + test("explicitly prevents historical releases becoming latest", () => { expect(githubReleaseWorkflow).toContain("--generate-notes"); expect(githubReleaseWorkflow).toContain('--latest="$MAKE_LATEST"'); From 1e4e8d9d57c46c01794c25d63a85d78aacfe6ff9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 14:54:03 -0700 Subject: [PATCH 06/20] test: derive release workflow versions from the current manifest --- sdk/typescript/tests-ts/release-automation.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 2e2654e1..9aef45ca 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1051,7 +1051,7 @@ describe("GitHub release workflow safeguards", () => { ); const mocks = [ "git() { return 0; }", - "npm() { printf '%s\\n' '[\"0.1.1\",\"2.0.0\"]'; }", + "npm() { printf '%s\\n' '[\"0.1.1\",\"999999999999999999999999.0.0\"]'; }", ].join("\n"); const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { cwd: fileURLToPath(new URL("../../../", import.meta.url)), @@ -1085,9 +1085,15 @@ describe("GitHub release workflow safeguards", () => { protectedReleaseWorkflow, "Validate release tag", ); + const checkedOutVersion = releaseVersion( + JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as ReleaseMetadata, + ); + const checkedOutTag = `npm-v${checkedOutVersion}`; const mocks = [ "git() { return 0; }", - "sfw() { printf '%s\\n' '[\"0.1.1\",\"2.0.0\"]'; }", + "sfw() { printf '%s\\n' '[\"0.1.1\",\"999999999999999999999999.0.0\"]'; }", ].join("\n"); const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { cwd: fileURLToPath(new URL("../../../", import.meta.url)), @@ -1095,8 +1101,8 @@ describe("GitHub release workflow safeguards", () => { env: { ...process.env, GITHUB_OUTPUT: "/dev/null", - GITHUB_REF: "refs/tags/npm-v0.1.2", - GITHUB_REF_NAME: "npm-v0.1.2", + GITHUB_REF: `refs/tags/${checkedOutTag}`, + GITHUB_REF_NAME: checkedOutTag, GITHUB_REF_TYPE: "tag", GITHUB_SHA: releaseCommit, }, From 75496c20ab1879a1545b7e4a5dedf470f93afcdb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 15:00:45 -0700 Subject: [PATCH 07/20] fix: resolve release commits from exact tag objects --- .github/workflows/node-github-release.yml | 23 +++++--- .../tests-ts/release-automation.test.ts | 57 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 05b5c4d7..cd97fde2 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -77,17 +77,26 @@ jobs: exit 1 fi - if ! gh api \ - "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" \ - --silent >/dev/null 2>&1; then + if ! tag_object="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" \ + --jq '.object.sha' + )"; then echo "GitHub releases require an existing stable npm-vX.Y.Z tag." >&2 exit 1 fi - release_sha="$( - gh api "repos/$GITHUB_REPOSITORY/commits/$release_tag" \ - --jq '.sha' - )" + if [[ ! "$tag_object" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "GitHub releases require a valid Git tag object." >&2 + exit 1 + fi + + if ! release_sha="$( + git rev-parse --verify "${tag_object}^{commit}" 2>/dev/null + )"; then + echo "GitHub releases require a tag pointing to a commit." >&2 + exit 1 + fi if [[ -z "$release_run" ]]; then release_run="$( diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 9aef45ca..6d54c777 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1182,6 +1182,63 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { kind: "lightweight", objectSha: releaseCommit }, + { + kind: "annotated", + objectSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ])( + "resolves the exact $kind tag when a same-named branch exists", + ({ objectSha }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Resolve the successful protected release", + ); + const branchCommit = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const mocks = [ + "git() {", + ` if [[ "$1" != "rev-parse" || "$2" != "--verify" || "$3" != "${objectSha}^{commit}" ]]; then return 66; fi`, + ` printf '%s\\n' '${releaseCommit}'`, + "}", + "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\\n' '${objectSha}'`, + " ;;", + ' "repos/openai/codex-security/commits/npm-v0.1.2")', + ` printf '%s\\n' '${branchCommit}'`, + " ;;", + ` "repos/openai/codex-security/actions/runs/${releaseRun}")`, + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'completed' 'success' '${releaseCommit}' 'npm-v0.1.2'`, + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: "openai/codex-security", + INPUT_RUN_ID: releaseRun, + INPUT_TAG: "npm-v0.1.2", + TRIGGER_RUN_ID: "", + TRIGGER_TAG: "", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain( + "The release run must successfully publish the exact tagged commit.", + ); + }, + ); + test("explicitly prevents historical releases becoming latest", () => { expect(githubReleaseWorkflow).toContain("--generate-notes"); expect(githubReleaseWorkflow).toContain('--latest="$MAKE_LATEST"'); From 5095ae6c8725d95ce74c7f4cf1f2cebcb4e148e1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 15:19:42 -0700 Subject: [PATCH 08/20] fix: recover verified releases and address review feedback --- .github/workflows/node-github-release.yml | 42 ++- .github/workflows/node-release-labels.yml | 8 +- .github/workflows/node-release.yml | 141 ++++++++- sdk/typescript/scripts/release-automation.mjs | 156 ++++++++- .../tests-ts/release-automation.test.ts | 296 +++++++++++++++++- 5 files changed, 610 insertions(+), 33 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index cd97fde2..9eb3f579 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -48,6 +48,25 @@ jobs: registry-url: "https://registry.npmjs.org" package-manager-cache: false + - name: Install provenance-capable npm + shell: bash + run: | + set -euo pipefail + npm_tools="$RUNNER_TEMP/codex-security-provenance-npm" + npm install \ + --prefix "$npm_tools" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --registry=https://openai.firewall.socket.dev/npm/ \ + npm@11.12.1 + + if [[ "$("$npm_tools/node_modules/.bin/npm" --version)" != "11.12.1" ]]; then + echo "npm 11.12.1 is required to verify Sigstore attestations." >&2 + exit 1 + fi + printf '%s\n' "$npm_tools/node_modules/.bin" >> "$GITHUB_PATH" + - name: Verify bundled provenance-capable npm shell: bash run: | @@ -163,7 +182,6 @@ jobs: env: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_SHA: ${{ steps.release.outputs.sha }} - RELEASE_RUN_ID: ${{ steps.release.outputs.run-id }} run: | set -euo pipefail shopt -s nullglob @@ -202,12 +220,11 @@ jobs: --json \ --include-attestations | node sdk/typescript/scripts/release-automation.mjs \ - verify-provenance \ + verify-recovered-provenance \ "$archive" \ "$RELEASE_VERSION" \ "$RELEASE_SHA" \ - "$GITHUB_REPOSITORY" \ - "$RELEASE_RUN_ID" + "$GITHUB_REPOSITORY" printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT" @@ -290,9 +307,24 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ 2>/dev/null )"; then + asset_name="$(basename "$RELEASE_ARCHIVE")" + downloaded_assets="$(mktemp -d)" + trap 'rm -rf "$downloaded_assets"' EXIT + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset_name" \ + --dir "$downloaded_assets" + + downloaded_archive="$downloaded_assets/$asset_name" + if [[ ! -f "$downloaded_archive" || -L "$downloaded_archive" ]]; then + echo "Expected the exact existing GitHub release asset." >&2 + exit 1 + fi + printf '%s\n' "$existing_release" | node sdk/typescript/scripts/release-automation.mjs \ - verify-github-release "$RELEASE_ARCHIVE" "$RELEASE_TAG" + verify-github-release "$RELEASE_ARCHIVE" "$RELEASE_TAG" \ + "$downloaded_archive" echo "The verified GitHub Release already exists." exit 0 fi diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index e3298ec7..b7717c39 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -33,16 +33,16 @@ jobs: release_note_label() { case "$1" in - feat:* | feat\(*\):*) + feat:* | feat\(*\):* | feat!:* | feat\(*\)!:*) printf '%s\n' enhancement ;; - fix:* | fix\(*\):*) + fix:* | fix\(*\):* | fix!:* | fix\(*\)!:*) printf '%s\n' bug ;; - docs:* | docs\(*\):*) + docs:* | docs\(*\):* | docs!:* | docs\(*\)!:*) printf '%s\n' documentation ;; - release:* | release\(*\):* | test:* | test\(*\):*) + release:* | release\(*\):* | release!:* | release\(*\)!:* | test:* | test\(*\):* | test!:* | test\(*\)!:*) printf '%s\n' skip-release-notes ;; *) diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 1b3c618d..bf65349c 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -24,6 +24,8 @@ jobs: BUN_CONFIG_REGISTRY: https://openai.firewall.socket.dev/npm/ outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} + mode: ${{ steps.release.outputs.mode }} + version: ${{ steps.release.outputs.version }} steps: - name: Checkout repository @@ -93,10 +95,18 @@ jobs: published_versions="$( sfw npm view @openai/codex-security versions --json )" - printf '%s\n' "$published_versions" | - node sdk/typescript/scripts/release-automation.mjs \ - require-published-increase "$version" >/dev/null - echo "version=$version" >> "$GITHUB_OUTPUT" + mode="$( + printf '%s\n' "$published_versions" | + node sdk/typescript/scripts/release-automation.mjs \ + release-mode "$version" + )" + if [[ "$mode" == "recover" ]]; then + echo "Recovering the already-published npm release." + fi + { + printf 'version=%s\n' "$version" + printf 'mode=%s\n' "$mode" + } >> "$GITHUB_OUTPUT" - name: Install dependencies run: sfw pnpm --dir sdk/typescript install --frozen-lockfile @@ -123,6 +133,49 @@ jobs: CODEX_SECURITY_EXPECTED_GIT_HEAD: ${{ github.sha }} run: pnpm run check:package ../../dist/*.tgz + - name: Recover the published npm release archive + if: steps.release.outputs.mode == 'recover' + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + shopt -s nullglob + + built_archives=(dist/*.tgz) + if (( ${#built_archives[@]} != 1 )); then + echo "Expected exactly one verified local release tarball." >&2 + exit 1 + fi + + recovery="$(mktemp -d)" + trap 'rm -rf "$recovery"' EXIT + npm pack "@openai/codex-security@$RELEASE_VERSION" \ + --ignore-scripts \ + --pack-destination "$recovery" \ + --registry=https://registry.npmjs.org/ + + published_archives=("$recovery"/*.tgz) + if (( ${#published_archives[@]} != 1 )); then + echo "Expected exactly one published npm release tarball." >&2 + exit 1 + fi + + metadata="$( + npm view "@openai/codex-security@$RELEASE_VERSION" \ + name version gitHead dist.integrity dist.attestations \ + --json \ + --registry=https://registry.npmjs.org/ + )" + printf '%s\n' "$metadata" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-publication \ + "${published_archives[0]}" \ + "$RELEASE_VERSION" \ + "$GITHUB_SHA" + + cp "${published_archives[0]}" "${built_archives[0]}" + - name: Smoke test package shell: bash run: | @@ -156,13 +209,38 @@ jobs: id-token: write steps: + - name: Checkout release verification source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: "24" + node-version: "24.15.0" registry-url: "https://registry.npmjs.org" package-manager-cache: false + - name: Install provenance-capable npm + shell: bash + run: | + set -euo pipefail + npm_tools="$RUNNER_TEMP/codex-security-provenance-npm" + npm install \ + --prefix "$npm_tools" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --registry=https://openai.firewall.socket.dev/npm/ \ + npm@11.12.1 + + if [[ "$("$npm_tools/node_modules/.bin/npm" --version)" != "11.12.1" ]]; then + echo "npm 11.12.1 is required to verify Sigstore attestations." >&2 + exit 1 + fi + printf '%s\n' "$npm_tools/node_modules/.bin" >> "$GITHUB_PATH" + - name: Verify bundled trusted-publishing npm shell: bash run: | @@ -193,8 +271,57 @@ jobs: merge-multiple: true digest-mismatch: error + - name: Verify recovered npm release provenance + if: needs.verify.outputs.mode == 'recover' + shell: bash + env: + RELEASE_VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + shopt -s nullglob + + release_version="$RELEASE_VERSION" + archives=(dist/*.tgz) + if (( ${#archives[@]} != 1 )); then + echo "Expected exactly one recovered npm release tarball." >&2 + exit 1 + fi + archive="${archives[0]}" + + metadata="$( + npm view "@openai/codex-security@$release_version" \ + name version gitHead dist.integrity dist.attestations \ + --json \ + --registry=https://registry.npmjs.org/ + )" + printf '%s\n' "$metadata" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-publication "$archive" "$release_version" "$GITHUB_SHA" + + consumer="$(mktemp -d)" + trap 'rm -rf "$consumer"' EXIT + npm install \ + --prefix "$consumer" \ + --ignore-scripts \ + --omit=optional \ + --no-audit \ + --no-fund \ + --registry=https://registry.npmjs.org/ \ + "@openai/codex-security@$release_version" + npm audit signatures \ + --prefix "$consumer" \ + --registry=https://registry.npmjs.org/ \ + --json \ + --include-attestations | + node sdk/typescript/scripts/release-automation.mjs \ + verify-recovered-provenance \ + "$archive" \ + "$release_version" \ + "$GITHUB_SHA" \ + "$GITHUB_REPOSITORY" + - name: Publish initial npm release - if: github.ref_name == 'npm-v0.1.0' + if: needs.verify.outputs.mode == 'publish' && github.ref_name == 'npm-v0.1.0' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_FIRST_PUBLISH_TOKEN }} shell: bash @@ -207,5 +334,5 @@ jobs: npm publish ./dist/*.tgz --provenance --access public --tag latest - name: Publish to npm using trusted publishing - if: github.ref_name != 'npm-v0.1.0' + if: needs.verify.outputs.mode == 'publish' && github.ref_name != 'npm-v0.1.0' run: npm publish ./dist/*.tgz --provenance --access public --tag latest diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 9a441024..4c16758a 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -73,24 +73,38 @@ export function requireReleaseIncrease(version, previousVersion) { return version; } -export function requirePublishedReleaseIncrease(version, publishedVersions) { +export function publishedReleaseMode(version, publishedVersions) { releaseVersion({ name: packageName, version }); if (!Array.isArray(publishedVersions)) { throw new Error("Published npm release versions must be an array."); } + let mode = "publish"; for (const publishedVersion of publishedVersions) { if ( typeof publishedVersion === "string" && - stableVersion.test(publishedVersion) && - compareReleaseVersions(version, publishedVersion) <= 0 + stableVersion.test(publishedVersion) ) { - throw new Error( - "Release version must be greater than every published stable version.", - ); + const comparison = compareReleaseVersions(version, publishedVersion); + if (comparison < 0) { + throw new Error( + "Release version must be greater than every published stable version.", + ); + } + if (comparison === 0) mode = "recover"; } } + return mode; +} + +export function requirePublishedReleaseIncrease(version, publishedVersions) { + if (publishedReleaseMode(version, publishedVersions) !== "publish") { + throw new Error( + "Release version must be greater than every published stable version.", + ); + } + return version; } @@ -369,10 +383,14 @@ export function releaseHistory(tag, history) { } } - const makeLatest = Array.from(publishedGitHubTags).every( - (candidate) => - compareReleaseVersions(version, candidate.slice("npm-v".length)) > 0, - ); + const makeLatest = + Array.from(publishedVersions).every( + (candidate) => compareReleaseVersions(version, candidate) >= 0, + ) && + Array.from(publishedGitHubTags).every( + (candidate) => + compareReleaseVersions(version, candidate.slice("npm-v".length)) > 0, + ); return { previousTag, makeLatest }; } @@ -567,7 +585,79 @@ export function verifySignatureAudit(report, archive, expected) { }; } -export function verifyGitHubRelease(release, archive, expectedTag, assetName) { +export function verifyRecoveredSignatureAudit(report, archive, expected) { + const version = releaseVersion({ + name: packageName, + version: expected.version, + }); + if ( + typeof expected.repository !== "string" || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(expected.repository) + ) { + throw new Error("Verified provenance requires an exact GitHub repository."); + } + + const verified = Array.isArray(report?.verified) + ? report.verified.find( + (candidate) => + candidate?.name === packageName && candidate.version === version, + ) + : undefined; + if (verified === undefined) { + throw new Error( + "The published package must have a cryptographically verified attestation.", + ); + } + + const provenance = Array.isArray(verified.attestationBundles) + ? verified.attestationBundles.find( + (candidate) => candidate?.predicateType === provenancePredicate, + ) + : undefined; + const encodedStatement = provenance?.bundle?.dsseEnvelope?.payload; + if (typeof encodedStatement !== "string") { + throw new Error("The verified SLSA provenance bundle is missing."); + } + + let statement; + try { + statement = JSON.parse( + Buffer.from(encodedStatement, "base64").toString("utf8"), + ); + } catch { + throw new Error("The verified SLSA provenance statement is invalid."); + } + + const prefix = `https://github.com/${expected.repository}/actions/runs/`; + const invocation = statement?.predicate?.runDetails?.metadata?.invocationId; + if (typeof invocation !== "string" || !invocation.startsWith(prefix)) { + throw new Error( + "Verified SLSA provenance must identify the protected release run.", + ); + } + const match = /^([1-9][0-9]*)\/attempts\/([1-9][0-9]*)$/u.exec( + invocation.slice(prefix.length), + ); + if (match === null) { + throw new Error( + "Verified SLSA provenance must identify the protected release run.", + ); + } + + return verifySignatureAudit(report, archive, { + ...expected, + version, + runId: match[1], + }); +} + +export function verifyGitHubRelease( + release, + archive, + expectedTag, + assetName, + downloadedArchive, +) { if (release?.tag_name !== expectedTag) { throw new Error("Existing GitHub Release must match the release tag."); } @@ -580,7 +670,18 @@ export function verifyGitHubRelease(release, archive, expectedTag, assetName) { const asset = Array.isArray(release.assets) ? release.assets.find((candidate) => candidate?.name === assetName) : undefined; - if (asset?.digest !== expectedDigest) { + const publishedDigest = asset?.digest; + const downloadedDigest = + downloadedArchive === undefined + ? undefined + : "sha256:" + + createHash("sha256").update(downloadedArchive).digest("hex"); + if ( + asset === undefined || + (publishedDigest != null && publishedDigest !== expectedDigest) || + (downloadedArchive === undefined && publishedDigest !== expectedDigest) || + (downloadedArchive !== undefined && downloadedDigest !== expectedDigest) + ) { throw new Error( "Existing GitHub Release asset must match the verified npm artifact.", ); @@ -628,6 +729,12 @@ function main() { return; } + if (command === "release-mode" && process.argv.length === 4) { + const publishedVersions = JSON.parse(readFileSync(0, "utf8")); + console.log(publishedReleaseMode(process.argv[3], publishedVersions)); + return; + } + if (command === "release-history" && process.argv.length === 4) { const registryVersions = JSON.parse( process.env.CODEX_SECURITY_PUBLISHED_NPM_VERSIONS ?? "[]", @@ -678,7 +785,22 @@ function main() { return; } - if (command === "verify-github-release" && process.argv.length === 5) { + if (command === "verify-recovered-provenance" && process.argv.length === 7) { + const report = JSON.parse(readFileSync(0, "utf8")); + const archive = readFileSync(process.argv[3]); + const verified = verifyRecoveredSignatureAudit(report, archive, { + version: process.argv[4], + gitHead: process.argv[5], + repository: process.argv[6], + }); + console.log(JSON.stringify(verified)); + return; + } + + if ( + command === "verify-github-release" && + (process.argv.length === 5 || process.argv.length === 6) + ) { const release = JSON.parse(readFileSync(0, "utf8")); const archivePath = process.argv[3]; const archive = readFileSync(archivePath); @@ -687,6 +809,7 @@ function main() { archive, process.argv[4], basename(archivePath), + process.argv[5] === undefined ? undefined : readFileSync(process.argv[5]), ); console.log(JSON.stringify(verified)); return; @@ -698,12 +821,15 @@ function main() { "require-increase , " + "require-published-increase " + "(published npm versions JSON from stdin), " + + "release-mode (published npm versions JSON from stdin), " + "release-history , " + "verify-publication " + "(package metadata JSON from stdin), " + "verify-provenance " + - "(signature audit JSON from stdin), or " + - "verify-github-release " + + "(signature audit JSON from stdin), " + + "verify-recovered-provenance " + + " (signature audit JSON from stdin), or " + + "verify-github-release [downloaded-asset] " + "(GitHub release JSON from stdin).", ); } diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 6d54c777..e2e4e711 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -16,6 +16,10 @@ type ReleaseAutomation = { ) => string; compareReleaseVersions: (left: string, right: string) => -1 | 0 | 1; requireReleaseIncrease: (version: string, previousVersion: string) => string; + publishedReleaseMode: ( + version: string, + publishedVersions: unknown, + ) => "publish" | "recover"; requirePublishedReleaseIncrease: ( version: string, publishedVersions: unknown, @@ -54,11 +58,27 @@ type ReleaseAutomation = { runId: string; sha512: string; }; + verifyRecoveredSignatureAudit: ( + report: ReleaseMetadata, + archive: Uint8Array, + expected: { + version: string; + gitHead: string; + repository: string; + }, + ) => { + version: string; + gitHead: string; + repository: string; + runId: string; + sha512: string; + }; verifyGitHubRelease: ( release: ReleaseMetadata, archive: Uint8Array, expectedTag: string, assetName: string, + downloadedArchive?: Uint8Array, ) => { tag: string; asset: string; digest: string }; }; @@ -71,10 +91,12 @@ const { releaseTagVersion, compareReleaseVersions, requireReleaseIncrease, + publishedReleaseMode, requirePublishedReleaseIncrease, releaseHistory, verifyPublishedRelease, verifySignatureAudit, + verifyRecoveredSignatureAudit, verifyGitHubRelease, } = (await import(automationScript.href)) as ReleaseAutomation; @@ -473,6 +495,34 @@ describe("monotonic stable release versions", () => { ).toBe("0.1.11"); }); + test("identifies a strictly increasing version as a new publication", () => { + expect(publishedReleaseMode("0.1.3", ["0.1.0", "0.1.2"])).toBe("publish"); + }); + + test("identifies the current published version as recoverable", () => { + expect(publishedReleaseMode("0.1.2", ["0.1.0", "0.1.2"])).toBe("recover"); + }); + + test("never recovers a published version below a newer stable release", () => { + expect(() => publishedReleaseMode("0.1.2", ["0.1.2", "0.1.3"])).toThrow( + "Release version must be greater than every published stable version.", + ); + }); + + test("ignores prerelease versions when determining recovery", () => { + expect(publishedReleaseMode("0.1.2", ["0.1.2", "0.1.3-beta.1"])).toBe( + "recover", + ); + }); + + test("fails safely when recovery history is malformed", () => { + for (const publishedVersions of [null, {}, "0.1.2"]) { + expect(() => publishedReleaseMode("0.1.2", publishedVersions)).toThrow( + "Published npm release versions must be an array.", + ); + } + }); + test("rejects a manual release below the highest published version", () => { expect(() => requirePublishedReleaseIncrease("1.9.9", ["1.9.8", "2.0.0"]), @@ -519,6 +569,16 @@ describe("published GitHub and npm release history", () => { ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: false }); }); + test("never marks a backfill latest when a newer npm release lacks GitHub notes", () => { + expect( + releaseHistory("npm-v0.1.2", { + registryVersions: ["0.1.1", "0.1.2", "0.1.3"], + githubReleaseTags: [], + reachableTags: ["npm-v0.1.1"], + }), + ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: false }); + }); + test("starts notes from the newest actually published version", () => { expect( releaseHistory("npm-v0.1.4", { @@ -652,6 +712,60 @@ describe("cryptographically verified npm provenance", () => { }); }); + test("recovers a published archive using its authenticated original run", () => { + expect( + verifyRecoveredSignatureAudit(signatureAudit(), archive, { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + }), + ).toEqual({ + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + sha512, + }); + }); + + test("rejects a recovered archive whose certificate identifies another run", () => { + expect(() => + verifyRecoveredSignatureAudit( + signatureAudit({ runId: "30481596228" }), + archive, + { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + }, + ), + ).toThrow("The Fulcio certificate must identify the exact release run."); + }); + + test("rejects a recovered archive signed for a different source commit", () => { + expect(() => + verifyRecoveredSignatureAudit(signatureAudit(), archive, { + version: "0.1.2", + gitHead: "0000000000000000000000000000000000000000", + repository: releaseRepository, + }), + ).toThrow("npm package gitHead must match release commit"); + }); + + test("rejects a recovery without an authentic provenance bundle", () => { + expect(() => + verifyRecoveredSignatureAudit( + signatureAudit({ includeBundle: false }), + archive, + { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + }, + ), + ).toThrow("The verified SLSA provenance bundle is missing."); + }); + test("accepts the real Fulcio certificate in the legacy chain format", () => { expect( verifySignatureAudit( @@ -944,6 +1058,60 @@ describe("idempotent GitHub release verification", () => { }); }); + test("verifies downloaded release bytes when GitHub omits the asset digest", () => { + const release = githubRelease(); + + expect( + verifyGitHubRelease( + { + ...release, + assets: [{ name: "openai-codex-security-0.1.2.tgz" }], + }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + archive, + ), + ).toEqual({ + tag: "npm-v0.1.2", + asset: "openai-codex-security-0.1.2.tgz", + digest, + }); + }); + + test("rejects a digestless release asset without its downloaded bytes", () => { + expect(() => + verifyGitHubRelease( + { + ...githubRelease(), + assets: [{ name: "openai-codex-security-0.1.2.tgz" }], + }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + ), + ).toThrow( + "Existing GitHub Release asset must match the verified npm artifact.", + ); + }); + + test("rejects downloaded release bytes that differ from the npm archive", () => { + expect(() => + verifyGitHubRelease( + { + ...githubRelease(), + assets: [{ name: "openai-codex-security-0.1.2.tgz" }], + }, + archive, + "npm-v0.1.2", + "openai-codex-security-0.1.2.tgz", + Buffer.from("different downloaded GitHub release"), + ), + ).toThrow( + "Existing GitHub Release asset must match the verified npm artifact.", + ); + }); + test("rejects a GitHub release for another tag", () => { expect(() => verifyGitHubRelease( @@ -1077,7 +1245,7 @@ describe("GitHub release workflow safeguards", () => { expect(protectedReleaseWorkflow).toContain( "sfw npm view @openai/codex-security versions", ); - expect(protectedReleaseWorkflow).toContain("require-published-increase"); + expect(protectedReleaseWorkflow).toContain("release-mode"); }); test("rejects manually publishing a tag older than npm latest", () => { @@ -1115,6 +1283,54 @@ describe("GitHub release workflow safeguards", () => { ); }); + test("allows the exact already-published release to enter verified recovery", () => { + const script = workflowStepShell( + protectedReleaseWorkflow, + "Validate release tag", + ); + const checkedOutVersion = releaseVersion( + JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as ReleaseMetadata, + ); + const checkedOutTag = `npm-v${checkedOutVersion}`; + const mocks = [ + "git() { return 0; }", + `sfw() { printf '%s\\n' '["0.1.0","${checkedOutVersion}"]'; }`, + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REF: `refs/tags/${checkedOutTag}`, + GITHUB_REF_NAME: checkedOutTag, + GITHUB_REF_TYPE: "tag", + GITHUB_SHA: releaseCommit, + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Recovering the already-published npm release.", + ); + }); + + test("verifies recovered packages without republishing immutable versions", () => { + expect(protectedReleaseWorkflow).toContain( + "Recover the published npm release archive", + ); + expect(protectedReleaseWorkflow).toContain( + "Verify recovered npm release provenance", + ); + expect(protectedReleaseWorkflow).toContain("verify-recovered-provenance"); + expect(protectedReleaseWorkflow).toContain( + "needs.verify.outputs.mode == 'publish'", + ); + }); + 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, @@ -1265,6 +1481,15 @@ describe("GitHub release workflow safeguards", () => { expect(githubReleaseWorkflow).toContain("--ignore-scripts"); }); + test("downloads and verifies existing GitHub release asset bytes", () => { + expect(githubReleaseWorkflow).toContain( + 'gh release download "$RELEASE_TAG"', + ); + expect(githubReleaseWorkflow).toContain( + 'verify-github-release "$RELEASE_ARCHIVE" "$RELEASE_TAG"', + ); + }); + test("cryptographically verifies the exact npm provenance bundle", () => { expect(githubReleaseWorkflow).toContain('node-version: "24.15.0"'); expect(githubReleaseWorkflow).toContain( @@ -1272,7 +1497,19 @@ describe("GitHub release workflow safeguards", () => { ); expect(githubReleaseWorkflow).toContain("npm audit signatures"); expect(githubReleaseWorkflow).toContain("--include-attestations"); - expect(githubReleaseWorkflow).toContain("verify-provenance"); + expect(githubReleaseWorkflow).toContain("verify-recovered-provenance"); + }); + + test("installs the exact attestation-capable npm through the Socket Firewall", () => { + for (const workflow of [githubReleaseWorkflow, protectedReleaseWorkflow]) { + expect(workflow).toContain("Install provenance-capable npm"); + expect(workflow).toContain("npm@11.12.1"); + expect(workflow).toContain( + "--registry=https://openai.firewall.socket.dev/npm/", + ); + expect(workflow).toContain('>> "$GITHUB_PATH"'); + expect(workflow).not.toContain("npm install --global"); + } }); test("serializes label reconciliation and reads the current PR title", () => { @@ -1301,6 +1538,61 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { title: "feat!: breaking feature", label: "enhancement" }, + { title: "feat(api)!: breaking feature", label: "enhancement" }, + { title: "fix!: breaking fix", label: "bug" }, + { title: "fix(api)!: breaking fix", label: "bug" }, + { title: "docs!: breaking documentation", label: "documentation" }, + { + title: "docs(api)!: breaking documentation", + label: "documentation", + }, + ])("categorizes breaking-change title $title", ({ title, label }) => { + const script = workflowStepShell( + releaseLabelsWorkflow, + "Categorize pull request without checking out its code", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' local endpoint="$1"', + " shift", + ' case "$method $endpoint" in', + ' "GET repos/test/codex-security/issues/17")', + " printf '%s\\n' \"$MOCK_PR_TITLE\"", + " ;;", + ' "GET repos/test/codex-security/issues/17/labels")', + " return 0", + " ;;", + ' "POST repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' \"$@\"", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + MOCK_PR_TITLE: title, + PR_NUMBER: "17", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`labels[]=${label}`); + }); + test("executes and recovers from concurrent skip-label creation", () => { const script = workflowStepShell( releaseLabelsWorkflow, From f9dda3847c36151b3741b499b54f6b84977c921c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 17:27:44 -0700 Subject: [PATCH 09/20] fix: preserve manual release-note exclusions --- .github/workflows/node-release-labels.yml | 7 ++ .../tests-ts/release-automation.test.ts | 66 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index b7717c39..a819a501 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -61,6 +61,13 @@ jobs: --jq '.[].name' )" + while IFS= read -r current_label; do + if [[ "$current_label" == "skip-release-notes" ]]; then + echo "Preserving existing skip-release-notes label." + exit 0 + fi + done <<< "$current_labels" + while IFS= read -r current_label; do case "$current_label" in enhancement | bug | documentation | skip-release-notes) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index e2e4e711..6f82ffc8 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1529,6 +1529,72 @@ describe("GitHub release workflow safeguards", () => { expect(releaseLabelsWorkflow).toContain("return 0"); }); + test.each([ + { title: "fix: retitle an internal change" }, + { title: "feat: retitle an internal change" }, + { title: "docs: retitle an internal change" }, + { title: "chore: retitle an internal change" }, + ])( + "preserves a manually excluded release after retitling to $title", + ({ title }) => { + const script = workflowStepShell( + releaseLabelsWorkflow, + "Categorize pull request without checking out its code", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' local endpoint="$1"', + " shift", + ' case "$method $endpoint" in', + ' "GET repos/test/codex-security/issues/17")', + " printf '%s\\n' \"$MOCK_PR_TITLE\"", + " ;;", + ' "GET repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' enhancement skip-release-notes", + " ;;", + ' "DELETE repos/test/codex-security/issues/17/labels/enhancement" | "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', + " printf '%s\\n' 'removed a manually excluded release label'", + " return 70", + " ;;", + ' "POST repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' 'overrode a manually excluded release'", + " return 71", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + MOCK_PR_TITLE: title, + PR_NUMBER: "17", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Preserving existing skip-release-notes label.", + ); + expect(result.stdout).not.toContain( + "removed a manually excluded release label", + ); + expect(result.stdout).not.toContain( + "overrode a manually excluded release", + ); + }, + ); + test("recovers when another PR concurrently creates the skip label", () => { expect(releaseLabelsWorkflow).toContain( 'if ! gh api --method POST "repos/$GITHUB_REPOSITORY/labels"', From 20afae0fd12e0504fe11e816512ae11032ef948d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 17:41:42 -0700 Subject: [PATCH 10/20] fix: harden release automation review safeguards --- .github/workflows/node-github-release.yml | 47 ++- .github/workflows/node-release-labels.yml | 20 +- .github/workflows/node-release.yml | 54 +++ sdk/typescript/scripts/release-automation.mjs | 3 + .../tests-ts/release-automation.test.ts | 310 ++++++++++++++++++ 5 files changed, 429 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 9eb3f579..0d735fe9 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -26,8 +26,10 @@ jobs: release: if: >- github.repository == 'openai/codex-security' && - (github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success') + ((github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success')) name: publish verified GitHub release runs-on: ubuntu-latest permissions: @@ -40,6 +42,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: refs/heads/main - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 @@ -325,7 +328,45 @@ jobs: node sdk/typescript/scripts/release-automation.mjs \ verify-github-release "$RELEASE_ARCHIVE" "$RELEASE_TAG" \ "$downloaded_archive" - echo "The verified GitHub Release already exists." + + notes_args=( + --method POST + "repos/$GITHUB_REPOSITORY/releases/generate-notes" + -f "tag_name=$RELEASE_TAG" + -f "target_commitish=$RELEASE_SHA" + -f "configuration_file_path=.github/release.yml" + ) + if [[ -n "$PREVIOUS_TAG" ]]; then + notes_args+=(-f "previous_tag_name=$PREVIOUS_TAG") + fi + generated_notes="$(gh api "${notes_args[@]}" --jq '.body')" + if [[ -z "$generated_notes" ]]; then + echo "Generated GitHub release notes must not be empty." >&2 + exit 1 + fi + + existing_notes="$( + printf '%s\n' "$existing_release" | + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const release = JSON.parse(readFileSync(0, "utf8")); + if (release.body != null && typeof release.body !== "string") { + throw new Error("Existing release notes must be a string."); + } + process.stdout.write(release.body ?? ""); + ' + )" + + if [[ "$existing_notes" != "$generated_notes" ]]; then + printf '%s\n' "$generated_notes" | + gh release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --notes-file - + echo "Updated existing GitHub Release with generated notes." + else + echo "The verified GitHub Release already exists." + fi exit 0 fi diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index a819a501..2e319774 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -63,8 +63,24 @@ jobs: while IFS= read -r current_label; do if [[ "$current_label" == "skip-release-notes" ]]; then - echo "Preserving existing skip-release-notes label." - exit 0 + skip_label_actors="$( + gh api \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/timeline?per_page=100" \ + --paginate \ + --jq '.[] | select(.event == "labeled" and .label.name == "skip-release-notes") | .actor.login' + )" + skip_label_actor="" + while IFS= read -r event_actor; do + if [[ -n "$event_actor" ]]; then + skip_label_actor="$event_actor" + fi + done <<< "$skip_label_actors" + + if [[ "$skip_label_actor" != "github-actions[bot]" ]]; then + echo "Preserving existing skip-release-notes label." + exit 0 + fi + break fi done <<< "$current_labels" diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 6b4bc800..0e5d4254 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -324,6 +324,60 @@ jobs: "$GITHUB_SHA" \ "$GITHUB_REPOSITORY" + - name: Revalidate protected release tag + if: needs.verify.outputs.mode == 'publish' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + invalid_tag() { + echo "Protected npm release tag must still point to the verified commit." >&2 + exit 1 + } + + if [[ "$GITHUB_REF_TYPE" != "tag" || + "$GITHUB_REF" != "refs/tags/$GITHUB_REF_NAME" ]]; then + invalid_tag + fi + + if ! tag_fields="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$GITHUB_REF_NAME" \ + --jq '[.object.type, .object.sha] | @tsv' + )"; then + invalid_tag + fi + IFS=$'\t' read -r tag_type tag_object <<< "$tag_fields" + if [[ ! "$tag_object" =~ ^[0-9a-f]{40}$ ]]; then + invalid_tag + fi + + case "$tag_type" in + commit) + tag_commit="$tag_object" + ;; + tag) + if ! tag_commit="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/tags/$tag_object" \ + --jq 'if .object.type == "commit" then .object.sha else empty end' + )"; then + invalid_tag + fi + ;; + *) + invalid_tag + ;; + esac + + if [[ ! "$tag_commit" =~ ^[0-9a-f]{40}$ || + "$tag_commit" != "$GITHUB_SHA" ]]; then + invalid_tag + fi + echo "Protected npm release tag matches the verified commit." + - name: Publish initial npm release if: needs.verify.outputs.mode == 'publish' && github.ref_name == 'npm-v0.1.0' env: diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 4c16758a..4f195b9b 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -156,6 +156,9 @@ function derChildren(bytes, element) { let cursor = element.start; while (cursor < element.end) { const child = derElement(bytes, cursor, element.end); + if (child.end <= cursor) { + throw invalidSigningCertificate(); + } children.push(child); cursor = child.end; } diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 6f82ffc8..d5f125cf 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -794,6 +794,34 @@ describe("cryptographically verified npm provenance", () => { } }); + test("fails closed when a certificate child does not advance", () => { + expect(readFileSync(automationScript, "utf8")).toMatch( + /const child = derElement\(bytes, cursor, element\.end\);\s*if \(child\.end <= cursor\) \{\s*throw invalidSigningCertificate\(\);\s*\}/u, + ); + }); + + test("rejects empty and noncanonical DER signing certificates", () => { + const invalidCertificates = [ + Buffer.from([0x30, 0x00]), + Buffer.from([0x30, 0x02, 0x30, 0x00]), + Buffer.from([0x30, 0x80, 0x00, 0x00]), + Buffer.from([0x30, 0x81, 0x00]), + Buffer.from([0x30, 0x82, 0x00, 0x01, 0x00]), + ]; + + for (const certificate of invalidCertificates) { + expect(() => + verifySignatureAudit( + signatureAudit({ + signingCertificate: certificate.toString("base64"), + }), + archive, + signatureExpected(), + ), + ).toThrow("The verified Fulcio signing certificate is invalid."); + } + }); + test("rejects a certificate from another OIDC issuer", () => { expect(() => verifySignatureAudit( @@ -1248,6 +1276,100 @@ describe("GitHub release workflow safeguards", () => { expect(protectedReleaseWorkflow).toContain("release-mode"); }); + test.each([ + { + kind: "deleted release tag", + tagType: "missing", + tagObject: "", + peeledCommit: "", + status: 1, + }, + { + kind: "retargeted lightweight tag", + tagType: "commit", + tagObject: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + peeledCommit: "", + status: 1, + }, + { + kind: "retargeted annotated tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: 1, + }, + { + kind: "verified lightweight tag", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + kind: "verified annotated tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: releaseCommit, + status: 0, + }, + ])( + "revalidates the authoritative $kind immediately before npm publication", + ({ tagType, tagObject, peeledCommit, status }) => { + const script = workflowStepShell( + protectedReleaseWorkflow, + "Revalidate protected release tag", + ); + const checkedOutVersion = releaseVersion( + JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as ReleaseMetadata, + ); + const checkedOutTag = `npm-v${checkedOutVersion}`; + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ` "repos/test/codex-security/git/ref/tags/${checkedOutTag}")`, + ' if [[ "$MOCK_TAG_TYPE" == "missing" ]]; then return 1; fi', + ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', + " ;;", + " repos/test/codex-security/git/tags/*)", + " printf '%s\\n' \"$MOCK_PEELED_COMMIT\"", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REF: `refs/tags/${checkedOutTag}`, + GITHUB_REF_NAME: checkedOutTag, + GITHUB_REF_TYPE: "tag", + GITHUB_REPOSITORY: "test/codex-security", + GITHUB_SHA: releaseCommit, + MOCK_PEELED_COMMIT: peeledCommit, + MOCK_TAG_OBJECT: tagObject, + MOCK_TAG_TYPE: tagType, + }, + timeout: 10_000, + }); + + expect(result.status).toBe(status); + if (status === 0) { + expect(result.stdout).toContain( + "Protected npm release tag matches the verified commit.", + ); + } else { + expect(result.stderr).toContain( + "Protected npm release tag must still point to the verified commit.", + ); + } + }, + ); + test("rejects manually publishing a tag older than npm latest", () => { const script = workflowStepShell( protectedReleaseWorkflow, @@ -1349,6 +1471,13 @@ describe("GitHub release workflow safeguards", () => { ); }); + test("runs manually dispatched GitHub backfills from trusted main", () => { + expect(githubReleaseWorkflow).toContain("github.ref == 'refs/heads/main'"); + expect(githubReleaseWorkflow).toMatch( + /- name: Checkout release automation\n(?:[^\n]*\n)*?\s+ref: refs\/heads\/main/u, + ); + }); + test("requires an actual Git tag for GitHub releases", () => { expect(githubReleaseWorkflow).toContain( '"repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag"', @@ -1490,6 +1619,113 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { description: "empty", existingNotes: "", updated: true }, + { + description: "manually written", + existingNotes: "Manually written release notes", + updated: true, + }, + { + description: "already current", + existingNotes: "Generated release notes", + updated: false, + }, + ])( + "reconciles $description notes on an existing verified GitHub release", + ({ existingNotes, updated }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Publish GitHub Release and generated notes", + ); + const mocks = [ + "gh() {", + ' if [[ "$1" == "api" ]]; then', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' case "$method $1" in', + ' "GET repos/test/codex-security/releases/tags/npm-v0.1.2")', + ' printf \'{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', + " ;;", + ' "POST repos/test/codex-security/releases/generate-notes")', + " printf '%s\\n' 'Generated release notes'", + " ;;", + " *) return 65 ;;", + " esac", + ' elif [[ "$1" == "release" ]]; then', + " shift", + ' case "$1" in', + " download)", + " shift", + " local destination= pattern=", + " while (( $# > 0 )); do", + ' case "$1" in', + ' --dir) destination="$2"; shift 2 ;;', + ' --pattern) pattern="$2"; shift 2 ;;', + " --repo) shift 2 ;;", + " *) shift ;;", + " esac", + " done", + " printf '%s\\n' 'verified archive' > \"$destination/$pattern\"", + " ;;", + " edit)", + " printf '%s: ' 'updated release notes'", + " cat", + " ;;", + " create) return 70 ;;", + " *) return 66 ;;", + " esac", + " else", + " return 64", + " fi", + "}", + "node() {", + ' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" &&', + ' "${2:-}" == "verify-github-release" ]]; then', + " printf '%s\\n' 'verified existing GitHub release asset'", + " return 0", + " fi", + ' command node "$@"', + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + MAKE_LATEST: "false", + MOCK_EXISTING_NOTES: existingNotes, + PREVIOUS_TAG: "npm-v0.1.1", + RELEASE_ARCHIVE: "/tmp/openai-codex-security-0.1.2.tgz", + RELEASE_SHA: releaseCommit, + RELEASE_TAG: "npm-v0.1.2", + RELEASE_VERSION: "0.1.2", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("verified existing GitHub release asset"); + if (updated) { + expect(result.stdout).toContain( + "updated release notes: Generated release notes", + ); + expect(result.stdout).toContain( + "Updated existing GitHub Release with generated notes.", + ); + } else { + expect(result.stdout).not.toContain("updated release notes:"); + expect(result.stdout).toContain( + "The verified GitHub Release already exists.", + ); + } + }, + ); + test("cryptographically verifies the exact npm provenance bundle", () => { expect(githubReleaseWorkflow).toContain('node-version: "24.15.0"'); expect(githubReleaseWorkflow).toContain( @@ -1559,6 +1795,9 @@ describe("GitHub release workflow safeguards", () => { ' "GET repos/test/codex-security/issues/17/labels")', " printf '%s\\n' enhancement skip-release-notes", " ;;", + ' "GET repos/test/codex-security/issues/17/timeline?per_page=100")', + " printf '%s\\n' 'github-actions[bot]' 'trusted-reviewer'", + " ;;", ' "DELETE repos/test/codex-security/issues/17/labels/enhancement" | "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', " printf '%s\\n' 'removed a manually excluded release label'", " return 70", @@ -1595,6 +1834,77 @@ describe("GitHub release workflow safeguards", () => { }, ); + test.each([ + { title: "feat: publish a customer feature", label: "enhancement" }, + { title: "fix: publish a customer fix", label: "bug" }, + { title: "docs: publish customer documentation", label: "documentation" }, + { title: "chore: stop excluding an internal change", label: null }, + ])( + "reconciles an automatic skip label after retitling to $title", + ({ title, label }) => { + const script = workflowStepShell( + releaseLabelsWorkflow, + "Categorize pull request without checking out its code", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' local endpoint="$1"', + " shift", + ' case "$method $endpoint" in', + ' "GET repos/test/codex-security/issues/17")', + " printf '%s\\n' \"$MOCK_PR_TITLE\"", + " ;;", + ' "GET repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' skip-release-notes", + " ;;", + ' "GET repos/test/codex-security/issues/17/timeline?per_page=100")', + " printf '%s\\n' 'github-actions[bot]'", + " ;;", + ' "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', + " printf '%s\\n' 'removed automatically applied skip-release-notes'", + " ;;", + ' "POST repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' \"$@\"", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + MOCK_PR_TITLE: title, + PR_NUMBER: "17", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "removed automatically applied skip-release-notes", + ); + expect(result.stdout).not.toContain( + "Preserving existing skip-release-notes label.", + ); + if (label === null) { + expect(result.stdout).toContain( + "No automatic release-note category applies.", + ); + } else { + expect(result.stdout).toContain(`labels[]=${label}`); + } + }, + ); + test("recovers when another PR concurrently creates the skip label", () => { expect(releaseLabelsWorkflow).toContain( 'if ! gh api --method POST "repos/$GITHUB_REPOSITORY/labels"', From be77a816a8c0af27b1ca8e1202ec6f17db82e27a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:20:55 -0700 Subject: [PATCH 11/20] fix: bind release provenance to authenticated workflow runs --- .github/workflows/node-github-release.yml | 83 ++++++- .github/workflows/node-release-cut.yml | 36 ++- .../tests-ts/release-automation.test.ts | 206 +++++++++++++++++- 3 files changed, 310 insertions(+), 15 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 0d735fe9..a5a5bb1f 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -183,8 +183,11 @@ jobs: id: artifact shell: bash env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ steps.release.outputs.run-id }} RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail shopt -s nullglob @@ -217,17 +220,75 @@ jobs: --no-fund \ --registry=https://registry.npmjs.org/ \ "@openai/codex-security@$RELEASE_VERSION" - npm audit signatures \ - --prefix "$consumer" \ - --registry=https://registry.npmjs.org/ \ - --json \ - --include-attestations | - node sdk/typescript/scripts/release-automation.mjs \ - verify-recovered-provenance \ - "$archive" \ - "$RELEASE_VERSION" \ - "$RELEASE_SHA" \ - "$GITHUB_REPOSITORY" + signature_report="$( + npm audit signatures \ + --prefix "$consumer" \ + --registry=https://registry.npmjs.org/ \ + --json \ + --include-attestations + )" + + if verified_provenance="$( + printf '%s\n' "$signature_report" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-provenance \ + "$archive" \ + "$RELEASE_VERSION" \ + "$RELEASE_SHA" \ + "$GITHUB_REPOSITORY" \ + "$RELEASE_RUN_ID" 2>/dev/null + )"; then + printf '%s\n' "$verified_provenance" + echo "Verified npm provenance for the resolved release run." + else + recovery_conclusion="$( + gh api \ + "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/jobs?per_page=100" \ + --paginate \ + --jq '.jobs[] | select(.name == "publish") | .steps[] | select(.name == "Verify recovered npm release provenance") | .conclusion' + )" + if [[ "$recovery_conclusion" != "success" ]]; then + echo "Signed npm provenance must identify the resolved successful release run." >&2 + exit 1 + fi + + verified_provenance="$( + printf '%s\n' "$signature_report" | + node sdk/typescript/scripts/release-automation.mjs \ + verify-recovered-provenance \ + "$archive" \ + "$RELEASE_VERSION" \ + "$RELEASE_SHA" \ + "$GITHUB_REPOSITORY" + )" + original_run_id="$( + printf '%s\n' "$verified_provenance" | + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const provenance = JSON.parse(readFileSync(0, "utf8")); + if (!/^[1-9][0-9]*$/.test(provenance.runId)) { + throw new Error("The signing run must be a valid workflow run."); + } + process.stdout.write(provenance.runId); + ' + )" + original_run_fields="$( + gh api \ + "repos/$GITHUB_REPOSITORY/actions/runs/$original_run_id" \ + --jq '[.name, .status, .head_sha, .head_branch] | @tsv' + )" + IFS=$'\t' read -r original_name original_status original_sha original_tag <<< "$original_run_fields" + if [[ "$original_name" != "node-release" || + "$original_status" != "completed" || + "$original_sha" != "$RELEASE_SHA" || + "$original_tag" != "$RELEASE_TAG" ]]; then + echo "Recovered npm provenance must identify the exact protected release." >&2 + exit 1 + fi + + printf '%s\n' "$verified_provenance" + echo "Verified protected recovery provenance from its original signing run." + fi printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index ce350cc3..8a430274 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -97,12 +97,42 @@ jobs: run: | set -euo pipefail - existing_sha="$( + existing_ref="$( gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '.object.sha' 2>/dev/null || true + --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null || true )" - if [[ -n "$existing_sha" ]]; then + if [[ -n "$existing_ref" ]]; then + IFS=$'\t' read -r tag_type tag_object <<< "$existing_ref" + if [[ ! "$tag_object" =~ ^[0-9a-f]{40}$ ]]; then + echo "Release tag $RELEASE_TAG does not identify a valid commit." >&2 + exit 1 + fi + + case "$tag_type" in + commit) + existing_sha="$tag_object" + ;; + tag) + if ! existing_sha="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/tags/$tag_object" \ + --jq 'if .object.type == "commit" then .object.sha else empty end' + )"; then + echo "Release tag $RELEASE_TAG does not identify a valid commit." >&2 + exit 1 + fi + ;; + *) + echo "Release tag $RELEASE_TAG does not identify a valid commit." >&2 + exit 1 + ;; + esac + + if [[ ! "$existing_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Release tag $RELEASE_TAG does not identify a valid commit." >&2 + exit 1 + fi if [[ "$existing_sha" != "$GITHUB_SHA" ]]; then echo "Release tag $RELEASE_TAG already points to a different commit." >&2 exit 1 diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index d5f125cf..b9cdddde 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1,6 +1,14 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, test } from "bun:test"; @@ -1269,6 +1277,77 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { + kind: "existing lightweight tag", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + kind: "existing annotated tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: releaseCommit, + status: 0, + }, + { + kind: "retargeted annotated tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: 1, + }, + ])( + "resolves an $kind to its exact commit before cutting a release", + ({ tagType, tagObject, peeledCommit, status }) => { + const script = workflowStepShell( + releaseCutWorkflow, + "Create the exact merged release tag", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ' "repos/test/codex-security/git/ref/tags/npm-v0.1.2")', + ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', + " ;;", + " repos/test/codex-security/git/tags/*)", + " printf '%s\\n' \"$MOCK_PEELED_COMMIT\"", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + GITHUB_SHA: releaseCommit, + MOCK_PEELED_COMMIT: peeledCommit, + MOCK_TAG_OBJECT: tagObject, + MOCK_TAG_TYPE: tagType, + RELEASE_TAG: "npm-v0.1.2", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(status); + if (status === 0) { + expect(result.stdout).toContain( + `Release tag npm-v0.1.2 already points to ${releaseCommit}.`, + ); + } else { + expect(result.stderr).toContain( + "Release tag npm-v0.1.2 already points to a different commit.", + ); + } + }, + ); + test("enforces increasing versions inside the protected npm publisher", () => { expect(protectedReleaseWorkflow).toContain( "sfw npm view @openai/codex-security versions", @@ -1619,6 +1698,131 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { + description: "the exact successful publishing run", + exact: true, + recoveryConclusion: "skipped", + originalCommit: releaseCommit, + status: 0, + message: "Verified npm provenance for the resolved release run.", + }, + { + description: "a different signing run without authenticated recovery", + exact: false, + recoveryConclusion: "skipped", + originalCommit: releaseCommit, + status: 1, + message: + "Signed npm provenance must identify the resolved successful release run.", + }, + { + description: "the authenticated original signing run after recovery", + exact: false, + recoveryConclusion: "success", + originalCommit: releaseCommit, + status: 0, + message: + "Verified protected recovery provenance from its original signing run.", + }, + { + description: "a recovery provenance run for a different source commit", + exact: false, + recoveryConclusion: "success", + originalCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: 1, + message: + "Recovered npm provenance must identify the exact protected release.", + }, + ])( + "binds GitHub release provenance to $description", + ({ exact, recoveryConclusion, originalCommit, status, message }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Verify the public npm package and signed provenance", + ); + const workspace = mkdtempSync( + join(tmpdir(), "codex-security-release-provenance-"), + ); + mkdirSync(join(workspace, "dist")); + writeFileSync(join(workspace, "dist", "release.tgz"), "verified archive"); + + try { + const mocks = [ + "npm() {", + ' case "$1" in', + " view) printf '%s\\n' '{}' ;;", + " install) return 0 ;;", + " audit) printf '%s\\n' '{\"verified\":[]}' ;;", + " *) return 65 ;;", + " esac", + "}", + "node() {", + ' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" ]]; then', + ' case "$2" in', + " verify-publication)", + " printf '%s\\n' 'verified published artifact'", + " ;;", + " verify-provenance)", + ' if [[ "$MOCK_EXACT_PROVENANCE" != "1" || "$7" != "$RELEASE_RUN_ID" ]]; then', + " return 68", + " fi", + ' printf \'{"runId":"%s"}\\n\' "$RELEASE_RUN_ID"', + " ;;", + " verify-recovered-provenance)", + ' printf \'{"runId":"%s"}\\n\' "$MOCK_ORIGINAL_RUN_ID"', + " ;;", + " *) return 66 ;;", + " esac", + " return 0", + " fi", + ' command node "$@"', + "}", + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + ' case "$1" in', + ` "repos/test/codex-security/actions/runs/${releaseRun}/jobs?per_page=100")`, + " printf '%s\\n' \"$MOCK_RECOVERY_CONCLUSION\"", + " ;;", + ' "repos/test/codex-security/actions/runs/30481596228")', + " printf '%s\\t%s\\t%s\\t%s\\n' node-release completed \"$MOCK_ORIGINAL_COMMIT\" npm-v0.1.2", + " ;;", + " *) return 67 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + cwd: workspace, + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: "test/codex-security", + MOCK_EXACT_PROVENANCE: exact ? "1" : "0", + MOCK_ORIGINAL_COMMIT: originalCommit, + MOCK_ORIGINAL_RUN_ID: "30481596228", + MOCK_RECOVERY_CONCLUSION: recoveryConclusion, + RELEASE_RUN_ID: releaseRun, + RELEASE_SHA: releaseCommit, + RELEASE_TAG: "npm-v0.1.2", + RELEASE_VERSION: "0.1.2", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(status); + if (status === 0) { + expect(result.stdout).toContain(message); + } else { + expect(result.stderr).toContain(message); + } + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }, + ); + test.each([ { description: "empty", existingNotes: "", updated: true }, { From 28e42ed65289af471822c4f3daafc07156f6f930 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:35:03 -0700 Subject: [PATCH 12/20] fix: validate release provenance attempts and annotated tags --- .github/workflows/node-github-release.yml | 30 ++++++++--- sdk/typescript/scripts/release-automation.mjs | 4 +- .../tests-ts/release-automation.test.ts | 52 +++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index a5a5bb1f..34a25609 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -99,23 +99,42 @@ jobs: exit 1 fi - if ! tag_object="$( + if ! tag_ref="$( gh api \ "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" \ - --jq '.object.sha' + --jq '[.object.type, .object.sha] | @tsv' )"; then echo "GitHub releases require an existing stable npm-vX.Y.Z tag." >&2 exit 1 fi + IFS=$'\t' read -r tag_type tag_object <<< "$tag_ref" if [[ ! "$tag_object" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "GitHub releases require a valid Git tag object." >&2 exit 1 fi - if ! release_sha="$( - git rev-parse --verify "${tag_object}^{commit}" 2>/dev/null - )"; then + case "$tag_type" in + commit) + release_sha="$tag_object" + ;; + tag) + if ! release_sha="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/tags/$tag_object" \ + --jq 'if .object.type == "commit" then .object.sha else empty end' + )"; then + echo "GitHub releases require a tag pointing to a commit." >&2 + exit 1 + fi + ;; + *) + echo "GitHub releases require a tag pointing to a commit." >&2 + exit 1 + ;; + esac + + if [[ ! "$release_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "GitHub releases require a tag pointing to a commit." >&2 exit 1 fi @@ -422,7 +441,6 @@ jobs: printf '%s\n' "$generated_notes" | gh release edit "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ --notes-file - echo "Updated existing GitHub Release with generated notes." else diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 4f195b9b..9ee952ee 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -554,9 +554,11 @@ export function verifySignatureAudit(report, archive, expected) { ); } const invocation = statement.predicate?.runDetails?.metadata?.invocationId; + const invocationPrefix = `${repository}/actions/runs/${runId}/attempts/`; if ( typeof invocation !== "string" || - !invocation.startsWith(`${repository}/actions/runs/${runId}/attempts/`) + !invocation.startsWith(invocationPrefix) || + !/^[1-9][0-9]*$/u.test(invocation.slice(invocationPrefix.length)) ) { throw new Error( "Verified SLSA provenance must identify the successful release run.", diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index b9cdddde..caef287f 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -210,6 +210,7 @@ type SignatureAuditFixture = { workflowRef?: string; sourceCommit?: string; runId?: string; + attempt?: string; builder?: string; }; @@ -264,7 +265,7 @@ function signatureAudit(options: SignatureAuditFixture = {}): ReleaseMetadata { metadata: { invocationId: `https://github.com/${repository}/actions/runs/` + - `${options.runId ?? releaseRun}/attempts/1`, + `${options.runId ?? releaseRun}/attempts/${options.attempt ?? "1"}`, }, }, }, @@ -736,6 +737,30 @@ describe("cryptographically verified npm provenance", () => { }); }); + test.each(["", "0", "01", "not-a-number", "1/extra", "1?attempt=2"])( + "rejects the malformed protected workflow run attempt %j", + (attempt) => { + expect(() => + verifySignatureAudit( + signatureAudit({ attempt }), + archive, + signatureExpected(), + ), + ).toThrow( + "Verified SLSA provenance must identify the successful release run.", + ); + expect(() => + verifyRecoveredSignatureAudit(signatureAudit({ attempt }), archive, { + version: "0.1.2", + gitHead: releaseCommit, + repository: releaseRepository, + }), + ).toThrow( + "Verified SLSA provenance must identify the protected release run.", + ); + }, + ); + test("rejects a recovered archive whose certificate identifies another run", () => { expect(() => verifyRecoveredSignatureAudit( @@ -1607,14 +1632,15 @@ describe("GitHub release workflow safeguards", () => { }); test.each([ - { kind: "lightweight", objectSha: releaseCommit }, + { kind: "lightweight", tagType: "commit", objectSha: releaseCommit }, { - kind: "annotated", + kind: "annotated with no locally fetched tag object", + tagType: "tag", objectSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, ])( "resolves the exact $kind tag when a same-named branch exists", - ({ objectSha }) => { + ({ tagType, objectSha }) => { const script = workflowStepShell( githubReleaseWorkflow, "Resolve the successful protected release", @@ -1623,6 +1649,7 @@ describe("GitHub release workflow safeguards", () => { const mocks = [ "git() {", ` if [[ "$1" != "rev-parse" || "$2" != "--verify" || "$3" != "${objectSha}^{commit}" ]]; then return 66; fi`, + ' if [[ "$MOCK_TAG_TYPE" == "tag" ]]; then return 67; fi', ` printf '%s\\n' '${releaseCommit}'`, "}", "gh() {", @@ -1630,7 +1657,14 @@ describe("GitHub release workflow safeguards", () => { " shift", ' case "$1" in', ' "repos/openai/codex-security/git/ref/tags/npm-v0.1.2")', - ` printf '%s\\n' '${objectSha}'`, + ' if [[ "$3" == ".object.sha" ]]; then', + ` printf '%s\\n' '${objectSha}'`, + " else", + ` printf '%s\\t%s\\n' '${tagType}' '${objectSha}'`, + " fi", + " ;;", + ` "repos/openai/codex-security/git/tags/${objectSha}")`, + ` printf '%s\\n' '${releaseCommit}'`, " ;;", ' "repos/openai/codex-security/commits/npm-v0.1.2")', ` printf '%s\\n' '${branchCommit}'`, @@ -1650,6 +1684,7 @@ describe("GitHub release workflow safeguards", () => { GITHUB_REPOSITORY: "openai/codex-security", INPUT_RUN_ID: releaseRun, INPUT_TAG: "npm-v0.1.2", + MOCK_TAG_TYPE: tagType, TRIGGER_RUN_ID: "", TRIGGER_TAG: "", }, @@ -1877,6 +1912,13 @@ describe("GitHub release workflow safeguards", () => { " printf '%s\\n' 'verified archive' > \"$destination/$pattern\"", " ;;", " edit)", + " shift", + ' for argument in "$@"; do', + ' if [[ "$argument" == "--verify-tag" ]]; then', + " printf '%s\\n' 'unknown flag: --verify-tag' >&2", + " return 67", + " fi", + " done", " printf '%s: ' 'updated release notes'", " cat", " ;;", From 5131296e2915a554c72c063cfb40dc8684055c58 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:49:11 -0700 Subject: [PATCH 13/20] fix: reconcile release metadata and initial publication safely --- .github/workflows/node-github-release.yml | 74 ++- .github/workflows/node-release-cut.yml | 10 +- .github/workflows/node-release-labels.yml | 6 +- .github/workflows/node-release.yml | 10 +- sdk/typescript/scripts/release-automation.mjs | 18 + .../tests-ts/release-automation.test.ts | 445 +++++++++++++++++- 6 files changed, 544 insertions(+), 19 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 34a25609..927b0af9 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -151,7 +151,7 @@ jobs: )" fi - if [[ ! "$release_run" =~ ^[0-9]+$ ]]; then + if [[ ! "$release_run" =~ ^[1-9][0-9]*$ ]]; then echo "No successful protected npm release exists for $release_tag." >&2 exit 1 fi @@ -386,6 +386,49 @@ jobs: run: | set -euo pipefail + verify_release_tag() { + local live_ref live_type live_object live_commit + if ! live_ref="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv' + )"; then + echo "GitHub release tag must still point to the verified commit." >&2 + return 1 + fi + IFS=$'\t' read -r live_type live_object <<< "$live_ref" + if [[ ! "$live_object" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "GitHub release tag must still point to the verified commit." >&2 + return 1 + fi + + case "$live_type" in + commit) + live_commit="$live_object" + ;; + tag) + if ! live_commit="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/tags/$live_object" \ + --jq 'if .object.type == "commit" then .object.sha else empty end' + )"; then + echo "GitHub release tag must still point to the verified commit." >&2 + return 1 + fi + ;; + *) + echo "GitHub release tag must still point to the verified commit." >&2 + return 1 + ;; + esac + + if [[ ! "$live_commit" =~ ^[0-9a-fA-F]{40}$ || + "$live_commit" != "$RELEASE_SHA" ]]; then + echo "GitHub release tag must still point to the verified commit." >&2 + return 1 + fi + } + if existing_release="$( gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ 2>/dev/null @@ -437,12 +480,32 @@ jobs: ' )" - if [[ "$existing_notes" != "$generated_notes" ]]; then - printf '%s\n' "$generated_notes" | + current_latest_tag="$( + gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' + )" + currently_latest=false + if [[ "$current_latest_tag" == "$RELEASE_TAG" ]]; then + currently_latest=true + fi + + if [[ "$existing_notes" != "$generated_notes" || + "$currently_latest" != "$MAKE_LATEST" ]]; then + verify_release_tag + if [[ "$existing_notes" != "$generated_notes" ]]; then + printf '%s\n' "$generated_notes" | + gh release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --latest="$MAKE_LATEST" \ + --notes-file - + echo "Updated existing GitHub Release with generated notes." + else gh release edit "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ - --notes-file - - echo "Updated existing GitHub Release with generated notes." + --latest="$MAKE_LATEST" + fi + if [[ "$currently_latest" != "$MAKE_LATEST" ]]; then + echo "Updated existing GitHub Release Latest status." + fi else echo "The verified GitHub Release already exists." fi @@ -462,4 +525,5 @@ jobs: release_args+=(--notes-start-tag "$PREVIOUS_TAG") fi + verify_release_tag gh release create "${release_args[@]}" diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index 8a430274..8b2d7867 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -73,11 +73,17 @@ jobs: fi fi - published_versions="$( + if ! published_versions="$( npm view @openai/codex-security versions \ --json \ --registry=https://registry.npmjs.org/ - )" + )"; then + published_versions="$( + printf '%s\n' "$published_versions" | + node sdk/typescript/scripts/release-automation.mjs \ + initial-published-versions "$version" + )" + fi printf '%s\n' "$published_versions" | node sdk/typescript/scripts/release-automation.mjs \ require-published-increase "$version" >/dev/null diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index 2e319774..a284f144 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -67,13 +67,11 @@ jobs: gh api \ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/timeline?per_page=100" \ --paginate \ - --jq '.[] | select(.event == "labeled" and .label.name == "skip-release-notes") | .actor.login' + --jq '.[] | select(.event == "labeled" and .label.name == "skip-release-notes") | if (.actor.login // "") == "" then "__unattributed__" else .actor.login end' )" skip_label_actor="" while IFS= read -r event_actor; do - if [[ -n "$event_actor" ]]; then - skip_label_actor="$event_actor" - fi + skip_label_actor="$event_actor" done <<< "$skip_label_actors" if [[ "$skip_label_actor" != "github-actions[bot]" ]]; then diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 0e5d4254..429e965c 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -93,9 +93,15 @@ jobs: echo "npm release tags must point to a commit on main." >&2 exit 1 fi - published_versions="$( + if ! published_versions="$( sfw npm view @openai/codex-security versions --json - )" + )"; then + published_versions="$( + printf '%s\n' "$published_versions" | + node sdk/typescript/scripts/release-automation.mjs \ + initial-published-versions "$version" + )" + fi mode="$( printf '%s\n' "$published_versions" | node sdk/typescript/scripts/release-automation.mjs \ diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 9ee952ee..97d4d16a 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -73,6 +73,14 @@ export function requireReleaseIncrease(version, previousVersion) { return version; } +export function initialPublishedVersions(version, registryError) { + releaseVersion({ name: packageName, version }); + if (version !== "0.1.0" || registryError?.error?.code !== "E404") { + throw new Error("Unable to verify published npm release history."); + } + return []; +} + export function publishedReleaseMode(version, publishedVersions) { releaseVersion({ name: packageName, version }); if (!Array.isArray(publishedVersions)) { @@ -726,6 +734,14 @@ function main() { return; } + if (command === "initial-published-versions" && process.argv.length === 4) { + const registryError = JSON.parse(readFileSync(0, "utf8")); + console.log( + JSON.stringify(initialPublishedVersions(process.argv[3], registryError)), + ); + return; + } + if (command === "require-published-increase" && process.argv.length === 4) { const publishedVersions = JSON.parse(readFileSync(0, "utf8")); console.log( @@ -824,6 +840,8 @@ function main() { "Usage: release-automation.mjs version , " + "release-tag , " + "require-increase , " + + "initial-published-versions " + + "(npm registry error JSON from stdin), " + "require-published-increase " + "(published npm versions JSON from stdin), " + "release-mode (published npm versions JSON from stdin), " + diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index caef287f..b1d8132c 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -24,6 +24,10 @@ type ReleaseAutomation = { ) => string; compareReleaseVersions: (left: string, right: string) => -1 | 0 | 1; requireReleaseIncrease: (version: string, previousVersion: string) => string; + initialPublishedVersions: ( + version: string, + registryError: unknown, + ) => string[]; publishedReleaseMode: ( version: string, publishedVersions: unknown, @@ -99,6 +103,7 @@ const { releaseTagVersion, compareReleaseVersions, requireReleaseIncrease, + initialPublishedVersions, publishedReleaseMode, requirePublishedReleaseIncrease, releaseHistory, @@ -508,6 +513,27 @@ describe("monotonic stable release versions", () => { expect(publishedReleaseMode("0.1.3", ["0.1.0", "0.1.2"])).toBe("publish"); }); + test("allows a missing npm package only for its initial stable release", () => { + expect( + initialPublishedVersions("0.1.0", { error: { code: "E404" } }), + ).toEqual([]); + }); + + test.each([ + { version: "0.1.1", registryError: { error: { code: "E404" } } }, + { version: "0.1.0", registryError: { error: { code: "E403" } } }, + { version: "0.1.0", registryError: { error: { code: "ETIMEDOUT" } } }, + { version: "0.1.0", registryError: { error: null } }, + { version: "0.1.0", registryError: null }, + ])( + "rejects unverified npm history for $version and registry response $registryError", + ({ version, registryError }) => { + expect(() => initialPublishedVersions(version, registryError)).toThrow( + "Unable to verify published npm release history.", + ); + }, + ); + test("identifies the current published version as recoverable", () => { expect(publishedReleaseMode("0.1.2", ["0.1.0", "0.1.2"])).toBe("recover"); }); @@ -1302,6 +1328,98 @@ describe("GitHub release workflow safeguards", () => { ); }); + test.each([ + { + workflow: "release cut", + version: "0.1.0", + errorCode: "E404", + status: 0, + }, + { + workflow: "protected publisher", + version: "0.1.0", + errorCode: "E404", + status: 0, + }, + { + workflow: "release cut", + version: "0.1.1", + errorCode: "E404", + status: 1, + }, + { + workflow: "protected publisher", + version: "0.1.1", + errorCode: "E404", + status: 1, + }, + { + workflow: "release cut", + version: "0.1.0", + errorCode: "ETIMEDOUT", + status: 1, + }, + { + workflow: "protected publisher", + version: "0.1.0", + errorCode: "E403", + status: 1, + }, + ])( + "handles $errorCode during $workflow npm-history validation for $version", + ({ workflow, version, errorCode, status }) => { + const cutting = workflow === "release cut"; + const script = workflowStepShell( + cutting ? releaseCutWorkflow : protectedReleaseWorkflow, + cutting ? "Resolve the stable package version" : "Validate release tag", + ); + const mocks = [ + "git() { return 0; }", + "node() {", + ' if [[ "${2:-}" == "version" || "${2:-}" == "release-tag" ]]; then', + " printf '%s\\n' \"$MOCK_RELEASE_VERSION\"", + " return 0", + " fi", + ' command node "$@"', + "}", + "npm() {", + " printf '%s\\n' \"$MOCK_REGISTRY_RESPONSE\"", + " return 1", + "}", + "sfw() {", + " printf '%s\\n' \"$MOCK_REGISTRY_RESPONSE\"", + " return 1", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + encoding: "utf8", + env: { + ...process.env, + BEFORE_SHA: "", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_OUTPUT: "/dev/null", + GITHUB_REF: cutting ? "refs/heads/main" : `refs/tags/npm-v${version}`, + GITHUB_REF_NAME: `npm-v${version}`, + GITHUB_REF_TYPE: "tag", + GITHUB_SHA: releaseCommit, + MOCK_REGISTRY_RESPONSE: JSON.stringify({ + error: { code: errorCode }, + }), + MOCK_RELEASE_VERSION: version, + }, + timeout: 10_000, + }); + + expect(result.status).toBe(status); + if (status !== 0) { + expect(result.stderr).toContain( + "Unable to verify published npm release history.", + ); + } + }, + ); + test.each([ { kind: "existing lightweight tag", @@ -1698,6 +1816,146 @@ describe("GitHub release workflow safeguards", () => { }, ); + test.each(["0", "01", "000123"])( + "rejects the noncanonical protected GitHub release run ID %j", + (runId) => { + 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/"*)', + ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' 'node-release' 'completed' 'success' '${releaseCommit}' 'npm-v0.1.2'`, + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_OUTPUT: "/dev/null", + GITHUB_REPOSITORY: "openai/codex-security", + INPUT_RUN_ID: runId, + INPUT_TAG: "npm-v0.1.2", + TRIGGER_RUN_ID: "", + TRIGGER_TAG: "", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "No successful protected npm release exists for npm-v0.1.2.", + ); + }, + ); + + test.each([ + { + description: "verified lightweight release tag", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + description: "verified annotated release tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: releaseCommit, + status: 0, + }, + { + description: "deleted release tag", + tagType: "missing", + tagObject: "", + peeledCommit: "", + status: 1, + }, + { + description: "retargeted lightweight release tag", + tagType: "commit", + tagObject: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + peeledCommit: "", + status: 1, + }, + { + description: "retargeted annotated release tag", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: 1, + }, + ])( + "revalidates the $description immediately before creating its GitHub release", + ({ tagType, tagObject, peeledCommit, status }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Publish GitHub Release and generated notes", + ); + const mocks = [ + "gh() {", + ' if [[ "$1" == "api" ]]; then', + " shift", + ' case "$1" in', + ' "repos/test/codex-security/releases/tags/npm-v0.1.2")', + " return 1", + " ;;", + ' "repos/test/codex-security/git/ref/tags/npm-v0.1.2")', + ' if [[ "$MOCK_TAG_TYPE" == "missing" ]]; then return 1; fi', + ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', + " ;;", + ' "repos/test/codex-security/git/tags/"*)', + " printf '%s\\n' \"$MOCK_PEELED_COMMIT\"", + " ;;", + " *) return 65 ;;", + " esac", + ' elif [[ "$1" == "release" && "$2" == "create" ]]; then', + " printf '%s\\n' 'created verified GitHub release'", + " else", + " return 64", + " fi", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + MAKE_LATEST: "false", + MOCK_PEELED_COMMIT: peeledCommit, + MOCK_TAG_OBJECT: tagObject, + MOCK_TAG_TYPE: tagType, + PREVIOUS_TAG: "npm-v0.1.1", + RELEASE_ARCHIVE: "/tmp/openai-codex-security-0.1.2.tgz", + RELEASE_SHA: releaseCommit, + RELEASE_TAG: "npm-v0.1.2", + RELEASE_VERSION: "0.1.2", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(status); + if (status === 0) { + expect(result.stdout).toContain("created verified GitHub release"); + } else { + expect(result.stdout).not.toContain("created verified GitHub release"); + expect(result.stderr).toContain( + "GitHub release tag must still point to the verified commit.", + ); + } + }, + ); + test("explicitly prevents historical releases becoming latest", () => { expect(githubReleaseWorkflow).toContain("--generate-notes"); expect(githubReleaseWorkflow).toContain('--latest="$MAKE_LATEST"'); @@ -1859,20 +2117,105 @@ describe("GitHub release workflow safeguards", () => { ); test.each([ - { description: "empty", existingNotes: "", updated: true }, + { + description: "empty", + existingNotes: "", + updated: true, + latestTag: "npm-v0.1.3", + makeLatest: false, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, { description: "manually written", existingNotes: "Manually written release notes", updated: true, + latestTag: "npm-v0.1.3", + makeLatest: false, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, }, { description: "already current", existingNotes: "Generated release notes", updated: false, + latestTag: "npm-v0.1.3", + makeLatest: false, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + description: + "already current on a newest release incorrectly marked non-Latest", + existingNotes: "Generated release notes", + updated: false, + latestTag: "npm-v0.1.1", + makeLatest: true, + latestUpdated: true, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + description: + "already current on a historical release incorrectly marked Latest", + existingNotes: "Generated release notes", + updated: false, + latestTag: "npm-v0.1.2", + makeLatest: false, + latestUpdated: true, + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: releaseCommit, + status: 0, + }, + { + description: "stale when its lightweight release tag was retargeted", + existingNotes: "Manually written release notes", + updated: false, + latestTag: "npm-v0.1.3", + makeLatest: false, + latestUpdated: false, + tagType: "commit", + tagObject: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + peeledCommit: "", + status: 1, + }, + { + description: "stale when its annotated release tag was retargeted", + existingNotes: "Manually written release notes", + updated: false, + latestTag: "npm-v0.1.3", + makeLatest: false, + latestUpdated: false, + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + status: 1, }, ])( "reconciles $description notes on an existing verified GitHub release", - ({ existingNotes, updated }) => { + ({ + existingNotes, + updated, + latestTag, + makeLatest, + latestUpdated, + tagType, + tagObject, + peeledCommit, + status, + }) => { const script = workflowStepShell( githubReleaseWorkflow, "Publish GitHub Release and generated notes", @@ -1890,6 +2233,15 @@ describe("GitHub release workflow safeguards", () => { ' "GET repos/test/codex-security/releases/tags/npm-v0.1.2")', ' printf \'{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', " ;;", + ' "GET repos/test/codex-security/releases/latest")', + " printf '%s\\n' \"$MOCK_LATEST_TAG\"", + " ;;", + ' "GET repos/test/codex-security/git/ref/tags/npm-v0.1.2")', + ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', + " ;;", + ' "GET repos/test/codex-security/git/tags/"*)', + " printf '%s\\n' \"$MOCK_PEELED_COMMIT\"", + " ;;", ' "POST repos/test/codex-security/releases/generate-notes")', " printf '%s\\n' 'Generated release notes'", " ;;", @@ -1913,14 +2265,23 @@ describe("GitHub release workflow safeguards", () => { " ;;", " edit)", " shift", + " local edit_latest= notes=0", ' for argument in "$@"; do', ' if [[ "$argument" == "--verify-tag" ]]; then', " printf '%s\\n' 'unknown flag: --verify-tag' >&2", " return 67", " fi", + ' if [[ "$argument" == --latest=* ]]; then', + ' edit_latest="${argument#--latest=}"', + " fi", + ' if [[ "$argument" == "--notes-file" ]]; then notes=1; fi', " done", - " printf '%s: ' 'updated release notes'", - " cat", + ' if [[ "$edit_latest" != "$MOCK_MAKE_LATEST" ]]; then return 68; fi', + " printf 'updated latest: %s\\n' \"$edit_latest\"", + ' if [[ "$notes" == 1 ]]; then', + " printf '%s: ' 'updated release notes'", + " cat", + " fi", " ;;", " create) return 70 ;;", " *) return 66 ;;", @@ -1943,8 +2304,13 @@ describe("GitHub release workflow safeguards", () => { env: { ...process.env, GITHUB_REPOSITORY: "test/codex-security", - MAKE_LATEST: "false", + MAKE_LATEST: String(makeLatest), MOCK_EXISTING_NOTES: existingNotes, + MOCK_LATEST_TAG: latestTag, + MOCK_MAKE_LATEST: String(makeLatest), + MOCK_PEELED_COMMIT: peeledCommit, + MOCK_TAG_OBJECT: tagObject, + MOCK_TAG_TYPE: tagType, PREVIOUS_TAG: "npm-v0.1.1", RELEASE_ARCHIVE: "/tmp/openai-codex-security-0.1.2.tgz", RELEASE_SHA: releaseCommit, @@ -1954,8 +2320,14 @@ describe("GitHub release workflow safeguards", () => { timeout: 10_000, }); - expect(result.status).toBe(0); + expect(result.status).toBe(status); expect(result.stdout).toContain("verified existing GitHub release asset"); + if (status !== 0) { + expect(result.stderr).toContain( + "GitHub release tag must still point to the verified commit.", + ); + return; + } if (updated) { expect(result.stdout).toContain( "updated release notes: Generated release notes", @@ -1963,6 +2335,12 @@ describe("GitHub release workflow safeguards", () => { expect(result.stdout).toContain( "Updated existing GitHub Release with generated notes.", ); + } else if (latestUpdated) { + expect(result.stdout).not.toContain("updated release notes:"); + expect(result.stdout).toContain(`updated latest: ${makeLatest}`); + expect(result.stdout).toContain( + "Updated existing GitHub Release Latest status.", + ); } else { expect(result.stdout).not.toContain("updated release notes:"); expect(result.stdout).toContain( @@ -2080,6 +2458,61 @@ describe("GitHub release workflow safeguards", () => { }, ); + test("preserves the latest unattributed skip label after earlier automation", () => { + const script = workflowStepShell( + releaseLabelsWorkflow, + "Categorize pull request without checking out its code", + ); + const mock = [ + "gh() {", + ' if [[ "$1" != "api" ]]; then return 64; fi', + " shift", + " local method=GET", + ' if [[ "${1:-}" == "--method" ]]; then', + ' method="$2"', + " shift 2", + " fi", + ' local endpoint="$1"', + " shift", + ' case "$method $endpoint" in', + ' "GET repos/test/codex-security/issues/17")', + " printf '%s\\n' 'feat: customer-visible change'", + " ;;", + ' "GET repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' skip-release-notes", + " ;;", + ' "GET repos/test/codex-security/issues/17/timeline?per_page=100")', + ' if [[ "$*" == *"__unattributed__"* ]]; then', + " printf '%s\\n' 'github-actions[bot]' '__unattributed__'", + " else", + " printf '%s\\n' 'github-actions[bot]' ''", + " fi", + " ;;", + ' "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', + " printf '%s\\n' 'removed an unattributed release exclusion'", + " return 70", + " ;;", + " *) return 65 ;;", + " esac", + "}", + ].join("\n"); + const result = spawnSync("bash", ["-c", `${mock}\n${script}`], { + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + PR_NUMBER: "17", + }, + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Preserving existing skip-release-notes label.", + ); + expect(result.stdout).not.toContain("removed an unattributed release"); + }); + test.each([ { title: "feat: publish a customer feature", label: "enhancement" }, { title: "fix: publish a customer fix", label: "bug" }, From 98f750a05d66c36e016b79feb6cbc7385858d6f5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:54:11 -0700 Subject: [PATCH 14/20] test: drain mocked release verification input on macOS --- sdk/typescript/tests-ts/release-automation.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index b1d8132c..aff28ee2 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2293,6 +2293,7 @@ describe("GitHub release workflow safeguards", () => { "node() {", ' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" &&', ' "${2:-}" == "verify-github-release" ]]; then', + " cat >/dev/null", " printf '%s\\n' 'verified existing GitHub release asset'", " return 0", " fi", From 300417e60a6c40a26f3a1bd1e0cb62404a2f194e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 29 Jul 2026 23:59:02 -0700 Subject: [PATCH 15/20] test: stabilize provenance mock pipelines on macOS --- sdk/typescript/tests-ts/release-automation.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index aff28ee2..fc71c53d 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2052,6 +2052,7 @@ describe("GitHub release workflow safeguards", () => { "}", "node() {", ' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" ]]; then', + " cat >/dev/null", ' case "$2" in', " verify-publication)", " printf '%s\\n' 'verified published artifact'", From 7c93c564b1d0e09369336e4084592a64ab4cc0f4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:01:36 -0700 Subject: [PATCH 16/20] fix: preserve latest status when reconciling existing releases --- sdk/typescript/scripts/release-automation.mjs | 2 +- sdk/typescript/tests-ts/release-automation.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 97d4d16a..8ce72b16 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -400,7 +400,7 @@ export function releaseHistory(tag, history) { ) && Array.from(publishedGitHubTags).every( (candidate) => - compareReleaseVersions(version, candidate.slice("npm-v".length)) > 0, + compareReleaseVersions(version, candidate.slice("npm-v".length)) >= 0, ); return { previousTag, makeLatest }; diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index fc71c53d..c97e50f7 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -594,6 +594,16 @@ describe("published GitHub and npm release history", () => { ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: true }); }); + test("keeps an already-published newest GitHub release marked latest", () => { + expect( + releaseHistory("npm-v0.1.2", { + registryVersions: ["0.1.1", "0.1.2"], + githubReleaseTags: ["npm-v0.1.1", "npm-v0.1.2"], + reachableTags: ["npm-v0.1.1", "npm-v0.1.2"], + }), + ).toEqual({ previousTag: "npm-v0.1.1", makeLatest: true }); + }); + test("never marks a historical backfill as latest", () => { expect( releaseHistory("npm-v0.1.2", { From 02662bee352b104e46a13cb26a4c9325b0b780b4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:15:24 -0700 Subject: [PATCH 17/20] fix: reconcile absent latest releases and managed labels --- .github/workflows/node-github-release.yml | 26 +++++- .github/workflows/node-release-cut.yml | 6 ++ .github/workflows/node-release-labels.yml | 18 +++- .../tests-ts/release-automation.test.ts | 82 +++++++++++++++---- 4 files changed, 115 insertions(+), 17 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 927b0af9..8fc69db6 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -480,8 +480,32 @@ jobs: ' )" + latest_query_status=0 + latest_response="$( + gh api "repos/$GITHUB_REPOSITORY/releases/latest" 2>/dev/null + )" || latest_query_status=$? current_latest_tag="$( - gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' + printf '%s\n' "$latest_response" | + CODEX_SECURITY_LATEST_QUERY_STATUS="$latest_query_status" \ + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const response = JSON.parse(readFileSync(0, "utf8")); + if (process.env.CODEX_SECURITY_LATEST_QUERY_STATUS === "0") { + if ( + typeof response.tag_name !== "string" || + response.tag_name.length === 0 + ) { + throw new Error( + "Unable to resolve the current Latest GitHub Release.", + ); + } + process.stdout.write(response.tag_name); + } else if (String(response?.status) !== "404") { + throw new Error( + "Unable to resolve the current Latest GitHub Release.", + ); + } + ' )" currently_latest=false if [[ "$current_latest_tag" == "$RELEASE_TAG" ]]; then diff --git a/.github/workflows/node-release-cut.yml b/.github/workflows/node-release-cut.yml index 8b2d7867..6d8b8d08 100644 --- a/.github/workflows/node-release-cut.yml +++ b/.github/workflows/node-release-cut.yml @@ -30,6 +30,12 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "24.15.0" + package-manager-cache: false + - name: Resolve the stable package version id: release shell: bash diff --git a/.github/workflows/node-release-labels.yml b/.github/workflows/node-release-labels.yml index a284f144..4bc17b86 100644 --- a/.github/workflows/node-release-labels.yml +++ b/.github/workflows/node-release-labels.yml @@ -61,6 +61,7 @@ jobs: --jq '.[].name' )" + preserve_skip_label=false while IFS= read -r current_label; do if [[ "$current_label" == "skip-release-notes" ]]; then skip_label_actors="$( @@ -76,19 +77,27 @@ jobs: if [[ "$skip_label_actor" != "github-actions[bot]" ]]; then echo "Preserving existing skip-release-notes label." - exit 0 + preserve_skip_label=true fi break fi done <<< "$current_labels" + label_already_present=false while IFS= read -r current_label; do case "$current_label" in enhancement | bug | documentation | skip-release-notes) - if [[ "$current_label" != "$label" ]]; then + if [[ "$current_label" == "skip-release-notes" && + "$preserve_skip_label" == true ]]; then + if [[ "$current_label" == "$label" ]]; then + label_already_present=true + fi + elif [[ "$current_label" != "$label" ]]; then gh api --method DELETE \ "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/$current_label" \ --silent + else + label_already_present=true fi ;; esac @@ -99,6 +108,11 @@ jobs: exit 0 fi + if [[ "$label_already_present" == true ]]; then + echo "The current release-note category is already applied." + exit 0 + fi + if [[ "$label" == "skip-release-notes" ]]; then if ! gh api \ "repos/$GITHUB_REPOSITORY/labels/skip-release-notes" \ diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index c97e50f7..4ec8837e 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1296,6 +1296,9 @@ describe("GitHub release workflow safeguards", () => { }); test("restricts release cuts to main and increasing stable versions", () => { + expect(releaseCutWorkflow).toMatch( + /- name: Set up Node\.js\n(?:[^\n]*\n)*?\s+node-version: "24\.15\.0"/u, + ); expect(releaseCutWorkflow).toContain( 'if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then', ); @@ -2177,6 +2180,30 @@ describe("GitHub release workflow safeguards", () => { peeledCommit: "", status: 0, }, + { + description: "already current when no GitHub release is marked Latest", + existingNotes: "Generated release notes", + updated: false, + latestTag: "__missing__", + makeLatest: true, + latestUpdated: true, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + description: "already current when the Latest lookup fails unexpectedly", + existingNotes: "Generated release notes", + updated: false, + latestTag: "__error__", + makeLatest: true, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, { description: "already current on a historical release incorrectly marked Latest", @@ -2245,7 +2272,15 @@ describe("GitHub release workflow safeguards", () => { ' printf \'{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', " ;;", ' "GET repos/test/codex-security/releases/latest")', - " printf '%s\\n' \"$MOCK_LATEST_TAG\"", + ' if [[ "$MOCK_LATEST_TAG" == "__missing__" ]]; then', + ' printf \'%s\\n\' \'{"message":"Not Found","status":"404"}\'', + " return 1", + " fi", + ' if [[ "$MOCK_LATEST_TAG" == "__error__" ]]; then', + ' printf \'%s\\n\' \'{"message":"Unavailable","status":"500"}\'', + " return 1", + " fi", + ' printf \'{"tag_name":"%s"}\\n\' "$MOCK_LATEST_TAG"', " ;;", ' "GET repos/test/codex-security/git/ref/tags/npm-v0.1.2")', ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', @@ -2336,7 +2371,9 @@ describe("GitHub release workflow safeguards", () => { expect(result.stdout).toContain("verified existing GitHub release asset"); if (status !== 0) { expect(result.stderr).toContain( - "GitHub release tag must still point to the verified commit.", + latestTag === "__error__" + ? "Unable to resolve the current Latest GitHub Release." + : "GitHub release tag must still point to the verified commit.", ); return; } @@ -2402,13 +2439,16 @@ describe("GitHub release workflow safeguards", () => { }); test.each([ - { title: "fix: retitle an internal change" }, - { title: "feat: retitle an internal change" }, - { title: "docs: retitle an internal change" }, - { title: "chore: retitle an internal change" }, + { title: "fix: retitle an internal change", expectedLabel: "bug" }, + { title: "feat: retitle an internal change", expectedLabel: "enhancement" }, + { + title: "docs: retitle an internal change", + expectedLabel: "documentation", + }, + { title: "chore: retitle an internal change", expectedLabel: null }, ])( - "preserves a manually excluded release after retitling to $title", - ({ title }) => { + "preserves a manually excluded release and reconciles its category after retitling to $title", + ({ title, expectedLabel }) => { const script = workflowStepShell( releaseLabelsWorkflow, "Categorize pull request without checking out its code", @@ -2434,13 +2474,15 @@ describe("GitHub release workflow safeguards", () => { ' "GET repos/test/codex-security/issues/17/timeline?per_page=100")', " printf '%s\\n' 'github-actions[bot]' 'trusted-reviewer'", " ;;", - ' "DELETE repos/test/codex-security/issues/17/labels/enhancement" | "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', + ' "DELETE repos/test/codex-security/issues/17/labels/enhancement")', + " printf '%s\\n' 'removed a stale managed category'", + " ;;", + ' "DELETE repos/test/codex-security/issues/17/labels/skip-release-notes")', " printf '%s\\n' 'removed a manually excluded release label'", " return 70", " ;;", ' "POST repos/test/codex-security/issues/17/labels")', - " printf '%s\\n' 'overrode a manually excluded release'", - " return 71", + " printf '%s\\n' \"$@\"", " ;;", " *) return 65 ;;", " esac", @@ -2464,9 +2506,17 @@ describe("GitHub release workflow safeguards", () => { expect(result.stdout).not.toContain( "removed a manually excluded release label", ); - expect(result.stdout).not.toContain( - "overrode a manually excluded release", - ); + if (expectedLabel === "enhancement") { + expect(result.stdout).not.toContain("removed a stale managed category"); + expect(result.stdout).not.toContain("labels[]=enhancement"); + } else { + expect(result.stdout).toContain("removed a stale managed category"); + if (expectedLabel === null) { + expect(result.stdout).not.toContain("labels[]="); + } else { + expect(result.stdout).toContain(`labels[]=${expectedLabel}`); + } + } }, ); @@ -2504,6 +2554,9 @@ describe("GitHub release workflow safeguards", () => { " printf '%s\\n' 'removed an unattributed release exclusion'", " return 70", " ;;", + ' "POST repos/test/codex-security/issues/17/labels")', + " printf '%s\\n' \"$@\"", + " ;;", " *) return 65 ;;", " esac", "}", @@ -2523,6 +2576,7 @@ describe("GitHub release workflow safeguards", () => { "Preserving existing skip-release-notes label.", ); expect(result.stdout).not.toContain("removed an unattributed release"); + expect(result.stdout).toContain("labels[]=enhancement"); }); test.each([ From ba3a17f440f7ff8708b824f9b95d361cadd3f873 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:31:27 -0700 Subject: [PATCH 18/20] fix: verify release API status and pin runtime minimum --- .github/workflows/node-github-release.yml | 28 ++++++++-- .github/workflows/node-release.yml | 2 +- .../tests-ts/release-automation.test.ts | 54 +++++++++++++++++-- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 8fc69db6..4c3c9975 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -482,16 +482,38 @@ jobs: latest_query_status=0 latest_response="$( - gh api "repos/$GITHUB_REPOSITORY/releases/latest" 2>/dev/null + gh api --include \ + "repos/$GITHUB_REPOSITORY/releases/latest" 2>/dev/null )" || latest_query_status=$? current_latest_tag="$( printf '%s\n' "$latest_response" | CODEX_SECURITY_LATEST_QUERY_STATUS="$latest_query_status" \ node --input-type=module --eval ' import { readFileSync } from "node:fs"; - const response = JSON.parse(readFileSync(0, "utf8")); + const raw = readFileSync(0, "utf8"); + const separator = raw.search(/\r?\n\r?\n/u); + const status = /^HTTP\/\d+(?:\.\d+)?\s+(\d{3})(?:\s|$)/u.exec( + raw.slice(0, separator), + ); + if (separator < 0 || status == null) { + throw new Error( + "Unable to resolve the current Latest GitHub Release.", + ); + } + let response; + try { + response = JSON.parse( + raw.slice(separator + (raw[separator] === "\r" ? 4 : 2)), + ); + } catch { + throw new Error( + "Unable to resolve the current Latest GitHub Release.", + ); + } if (process.env.CODEX_SECURITY_LATEST_QUERY_STATUS === "0") { if ( + Number(status[1]) < 200 || + Number(status[1]) >= 300 || typeof response.tag_name !== "string" || response.tag_name.length === 0 ) { @@ -500,7 +522,7 @@ jobs: ); } process.stdout.write(response.tag_name); - } else if (String(response?.status) !== "404") { + } else if (status[1] !== "404") { throw new Error( "Unable to resolve the current Latest GitHub Release.", ); diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 429e965c..8cb36aae 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -43,7 +43,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: "22" + node-version: "22.13.0" package-manager-cache: false - name: Activate Socket Firewall diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 4ec8837e..5e0f7c81 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1295,6 +1295,11 @@ describe("GitHub release workflow safeguards", () => { expect(protectedReleaseWorkflow).toContain('"$GITHUB_REF"'); }); + test("pins both supported-minimum verification and protected signing runtimes", () => { + expect(protectedReleaseWorkflow).toContain('node-version: "22.13.0"'); + expect(protectedReleaseWorkflow).toContain('node-version: "24.15.0"'); + }); + test("restricts release cuts to main and increasing stable versions", () => { expect(releaseCutWorkflow).toMatch( /- name: Set up Node\.js\n(?:[^\n]*\n)*?\s+node-version: "24\.15\.0"/u, @@ -2204,6 +2209,31 @@ describe("GitHub release workflow safeguards", () => { peeledCommit: "", status: 1, }, + { + description: + "already current when the Latest lookup has no HTTP response", + existingNotes: "Generated release notes", + updated: false, + latestTag: "__network__", + makeLatest: true, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, + { + description: "already current when the Latest 404 response is malformed", + existingNotes: "Generated release notes", + updated: false, + latestTag: "__malformed_missing__", + makeLatest: true, + latestUpdated: false, + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, { description: "already current on a historical release incorrectly marked Latest", @@ -2263,6 +2293,11 @@ describe("GitHub release workflow safeguards", () => { ' if [[ "$1" == "api" ]]; then', " shift", " local method=GET", + " local include=false", + ' if [[ "${1:-}" == "--include" ]]; then', + " include=true", + " shift", + " fi", ' if [[ "${1:-}" == "--method" ]]; then', ' method="$2"', " shift 2", @@ -2272,15 +2307,21 @@ describe("GitHub release workflow safeguards", () => { ' printf \'{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', " ;;", ' "GET repos/test/codex-security/releases/latest")', + ' if [[ "$include" != "true" ]]; then return 71; fi', ' if [[ "$MOCK_LATEST_TAG" == "__missing__" ]]; then', - ' printf \'%s\\n\' \'{"message":"Not Found","status":"404"}\'', + " printf '%s\\n' 'HTTP/2.0 404 Not Found' 'content-type: application/json' '' '{\"message\":\"Not Found\"}'", " return 1", " fi", ' if [[ "$MOCK_LATEST_TAG" == "__error__" ]]; then', - ' printf \'%s\\n\' \'{"message":"Unavailable","status":"500"}\'', + " printf '%s\\n' 'HTTP/2.0 500 Internal Server Error' 'content-type: application/json' '' '{\"message\":\"Unavailable\"}'", + " return 1", + " fi", + ' if [[ "$MOCK_LATEST_TAG" == "__network__" ]]; then return 1; fi', + ' if [[ "$MOCK_LATEST_TAG" == "__malformed_missing__" ]]; then', + " printf '%s\\n' 'HTTP/2.0 404 Not Found' 'content-type: application/json' '' 'not-json'", " return 1", " fi", - ' printf \'{"tag_name":"%s"}\\n\' "$MOCK_LATEST_TAG"', + ' printf \'HTTP/2.0 200 OK\\ncontent-type: application/json\\n\\n{"tag_name":"%s"}\\n\' "$MOCK_LATEST_TAG"', " ;;", ' "GET repos/test/codex-security/git/ref/tags/npm-v0.1.2")', ' printf \'%s\\t%s\\n\' "$MOCK_TAG_TYPE" "$MOCK_TAG_OBJECT"', @@ -2370,8 +2411,13 @@ describe("GitHub release workflow safeguards", () => { expect(result.status).toBe(status); expect(result.stdout).toContain("verified existing GitHub release asset"); if (status !== 0) { + const latestLookupFailed = [ + "__error__", + "__network__", + "__malformed_missing__", + ].includes(latestTag); expect(result.stderr).toContain( - latestTag === "__error__" + latestLookupFailed ? "Unable to resolve the current Latest GitHub Release." : "GitHub release tag must still point to the verified commit.", ); From 68e0595682d15b589c2349ab64b6f7feaa30c8e9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:41:51 -0700 Subject: [PATCH 19/20] test: stream oversized workflow fixtures on Windows --- sdk/typescript/tests-ts/release-automation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index 5e0f7c81..a96daaca 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -2387,7 +2387,8 @@ describe("GitHub release workflow safeguards", () => { ' command node "$@"', "}", ].join("\n"); - const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + const result = spawnSync("bash", [], { + input: `${mocks}\n${script}`, encoding: "utf8", env: { ...process.env, From 6820cc44cd80b95ac1192f1d73bbd9a144f8add5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 30 Jul 2026 00:45:58 -0700 Subject: [PATCH 20/20] fix: fail closed on existing release lookup errors --- .github/workflows/node-github-release.yml | 107 ++++++++++-------- .../tests-ts/release-automation.test.ts | 66 ++++++++++- 2 files changed, 124 insertions(+), 49 deletions(-) diff --git a/.github/workflows/node-github-release.yml b/.github/workflows/node-github-release.yml index 4c3c9975..a93bee2b 100644 --- a/.github/workflows/node-github-release.yml +++ b/.github/workflows/node-github-release.yml @@ -429,10 +429,52 @@ jobs: fi } - if existing_release="$( - gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ - 2>/dev/null - )"; then + github_release_or_missing() { + local endpoint="$1" label="$2" query_status=0 response + response="$(gh api --include "$endpoint" 2>/dev/null)" || + query_status=$? + printf '%s\n' "$response" | + CODEX_SECURITY_RELEASE_QUERY_STATUS="$query_status" \ + CODEX_SECURITY_RELEASE_LABEL="$label" \ + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + const fail = () => { + throw new Error( + "Unable to resolve the " + + process.env.CODEX_SECURITY_RELEASE_LABEL + + ".", + ); + }; + const raw = readFileSync(0, "utf8"); + const separator = raw.search(/\r?\n\r?\n/u); + const status = /^HTTP\/\d+(?:\.\d+)?\s+(\d{3})(?:\s|$)/u.exec( + raw.slice(0, separator), + ); + if (separator < 0 || status == null) fail(); + let response; + try { + response = JSON.parse( + raw.slice(separator + (raw[separator] === "\r" ? 4 : 2)), + ); + } catch { + fail(); + } + if (response == null || typeof response !== "object") fail(); + if (process.env.CODEX_SECURITY_RELEASE_QUERY_STATUS === "0") { + if (Number(status[1]) < 200 || Number(status[1]) >= 300) fail(); + process.stdout.write(JSON.stringify(response)); + } else if (status[1] !== "404") { + fail(); + } + ' + } + + existing_release="$( + github_release_or_missing \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ + "existing GitHub Release" + )" + if [[ -n "$existing_release" ]]; then asset_name="$(basename "$RELEASE_ARCHIVE")" downloaded_assets="$(mktemp -d)" trap 'rm -rf "$downloaded_assets"' EXIT @@ -480,55 +522,30 @@ jobs: ' )" - latest_query_status=0 latest_response="$( - gh api --include \ - "repos/$GITHUB_REPOSITORY/releases/latest" 2>/dev/null - )" || latest_query_status=$? - current_latest_tag="$( - printf '%s\n' "$latest_response" | - CODEX_SECURITY_LATEST_QUERY_STATUS="$latest_query_status" \ + github_release_or_missing \ + "repos/$GITHUB_REPOSITORY/releases/latest" \ + "current Latest GitHub Release" + )" + current_latest_tag="" + if [[ -n "$latest_response" ]]; then + current_latest_tag="$( + printf '%s\n' "$latest_response" | node --input-type=module --eval ' import { readFileSync } from "node:fs"; - const raw = readFileSync(0, "utf8"); - const separator = raw.search(/\r?\n\r?\n/u); - const status = /^HTTP\/\d+(?:\.\d+)?\s+(\d{3})(?:\s|$)/u.exec( - raw.slice(0, separator), - ); - if (separator < 0 || status == null) { - throw new Error( - "Unable to resolve the current Latest GitHub Release.", - ); - } - let response; - try { - response = JSON.parse( - raw.slice(separator + (raw[separator] === "\r" ? 4 : 2)), - ); - } catch { - throw new Error( - "Unable to resolve the current Latest GitHub Release.", - ); - } - if (process.env.CODEX_SECURITY_LATEST_QUERY_STATUS === "0") { - if ( - Number(status[1]) < 200 || - Number(status[1]) >= 300 || - typeof response.tag_name !== "string" || - response.tag_name.length === 0 - ) { - throw new Error( - "Unable to resolve the current Latest GitHub Release.", - ); - } - process.stdout.write(response.tag_name); - } else if (status[1] !== "404") { + const response = JSON.parse(readFileSync(0, "utf8")); + if ( + typeof response.tag_name !== "string" || + response.tag_name.length === 0 + ) { throw new Error( "Unable to resolve the current Latest GitHub Release.", ); } + process.stdout.write(response.tag_name); ' - )" + )" + fi currently_latest=false if [[ "$current_latest_tag" == "$RELEASE_TAG" ]]; then currently_latest=true diff --git a/sdk/typescript/tests-ts/release-automation.test.ts b/sdk/typescript/tests-ts/release-automation.test.ts index a96daaca..f4775b6b 100644 --- a/sdk/typescript/tests-ts/release-automation.test.ts +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -1880,6 +1880,7 @@ describe("GitHub release workflow safeguards", () => { test.each([ { description: "verified lightweight release tag", + existingLookup: "missing", tagType: "commit", tagObject: releaseCommit, peeledCommit: "", @@ -1887,6 +1888,7 @@ describe("GitHub release workflow safeguards", () => { }, { description: "verified annotated release tag", + existingLookup: "missing", tagType: "tag", tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", peeledCommit: releaseCommit, @@ -1894,6 +1896,7 @@ describe("GitHub release workflow safeguards", () => { }, { description: "deleted release tag", + existingLookup: "missing", tagType: "missing", tagObject: "", peeledCommit: "", @@ -1901,6 +1904,7 @@ describe("GitHub release workflow safeguards", () => { }, { description: "retargeted lightweight release tag", + existingLookup: "missing", tagType: "commit", tagObject: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", peeledCommit: "", @@ -1908,14 +1912,47 @@ describe("GitHub release workflow safeguards", () => { }, { description: "retargeted annotated release tag", + existingLookup: "missing", tagType: "tag", tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", peeledCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", status: 1, }, + { + description: "release lookup returning an unexpected HTTP 500", + existingLookup: "unavailable", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, + { + description: "release lookup rejecting its authentication", + existingLookup: "unauthorized", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, + { + description: "release lookup without an HTTP response", + existingLookup: "network", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, + { + description: "release lookup returning a malformed HTTP 404", + existingLookup: "malformed", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 1, + }, ])( "revalidates the $description immediately before creating its GitHub release", - ({ tagType, tagObject, peeledCommit, status }) => { + ({ existingLookup, tagType, tagObject, peeledCommit, status }) => { const script = workflowStepShell( githubReleaseWorkflow, "Publish GitHub Release and generated notes", @@ -1924,8 +1961,24 @@ describe("GitHub release workflow safeguards", () => { "gh() {", ' if [[ "$1" == "api" ]]; then', " shift", + ' if [[ "${1:-}" == "--include" ]]; then shift; fi', ' case "$1" in', ' "repos/test/codex-security/releases/tags/npm-v0.1.2")', + ' case "$MOCK_EXISTING_LOOKUP" in', + " missing)", + " printf '%s\\n' 'HTTP/2.0 404 Not Found' 'content-type: application/json' '' '{\"message\":\"Not Found\"}'", + " ;;", + " unavailable)", + " printf '%s\\n' 'HTTP/2.0 500 Internal Server Error' 'content-type: application/json' '' '{\"message\":\"Unavailable\"}'", + " ;;", + " unauthorized)", + " printf '%s\\n' 'HTTP/2.0 401 Unauthorized' 'content-type: application/json' '' '{\"message\":\"Bad credentials\"}'", + " ;;", + " network) ;;", + " malformed)", + " printf '%s\\n' 'HTTP/2.0 404 Not Found' 'content-type: application/json' '' 'not-json'", + " ;;", + " esac", " return 1", " ;;", ' "repos/test/codex-security/git/ref/tags/npm-v0.1.2")', @@ -1944,12 +1997,14 @@ describe("GitHub release workflow safeguards", () => { " fi", "}", ].join("\n"); - const result = spawnSync("bash", ["-c", `${mocks}\n${script}`], { + const result = spawnSync("bash", [], { + input: `${mocks}\n${script}`, encoding: "utf8", env: { ...process.env, GITHUB_REPOSITORY: "test/codex-security", MAKE_LATEST: "false", + MOCK_EXISTING_LOOKUP: existingLookup, MOCK_PEELED_COMMIT: peeledCommit, MOCK_TAG_OBJECT: tagObject, MOCK_TAG_TYPE: tagType, @@ -1968,7 +2023,9 @@ describe("GitHub release workflow safeguards", () => { } else { expect(result.stdout).not.toContain("created verified GitHub release"); expect(result.stderr).toContain( - "GitHub release tag must still point to the verified commit.", + existingLookup === "missing" + ? "GitHub release tag must still point to the verified commit." + : "Unable to resolve the existing GitHub Release.", ); } }, @@ -2304,7 +2361,8 @@ describe("GitHub release workflow safeguards", () => { " fi", ' case "$method $1" in', ' "GET repos/test/codex-security/releases/tags/npm-v0.1.2")', - ' printf \'{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', + ' if [[ "$include" != "true" ]]; then return 71; fi', + ' printf \'HTTP/2.0 200 OK\\ncontent-type: application/json\\n\\n{"tag_name":"npm-v0.1.2","draft":false,"prerelease":false,"body":"%s","assets":[]}\\n\' "$MOCK_EXISTING_NOTES"', " ;;", ' "GET repos/test/codex-security/releases/latest")', ' if [[ "$include" != "true" ]]; then return 71; fi',