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..a93bee2b --- /dev/null +++ b/.github/workflows/node-github-release.yml @@ -0,0 +1,592 @@ +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 + queue: max + +jobs: + release: + if: >- + github.repository == 'openai/codex-security' && + ((github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success')) + 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 + ref: refs/heads/main + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + 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 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 + 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 + + if ! tag_ref="$( + gh api \ + "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" \ + --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 + + 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 + + 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")] | first | .databaseId // empty' + )" + fi + + if [[ ! "$release_run" =~ ^[1-9][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 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 + mkdir -p dist + if ! gh run download "$RELEASE_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --name npm-release-tarball \ + --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 signed provenance + 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 + + 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" + + 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" + 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" + + - 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: + 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 }} + PREVIOUS_TAG: ${{ steps.history.outputs.previous-tag }} + MAKE_LATEST: ${{ steps.history.outputs.make-latest }} + 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 + } + + 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 + 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" \ + "$downloaded_archive" + + 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 ?? ""); + ' + )" + + latest_response="$( + 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 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 + 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" \ + --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 + exit 0 + fi + + release_args=( + "$RELEASE_TAG" + "$RELEASE_ARCHIVE" + --repo "$GITHUB_REPOSITORY" + --title "Codex Security $RELEASE_VERSION" + --verify-tag + --generate-notes + --latest="$MAKE_LATEST" + ) + if [[ -n "$PREVIOUS_TAG" ]]; then + 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 new file mode 100644 index 00000000..6d8b8d08 --- /dev/null +++ b/.github/workflows/node-release-cut.yml @@ -0,0 +1,171 @@ +name: node-release-cut + +on: + push: + branches: [main] + paths: + - sdk/typescript/package.json + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: node-release-cut + queue: max + +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: 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 + env: + BEFORE_SHA: ${{ github.event.before }} + 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 + 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 + node sdk/typescript/scripts/release-automation.mjs \ + require-increase "$version" "$previous_version" >/dev/null + fi + fi + + 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 + + { + 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_ref="$( + gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '[.object.type, .object.sha] | @tsv' 2>/dev/null || true + )" + + 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 + 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..4bc17b86 --- /dev/null +++ b/.github/workflows/node-release-labels.yml @@ -0,0 +1,135 @@ +name: node-release-labels + +on: + pull_request_target: + branches: [main] + types: [opened, edited, reopened] + +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' + 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 }} + run: | + set -euo pipefail + + release_note_label() { + case "$1" in + feat:* | feat\(*\):* | feat!:* | feat\(*\)!:*) + printf '%s\n' enhancement + ;; + fix:* | fix\(*\):* | fix!:* | fix\(*\)!:*) + printf '%s\n' bug + ;; + docs:* | docs\(*\):* | docs!:* | docs\(*\)!:*) + printf '%s\n' documentation + ;; + release:* | release\(*\):* | release!:* | release\(*\)!:* | test:* | test\(*\):* | test!:* | test\(*\)!:*) + printf '%s\n' skip-release-notes + ;; + *) + return 0 + ;; + esac + } + + 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' + )" + + preserve_skip_label=false + while IFS= read -r current_label; do + if [[ "$current_label" == "skip-release-notes" ]]; then + 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") | if (.actor.login // "") == "" then "__unattributed__" else .actor.login end' + )" + skip_label_actor="" + while IFS= read -r event_actor; do + skip_label_actor="$event_actor" + done <<< "$skip_label_actors" + + if [[ "$skip_label_actor" != "github-actions[bot]" ]]; then + echo "Preserving existing skip-release-notes label." + 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" == "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 + done <<< "$current_labels" + + if [[ -z "$label" ]]; then + echo "No automatic release-note category applies." + 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" \ + --silent >/dev/null 2>&1; then + 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 >/dev/null 2>&1; then + gh api \ + "repos/$GITHUB_REPOSITORY/labels/skip-release-notes" \ + --silent >/dev/null + fi + 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 0c7ca663..8cb36aae 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -4,10 +4,11 @@ on: push: tags: - "npm-v*" + workflow_dispatch: concurrency: group: ${{ github.workflow }} - cancel-in-progress: false + queue: max jobs: verify: @@ -23,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 @@ -40,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 @@ -78,22 +81,39 @@ 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 exit 1 fi - echo "version=$version" >> "$GITHUB_OUTPUT" + 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 \ + 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 +143,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 +219,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 +281,111 @@ 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: 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: 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 +398,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 new file mode 100644 index 00000000..8ce72b16 --- /dev/null +++ b/sdk/typescript/scripts/release-automation.mjs @@ -0,0 +1,865 @@ +import { createHash, X509Certificate } 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"; +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")) { + 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) { + 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 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 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)) { + 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) + ) { + 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; +} + +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); + if (child.end <= cursor) { + throw invalidSigningCertificate(); + } + 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 ( + !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(publishedVersions).every( + (candidate) => compareReleaseVersions(version, candidate) >= 0, + ) && + 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) { + 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 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 = 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."); + } + 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; + const invocationPrefix = `${repository}/actions/runs/${runId}/attempts/`; + if ( + typeof invocation !== "string" || + !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.", + ); + } + + 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.", + ); + } + + verifySigningCertificate(provenance.bundle, { + repository, + releaseRef, + gitHead: expected.gitHead, + runId, + }); + + return { + version, + gitHead: expected.gitHead, + repository: expected.repository, + runId, + sha512, + }; +} + +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."); + } + 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; + 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.", + ); + } + + 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 === "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 === "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( + requirePublishedReleaseIncrease(process.argv[3], publishedVersions), + ); + 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 ?? "[]", + ); + 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]); + const verified = verifyPublishedRelease(metadata, archive, { + version: process.argv[4], + gitHead: process.argv[5], + }); + console.log(JSON.stringify(verified)); + 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-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); + const verified = verifyGitHubRelease( + release, + archive, + process.argv[4], + basename(archivePath), + process.argv[5] === undefined ? undefined : readFileSync(process.argv[5]), + ); + console.log(JSON.stringify(verified)); + return; + } + + throw new Error( + "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), " + + "release-history , " + + "verify-publication " + + "(package metadata JSON from stdin), " + + "verify-provenance " + + "(signature audit JSON from stdin), " + + "verify-recovered-provenance " + + " (signature audit JSON from stdin), or " + + "verify-github-release [downloaded-asset] " + + "(GitHub release JSON from stdin).", + ); +} + +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..f4775b6b --- /dev/null +++ b/sdk/typescript/tests-ts/release-automation.test.ts @@ -0,0 +1,2886 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +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"; + +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; + initialPublishedVersions: ( + version: string, + registryError: unknown, + ) => string[]; + publishedReleaseMode: ( + version: string, + publishedVersions: unknown, + ) => "publish" | "recover"; + requirePublishedReleaseIncrease: ( + version: string, + publishedVersions: unknown, + ) => string; + releaseHistory: ( + tag: string, + history: { + registryVersions: string[]; + githubReleaseTags: string[]; + reachableTags: string[]; + }, + ) => { previousTag: string | null; makeLatest: boolean }; + verifyPublishedRelease: ( + metadata: ReleaseMetadata, + archive: Uint8Array, + expected: { version: string; gitHead: string }, + ) => { + version: string; + gitHead: string; + 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; + }; + 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 }; +}; + +const automationScript = new URL( + "../scripts/release-automation.mjs", + import.meta.url, +); +const { + releaseVersion, + releaseTagVersion, + compareReleaseVersions, + requireReleaseIncrease, + initialPublishedVersions, + publishedReleaseMode, + requirePublishedReleaseIncrease, + releaseHistory, + verifyPublishedRelease, + verifySignatureAudit, + verifyRecoveredSignatureAudit, + verifyGitHubRelease, +} = (await import(automationScript.href)) as ReleaseAutomation; + +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"); +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", + import.meta.url, + ), + "utf8", +); +const releaseLabelsWorkflow = readFileSync( + new URL( + "../../../.github/workflows/node-release-labels.yml", + import.meta.url, + ), + "utf8", +); + +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 }], + }; +} + +type SignatureAuditFixture = { + name?: string; + version?: string; + registry?: string; + invalid?: unknown[]; + missing?: unknown[]; + includeVerified?: boolean; + includeProvenance?: boolean; + includeBundle?: boolean; + signingCertificate?: string | null; + certificateLocation?: "certificate" | "chain"; + subjectName?: string; + subjectDigest?: string; + repository?: string; + workflowPath?: string; + workflowRef?: string; + sourceCommit?: string; + runId?: string; + attempt?: 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 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: [ + { + 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/${options.attempt ?? "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: { + verificationMaterial, + dsseEnvelope: { + payload: Buffer.from( + JSON.stringify(statement), + ).toString("base64"), + }, + }, + }, + ], + }, + ], + }; +} + +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", + gitHead: releaseCommit, + repository: releaseRepository, + runId: releaseRun, + }; +} + +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( + 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("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.", + ); + }); + + 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("identifies a strictly increasing version as a new publication", () => { + 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"); + }); + + 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"]), + ).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", () => { + 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("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", { + 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("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", { + 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( + 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("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("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.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( + 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( + 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("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( + 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( + 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 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( + 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( + 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("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( + { ...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.", + ); + }); + + 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", () => { + 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("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, + ); + 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"); + 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\",\"999999999999999999999999.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.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", + 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", + ); + 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, + "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\",\"999999999999999999999999.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/${checkedOutTag}`, + GITHUB_REF_NAME: checkedOutTag, + 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("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, + ); + 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("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"', + ); + }); + + 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.each([ + { kind: "lightweight", tagType: "commit", objectSha: releaseCommit }, + { + kind: "annotated with no locally fetched tag object", + tagType: "tag", + objectSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ])( + "resolves the exact $kind tag when a same-named branch exists", + ({ tagType, 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`, + ' if [[ "$MOCK_TAG_TYPE" == "tag" ]]; then return 67; 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")', + ' 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}'`, + " ;;", + ` "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", + MOCK_TAG_TYPE: tagType, + 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.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", + existingLookup: "missing", + tagType: "commit", + tagObject: releaseCommit, + peeledCommit: "", + status: 0, + }, + { + description: "verified annotated release tag", + existingLookup: "missing", + tagType: "tag", + tagObject: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + peeledCommit: releaseCommit, + status: 0, + }, + { + description: "deleted release tag", + existingLookup: "missing", + tagType: "missing", + tagObject: "", + peeledCommit: "", + status: 1, + }, + { + description: "retargeted lightweight release tag", + existingLookup: "missing", + tagType: "commit", + tagObject: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + peeledCommit: "", + status: 1, + }, + { + 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", + ({ existingLookup, tagType, tagObject, peeledCommit, status }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Publish GitHub Release and generated notes", + ); + const mocks = [ + "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")', + ' 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", [], { + 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, + 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( + existingLookup === "missing" + ? "GitHub release tag must still point to the verified commit." + : "Unable to resolve the existing GitHub Release.", + ); + } + }, + ); + + test("explicitly prevents historical releases becoming latest", () => { + expect(githubReleaseWorkflow).toContain("--generate-notes"); + 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("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("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.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', + " cat >/dev/null", + ' 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, + 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 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 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", + 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, + latestTag, + makeLatest, + latestUpdated, + tagType, + tagObject, + peeledCommit, + status, + }) => { + const script = workflowStepShell( + githubReleaseWorkflow, + "Publish GitHub Release and generated notes", + ); + const mocks = [ + "gh() {", + ' 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", + " fi", + ' case "$method $1" in', + ' "GET repos/test/codex-security/releases/tags/npm-v0.1.2")', + ' 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', + ' if [[ "$MOCK_LATEST_TAG" == "__missing__" ]]; then', + " 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' '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 \'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"', + " ;;", + ' "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'", + " ;;", + " *) 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)", + " 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", + ' 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 ;;", + " esac", + " else", + " return 64", + " fi", + "}", + "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", + ' command node "$@"', + "}", + ].join("\n"); + const result = spawnSync("bash", [], { + input: `${mocks}\n${script}`, + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: "test/codex-security", + 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, + RELEASE_TAG: "npm-v0.1.2", + RELEASE_VERSION: "0.1.2", + }, + timeout: 10_000, + }); + + 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( + latestLookupFailed + ? "Unable to resolve the current Latest GitHub Release." + : "GitHub release tag must still point to the verified commit.", + ); + return; + } + if (updated) { + expect(result.stdout).toContain( + "updated release notes: Generated release notes", + ); + 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( + "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( + 'if [[ "$(npm --version)" != "11.12.1" ]]; then', + ); + expect(githubReleaseWorkflow).toContain("npm audit signatures"); + expect(githubReleaseWorkflow).toContain("--include-attestations"); + 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", () => { + expect(releaseLabelsWorkflow).toContain( + "group: node-release-labels-${{ github.event.pull_request.number }}", + ); + expect(releaseLabelsWorkflow).toContain( + 'gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER"', + ); + expect(releaseLabelsWorkflow).toContain( + 'label="$(release_note_label "$current_title")"', + ); + expect(releaseLabelsWorkflow).toContain( + "enhancement | bug | documentation | skip-release-notes)", + ); + expect(releaseLabelsWorkflow).toContain("gh api --method DELETE"); + expect(releaseLabelsWorkflow).toContain("return 0"); + }); + + test.each([ + { 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 and reconciles its category after retitling to $title", + ({ title, expectedLabel }) => { + 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", + " ;;", + ' "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")', + " 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' \"$@\"", + " ;;", + " *) 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", + ); + 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}`); + } + } + }, + ); + + 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", + " ;;", + ' "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", + 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"); + expect(result.stdout).toContain("labels[]=enhancement"); + }); + + 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"', + ); + expect(releaseLabelsWorkflow).toContain( + '"repos/$GITHUB_REPOSITORY/labels/skip-release-notes"', + ); + }); + + 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, + "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", + [fileURLToPath(automationScript), "unknown"], + { 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"); + }); +});