From 787f52f4449aec66b845a3a1f69d456f8d25a481 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 13:43:50 +0800 Subject: [PATCH 1/4] fix: harden release preparation and publish handoff --- .github/workflows/publish.yml | 896 +++++++++++++++-- .github/workflows/release-please.yml | 53 +- RELEASING.md | 558 ++++++++++- scripts/release-workflow-validation.mjs | 1030 +++++++++++++++++++- tests/release-workflow-validation.test.mjs | 935 +++++++++++++++++- tests/workflow-contract.test.mjs | 242 ++++- 6 files changed, 3535 insertions(+), 179 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5fe7c7d..2defe69 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,12 +8,16 @@ on: - completed workflow_dispatch: inputs: - recovery_task: - description: Exact one-cycle recovery task. + publish_operation: + description: Exact publication operation. + required: true + type: string + control_commit: + description: Commit containing the dispatched workflow. required: true type: string release_commit: - description: Immutable v0.1.1 release commit. + description: Immutable release commit. required: true type: string release_tag: @@ -29,12 +33,16 @@ on: required: true type: string source_publish_run_id: - description: Failed Publish run whose evidence is being recovered. - required: true + description: Failed Publish run used only by an authorized recovery. + required: false type: string source_publish_run_attempt: - description: Failed Publish run attempt. - required: true + description: Failed Publish attempt used only by an authorized recovery. + required: false + type: string + recovery_policy_id: + description: Captured temporary main deployment-policy ID for recovery. + required: false type: string permissions: @@ -47,17 +55,271 @@ concurrency: cancel-in-progress: false jobs: + handoff: + name: Dispatch publication from the immutable release tag + if: >- + vars.RELEASE_PLEASE_ENABLED == 'true' && + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + steps: + - name: Check out the successful Release Please commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha }} + - name: Set up Node.js 24 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + cache: npm + - name: Classify the exact Release Please handoff + id: result + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ARTIFACTS: ${{ runner.temp }}/release-please-artifacts.json + RELEASE_JOBS: ${{ runner.temp }}/release-please-jobs.json + RELEASE_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + RELEASE_RUN_ID: ${{ github.event.workflow_run.id }} + shell: bash + run: | + set -euo pipefail + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_RUN_ID}/artifacts?per_page=100" \ + > "$RELEASE_ARTIFACTS" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_RUN_ID}/attempts/${RELEASE_RUN_ATTEMPT}/jobs?per_page=100" \ + > "$RELEASE_JOBS" + node --input-type=module <<'EOF' + import { appendFileSync, readFileSync } from "node:fs"; + import { classifyReleasePleaseHandoff } from "./scripts/release-workflow-validation.mjs"; + + const result = classifyReleasePleaseHandoff({ + artifacts: JSON.parse( + readFileSync(process.env.RELEASE_ARTIFACTS, "utf8"), + ).artifacts, + jobs: JSON.parse(readFileSync(process.env.RELEASE_JOBS, "utf8")).jobs, + runAttempt: Number(process.env.RELEASE_RUN_ATTEMPT), + runId: Number(process.env.RELEASE_RUN_ID), + }); + appendFileSync( + process.env.GITHUB_OUTPUT, + `has-result=${String(result.hasResult)}\n`, + ); + EOF + - name: Install validation dependencies without lifecycle scripts + if: steps.result.outputs.has-result == 'true' + run: npm ci --ignore-scripts + - name: Download the exact Release Please result + if: steps.result.outputs.has-result == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} + path: ${{ runner.temp }}/release-please-result + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + - name: Validate the exact release and tag dispatch contract + id: release + if: steps.result.outputs.has-result == 'true' + env: + EXPECTED_REPOSITORY: cometapi-dev/cometapi-node + EXPECTED_WORKFLOW: Release Please + EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml + GH_TOKEN: ${{ github.token }} + RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json + TAGGED_PUBLISH_WORKFLOW: ${{ runner.temp }}/tagged-publish.yml + WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -euo pipefail + if [[ "$(git rev-parse HEAD)" != "$WORKFLOW_SHA" ]]; then + echo "The handoff checkout does not match the successful Release Please SHA." >&2 + exit 1 + fi + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then + echo "The release commit is no longer an ancestor of origin/main." >&2 + exit 1 + fi + + node --input-type=module <<'EOF' + import { appendFileSync, readFileSync } from "node:fs"; + import { + validateReleasePleaseActionResult, + validateReleaseWorkflowRun, + } from "./scripts/release-workflow-validation.mjs"; + + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const run = validateReleaseWorkflowRun(event, { + checkedOutSha: process.env.WORKFLOW_SHA, + repository: process.env.EXPECTED_REPOSITORY, + workflowName: process.env.EXPECTED_WORKFLOW, + workflowPath: process.env.EXPECTED_WORKFLOW_PATH, + }); + const manifest = JSON.parse(readFileSync("package.json", "utf8")); + const release = validateReleasePleaseActionResult( + JSON.parse(readFileSync(process.env.RELEASE_RESULT, "utf8")), + { + releaseCommit: run.releaseCommit, + repository: process.env.EXPECTED_REPOSITORY, + runAttempt: run.runAttempt, + runId: run.runId, + version: manifest.version, + workflowName: process.env.EXPECTED_WORKFLOW, + workflowPath: process.env.EXPECTED_WORKFLOW_PATH, + }, + ); + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `release-commit=${release.releaseCommit}`, + `release-run-attempt=${run.runAttempt}`, + `release-run-id=${run.runId}`, + `release-tag=${release.tag}`, + `release-url=${release.htmlUrl}`, + "", + ].join("\n"), + ); + EOF + + release_tag="$(sed -n 's/^release-tag=//p' "$GITHUB_OUTPUT")" + release_url="$(sed -n 's/^release-url=//p' "$GITHUB_OUTPUT")" + if [[ -z "$release_tag" || -z "$release_url" ]]; then + echo "The exact release handoff outputs are missing." >&2 + exit 1 + fi + release_json="$RUNNER_TEMP/handoff-release.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" > "$release_json" + git fetch --no-tags origin "+refs/tags/${release_tag}:refs/tags/${release_tag}" + tag_commit="$(git rev-parse --verify "refs/tags/${release_tag}^{commit}")" + git show "refs/tags/${release_tag}:.github/workflows/publish.yml" \ + > "$TAGGED_PUBLISH_WORKFLOW" + + RELEASE_JSON="$release_json" RELEASE_TAG="$release_tag" \ + RELEASE_URL="$release_url" TAG_COMMIT="$tag_commit" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { parse } from "yaml"; + import { + extractReleaseNotesFromChangelog, + validateGitHubRelease, + validatePublishWorkflowContract, + } from "./scripts/release-workflow-validation.mjs"; + + const version = JSON.parse(readFileSync("package.json", "utf8")).version; + validateGitHubRelease( + JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8")), + { + expectedBody: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + version, + ), + htmlUrl: process.env.RELEASE_URL, + releaseCommit: process.env.WORKFLOW_SHA, + tag: process.env.RELEASE_TAG, + tagCommit: process.env.TAG_COMMIT, + }, + ); + validatePublishWorkflowContract( + parse(readFileSync(process.env.TAGGED_PUBLISH_WORKFLOW, "utf8")), + ); + EOF + - name: Dispatch the exact immutable tag + if: steps.result.outputs.has-result == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ steps.release.outputs.release-commit }} + RELEASE_RUN_ATTEMPT: ${{ steps.release.outputs.release-run-attempt }} + RELEASE_RUN_ID: ${{ steps.release.outputs.release-run-id }} + RELEASE_TAG: ${{ steps.release.outputs.release-tag }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then + echo "origin/main moved away from the release commit before handoff." >&2 + exit 1 + fi + if [[ "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" != "$RELEASE_COMMIT" ]]; then + echo "The immutable release tag changed before handoff." >&2 + exit 1 + fi + before_runs="$RUNNER_TEMP/tag-dispatch-runs-before.json" + after_runs="$RUNNER_TEMP/tag-dispatch-runs-after.json" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100" \ + | jq '[.[].workflow_runs[].id]' > "$before_runs" + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/dispatches" \ + -f ref="$RELEASE_TAG" \ + -f "inputs[publish_operation]=release" \ + -f "inputs[control_commit]=$RELEASE_COMMIT" \ + -f "inputs[release_commit]=$RELEASE_COMMIT" \ + -f "inputs[release_tag]=$RELEASE_TAG" \ + -f "inputs[release_run_id]=$RELEASE_RUN_ID" \ + -f "inputs[release_run_attempt]=$RELEASE_RUN_ATTEMPT" + + publish_run_id="" + for _ in {1..12}; do + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100" \ + | jq '[.[].workflow_runs[]]' > "$after_runs" + candidate_count="$(jq \ + --arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \ + --slurpfile before "$before_runs" \ + '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | + select(.actor.login == "github-actions[bot]" and + .triggering_actor.login == "github-actions[bot]" and + .event == "workflow_dispatch" and .head_branch == $tag and + .head_sha == $commit)] | length' "$after_runs")" + if [[ "$candidate_count" -gt 1 ]]; then + echo "Multiple Publish runs matched the immutable tag handoff." >&2 + exit 1 + fi + if [[ "$candidate_count" == "1" ]]; then + publish_run_id="$(jq -r \ + --arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \ + --slurpfile before "$before_runs" \ + '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | + select(.actor.login == "github-actions[bot]" and + .triggering_actor.login == "github-actions[bot]" and + .event == "workflow_dispatch" and .head_branch == $tag and + .head_sha == $commit)][0].id' "$after_runs")" + break + fi + sleep 5 + done + if [[ ! "$publish_run_id" =~ ^[1-9][0-9]*$ ]]; then + echo "The immutable tag dispatch did not create one exact Publish run." >&2 + exit 1 + fi + + echo "Dispatched exact Publish run ${publish_run_id} from ${RELEASE_TAG}." + verify: name: Verify the immutable release artifact if: >- vars.RELEASE_PLEASE_ENABLED == 'true' && - ((github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main') || - (github.event_name == 'workflow_dispatch' && - github.ref == 'refs/tags/v0.1.1' && - inputs.recovery_task == 'npm-publish-recovery' && + github.event_name == 'workflow_dispatch' && + ((inputs.publish_operation == 'release' && + startsWith(github.ref, 'refs/tags/v0.1.') && + github.ref == format('refs/tags/{0}', inputs.release_tag) && + github.sha == inputs.release_commit && + github.workflow_sha == inputs.control_commit && + inputs.control_commit == inputs.release_commit) || + (inputs.publish_operation == 'recover-v0.1.1' && + inputs.recovery_policy_id != '' && + github.ref == 'refs/heads/main' && + github.sha == inputs.control_commit && + github.workflow_sha == inputs.control_commit && inputs.release_commit == 'c98b514227858cd183c781270a7f78f65b577e82' && inputs.release_tag == 'v0.1.1' && inputs.release_run_id == '30469181724' && @@ -67,14 +329,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 env: - SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'workflow_dispatch' && inputs.release_commit || github.event.workflow_run.head_sha }} - SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'workflow_dispatch' && inputs.release_run_attempt || github.event.workflow_run.run_attempt }} - SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.release_run_id || github.event.workflow_run.id }} + SOURCE_RELEASE_COMMIT: ${{ inputs.release_commit }} + SOURCE_RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + SOURCE_RELEASE_RUN_ID: ${{ inputs.release_run_id }} outputs: artifact-name: ${{ steps.artifact-name.outputs.name }} + control-commit: ${{ inputs.control_commit }} dist-tag: ${{ steps.version.outputs.dist-tag }} release-commit: ${{ steps.trust.outputs.release-commit }} release-tag: ${{ steps.trust.outputs.release-tag }} + publish-operation: ${{ inputs.publish_operation }} + release-please-snapshot: ${{ steps.release-please-snapshot.outputs.digest }} reuse-live-smoke: ${{ steps.recovery-evidence.outputs.reuse-live-smoke }} version: ${{ steps.version.outputs.version }} steps: @@ -83,26 +348,28 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && github.workflow_sha || github.sha }} - - name: Validate the exact tag workflow dispatch recovery - if: github.event_name == 'workflow_dispatch' + ref: ${{ github.workflow_sha }} + - name: Validate the exact workflow dispatch env: ACTOR: ${{ github.actor }} CHANGED_FILES: ${{ runner.temp }}/publish-recovery-files CONTROL_COMMIT: ${{ github.workflow_sha }} + CONTROL_COMMIT_INPUT: ${{ inputs.control_commit }} EVENT_NAME: ${{ github.event_name }} EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} MAIN_COMMIT: ${{ github.workflow_sha }} + OPERATION: ${{ inputs.publish_operation }} RELEASE_COMMIT: ${{ inputs.release_commit }} RELEASE_TAG: ${{ inputs.release_tag }} RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }} SOURCE_PUBLISH_RUN_ATTEMPT: ${{ inputs.source_publish_run_attempt }} SOURCE_PUBLISH_RUN_ID: ${{ inputs.source_publish_run_id }} - TASK: ${{ inputs.recovery_task }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + WORKFLOW_SHA: ${{ github.workflow_sha }} shell: bash run: | set -euo pipefail @@ -112,45 +379,93 @@ jobs: fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main MAIN_COMMIT="$(git rev-parse refs/remotes/origin/main)" - if [[ "$MAIN_COMMIT" != "$CONTROL_COMMIT" ]]; then - echo "main moved after the publish recovery was triggered." >&2 - exit 1 - fi - CONTROL_FIRST_PARENT="$(git rev-parse "${CONTROL_COMMIT}^1")" - git diff --name-only "$CONTROL_FIRST_PARENT" "$CONTROL_COMMIT" > "$CHANGED_FILES" + case "$OPERATION" in + recover-v0.1.1) + if [[ "$MAIN_COMMIT" != "$CONTROL_COMMIT" ]]; then + echo "main moved after the publish recovery was triggered." >&2 + exit 1 + fi + CONTROL_FIRST_PARENT="$(git rev-parse "${CONTROL_COMMIT}^1")" + git diff --name-only "$CONTROL_FIRST_PARENT" "$CONTROL_COMMIT" > "$CHANGED_FILES" + ;; + release) + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then + echo "The tag release commit is not an ancestor of origin/main." >&2 + exit 1 + fi + CONTROL_FIRST_PARENT="" + : > "$CHANGED_FILES" + ;; + *) echo "Publish received an unsupported operation." >&2; exit 1 ;; + esac MAIN_COMMIT="$MAIN_COMMIT" CONTROL_FIRST_PARENT="$CONTROL_FIRST_PARENT" \ node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; - import { validatePublishWorkflowDispatchRecoveryTrigger } from "./scripts/release-workflow-validation.mjs"; + import { + validatePublishWorkflowDispatchRecoveryTrigger, + validatePublishWorkflowDispatchTrigger, + } from "./scripts/release-workflow-validation.mjs"; - validatePublishWorkflowDispatchRecoveryTrigger({ + const dispatchIdentity = { actor: process.env.ACTOR, - changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8") - .split("\n") - .filter((file) => file !== ""), controlCommit: process.env.CONTROL_COMMIT, - controlFirstParent: process.env.CONTROL_FIRST_PARENT, eventName: process.env.EVENT_NAME, eventRef: process.env.EVENT_REF, eventSha: process.env.EVENT_SHA, - mainCommit: process.env.MAIN_COMMIT, + operation: process.env.OPERATION, releaseCommit: process.env.RELEASE_COMMIT, releaseTag: process.env.RELEASE_TAG, - sourcePublishRunAttempt: Number( - process.env.SOURCE_PUBLISH_RUN_ATTEMPT, - ), - sourcePublishRunId: Number(process.env.SOURCE_PUBLISH_RUN_ID), + recoveryPolicyId: Number(process.env.RECOVERY_POLICY_ID), sourceReleaseCommit: process.env.SOURCE_RELEASE_COMMIT, sourceRunAttempt: Number(process.env.SOURCE_RELEASE_RUN_ATTEMPT), sourceRunId: Number(process.env.SOURCE_RELEASE_RUN_ID), - task: process.env.TASK, triggeringActor: process.env.TRIGGERING_ACTOR, workflowRunAttempt: Number(process.env.WORKFLOW_RUN_ATTEMPT), - }); + workflowSha: process.env.WORKFLOW_SHA, + }; + if (process.env.OPERATION === "release") { + validatePublishWorkflowDispatchTrigger(dispatchIdentity); + } else { + validatePublishWorkflowDispatchRecoveryTrigger({ + ...dispatchIdentity, + changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8") + .split("\n") + .filter((file) => file !== ""), + controlCommitInput: process.env.CONTROL_COMMIT_INPUT, + controlFirstParent: process.env.CONTROL_FIRST_PARENT, + mainCommit: process.env.MAIN_COMMIT, + sourcePublishRunAttempt: Number( + process.env.SOURCE_PUBLISH_RUN_ATTEMPT, + ), + sourcePublishRunId: Number(process.env.SOURCE_PUBLISH_RUN_ID), + }); + } + EOF + - name: Freeze the Release Please run set + id: release-please-snapshot + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PLEASE_RUNS: ${{ runner.temp }}/release-please-runs.json + shell: bash + run: | + set -euo pipefail + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/release-please.yml/runs?per_page=100" \ + | jq '[.[].workflow_runs[]]' > "$RELEASE_PLEASE_RUNS" + node --input-type=module <<'EOF' + import { createHash } from "node:crypto"; + import { appendFileSync, readFileSync } from "node:fs"; + import { snapshotReleasePleaseRuns } from "./scripts/release-workflow-validation.mjs"; + + const snapshot = snapshotReleasePleaseRuns( + JSON.parse(readFileSync(process.env.RELEASE_PLEASE_RUNS, "utf8")), + ); + const digest = createHash("sha256").update(snapshot).digest("hex"); + appendFileSync(process.env.GITHUB_OUTPUT, `digest=${digest}\n`); EOF - name: Validate the prior artifact and bounded live evidence id: recovery-evidence - if: github.event_name == 'workflow_dispatch' + if: inputs.publish_operation == 'recover-v0.1.1' env: GH_TOKEN: ${{ github.token }} RECOVERY_ANNOTATIONS: ${{ runner.temp }}/publish-recovery-annotations.json @@ -236,8 +551,8 @@ jobs: EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git EXPECTED_WORKFLOW: Release Please EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml - CONTROL_SHA: ${{ github.event_name == 'workflow_dispatch' && github.workflow_sha || github.sha }} - EVENT_NAME: ${{ github.event_name }} + CONTROL_SHA: ${{ inputs.control_commit }} + OPERATION: ${{ inputs.publish_operation }} RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json SOURCE_RUN_ATTEMPT: ${{ env.SOURCE_RELEASE_RUN_ATTEMPT }} @@ -253,15 +568,21 @@ jobs: fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - case "$EVENT_NAME" in - workflow_run) expected_main="$WORKFLOW_SHA" ;; - workflow_dispatch) expected_main="$CONTROL_SHA" ;; - *) echo "Publish received an unsupported event." >&2; exit 1 ;; + case "$OPERATION" in + recover-v0.1.1) + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$CONTROL_SHA" ]]; then + echo "origin/main moved after the trusted recovery event." >&2 + exit 1 + fi + ;; + release) + if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then + echo "The release commit is not an ancestor of origin/main." >&2 + exit 1 + fi + ;; + *) echo "Publish received an unsupported operation." >&2; exit 1 ;; esac - if [[ "$(git rev-parse refs/remotes/origin/main)" != "$expected_main" ]]; then - echo "origin/main moved after the trusted publish event." >&2 - exit 1 - fi node --input-type=module <<'EOF' import { appendFileSync, readFileSync } from "node:fs"; @@ -302,22 +623,6 @@ jobs: "Release workflow source run ID or attempt changed before publication.", ); } - if (process.env.EVENT_NAME === "workflow_run") { - const eventRun = validateReleaseWorkflowRun( - JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")), - { - checkedOutSha: process.env.WORKFLOW_SHA, - repository: process.env.EXPECTED_REPOSITORY, - workflowName: process.env.EXPECTED_WORKFLOW, - workflowPath: process.env.EXPECTED_WORKFLOW_PATH, - }, - ); - if (JSON.stringify(eventRun) !== JSON.stringify(run)) { - throw new Error( - "Release workflow source run differs from the workflow_run event.", - ); - } - } const manifest = JSON.parse(readFileSync("package.json", "utf8")); if ( manifest.repository?.type !== "git" || @@ -424,13 +729,29 @@ jobs: npm run test:live-contract npm run test:compat -- --lane locked npm run check:self-contained + - name: Download the prior live-verified release artifact + if: inputs.publish_operation == 'recover-v0.1.1' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.recovery-evidence.outputs.artifact-id }} + path: release-artifacts + merge-multiple: true + digest-mismatch: error + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.source_publish_run_id }} - name: Pack the exact release artifact - id: pack + if: inputs.publish_operation == 'release' shell: bash run: | set -euo pipefail mkdir -p release-artifacts npm pack --pack-destination release-artifacts + - name: Select the exact release artifact + id: pack + shell: bash + run: | + set -euo pipefail mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print) if [[ "${#tarballs[@]}" -ne 1 ]]; then echo "Expected exactly one packed artifact, found ${#tarballs[@]}." >&2 @@ -472,7 +793,7 @@ jobs: environment: live-smoke steps: - name: Reuse the successful bounded live smoke - if: github.event_name == 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'recover-v0.1.1' env: REUSE_LIVE_SMOKE: ${{ needs.verify.outputs.reuse-live-smoke }} shell: bash @@ -483,25 +804,25 @@ jobs: exit 1 fi - name: Check out the verified release tag - if: github.event_name != 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'release' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.verify.outputs.release-commit }} - name: Set up Node.js 24 - if: github.event_name != 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'release' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x cache: npm - name: Install locked dependencies - if: github.event_name != 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'release' run: npm ci - name: Build the release tag - if: github.event_name != 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'release' run: npm run build - name: Run the bounded live smoke - if: github.event_name != 'workflow_dispatch' + if: needs.verify.outputs.publish-operation == 'release' env: COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }} COMETAPI_LIVE_SMOKE: "1" @@ -525,12 +846,16 @@ jobs: name: npm url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }} permissions: + actions: read + checks: read contents: read + deployments: read id-token: write steps: - name: Check out the verified release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false ref: ${{ needs.verify.outputs.release-commit }} - name: Set up Node.js 24 @@ -546,6 +871,262 @@ jobs: with: name: ${{ needs.verify.outputs.artifact-name }} path: release-artifacts + - name: Reconfirm protected state immediately before publication + env: + CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} + CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs + ENVIRONMENT_FILE: ${{ runner.temp }}/npm-environment.json + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + OPERATION: ${{ needs.verify.outputs.publish-operation }} + POLICIES_FILE: ${{ runner.temp }}/npm-deployment-policies.json + RECOVERY_ANNOTATIONS: ${{ runner.temp }}/publish-recovery-annotations-before-publish.json + RECOVERY_ARTIFACTS: ${{ runner.temp }}/publish-recovery-artifacts-before-publish.json + RECOVERY_JOBS: ${{ runner.temp }}/publish-recovery-jobs-before-publish.json + RECOVERY_LIVE_LOG: ${{ runner.temp }}/publish-recovery-live-before-publish.log + RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }} + RECOVERY_RUN: ${{ runner.temp }}/publish-recovery-run-before-publish.json + RELEASE_COMMIT: ${{ needs.verify.outputs.release-commit }} + RELEASE_FILE: ${{ runner.temp }}/github-release-before-publish.json + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_FILE: ${{ runner.temp }}/release-run-before-publish.json + RELEASE_TAG: ${{ needs.verify.outputs.release-tag }} + RELEASE_PLEASE_ENABLED: ${{ vars.RELEASE_PLEASE_ENABLED }} + RELEASE_PLEASE_RUNS: ${{ runner.temp }}/release-please-runs-before-publish.json + RELEASE_PLEASE_SNAPSHOT: ${{ needs.verify.outputs.release-please-snapshot }} + SOURCE_PUBLISH_RUN_ATTEMPT: ${{ inputs.source_publish_run_attempt }} + SOURCE_PUBLISH_RUN_ID: ${{ inputs.source_publish_run_id }} + VERSION: ${{ needs.verify.outputs.version }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + shell: bash + run: | + set -euo pipefail + if [[ "$(git rev-parse HEAD)" != "$RELEASE_COMMIT" ]]; then + echo "The publication checkout no longer matches the verified release commit." >&2 + exit 1 + fi + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + main_commit="$(git rev-parse refs/remotes/origin/main)" + tag_commit="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")" + if [[ "$tag_commit" != "$RELEASE_COMMIT" ]]; then + echo "The release tag no longer resolves to the verified commit." >&2 + exit 1 + fi + case "$OPERATION" in + release) + if [[ "$EVENT_REF" != "refs/tags/${RELEASE_TAG}" || + "$EVENT_SHA" != "$RELEASE_COMMIT" || + "$WORKFLOW_SHA" != "$RELEASE_COMMIT" || + "$CONTROL_COMMIT" != "$RELEASE_COMMIT" ]]; then + echo "The tag publication identity changed while awaiting approval." >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then + echo "The release commit is no longer an ancestor of origin/main." >&2 + exit 1 + fi + ;; + recover-v0.1.1) + if [[ "$EVENT_REF" != "refs/heads/main" || + "$EVENT_SHA" != "$CONTROL_COMMIT" || + "$WORKFLOW_SHA" != "$CONTROL_COMMIT" || + "$main_commit" != "$CONTROL_COMMIT" || + "$RELEASE_COMMIT" != "c98b514227858cd183c781270a7f78f65b577e82" || + "$RELEASE_TAG" != "v0.1.1" ]]; then + echo "The exact main recovery identity changed while awaiting approval." >&2 + exit 1 + fi + ;; + *) echo "Publish received an unsupported operation." >&2; exit 1 ;; + esac + + git show "${CONTROL_COMMIT}:scripts/release-workflow-validation.mjs" \ + > "$CONTROL_VALIDATOR" + + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" \ + > "$RELEASE_FILE" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RELEASE_RUN_ID}" \ + > "$RELEASE_RUN_FILE" + gh api "repos/${GITHUB_REPOSITORY}/environments/npm" \ + > "$ENVIRONMENT_FILE" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies?per_page=100" \ + | jq '{branch_policies: [.[].branch_policies[]]}' > "$POLICIES_FILE" + if [[ "$RELEASE_PLEASE_ENABLED" != "true" ]]; then + echo "RELEASE_PLEASE_ENABLED changed while publication awaited approval." >&2 + exit 1 + fi + + if [[ "$OPERATION" == "recover-v0.1.1" ]]; then + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_PUBLISH_RUN_ID}" \ + > "$RECOVERY_RUN" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_PUBLISH_RUN_ID}/attempts/${SOURCE_PUBLISH_RUN_ATTEMPT}/jobs?per_page=100" \ + > "$RECOVERY_JOBS" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_PUBLISH_RUN_ID}/artifacts" \ + > "$RECOVERY_ARTIFACTS" + gh api "repos/${GITHUB_REPOSITORY}/check-runs/90643868523/annotations" \ + > "$RECOVERY_ANNOTATIONS" + gh api "repos/${GITHUB_REPOSITORY}/actions/jobs/90643725110/logs" \ + > "$RECOVERY_LIVE_LOG" + if [[ "$(grep -Fc 'Live smoke passed 3 sequential requests with a 16-token output cap.' "$RECOVERY_LIVE_LOG")" != "1" ]]; then + echo "The bounded live evidence changed while publication awaited approval." >&2 + exit 1 + fi + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validatePublishRecoveryEvidence } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + validatePublishRecoveryEvidence({ + annotations: JSON.parse( + readFileSync(process.env.RECOVERY_ANNOTATIONS, "utf8"), + ), + artifacts: JSON.parse( + readFileSync(process.env.RECOVERY_ARTIFACTS, "utf8"), + ).artifacts, + jobs: JSON.parse( + readFileSync(process.env.RECOVERY_JOBS, "utf8"), + ).jobs, + run: JSON.parse(readFileSync(process.env.RECOVERY_RUN, "utf8")), + }); + EOF + fi + + for state in in_progress queued waiting requested pending; do + runs_file="$RUNNER_TEMP/publish-${state}-runs.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?status=${state}&per_page=100" \ + > "$runs_file" + if ! jq -e --argjson current "$GITHUB_RUN_ID" \ + '[.workflow_runs[] | select(.id != $current)] | length == 0' \ + "$runs_file" >/dev/null; then + echo "A competing Publish run appeared before registry mutation." >&2 + exit 1 + fi + done + + view_error="$RUNNER_TEMP/npm-view-before-publish.err" + set +e + exact_version="$(npm view "cometapi@${VERSION}" version 2>"$view_error")" + view_status=$? + set -e + if [[ "$view_status" -ne 0 ]]; then + if grep -q "E404" "$view_error"; then + exact_version="" + else + echo "Unable to read the exact registry version before publication." >&2 + exit 1 + fi + fi + latest_version="$(npm view cometapi@latest version)" + next_version="$(npm view cometapi@next version)" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/release-please.yml/runs?per_page=100" \ + | jq '[.[].workflow_runs[]]' > "$RELEASE_PLEASE_RUNS" + + EXACT_VERSION="$exact_version" LATEST_VERSION="$latest_version" \ + NEXT_VERSION="$next_version" TAG_COMMIT="$tag_commit" \ + node --input-type=module <<'EOF' + import { createHash } from "node:crypto"; + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { + extractReleaseNotesFromChangelog, + validateGitHubRelease, + validateNpmEnvironmentState, + validateRegistryStateBeforePublish, + validateReleaseWorkflowRun, + snapshotReleasePleaseRuns, + } = await import(pathToFileURL(process.env.CONTROL_VALIDATOR)); + + const snapshot = snapshotReleasePleaseRuns( + JSON.parse(readFileSync(process.env.RELEASE_PLEASE_RUNS, "utf8")), + ); + const digest = createHash("sha256").update(snapshot).digest("hex"); + if (digest !== process.env.RELEASE_PLEASE_SNAPSHOT) { + throw new Error( + "Release workflow Release Please run set changed while publication awaited approval.", + ); + } + + const sourceRun = JSON.parse( + readFileSync(process.env.RELEASE_RUN_FILE, "utf8"), + ); + const run = validateReleaseWorkflowRun( + { + action: "completed", + repository: { full_name: sourceRun.repository?.full_name }, + workflow_run: { + conclusion: sourceRun.conclusion, + event: sourceRun.event, + head_branch: sourceRun.head_branch, + head_repository: sourceRun.head_repository, + head_sha: sourceRun.head_sha, + id: sourceRun.id, + name: sourceRun.name, + path: sourceRun.path, + run_attempt: sourceRun.run_attempt, + }, + }, + { + checkedOutSha: process.env.RELEASE_COMMIT, + repository: "cometapi-dev/cometapi-node", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }, + ); + if ( + run.runId !== Number(process.env.RELEASE_RUN_ID) || + run.runAttempt !== Number(process.env.RELEASE_RUN_ATTEMPT) + ) { + throw new Error( + "Release workflow source run changed while publication awaited approval.", + ); + } + const version = JSON.parse(readFileSync("package.json", "utf8")).version; + validateGitHubRelease( + JSON.parse(readFileSync(process.env.RELEASE_FILE, "utf8")), + { + expectedBody: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + version, + ), + htmlUrl: `https://github.com/cometapi-dev/cometapi-node/releases/tag/${process.env.RELEASE_TAG}`, + releaseCommit: process.env.RELEASE_COMMIT, + tag: process.env.RELEASE_TAG, + tagCommit: process.env.TAG_COMMIT, + }, + ); + validateNpmEnvironmentState({ + environment: JSON.parse( + readFileSync(process.env.ENVIRONMENT_FILE, "utf8"), + ), + expectedPolicyIds: + process.env.OPERATION === "recover-v0.1.1" + ? { + "branch:main": Number(process.env.RECOVERY_POLICY_ID), + "tag:v*": 55718965, + } + : { "tag:v*": 55718965 }, + operation: process.env.OPERATION, + policies: JSON.parse( + readFileSync(process.env.POLICIES_FILE, "utf8"), + ).branch_policies, + }); + validateRegistryStateBeforePublish({ + exactVersion: process.env.EXACT_VERSION || null, + latestVersion: process.env.LATEST_VERSION, + nextVersion: process.env.NEXT_VERSION, + version, + }); + EOF - name: Publish the exact artifact with provenance env: DIST_TAG: ${{ needs.verify.outputs.dist-tag }} @@ -553,7 +1134,11 @@ jobs: run: bash scripts/publish-artifact.sh - name: Verify the public registry artifact env: + CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} + CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs DIST_TAG: ${{ needs.verify.outputs.dist-tag }} + GH_TOKEN: ${{ github.token }} + WORKFLOW_REF: ${{ github.ref }} VERSION: ${{ needs.verify.outputs.version }} shell: bash run: | @@ -594,12 +1179,89 @@ jobs: echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2 exit 1 fi + if [[ "$(npm view cometapi@next version)" != "0.1.0-alpha.3" ]]; then + echo "The next dist-tag changed during publication." >&2 + exit 1 + fi + + attestations_url="$(REGISTRY_DIST="$registry_dist" node -e 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')" + attestations_file="$RUNNER_TEMP/npm-attestations.json" + curl --fail --silent --show-error "$attestations_url" > "$attestations_file" + local_sha512="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write(createHash("sha512").update(readFileSync(process.argv[1])).digest("hex"))' "${tarballs[0]}")" + provenance_identity="$(ATTESTATIONS_FILE="$attestations_file" \ + LOCAL_SHA512="$local_sha512" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validateRegistryProvenance } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + const provenance = validateRegistryProvenance({ + attestations: JSON.parse( + readFileSync(process.env.ATTESTATIONS_FILE, "utf8"), + ), + commit: process.env.CONTROL_COMMIT, + sha512: process.env.LOCAL_SHA512, + version: process.env.VERSION, + workflowRef: process.env.WORKFLOW_REF, + }); + process.stdout.write( + `${provenance.provenanceRunId} ${provenance.provenanceRunAttempt}`, + ); + EOF + )" + read -r provenance_run_id provenance_run_attempt <<< "$provenance_identity" + provenance_run_file="$RUNNER_TEMP/provenance-run.json" + provenance_jobs_file="$RUNNER_TEMP/provenance-jobs.json" + provenance_run_valid="false" + for attempt in {1..6}; do + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${provenance_run_id}/attempts/${provenance_run_attempt}" \ + > "$provenance_run_file" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${provenance_run_id}/attempts/${provenance_run_attempt}/jobs?per_page=100" \ + > "$provenance_jobs_file" + if PROVENANCE_JOBS_FILE="$provenance_jobs_file" \ + PROVENANCE_RUN_ATTEMPT="$provenance_run_attempt" \ + PROVENANCE_RUN_FILE="$provenance_run_file" \ + PROVENANCE_RUN_ID="$provenance_run_id" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validateRegistryProvenanceInvocation } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + validateRegistryProvenanceInvocation({ + commit: process.env.CONTROL_COMMIT, + jobs: JSON.parse( + readFileSync(process.env.PROVENANCE_JOBS_FILE, "utf8"), + ).jobs, + run: JSON.parse( + readFileSync(process.env.PROVENANCE_RUN_FILE, "utf8"), + ), + runAttempt: Number(process.env.PROVENANCE_RUN_ATTEMPT), + runId: Number(process.env.PROVENANCE_RUN_ID), + workflowRef: process.env.WORKFLOW_REF, + }); + EOF + then + provenance_run_valid="true" + break + fi + if [[ "$attempt" -lt 6 ]]; then + sleep 5 + fi + done + if [[ "$provenance_run_valid" != "true" ]]; then + echo "The signed provenance invocation did not match a successful npm publish step." >&2 + exit 1 + fi verify_dir="$(mktemp -d)" cd "$verify_dir" npm init --yes >/dev/null npm install --ignore-scripts --no-audit --no-fund \ - "openai@6.47.0" "cometapi@${VERSION}" + "openai@6.47.0" "cometapi@${VERSION}" "typescript@5.9.3" signatures_verified="false" for attempt in {1..3}; do if npm audit signatures; then @@ -627,16 +1289,94 @@ jobs: const client = new CometAPI({ apiKey: "mock-registry-key", maxRetries: 0, - fetch: async () => - new Response(JSON.stringify({ object: "list", data: [] }), { + fetch: async (input) => { + const url = new URL( + input instanceof Request ? input.url : String(input), + ); + let body; + if (url.pathname.endsWith("/chat/completions")) { + body = { + id: "chatcmpl_registry", + object: "chat.completion", + created: 1, + model: "gpt-5.4", + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + }; + } else if (url.pathname.endsWith("/responses")) { + body = { + id: "resp_registry", + object: "response", + created_at: 1, + status: "completed", + model: "gpt-5.4", + output: [], + parallel_tool_calls: true, + tool_choice: "auto", + tools: [], + }; + } else if (url.pathname.endsWith("/models")) { + body = { object: "list", data: [] }; + } else { + throw new Error(`Unexpected registry fixture URL: ${url}`); + } + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, - }), + }); + }, + }); + const completion = await client.chat.completions.create({ + model: "gpt-5.4", + messages: [{ role: "user", content: "test" }], + }); + assert.equal(completion.id, "chatcmpl_registry"); + const response = await client.responses.create({ + model: "gpt-5.4", + input: "test", }); + assert.equal(response.id, "resp_registry"); const models = await client.models.list(); assert.deepEqual(models.data, []); EOF + node --input-type=module <<'EOF' + import { writeFileSync } from "node:fs"; + + const source = `import { CometAPI, type CometAPIOptions } from "cometapi"; + const options: CometAPIOptions = { apiKey: "typed-registry-key", maxRetries: 0 }; + const client = new CometAPI(options); + void client.chat.completions.create({ model: "gpt-5.4", messages: [] }); + void client.responses.create({ model: "gpt-5.4", input: "test" }); + void client.models.list(); + `; + writeFileSync("consumer.mts", source); + writeFileSync("consumer.cts", source); + writeFileSync( + "tsconfig.json", + `${JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + target: "ES2022", + }, + include: ["consumer.mts", "consumer.cts"], + }, + null, + 2, + )}\n`, + ); + EOF + ./node_modules/.bin/tsc --noEmit + node <<'EOF' const assert = require("node:assert/strict"); const { CometAPI } = require("cometapi"); diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5159f31..997fbf0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,8 +4,6 @@ on: push: branches: - main - paths: - - package.json workflow_dispatch: permissions: @@ -77,11 +75,18 @@ jobs: node scripts/validate-release.mjs node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; - import { validateReleasePleaseMutationConfiguration } from "./scripts/release-workflow-validation.mjs"; + import { parse } from "yaml"; + import { + validatePublishWorkflowContract, + validateReleasePleaseMutationConfiguration, + } from "./scripts/release-workflow-validation.mjs"; validateReleasePleaseMutationConfiguration( JSON.parse(readFileSync("release-please-config.json", "utf8")), ); + validatePublishWorkflowContract( + parse(readFileSync(".github/workflows/publish.yml", "utf8")), + ); EOF - name: Reject commit-level version overrides env: @@ -221,10 +226,16 @@ jobs: EOF )" echo "operation=${operation}" >> "$GITHUB_OUTPUT" - if [[ "$operation" == "ignore" ]]; then - echo "The push did not change the published package version; no release mutation is needed." + if [[ "$operation" == "prepare" ]]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "run-created-at=${run_created_at}" >> "$GITHUB_OUTPUT" + echo "The source push will prepare or refresh the canonical patch PR." exit 0 fi + if [[ "$operation" != "release" ]]; then + echo "Release Please returned an unsupported push operation." >&2 + exit 1 + fi ATTEMPTS_FILE="$attempts_file" RELEASE_FILE="$release_file" \ RUN_CREATED_AT="$run_created_at" RUN_ID="$RUN_ID" \ @@ -369,6 +380,7 @@ jobs: RELEASE_BRANCH: release-please--branches--main--components--cometapi RELEASE_EXISTS: ${{ steps.release-state.outputs.exists || 'false' }} RUN_ATTEMPT: ${{ github.run_attempt }} + OPERATION: ${{ steps.release-state.outputs.operation || 'prepare' }} shell: bash run: | set -euo pipefail @@ -398,6 +410,7 @@ jobs: })); const releasePullRequest = selectPendingReleasePullRequest(pulls, { eventName: process.env.EVENT_NAME, + operation: process.env.OPERATION, releaseBranch: process.env.RELEASE_BRANCH, releaseCommit: process.env.GITHUB_SHA, releaseExists: process.env.RELEASE_EXISTS === "true", @@ -412,7 +425,7 @@ jobs: if [[ -z "$release_pr_number" ]]; then echo "mode=prepare" >> "$GITHUB_OUTPUT" echo "release-pr-number=" >> "$GITHUB_OUTPUT" - echo "This first-attempt manual run may prepare exactly one release PR." + echo "This first-attempt run may prepare exactly one release PR." exit 0 fi echo "mode=release" >> "$GITHUB_OUTPUT" @@ -432,6 +445,7 @@ jobs: EXPECTED_BRANCH_EXISTS: ${{ steps.branch-state.outputs.branch-exists }} EXPECTED_BRANCH_SHA: ${{ steps.branch-state.outputs.branch-sha }} EXPECTED_MODE: ${{ steps.preflight.outputs.mode }} + EXPECTED_OPERATION: ${{ steps.release-state.outputs.operation || 'prepare' }} EXPECTED_PR_NUMBER: ${{ steps.preflight.outputs.release-pr-number }} EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists || 'false' }} EXPECTED_SHA: ${{ github.sha }} @@ -499,6 +513,7 @@ jobs: }); const releasePullRequest = selectPendingReleasePullRequest(pulls, { eventName: process.env.EVENT_NAME, + operation: process.env.EXPECTED_OPERATION, releaseBranch: process.env.RELEASE_BRANCH, releaseCommit: process.env.GITHUB_SHA, releaseExists: process.env.EXPECTED_RELEASE_EXISTS === "true", @@ -603,7 +618,7 @@ jobs: EOF fi - name: Reconfirm the exact release state before mutation - if: github.event_name == 'push' && steps.release-state.outputs.operation != 'ignore' + if: github.event_name == 'push' && steps.preflight.outputs.mode == 'release' env: EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists }} GH_TOKEN: ${{ github.token }} @@ -672,6 +687,19 @@ jobs: echo "The exact tag or Release changed after preflight." >&2 exit 1 fi + - name: Require the exact current main commit immediately before mutation + if: steps.release-state.outputs.operation != 'ignore' + env: + EXPECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse HEAD)" != "$EXPECTED_SHA" || + "$(git rev-parse refs/remotes/origin/main)" != "$EXPECTED_SHA" ]]; then + echo "main moved immediately before the Release Please mutation." >&2 + exit 1 + fi - name: Run Release Please id: release if: steps.release-state.outputs.operation != 'ignore' @@ -1107,14 +1135,20 @@ jobs: git fetch --no-tags origin \ "+refs/tags/${tag}:refs/tags/${tag}" final_tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" + tagged_publish_workflow="$RUNNER_TEMP/release-tag-publish.yml" + git show "refs/tags/${tag}:.github/workflows/publish.yml" \ + > "$tagged_publish_workflow" mkdir -p release-please-result COMPLETION_FILE="$completion_file" FINAL_RELEASE_FILE="$final_release_file" \ - FINAL_TAG_COMMIT="$final_tag_commit" VERSION="$version" \ + FINAL_TAG_COMMIT="$final_tag_commit" \ + TAGGED_PUBLISH_WORKFLOW="$tagged_publish_workflow" VERSION="$version" \ node --input-type=module <<'EOF' import { readFileSync, writeFileSync } from "node:fs"; + import { parse } from "yaml"; import { extractReleaseNotesFromChangelog, validateGitHubRelease, + validatePublishWorkflowContract, validateReleasePleaseActionResult, } from "./scripts/release-workflow-validation.mjs"; @@ -1134,6 +1168,9 @@ jobs: tagCommit: process.env.FINAL_TAG_COMMIT, }, ); + validatePublishWorkflowContract( + parse(readFileSync(process.env.TAGGED_PUBLISH_WORKFLOW, "utf8")), + ); const result = { actionOutcome: completion.actionOutcome, htmlUrl: completion.htmlUrl, diff --git a/RELEASING.md b/RELEASING.md index 74adb7e..75fcfc8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -238,12 +238,14 @@ The repository maintains four independently auditable workflows: `RELEASE_PLEASE_ENABLED=true` and uses the default `GITHUB_TOKEN`. The authorized repository baseline keeps default workflow permissions read-only and allows Actions to create pull requests; it does not make bot review valid - release approval. Automatic push execution is limited to `main` commits that - change canonical `package.json`; an in-job before/after check then classifies - unchanged published versions without mutation and requires release pushes to - increment exactly one patch. Ordinary repair and documentation merges that do - not change `package.json` therefore cannot be mistaken for an already - published version. A manual dispatch is + release approval. Automatic push execution covers every `main` commit so a + normal `fix:` commit that changes source, tests, or documentation cannot be + filtered out before Release Please classifies it. The in-job before/after + check sends an unchanged current package version to patch-PR preparation and + requires a release-PR merge to increment exactly one patch. Thus an ordinary + source `fix:` prepares or refreshes exactly one canonical patch PR, while the + reviewed package-version bump alone can enter GitHub Release creation. A + manual dispatch is attempt-1-only, runs with GitHub Release creation disabled, and prepares exactly one action-authored patch PR after the variable is enabled. A new dispatch may revalidate an unchanged canonical PR @@ -260,7 +262,11 @@ The repository maintains four independently auditable workflows: from an earlier attempt of that run. Release Please then creates or recovers the normal tag and GitHub Release, verifies its notes byte-for-byte against the normalized `CHANGELOG` entry, reconciles the release label, and uploads a - schema-v2 attempt-qualified result artifact. + schema-v2 attempt-qualified result artifact. Before Release Please can mutate + release state, it parses the current `publish.yml` and requires the permanent + tag-dispatch inputs. After tag creation it parses the workflow from that exact + tag again. An immutable Release cannot therefore be created from a commit that + lacks its own executable publication entry point. The triggering SHA must still equal the fetched `main` tip at checkout and immediately before the Release Please action; an older queued run stops before mutation. @@ -269,27 +275,36 @@ The repository maintains four independently auditable workflows: `include-component-in-tag=false` requires the exact public `v` tag. The workflow has contents, pull-request, and issue permissions only for those repository operations; it has no npm OIDC permission. -- `publish.yml`: starts only after successful completion of the trusted Release - Please workflow for `main`. This indirection is required because a GitHub - Release created with the default `GITHUB_TOKEN` does not trigger a new - `release.published` workflow. The handoff accepts only the canonical - repository's successful attempt-qualified `push` run for the still-current - exact `main` SHA. It downloads the output artifact from that exact upstream - run ID and attempt and requires schema version, normalized action outcome, - recovery state, pre-action Release presence, exact Release-producing attempt, - SHA, tag, version, URL, repository, workflow path, run ID, and attempt to agree - before accepting the - matching version tag and immutable GitHub Release. A successful manual - preparation run is release-inert and cannot enter publication; a successful - `push` run without the exact release result fails before live or publication - work. The release path then packs and tests one exact, attempt-qualified - artifact, runs the protected release live smoke, and publishes the same file - through npm OIDC. Registry token credentials are rejected. Re-running all jobs - creates a new attempt-qualified artifact, while re-running failed downstream - jobs consumes the already verified producer attempt. Publication resumes after - an already accepted version only when its registry integrity matches the - downloaded artifact, then repeats every bounded registry-state and signature - check. +- `publish.yml`: separates release discovery from registry authority. A + successful trusted Release Please `workflow_run` can enter only the `handoff` + job. That job has no Environment and no OIDC permission. It validates the + exact source job and treats a preparation run whose result-upload step was + skipped as release-inert. For a release run, it requires the unique + attempt-qualified result, immutable bot-authored Release, tag commit, `main` + ancestry, and the dispatch contract stored in the tag, then uses its sole + `actions: write` permission to dispatch the same workflow with `ref=v`. + The `verify`, `live-smoke`, and `publish` jobs accept only + that tag-bound `workflow_dispatch`; they are unreachable from the original + main-context `workflow_run`. A successful manual Release Please preparation + run is release-inert and cannot enter the handoff. + + The tag run independently revalidates the source run and result artifact, + exact tag and immutable Release, package metadata, and `main` ancestry. The + normal operation packs and tests one attempt-qualified artifact, runs a fresh + bounded live smoke, and sends that same file to npm OIDC; the one-time + recovery operation downloads and retests the already live-verified tarball + instead of repacking it. Immediately after protected + Environment approval and directly before `npm publish`, it reads the source + run, re-evaluates the Actions enable-variable context, and reads the Release, + tag, current `main`, active Publish runs, npm Environment reviewer and branch + policies, and public npm versions and dist-tags again. The one-time recovery + additionally revalidates the failed source Publish run and attempt, artifact + digest, branch-policy annotation, and exact bounded-live log after approval. + The normal path requires the Environment policy set to equal only `tag:v*`. + Registry token credentials are rejected. Replays are + integrity-idempotent, not exactly-once: an existing version is accepted only + when its registry integrity matches the downloaded artifact, after which all + bounded registry, signature, and provenance checks run again. The first `0.1.1` Publish run [30469240186](https://github.com/cometapi-dev/cometapi-node/actions/runs/30469240186) @@ -304,22 +319,150 @@ The repository maintains four independently auditable workflows: No OIDC token or npm mutation occurred in either failed run. A first attempted recovery deployment [5667717157](https://github.com/cometapi-dev/cometapi-node/deployments/5667717157) - was intentionally marked failed: the immutable `v0.1.1` tag predates the - temporary deployment trigger, so GitHub found no workflow at that tag and no - runner or npm mutation occurred. The replacement one-cycle recovery uses the - documented `workflow_dispatch` API with `ref=v0.1.1`; GitHub dispatches the - workflow from the reviewed default-branch definition while setting - `GITHUB_REF=refs/tags/v0.1.1`, which satisfies the existing npm tag policy. - The exact request is: + was intentionally marked failed. The immutable `v0.1.1` tag predates both + attempted recovery triggers, and GitHub resolves a dispatch from the requested + ref rather than borrowing the later default-branch workflow. No runner or npm + mutation occurred. The historical tag cannot be rewritten and no auxiliary + tag may be fabricated. + + The authorized one-cycle exception dispatches the reviewed recovery merge from + `refs/heads/main`. Immediately beforehand, freeze `main`, every Publish + dispatch or rerun, and every Release Please dispatch or rerun; reconfirm the tag, immutable Release, + source runs and artifact, npm E404 and dist-tags, absence of competing runs, + and unchanged npm Environment reviewer. Use the npm package settings UI + read-only to reconfirm the Trusted Publisher tuple + `cometapi-dev/cometapi-node`, workflow `publish.yml`, and Environment `npm`. + Historical provenance is not evidence that this current setting has not + drifted. Stop before any configuration write if the tuple cannot be observed + or differs. Read the deployment-policy collection + and require exactly policy ID `55718965`, `tag:v*`. Create `branch:main` only + through the collection endpoint, record the returned policy ID, and require + the read-back set to equal `{branch:main, tag:v*}`. During this freeze do not + change branch/tag protections, the npm Environment reviewer or secrets, the + Trusted Publisher tuple, the immutable tag/Release, the recorded source run + or artifact, or any repository variable other than the documented + `RELEASE_PLEASE_ENABLED` toggle: ```bash + set -euo pipefail + umask 077 + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + before_policies="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies?per_page=100' \ + | jq '{branch_policies: [.[].branch_policies[]]}' > "$before_policies" + jq -e \ + '[.branch_policies[] | {id, name, type}] == + [{"id":55718965,"name":"v*","type":"tag"}]' \ + "$before_policies" >/dev/null + jq -n --slurpfile before_policies "$before_policies" \ + '{before_policies: $before_policies[0], + main_policy_id: null, + control_commit: null, + recovery_run_id: null, + before_publish_run_ids: null}' > "$recovery_state" + chmod 600 "$recovery_state" + echo "Recovery state: $recovery_state" + policy_file="$(mktemp)" + gh api --method POST \ + repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies \ + -f name=main -f type=branch >"$policy_file" + main_policy_id="$(jq -er \ + 'select(.name == "main" and .type == "branch") | .id' \ + "$policy_file")" + [[ "$main_policy_id" =~ ^[1-9][0-9]*$ ]] + state_next="${recovery_state}.next" + jq --argjson main_policy_id "$main_policy_id" \ + '.main_policy_id = $main_policy_id' \ + "$recovery_state" > "$state_next" + chmod 600 "$state_next" + mv "$state_next" "$recovery_state" + after_policies="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies?per_page=100' \ + | jq '{branch_policies: [.[].branch_policies[]]}' > "$after_policies" + jq -e --argjson main_policy_id "$main_policy_id" \ + '[.branch_policies[] | {id, name, type}] | sort_by(.type, .name) == + [{"id":$main_policy_id,"name":"main","type":"branch"}, + {"id":55718965,"name":"v*","type":"tag"}]' \ + "$after_policies" >/dev/null + ``` + + If the policy POST or its response parsing fails, do not continue. Read the + collection once more and capture a new policy ID only when the recorded + pre-write set is unchanged and exactly one new `branch:main` policy exists; + otherwise stop and report the external state rather than deleting by name. + When that recovery is unambiguous, write the recovered ID into the state file + before doing anything else: + + ```bash + set -euo pipefail + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + current_policies="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies?per_page=100' \ + | jq '{branch_policies: [.[].branch_policies[]]}' > "$current_policies" + recovered_main_policy_id="$(jq -er --slurpfile state "$recovery_state" \ + '[.branch_policies[] as $candidate | + select($candidate.name == "main" and $candidate.type == "branch") | + select(($state[0].before_policies.branch_policies | + map(.id) | index($candidate.id)) == null) | + $candidate.id] | + if length == 1 then .[0] else empty end' \ + "$current_policies")" + [[ "$recovered_main_policy_id" =~ ^[1-9][0-9]*$ ]] + jq -e --argjson main_policy_id "$recovered_main_policy_id" \ + --slurpfile state "$recovery_state" \ + '([.branch_policies[] | {id, name, type}] | sort_by(.type, .name) == + [{"id":$main_policy_id,"name":"main","type":"branch"}, + {"id":55718965,"name":"v*","type":"tag"}]) and + ([$state[0].before_policies.branch_policies[] | {id, name, type}] == + [{"id":55718965,"name":"v*","type":"tag"}])' \ + "$current_policies" >/dev/null + state_next="${recovery_state}.next" + jq --argjson main_policy_id "$recovered_main_policy_id" \ + '.main_policy_id = $main_policy_id' \ + "$recovery_state" > "$state_next" + chmod 600 "$state_next" + mv "$state_next" "$recovery_state" + ``` + + Set `RELEASE_PLEASE_ENABLED=true`, resolve the current reviewed `main` SHA as + `control_commit`, save the existing Publish run IDs, and dispatch: + + ```bash + set -euo pipefail + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + main_policy_id="$(jq -er '.main_policy_id | select(type == "number" and . > 0)' \ + "$recovery_state")" + control_commit="$(gh api repos/cometapi-dev/cometapi-node/commits/main --jq '.sha')" + [[ "$control_commit" =~ ^[0-9a-f]{40}$ ]] + before_runs="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100' \ + | jq '[.[].workflow_runs[].id]' > "$before_runs" + state_next="${recovery_state}.next" + jq --arg control_commit "$control_commit" --slurpfile before_runs "$before_runs" \ + '.control_commit = $control_commit | + .before_publish_run_ids = $before_runs[0]' \ + "$recovery_state" > "$state_next" + chmod 600 "$state_next" + mv "$state_next" "$recovery_state" + gh api --method PATCH \ + repos/cometapi-dev/cometapi-node/actions/variables/RELEASE_PLEASE_ENABLED \ + -f name=RELEASE_PLEASE_ENABLED -f value=true + test "$(gh api \ + repos/cometapi-dev/cometapi-node/actions/variables/RELEASE_PLEASE_ENABLED \ + --jq '.value')" = "true" gh api --method POST \ repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/dispatches \ - --input - <<'JSON' + --input - </dev/null + jq '.before_publish_run_ids' "$recovery_state" > "$before_runs" + actor="$(gh api user --jq '.login')" + [[ "$actor" == "tensornull" ]] + recovery_run_id="" + for poll in {1..12}; do + after_runs="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100' \ + | jq '[.[].workflow_runs[]]' > "$after_runs" + recovery_run_id="$(jq -r \ + --arg actor "$actor" --arg control "$control_commit" \ + --slurpfile before "$before_runs" \ + '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | + select(.actor.login == $actor and .triggering_actor.login == $actor and + .event == "workflow_dispatch" and .head_branch == "main" and + .head_sha == $control and .run_attempt == 1)] | + if length == 1 then .[0].id else empty end' "$after_runs")" + [[ -n "$recovery_run_id" ]] && break + sleep 5 + done + [[ "$recovery_run_id" =~ ^[1-9][0-9]*$ ]] + state_next="${recovery_state}.next" + jq --argjson recovery_run_id "$recovery_run_id" \ + '.recovery_run_id = $recovery_run_id' \ + "$recovery_state" > "$state_next" + chmod 600 "$state_next" + mv "$state_next" "$recovery_state" + ``` + + Abort on zero, multiple, or competing candidates. After `verify` and the + reused live-evidence job pass, read the exact run and pending Environment + deployment before asking the human owner to approve it: + + ```bash + set -euo pipefail + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + control_commit="$(jq -er \ + '.control_commit | select(type == "string" and test("^[0-9a-f]{40}$"))' \ + "$recovery_state")" + recovery_run_id="$(jq -er \ + '.recovery_run_id | select(type == "number" and . > 0)' \ + "$recovery_state")" + recovery_run="$(mktemp)" + pending_deployments="$(mktemp)" + gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${recovery_run_id}" \ + > "$recovery_run" + gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${recovery_run_id}/pending_deployments" \ + > "$pending_deployments" + jq -e --arg control "$control_commit" --argjson run_id "$recovery_run_id" \ + '.id == $run_id and .name == "Publish" and + .path == ".github/workflows/publish.yml" and + .event == "workflow_dispatch" and .head_branch == "main" and + .head_sha == $control and .run_attempt == 1 and + (.status == "waiting" or .status == "in_progress") and + .conclusion == null and .actor.login == "tensornull" and + .triggering_actor.login == "tensornull"' \ + "$recovery_run" >/dev/null + jq -e \ + 'length == 1 and .[0].environment.id == 18800205839 and + .[0].environment.name == "npm" and + .[0].current_user_can_approve == true' \ + "$pending_deployments" >/dev/null + ``` + + Stop here for the human owner to approve this exact `npm` Environment + deployment in GitHub. Do not automate the approval and do not approve a + different run, commit, attempt, environment ID, or pending-deployment set. + The recovery validator accepts + only actor and triggering actor `tensornull`, + `github.sha == github.workflow_sha == control_commit == origin/main`, first parent + `5f493045a2205fe19904ca5be36f5bbf23378aec`, the recorded repair file set, + release `c98b514227858cd183c781270a7f78f65b577e82` and `v0.1.1`, Release Please + run `30469181724/1`, failed Publish run `30471665743/1`, artifact ID and + digest, branch-policy failure, and the prior successful three-request, + 16-token, concurrency-one live smoke. It repeats all offline and exact-artifact + gates without making another live request. Recovery downloads artifact ID + `8731956162` from the recorded source run, re-runs the package, example, and + host-fixture checks against that same tarball, and publishes that same tarball; + it does not substitute a repacked archive whose gzip bytes could vary across + runner images. + + The verify job also snapshots every completed Release Please run by ID and + attempt. After Environment approval, the publish job recomputes that full set + immediately before registry mutation and fails if a run was created, rerun, or + remains active while the recovery was waiting. This makes the operator freeze + observable rather than relying only on timing. + + If the npm publish request may have reached the registry but its response or + the remaining workflow result was lost, do not infer success or a safe retry + from the Actions conclusion. First stop or let the recorded run finish, then + inspect the immutable registry state with the recorded source artifact. An + `E404` means publication is not proven and must be reported as ambiguous; it + does not authorize another dispatch. Treat publication as successful only if + the exact integrity, registry signatures, SLSA subject and source commit, and + originating Publish run all agree: + + ```bash + set -euo pipefail + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + control_commit="$(jq -er \ + '.control_commit | select(type == "string" and test("^[0-9a-f]{40}$"))' \ + "$recovery_state")" + recovery_run_id="$(jq -er \ + '.recovery_run_id | select(type == "number" and . > 0)' \ + "$recovery_state")" + evidence_dir="$(mktemp -d)" + artifact_metadata="$(mktemp)" + gh api \ + repos/cometapi-dev/cometapi-node/actions/artifacts/8731956162 \ + > "$artifact_metadata" + jq -e \ + '.id == 8731956162 and + .name == "npm-package-0.1.1-30471665743-1" and + .digest == "sha256:567b00f1ec32168d5c5be7d0b553542441920d3bb401959bcc2d6e157f35d08b" and + .expired == false and + .workflow_run.id == 30471665743 and + .workflow_run.head_sha == "22c313d4f80c53ba01672dd35cc27b621d5ec9ce"' \ + "$artifact_metadata" >/dev/null + gh run download 30471665743 \ + --repo cometapi-dev/cometapi-node \ + --name npm-package-0.1.1-30471665743-1 \ + --dir "$evidence_dir" + tarball_count="$(find "$evidence_dir" -type f -name 'cometapi-0.1.1.tgz' \ + -print | wc -l | tr -d ' ')" + [[ "$tarball_count" == "1" ]] + tarball="$(find "$evidence_dir" -type f -name 'cometapi-0.1.1.tgz' -print)" + local_sha256="$(node -e \ + 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write(createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"))' \ + "$tarball")" + [[ "$local_sha256" == "3c926a2b15be99fbba92e1e100c2ee254ff866da27496a5c31824c542cccbf91" ]] + local_integrity="$(node -e \ + 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' \ + "$tarball")" + local_sha512="$(node -e \ + 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write(createHash("sha512").update(readFileSync(process.argv[1])).digest("hex"))' \ + "$tarball")" + + view_error="$(mktemp)" + set +e + registry_dist="$(npm view cometapi@0.1.1 dist --json 2>"$view_error")" + view_status=$? + set -e + if [[ "$view_status" -ne 0 ]]; then + if grep -q E404 "$view_error"; then + echo "cometapi@0.1.1 is still absent; publication remains ambiguous and must not be retried automatically." >&2 + else + sed -n '1,20p' "$view_error" >&2 + fi + exit 1 + fi + REGISTRY_DIST="$registry_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF' + const dist = JSON.parse(process.env.REGISTRY_DIST); + if ( + dist.integrity !== process.env.LOCAL_INTEGRITY || + !dist.attestations?.url || + dist.attestations?.provenance?.predicateType !== + "https://slsa.dev/provenance/v1" + ) { + throw new Error("The npm integrity or provenance metadata does not match the recovery artifact."); + } + EOF + + attestations_url="$(REGISTRY_DIST="$registry_dist" node -e \ + 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')" + attestations_file="$(mktemp)" + curl --fail --silent --show-error "$attestations_url" > "$attestations_file" + validator_dir="$(mktemp -d)" + validator_file="${validator_dir}/release-workflow-validation.mjs" + git fetch --no-tags origin "$control_commit" + git show "${control_commit}:scripts/release-workflow-validation.mjs" \ + > "$validator_file" + provenance_identity="$(ATTESTATIONS_FILE="$attestations_file" \ + CONTROL_COMMIT="$control_commit" LOCAL_SHA512="$local_sha512" \ + VALIDATOR_FILE="$validator_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validateRegistryProvenance } = await import( + pathToFileURL(process.env.VALIDATOR_FILE) + ); + const provenance = validateRegistryProvenance({ + attestations: JSON.parse( + readFileSync(process.env.ATTESTATIONS_FILE, "utf8"), + ), + commit: process.env.CONTROL_COMMIT, + sha512: process.env.LOCAL_SHA512, + version: "0.1.1", + workflowRef: "refs/heads/main", + }); + process.stdout.write( + `${provenance.provenanceRunId} ${provenance.provenanceRunAttempt}`, + ); + EOF + )" + read -r provenance_run_id provenance_run_attempt <<< "$provenance_identity" + [[ "$provenance_run_id" == "$recovery_run_id" ]] + provenance_run_file="$(mktemp)" + provenance_jobs_file="$(mktemp)" + gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${provenance_run_id}/attempts/${provenance_run_attempt}" \ + > "$provenance_run_file" + gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${provenance_run_id}/attempts/${provenance_run_attempt}/jobs?per_page=100" \ + > "$provenance_jobs_file" + PROVENANCE_JOBS_FILE="$provenance_jobs_file" \ + PROVENANCE_RUN_ATTEMPT="$provenance_run_attempt" \ + PROVENANCE_RUN_FILE="$provenance_run_file" \ + PROVENANCE_RUN_ID="$provenance_run_id" VALIDATOR_FILE="$validator_file" \ + CONTROL_COMMIT="$control_commit" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validateRegistryProvenanceInvocation } = await import( + pathToFileURL(process.env.VALIDATOR_FILE) + ); + validateRegistryProvenanceInvocation({ + commit: process.env.CONTROL_COMMIT, + jobs: JSON.parse( + readFileSync(process.env.PROVENANCE_JOBS_FILE, "utf8"), + ).jobs, + run: JSON.parse(readFileSync(process.env.PROVENANCE_RUN_FILE, "utf8")), + runAttempt: Number(process.env.PROVENANCE_RUN_ATTEMPT), + runId: Number(process.env.PROVENANCE_RUN_ID), + workflowRef: "refs/heads/main", + }); + EOF + signature_dir="$(mktemp -d)" + ( + cd "$signature_dir" + npm init --yes >/dev/null + npm install --ignore-scripts --no-audit --no-fund cometapi@0.1.1 + signatures_verified="false" + for attempt in 1 2 3; do + if npm audit signatures; then + signatures_verified="true" + break + fi + [[ "$attempt" == "3" ]] || sleep 10 + done + [[ "$signatures_verified" == "true" ]] + ) + echo "cometapi@0.1.1 is the exact recovery artifact with matching signatures and provenance." + ``` + + On success, dispatch error, validation failure, cancellation, deployment + rejection, timeout, or ambiguous run discovery, stop or cancel the recovery + run before cleanup, immediately set `RELEASE_PLEASE_ENABLED=false`, delete + only the recorded policy ID, and require the remaining set to equal + `tag:v*`: + + ```bash + set -euo pipefail + recovery_state="$(git rev-parse --git-path cometapi-v0.1.1-recovery-state.json)" + main_policy_id="$(jq -er '.main_policy_id | select(type == "number" and . > 0)' \ + "$recovery_state")" + recovery_run_id="$(jq -er \ + '(.recovery_run_id // 0) | select(type == "number" and . >= 0)' \ + "$recovery_state")" + gh api --method PATCH \ + repos/cometapi-dev/cometapi-node/actions/variables/RELEASE_PLEASE_ENABLED \ + -f name=RELEASE_PLEASE_ENABLED -f value=false + if [[ "$recovery_run_id" =~ ^[1-9][0-9]*$ ]]; then + recovery_status="$(gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${recovery_run_id}" \ + --jq '.status')" + if [[ "$recovery_status" != "completed" ]]; then + gh api --method POST \ + "repos/cometapi-dev/cometapi-node/actions/runs/${recovery_run_id}/cancel" \ + >/dev/null + for poll in {1..12}; do + recovery_status="$(gh api \ + "repos/cometapi-dev/cometapi-node/actions/runs/${recovery_run_id}" \ + --jq '.status')" + [[ "$recovery_status" == "completed" ]] && break + sleep 5 + done + fi + [[ "$recovery_status" == "completed" ]] + fi + for workflow in publish.yml release-please.yml; do + for state in in_progress queued waiting requested pending; do + active="$(gh api \ + "repos/cometapi-dev/cometapi-node/actions/workflows/${workflow}/runs?status=${state}&per_page=100" \ + | jq --argjson current "${recovery_run_id:-0}" \ + '[.workflow_runs[] | select(.id != $current)] | length')" + [[ "$active" == "0" ]] + done + done + gh api --method DELETE \ + "repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies/${main_policy_id}" + restored_policies="$(mktemp)" + gh api --paginate --slurp \ + 'repos/cometapi-dev/cometapi-node/environments/npm/deployment-branch-policies?per_page=100' \ + | jq '{branch_policies: [.[].branch_policies[]]}' > "$restored_policies" + jq -e --slurpfile recovery_state "$recovery_state" \ + '([.branch_policies[] | {id, name, type}] | + sort_by(.type, .name)) as $actual | + ([$recovery_state[0].before_policies.branch_policies[] | + {id, name, type}] | sort_by(.type, .name)) as $before | + ($actual == [{"id":55718965,"name":"v*","type":"tag"}] and + $actual == $before)' \ + "$restored_policies" >/dev/null + test "$(gh api \ + repos/cometapi-dev/cometapi-node/actions/variables/RELEASE_PLEASE_ENABLED \ + --jq '.value')" = "false" + ``` + + Never delete a policy whose ID was not returned by the create call or + unambiguously recovered by the documented pre/post collection diff, and never + clean up while a recovery job can still reach the npm Environment. If either + restoration fails, stop all release work and report the exact external state. + The temporary main dispatch produces provenance bound to the reviewed recovery + control commit rather than the older `v0.1.1` commit. This disclosed exception + is unavoidable for the immutable historical tag and is not the normal release + model. After registry verification, a cleanup PR removes every `0.1.1` + recovery constant and the main-dispatch branch while retaining the generic + tag handoff and dispatch. No later release may temporarily allow `main`. Third-party actions are pinned to full commit SHAs. Workflow permissions remain read-only except where a documented job requires more; `id-token: write` belongs diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 1d07135..2cfd2c5 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -11,6 +11,7 @@ const RELEASE_PR_FOOTER = const RELEASE_WORKFLOW_JOB = "Prepare a reviewed release pull request or GitHub release"; const RELEASE_WORKFLOW_STEP = "Run Release Please"; +const PUBLISH_OPERATION = "release"; const PUBLISH_RECOVERY = Object.freeze({ actor: "tensornull", artifactDigest: @@ -19,19 +20,21 @@ const PUBLISH_RECOVERY = Object.freeze({ artifactName: "npm-package-0.1.1-30471665743-1", changedFiles: Object.freeze([ ".github/workflows/publish.yml", + ".github/workflows/release-please.yml", "RELEASING.md", "scripts/release-workflow-validation.mjs", "tests/release-workflow-validation.test.mjs", "tests/workflow-contract.test.mjs", ]), - dispatchControlParent: "8a80d8272a490ed6a7b47eede45aaeccae03c819", - dispatchTask: "npm-publish-recovery", + dispatchControlParent: "5f493045a2205fe19904ca5be36f5bbf23378aec", + dispatchTask: "recover-v0.1.1", failedPublishJobId: 90643868523, liveJobId: 90643725110, releaseCommit: "c98b514227858cd183c781270a7f78f65b577e82", releaseRunAttempt: 1, releaseRunId: 30469181724, releaseTag: "v0.1.1", + permanentTagPolicyId: 55718965, sourcePublishCommit: "22c313d4f80c53ba01672dd35cc27b621d5ec9ce", sourcePublishRunAttempt: 1, sourcePublishRunId: 30471665743, @@ -88,25 +91,987 @@ function stablePatch(version, label) { return Number(match[1]); } +export function validatePublishWorkflowContract(workflow) { + if ( + workflow === null || + typeof workflow !== "object" || + Array.isArray(workflow) + ) { + fail("Release workflow Publish definition must be an object."); + } + requireEqual(workflow.name, "Publish", "Publish workflow name"); + const inputs = workflow.on?.workflow_dispatch?.inputs; + if (inputs === null || typeof inputs !== "object" || Array.isArray(inputs)) { + fail( + "Release workflow Publish workflow_dispatch inputs must be an object.", + ); + } + for (const name of [ + "publish_operation", + "control_commit", + "release_commit", + "release_tag", + "release_run_id", + "release_run_attempt", + ]) { + requireEqual(inputs[name]?.required, true, `Publish ${name} requirement`); + requireEqual(inputs[name]?.type, "string", `Publish ${name} type`); + } + for (const name of [ + "source_publish_run_id", + "source_publish_run_attempt", + "recovery_policy_id", + ]) { + requireEqual(inputs[name]?.required, false, `Publish ${name} requirement`); + requireEqual(inputs[name]?.type, "string", `Publish ${name} type`); + } + requireEqual( + JSON.stringify(workflow.on?.workflow_run), + JSON.stringify({ workflows: ["Release Please"], types: ["completed"] }), + "Publish workflow_run trigger", + ); + requireEqual( + JSON.stringify(workflow.permissions), + JSON.stringify({ actions: "read", checks: "read", contents: "read" }), + "Publish default permissions", + ); + requireEqual( + JSON.stringify(workflow.concurrency), + JSON.stringify({ group: "npm-publish", "cancel-in-progress": false }), + "Publish workflow concurrency", + ); + const jobs = workflow.jobs; + if (jobs === null || typeof jobs !== "object" || Array.isArray(jobs)) { + fail("Release workflow Publish jobs must be an object."); + } + requireEqual( + JSON.stringify(Object.keys(jobs).sort()), + JSON.stringify(["handoff", "live-smoke", "publish", "verify"]), + "Publish job set", + ); + requireEqual( + JSON.stringify(jobs.handoff?.permissions), + JSON.stringify({ actions: "write", contents: "read" }), + "Publish handoff permissions", + ); + requireEqual( + jobs.handoff?.environment, + undefined, + "Publish handoff environment", + ); + const handoffIf = jobs.handoff?.if; + for (const fragment of [ + "vars.RELEASE_PLEASE_ENABLED == 'true'", + "github.event_name == 'workflow_run'", + "github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.event == 'push'", + "github.event.workflow_run.head_branch == 'main'", + ]) { + if (typeof handoffIf !== "string" || !handoffIf.includes(fragment)) { + fail(`Release workflow Publish handoff must require ${fragment}.`); + } + } + const dispatchStep = jobs.handoff?.steps?.find( + (step) => step?.name === "Dispatch the exact immutable tag", + ); + for (const fragment of [ + "actions/runs/${publish_run_id}/jobs", + "The tag-bound Publish verify job did not become observable.", + ]) { + if ( + typeof dispatchStep?.run === "string" && + dispatchStep.run.includes(fragment) + ) { + fail( + `Release workflow Publish handoff must stop after exact run discovery and cannot contain ${fragment}.`, + ); + } + } + const handoffResultStep = jobs.handoff?.steps?.find( + (step) => step?.name === "Classify the exact Release Please handoff", + ); + if (typeof handoffResultStep?.run !== "string") { + fail( + "Release workflow Publish handoff must classify the exact Release Please result before download.", + ); + } + for (const stepName of [ + "Install validation dependencies without lifecycle scripts", + "Download the exact Release Please result", + "Validate the exact release and tag dispatch contract", + "Dispatch the exact immutable tag", + ]) { + const step = jobs.handoff?.steps?.find( + (candidate) => candidate?.name === stepName, + ); + if ( + typeof step?.if !== "string" || + !step.if.includes("steps.result.outputs.has-result == 'true'") + ) { + fail( + `Release workflow Publish handoff ${stepName} must require an exact result.`, + ); + } + } + for (const fragment of [ + "actions/workflows/publish.yml/dispatches", + "tag-dispatch-runs-before.json", + "Multiple Publish runs matched the immutable tag handoff.", + '-f ref="$RELEASE_TAG"', + '-f "inputs[publish_operation]=release"', + '-f "inputs[control_commit]=$RELEASE_COMMIT"', + ]) { + if ( + typeof dispatchStep?.run !== "string" || + !dispatchStep.run.includes(fragment) + ) { + fail( + `Release workflow Publish handoff dispatch must contain ${fragment}.`, + ); + } + } + requireEqual( + jobs.verify?.environment, + undefined, + "Publish verify environment", + ); + requireEqual( + jobs.verify?.permissions, + undefined, + "Publish verify job permissions", + ); + const verifyIf = jobs.verify?.if; + for (const fragment of [ + "github.event_name == 'workflow_dispatch'", + "inputs.publish_operation == 'release'", + "startsWith(github.ref, 'refs/tags/v0.1.')", + "github.ref == format('refs/tags/{0}', inputs.release_tag)", + "github.sha == inputs.release_commit", + "github.workflow_sha == inputs.control_commit", + "inputs.control_commit == inputs.release_commit", + "inputs.recovery_policy_id != ''", + ]) { + if (typeof verifyIf !== "string" || !verifyIf.includes(fragment)) { + fail(`Release workflow Publish verify must require ${fragment}.`); + } + } + for (const [name, job] of Object.entries(jobs)) { + if ( + name !== "handoff" && + JSON.stringify(job).includes("github.event.workflow_run") + ) { + fail( + `Release workflow Publish ${name} cannot consume workflow_run context.`, + ); + } + } + requireEqual( + jobs["live-smoke"]?.environment, + "live-smoke", + "live environment", + ); + requireEqual( + jobs["live-smoke"]?.permissions, + undefined, + "Publish live-smoke permissions", + ); + requireEqual( + JSON.stringify(jobs["live-smoke"]?.needs), + JSON.stringify(["verify"]), + "Publish live-smoke dependencies", + ); + const requireUniqueStep = (job, name, label) => { + const matches = Array.isArray(job?.steps) + ? job.steps.filter((step) => step?.name === name) + : []; + requireEqual(matches.length, 1, `${label} step count`); + return matches[0]; + }; + requireEqual( + jobs.verify?.outputs?.["release-please-snapshot"], + "${{ steps.release-please-snapshot.outputs.digest }}", + "Publish Release Please snapshot output", + ); + const releasePleaseSnapshotStep = requireUniqueStep( + jobs.verify, + "Freeze the Release Please run set", + "Publish Release Please snapshot", + ); + for (const fragment of [ + "actions/workflows/release-please.yml/runs?per_page=100", + "snapshotReleasePleaseRuns", + "createHash", + ]) { + if ( + typeof releasePleaseSnapshotStep?.run !== "string" || + !releasePleaseSnapshotStep.run.includes(fragment) + ) { + fail(`Release workflow Publish run snapshot must contain ${fragment}.`); + } + } + const recoveryArtifactStep = requireUniqueStep( + jobs.verify, + "Download the prior live-verified release artifact", + "Publish recovery artifact download", + ); + requireEqual( + recoveryArtifactStep?.if, + "inputs.publish_operation == 'recover-v0.1.1'", + "Publish recovery artifact gate", + ); + for (const [name, expected] of Object.entries({ + "artifact-ids": "${{ steps.recovery-evidence.outputs.artifact-id }}", + path: "release-artifacts", + "merge-multiple": true, + "digest-mismatch": "error", + "github-token": "${{ github.token }}", + repository: "${{ github.repository }}", + "run-id": "${{ inputs.source_publish_run_id }}", + })) { + requireEqual( + recoveryArtifactStep?.with?.[name], + expected, + `Publish recovery artifact ${name}`, + ); + } + const packArtifactStep = requireUniqueStep( + jobs.verify, + "Pack the exact release artifact", + "Publish normal artifact pack", + ); + requireEqual( + packArtifactStep?.if, + "inputs.publish_operation == 'release'", + "Publish normal artifact gate", + ); + if ( + typeof packArtifactStep?.run !== "string" || + !packArtifactStep.run.includes( + "npm pack --pack-destination release-artifacts", + ) + ) { + fail( + "Release workflow Publish normal release must pack one exact artifact.", + ); + } + const selectArtifactStep = requireUniqueStep( + jobs.verify, + "Select the exact release artifact", + "Publish exact artifact selection", + ); + requireEqual(selectArtifactStep?.id, "pack", "Publish artifact selector ID"); + if ( + typeof selectArtifactStep?.run !== "string" || + !selectArtifactStep.run.includes('if [[ "${#tarballs[@]}" -ne 1 ]]') + ) { + fail("Release workflow Publish must select exactly one release artifact."); + } + const consumerArtifactStep = requireUniqueStep( + jobs.verify, + "Test consumers against the exact release artifact", + "Publish exact artifact consumer tests", + ); + for (const command of [ + "npm run test:package --", + "npm run test:examples --", + "npm run test:fixtures --", + ]) { + if ( + typeof consumerArtifactStep?.run !== "string" || + !consumerArtifactStep.run.includes(command) || + !consumerArtifactStep.run.includes("steps.pack.outputs.tarball") + ) { + fail( + `Release workflow Publish exact artifact consumer tests must contain ${command}.`, + ); + } + } + const uploadArtifactStep = requireUniqueStep( + jobs.verify, + "Upload the verified release artifact", + "Publish exact artifact upload", + ); + requireEqual( + uploadArtifactStep?.with?.path, + "${{ steps.pack.outputs.tarball }}", + "Publish exact artifact upload path", + ); + const reuseLiveStep = requireUniqueStep( + jobs["live-smoke"], + "Reuse the successful bounded live smoke", + "Publish recovery live-smoke", + ); + requireEqual( + reuseLiveStep?.if, + "needs.verify.outputs.publish-operation == 'recover-v0.1.1'", + "Publish recovery live gate", + ); + if ( + typeof reuseLiveStep?.run !== "string" || + !reuseLiveStep.run.includes('if [[ "$REUSE_LIVE_SMOKE" != "true" ]]') + ) { + fail("Release workflow Publish recovery must require exact live evidence."); + } + const boundedLiveStep = requireUniqueStep( + jobs["live-smoke"], + "Run the bounded live smoke", + "Publish normal live-smoke", + ); + requireEqual( + boundedLiveStep?.if, + "needs.verify.outputs.publish-operation == 'release'", + "Publish normal live gate", + ); + requireEqual( + boundedLiveStep?.run, + "npm run test:live", + "Publish normal live command", + ); + requireEqual( + JSON.stringify(boundedLiveStep?.env), + JSON.stringify({ + COMETAPI_KEY: "${{ secrets.COMETAPI_KEY }}", + COMETAPI_LIVE_SMOKE: "1", + COMETAPI_SMOKE_MODEL: "${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.4' }}", + COMETAPI_LIVE_REQUEST_LIMIT: "3", + COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16", + COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000", + COMETAPI_LIVE_CONCURRENCY: "1", + }), + "Publish normal live environment", + ); + requireEqual( + JSON.stringify(jobs.publish?.needs), + JSON.stringify(["live-smoke", "verify"]), + "Publish deployment dependencies", + ); + requireEqual(jobs.publish?.environment?.name, "npm", "Publish environment"); + requireEqual( + JSON.stringify(jobs.publish?.permissions), + JSON.stringify({ + actions: "read", + checks: "read", + contents: "read", + deployments: "read", + "id-token": "write", + }), + "Publish deployment permissions", + ); + const permissionCounts = { actionsWrite: 0, idTokenWrite: 0 }; + for (const job of Object.values(jobs)) { + if (job?.permissions?.actions === "write") + permissionCounts.actionsWrite += 1; + if (job?.permissions?.["id-token"] === "write") + permissionCounts.idTokenWrite += 1; + } + requireEqual(permissionCounts.actionsWrite, 1, "actions-write job count"); + requireEqual(permissionCounts.idTokenWrite, 1, "OIDC job count"); + const reconfirmStep = requireUniqueStep( + jobs.publish, + "Reconfirm protected state immediately before publication", + "Publish protected-state reconfirmation", + ); + requireEqual( + reconfirmStep?.env?.RELEASE_PLEASE_SNAPSHOT, + "${{ needs.verify.outputs.release-please-snapshot }}", + "Publish Release Please snapshot reconfirmation", + ); + requireEqual( + reconfirmStep?.env?.RECOVERY_POLICY_ID, + "${{ inputs.recovery_policy_id }}", + "Publish recovery policy ID reconfirmation", + ); + requireEqual( + reconfirmStep?.env?.RELEASE_PLEASE_ENABLED, + "${{ vars.RELEASE_PLEASE_ENABLED }}", + "Publish Release Please variable context", + ); + for (const fragment of [ + "actions/workflows/release-please.yml/runs?per_page=100", + 'gh api --paginate --slurp \\\n "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies?per_page=100"', + "{branch_policies: [.[].branch_policies[]]}", + "snapshotReleasePleaseRuns", + "expectedPolicyIds", + "if (digest !== process.env.RELEASE_PLEASE_SNAPSHOT)", + ]) { + if ( + typeof reconfirmStep?.run !== "string" || + !reconfirmStep.run.includes(fragment) + ) { + fail( + `Release workflow Publish protected-state reconfirmation must contain ${fragment}.`, + ); + } + } + const publishStep = requireUniqueStep( + jobs.publish, + "Publish the exact artifact with provenance", + "Publish npm publication", + ); + const registryStep = requireUniqueStep( + jobs.publish, + "Verify the public registry artifact", + "Publish public-registry verification", + ); + requireEqual( + registryStep?.env?.GH_TOKEN, + "${{ github.token }}", + "Publish registry verification token", + ); + const publishSteps = jobs.publish?.steps ?? []; + if ( + publishSteps.indexOf(reconfirmStep) >= publishSteps.indexOf(publishStep) || + publishSteps.indexOf(publishStep) >= publishSteps.indexOf(registryStep) + ) { + fail( + "Release workflow Publish must reconfirm protected state, publish, and then verify the registry in order.", + ); + } + requireEqual( + publishStep?.run, + "bash scripts/publish-artifact.sh", + "npm publication command", + ); + return { supportsTagDispatch: true }; +} + +export function snapshotReleasePleaseRuns(runs) { + if (!Array.isArray(runs)) { + fail("Release workflow Release Please run snapshot must be an array."); + } + const seen = new Set(); + const snapshot = runs.map((run) => { + requirePositiveInteger(run?.id, "Release Please snapshot run ID"); + if (seen.has(run.id)) { + fail( + "Release workflow Release Please run snapshot contains a duplicate run ID.", + ); + } + seen.add(run.id); + requirePositiveInteger( + run?.run_attempt, + "Release Please snapshot run attempt", + ); + requireEqual(run?.name, "Release Please", "snapshot workflow name"); + requireEqual( + run?.path, + ".github/workflows/release-please.yml", + "snapshot workflow path", + ); + requireEqual( + run?.repository?.full_name, + "cometapi-dev/cometapi-node", + "snapshot repository", + ); + requireCommit(run?.head_sha, "Release Please snapshot head SHA"); + if (run?.event !== "push" && run?.event !== "workflow_dispatch") { + fail("Release workflow Release Please snapshot event is unsupported."); + } + requireEqual(run?.status, "completed", "snapshot run status"); + if (typeof run?.conclusion !== "string" || run.conclusion === "") { + fail( + "Release workflow Release Please snapshot conclusion must be final.", + ); + } + return { + conclusion: run.conclusion, + event: run.event, + headSha: run.head_sha, + id: run.id, + runAttempt: run.run_attempt, + status: run.status, + }; + }); + snapshot.sort((left, right) => left.id - right.id); + return JSON.stringify(snapshot); +} + +export function classifyReleasePleaseHandoff({ + artifacts, + jobs, + runAttempt, + runId, +}) { + requirePositiveInteger(runId, "handoff source run ID"); + requirePositiveInteger(runAttempt, "handoff source run attempt"); + if (!Array.isArray(jobs)) { + fail("Release workflow handoff source jobs must be an array."); + } + const matchingJobs = jobs.filter((job) => job?.name === RELEASE_WORKFLOW_JOB); + requireEqual(matchingJobs.length, 1, "handoff source job count"); + const sourceJob = matchingJobs[0]; + requireEqual(sourceJob?.run_id, runId, "handoff source job run ID"); + requireEqual( + sourceJob?.run_attempt, + runAttempt, + "handoff source job run attempt", + ); + requireEqual(sourceJob?.status, "completed", "handoff source job status"); + requireEqual( + sourceJob?.conclusion, + "success", + "handoff source job conclusion", + ); + const uploadSteps = Array.isArray(sourceJob?.steps) + ? sourceJob.steps.filter( + (step) => step?.name === "Upload the exact Release Please result", + ) + : []; + requireEqual(uploadSteps.length, 1, "handoff result upload-step count"); + requireEqual( + uploadSteps[0]?.status, + "completed", + "handoff result upload-step status", + ); + if (!Array.isArray(artifacts)) { + fail("Release workflow handoff source artifacts must be an array."); + } + const artifactName = `release-please-result-${runId}-${runAttempt}`; + const matchingArtifacts = artifacts.filter( + (artifact) => artifact?.name === artifactName, + ); + if (uploadSteps[0]?.conclusion === "skipped") { + requireEqual( + matchingArtifacts.length, + 0, + "preparation handoff result-artifact count", + ); + return { artifactName, hasResult: false }; + } + requireEqual( + uploadSteps[0]?.conclusion, + "success", + "handoff result upload-step conclusion", + ); + requireEqual( + matchingArtifacts.length, + 1, + "release handoff result-artifact count", + ); + requireEqual( + matchingArtifacts[0]?.expired, + false, + "release handoff result-artifact expiration", + ); + requireEqual( + matchingArtifacts[0]?.workflow_run?.id, + runId, + "release handoff result-artifact run ID", + ); + return { artifactName, hasResult: true }; +} + +export function validatePublishWorkflowDispatchTrigger({ + actor, + controlCommit, + eventName, + eventRef, + eventSha, + operation, + releaseCommit, + releaseTag, + sourceReleaseCommit, + sourceRunAttempt, + sourceRunId, + triggeringActor, + workflowRunAttempt, + workflowSha, +}) { + requireEqual(actor, "github-actions[bot]", "tag dispatch actor"); + requireEqual( + triggeringActor, + "github-actions[bot]", + "tag dispatch triggering actor", + ); + requireEqual(eventName, "workflow_dispatch", "tag dispatch event"); + requireEqual(operation, PUBLISH_OPERATION, "tag dispatch operation"); + requireCommit(controlCommit, "tag dispatch control commit"); + requireCommit(releaseCommit, "tag dispatch release commit"); + requireCommit(sourceReleaseCommit, "tag dispatch source release commit"); + requireCommit(eventSha, "tag dispatch event SHA"); + requireCommit(workflowSha, "tag dispatch workflow SHA"); + requireEqual(controlCommit, releaseCommit, "tag dispatch control commit"); + requireEqual(eventSha, releaseCommit, "tag dispatch event SHA"); + requireEqual(workflowSha, releaseCommit, "tag dispatch workflow SHA"); + requireEqual( + sourceReleaseCommit, + releaseCommit, + "tag dispatch source release commit", + ); + const patch = stablePatch(releaseTag?.slice(1), "tag dispatch version"); + if (patch < 1) { + fail("Release workflow tag dispatch requires a post-0.1.0 patch release."); + } + requireEqual(eventRef, `refs/tags/${releaseTag}`, "tag dispatch ref"); + requirePositiveInteger(sourceRunId, "tag dispatch source run ID"); + requirePositiveInteger(sourceRunAttempt, "tag dispatch source run attempt"); + requirePositiveInteger(workflowRunAttempt, "tag dispatch run attempt"); + return { + releaseCommit, + releaseRunAttempt: sourceRunAttempt, + releaseRunId: sourceRunId, + releaseTag, + }; +} + +export function validateNpmEnvironmentState({ + environment, + expectedPolicyIds, + operation, + policies, +}) { + requireEqual(environment?.id, 18800205839, "npm environment ID"); + requireEqual(environment?.name, "npm", "npm environment name"); + requireEqual(environment?.can_admins_bypass, false, "npm admin bypass state"); + requireEqual( + environment?.deployment_branch_policy?.protected_branches, + false, + "npm protected-branch policy state", + ); + requireEqual( + environment?.deployment_branch_policy?.custom_branch_policies, + true, + "npm custom branch-policy state", + ); + const rules = Array.isArray(environment?.protection_rules) + ? environment.protection_rules + : []; + requireEqual(rules.length, 2, "npm protection-rule count"); + const reviewerRule = rules.find( + (rule) => rule?.type === "required_reviewers", + ); + const branchRule = rules.find((rule) => rule?.type === "branch_policy"); + requireEqual(Boolean(branchRule), true, "npm branch-policy rule presence"); + requireEqual( + reviewerRule?.prevent_self_review, + false, + "npm self-review policy", + ); + requireEqual(reviewerRule?.reviewers?.length, 1, "npm reviewer count"); + requireEqual(reviewerRule?.reviewers?.[0]?.type, "User", "npm reviewer type"); + requireEqual( + reviewerRule?.reviewers?.[0]?.reviewer?.login, + "tensornull", + "npm reviewer login", + ); + requireEqual( + reviewerRule?.reviewers?.[0]?.reviewer?.id, + 129579691, + "npm reviewer ID", + ); + if (!Array.isArray(policies)) { + fail("Release workflow npm deployment policies must be an array."); + } + for (const policy of policies) { + requirePositiveInteger(policy?.id, "npm deployment-policy ID"); + } + const actual = policies.map(({ name, type }) => `${type}:${name}`).sort(); + const expected = + operation === PUBLISH_OPERATION + ? ["tag:v*"] + : operation === PUBLISH_RECOVERY.dispatchTask + ? ["branch:main", "tag:v*"] + : null; + if (expected === null) { + fail("Release workflow npm environment operation is unsupported."); + } + requireEqual( + JSON.stringify(actual), + JSON.stringify(expected), + "npm deployment policies", + ); + if (expectedPolicyIds !== undefined) { + if ( + expectedPolicyIds === null || + typeof expectedPolicyIds !== "object" || + Array.isArray(expectedPolicyIds) + ) { + fail("Release workflow expected npm policy IDs must be an object."); + } + requireEqual( + JSON.stringify(Object.keys(expectedPolicyIds).sort()), + JSON.stringify([...actual].sort()), + "expected npm policy ID keys", + ); + requireEqual( + expectedPolicyIds["tag:v*"], + PUBLISH_RECOVERY.permanentTagPolicyId, + "permanent npm tag policy ID", + ); + for (const policy of policies) { + const key = `${policy.type}:${policy.name}`; + requirePositiveInteger( + expectedPolicyIds[key], + `expected npm policy ID for ${key}`, + ); + requireEqual( + policy.id, + expectedPolicyIds[key], + `npm policy ID for ${key}`, + ); + } + } + return { policies: actual }; +} + +export function validateRegistryStateBeforePublish({ + exactVersion, + latestVersion, + nextVersion, + version, +}) { + const patch = stablePatch(version, "registry candidate version"); + if (patch < 1) { + fail("Release workflow registry candidate must be newer than 0.1.0."); + } + if (exactVersion !== null) { + requireEqual(exactVersion, version, "registry exact version"); + } + const previousVersion = `0.1.${patch - 1}`; + const allowedLatest = + exactVersion === null ? [previousVersion] : [previousVersion, version]; + if (!allowedLatest.includes(latestVersion)) { + fail( + `Release workflow registry latest must equal ${allowedLatest.join(" or ")}; received ${String(latestVersion)}.`, + ); + } + requireEqual(nextVersion, "0.1.0-alpha.3", "registry next version"); + return { exactVersion, latestVersion, nextVersion, previousVersion, version }; +} + +export function validateRegistryProvenance({ + attestations, + commit, + sha512, + version, + workflowRef, +}) { + requireCommit(commit, "provenance workflow commit"); + stablePatch(version, "provenance version"); + if (typeof sha512 !== "string" || !/^[0-9a-f]{128}$/.test(sha512)) { + fail("Release workflow provenance sha512 must be lowercase hexadecimal."); + } + if ( + typeof workflowRef !== "string" || + !/^refs\/(heads\/main|tags\/v0\.1\.[1-9]\d*)$/.test(workflowRef) + ) { + fail("Release workflow provenance ref must be main or a stable 0.1.x tag."); + } + const entries = Array.isArray(attestations?.attestations) + ? attestations.attestations + : []; + const matches = entries.filter( + (entry) => entry?.predicateType === "https://slsa.dev/provenance/v1", + ); + requireEqual(matches.length, 1, "SLSA provenance attestation count"); + const encoded = matches[0]?.bundle?.dsseEnvelope?.payload; + if (typeof encoded !== "string" || encoded === "") { + fail("Release workflow SLSA provenance payload must be base64 encoded."); + } + let statement; + try { + statement = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + } catch { + fail("Release workflow SLSA provenance payload must contain JSON."); + } + requireEqual( + statement?._type, + "https://in-toto.io/Statement/v1", + "provenance statement type", + ); + requireEqual( + statement?.predicateType, + "https://slsa.dev/provenance/v1", + "provenance predicate type", + ); + requireEqual(statement?.subject?.length, 1, "provenance subject count"); + requireEqual( + statement?.subject?.[0]?.name, + `pkg:npm/cometapi@${version}`, + "provenance subject name", + ); + requireEqual( + statement?.subject?.[0]?.digest?.sha512, + sha512, + "provenance subject digest", + ); + const predicate = statement?.predicate; + requireEqual( + predicate?.buildDefinition?.buildType, + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + "provenance build type", + ); + const workflow = predicate?.buildDefinition?.externalParameters?.workflow; + requireEqual( + workflow?.repository, + "https://github.com/cometapi-dev/cometapi-node", + "provenance workflow repository", + ); + requireEqual( + workflow?.path, + ".github/workflows/publish.yml", + "provenance workflow path", + ); + requireEqual(workflow?.ref, workflowRef, "provenance workflow ref"); + const github = predicate?.buildDefinition?.internalParameters?.github; + requireEqual(github?.event_name, "workflow_dispatch", "provenance event"); + requireEqual(github?.repository_id, "1307188651", "provenance repository ID"); + requireEqual( + github?.repository_owner_id, + "225111184", + "provenance repository owner ID", + ); + requireEqual( + predicate?.buildDefinition?.resolvedDependencies?.length, + 1, + "provenance resolved-dependency count", + ); + const dependency = predicate?.buildDefinition?.resolvedDependencies?.[0]; + requireEqual( + dependency?.uri, + `git+https://github.com/cometapi-dev/cometapi-node@${workflowRef}`, + "provenance source URI", + ); + requireEqual( + dependency?.digest?.gitCommit, + commit, + "provenance source commit", + ); + requireEqual( + predicate?.runDetails?.builder?.id, + "https://github.com/actions/runner/github-hosted", + "provenance builder", + ); + const invocation = predicate?.runDetails?.metadata?.invocationId; + const invocationMatch = + typeof invocation === "string" && + invocation.match( + /^https:\/\/github\.com\/cometapi-dev\/cometapi-node\/actions\/runs\/([1-9]\d*)\/attempts\/([1-9]\d*)$/, + ); + if (!invocationMatch) { + fail( + "Release workflow provenance invocation must identify an exact run attempt.", + ); + } + const provenanceRunId = Number(invocationMatch[1]); + const provenanceRunAttempt = Number(invocationMatch[2]); + requirePositiveInteger(provenanceRunId, "provenance run ID"); + requirePositiveInteger(provenanceRunAttempt, "provenance run attempt"); + return { + commit, + provenanceRunAttempt, + provenanceRunId, + version, + workflowRef, + }; +} + +export function validateRegistryProvenanceInvocation({ + commit, + jobs, + run, + runAttempt, + runId, + workflowRef, +}) { + requireCommit(commit, "provenance invocation commit"); + requirePositiveInteger(runId, "provenance invocation run ID"); + requirePositiveInteger(runAttempt, "provenance invocation run attempt"); + requireEqual(run?.id, runId, "provenance invocation run ID"); + requireEqual( + run?.run_attempt, + runAttempt, + "provenance invocation run attempt", + ); + requireEqual(run?.name, "Publish", "provenance invocation workflow name"); + requireEqual( + run?.path, + ".github/workflows/publish.yml", + "provenance invocation workflow path", + ); + requireEqual(run?.event, "workflow_dispatch", "provenance invocation event"); + requireEqual( + run?.head_branch, + workflowRef.replace(/^refs\/(?:heads|tags)\//, ""), + "provenance invocation ref", + ); + requireEqual(run?.head_sha, commit, "provenance invocation commit"); + requireEqual( + run?.repository?.full_name, + "cometapi-dev/cometapi-node", + "provenance invocation repository", + ); + if (run?.status !== "in_progress" && run?.status !== "completed") { + fail("Release workflow provenance invocation must be active or completed."); + } + if ( + run.status === "completed" && + run.conclusion !== "success" && + run.conclusion !== "failure" + ) { + fail( + "Release workflow completed provenance invocation must succeed or fail after publication.", + ); + } + if (!Array.isArray(jobs)) { + fail("Release workflow provenance invocation jobs must be an array."); + } + const publishJobs = jobs.filter( + (job) => job?.name === "Publish with npm Trusted Publishing", + ); + requireEqual(publishJobs.length, 1, "provenance publish-job count"); + const publishJob = publishJobs[0]; + requireEqual(publishJob?.run_id, runId, "provenance publish-job run ID"); + requireEqual( + publishJob?.run_attempt, + runAttempt, + "provenance publish-job run attempt", + ); + requireEqual(publishJob?.head_sha, commit, "provenance publish-job commit"); + if (!Number.isInteger(publishJob?.runner_id) || publishJob.runner_id < 1) { + fail("Release workflow provenance publish job must have a runner."); + } + const publishSteps = Array.isArray(publishJob?.steps) + ? publishJob.steps.filter( + (step) => step?.name === "Publish the exact artifact with provenance", + ) + : []; + requireEqual(publishSteps.length, 1, "provenance publish-step count"); + requireEqual( + publishSteps[0]?.status, + "completed", + "provenance publish-step status", + ); + requireEqual( + publishSteps[0]?.conclusion, + "success", + "provenance publish-step conclusion", + ); + return { commit, runAttempt, runId, workflowRef }; +} + export function validatePublishWorkflowDispatchRecoveryTrigger({ actor, changedFiles, controlCommit, + controlCommitInput, controlFirstParent, eventName, eventRef, eventSha, mainCommit, + operation, releaseCommit, releaseTag, + recoveryPolicyId, sourcePublishRunAttempt, sourcePublishRunId, sourceReleaseCommit, sourceRunAttempt, sourceRunId, - task, triggeringActor, workflowRunAttempt, + workflowSha, }) { requireEqual(actor, PUBLISH_RECOVERY.actor, "publish recovery actor"); requireEqual( @@ -115,18 +1080,11 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ "publish recovery triggering actor", ); requireEqual(eventName, "workflow_dispatch", "publish recovery event"); - requireEqual( - eventRef, - `refs/tags/${PUBLISH_RECOVERY.releaseTag}`, - "publish recovery ref", - ); + requireEqual(eventRef, "refs/heads/main", "publish recovery ref"); requireCommit(eventSha, "publish recovery event SHA"); - requireEqual( - eventSha, - PUBLISH_RECOVERY.releaseCommit, - "publish recovery event SHA", - ); + requireCommit(workflowSha, "publish recovery workflow SHA"); requireCommit(controlCommit, "publish recovery control commit"); + requireCommit(controlCommitInput, "publish recovery input control commit"); requireCommit(controlFirstParent, "publish recovery control first parent"); requireCommit(mainCommit, "publish recovery main commit"); requireCommit(sourceReleaseCommit, "publish recovery source release commit"); @@ -135,6 +1093,13 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ mainCommit, "publish recovery control and main commit agreement", ); + requireEqual(eventSha, controlCommit, "publish recovery event SHA"); + requireEqual(workflowSha, controlCommit, "publish recovery workflow SHA"); + requireEqual( + controlCommitInput, + controlCommit, + "publish recovery input control commit", + ); requireEqual( controlFirstParent, PUBLISH_RECOVERY.dispatchControlParent, @@ -188,8 +1153,13 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ PUBLISH_RECOVERY.sourcePublishRunAttempt, "publish recovery source Publish run attempt", ); - requireEqual(task, PUBLISH_RECOVERY.dispatchTask, "publish recovery task"); + requireEqual( + operation, + PUBLISH_RECOVERY.dispatchTask, + "publish recovery operation", + ); requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt"); + requirePositiveInteger(recoveryPolicyId, "publish recovery policy ID"); if (!Array.isArray(changedFiles)) { fail("Release workflow publish recovery changed files must be an array."); } @@ -650,6 +1620,7 @@ export function selectPendingReleasePullRequest( pullRequests, { eventName, + operation = eventName === "workflow_dispatch" ? "prepare" : "release", releaseBranch, releaseCommit, releaseExists = false, @@ -663,6 +1634,9 @@ export function selectPendingReleasePullRequest( requireCommit(releaseCommit, "current release commit"); requireBoolean(releaseExists, "pre-action release existence"); requirePositiveInteger(runAttempt, "run attempt"); + if (operation !== "prepare" && operation !== "release") { + fail("Release workflow release operation must be prepare or release."); + } const pendingReleasePullRequests = pullRequests.filter( (pullRequest) => pullRequest?.baseRef === "main" && @@ -675,7 +1649,13 @@ export function selectPendingReleasePullRequest( fail("Release workflow found multiple pending merged release PRs."); } if (pendingReleasePullRequests.length === 0) { - if (eventName === "push" && releaseExists) { + if (operation === "release") { + requireEqual(eventName, "push", "release operation event"); + requireEqual( + releaseExists, + true, + "release recovery pre-action existence", + ); if (runAttempt === 1) { fail( "Release workflow recovery is forbidden on the first run attempt.", @@ -706,11 +1686,22 @@ export function selectPendingReleasePullRequest( ); return recoveryPullRequest; } - requireEqual(eventName, "workflow_dispatch", "release preparation event"); + if (eventName !== "workflow_dispatch" && eventName !== "push") { + fail( + "Release workflow preparation event must be push or workflow_dispatch.", + ); + } + requireEqual(operation, "prepare", "release preparation operation"); + requireEqual( + releaseExists, + false, + "release preparation candidate existence", + ); requireEqual(runAttempt, 1, "release preparation run attempt"); return null; } + requireEqual(operation, "release", "pending release operation"); const pullRequest = pendingReleasePullRequests[0]; requireEqual( pullRequest.headRef, @@ -1254,11 +2245,6 @@ export function classifyPushReleasePresence({ ); } if (currentPatch === previousPatch) { - if (packageChanged) { - fail( - "Release workflow package.json push must change the stable package version.", - ); - } if (!releaseExists) { fail( "Release workflow ignored push must retain the exact published current version.", @@ -1290,7 +2276,7 @@ export function classifyPushReleasePresence({ true, "published current release immutable state", ); - return { mode: "ignore", version }; + return { mode: "prepare", version }; } if (currentPatch !== previousPatch + 1) { fail("Release workflow push must increment exactly one stable patch."); diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 3f60154..62b4daa 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -1,18 +1,28 @@ +import { readFileSync } from "node:fs"; import { fileURLToPath, URL } from "node:url"; import { getFileInfo } from "prettier"; import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; import { + classifyReleasePleaseHandoff, classifyPushReleasePresence, extractReleaseNotesFromChangelog, + snapshotReleasePleaseRuns, validatePreparedReleasePullRequest, validateGitHubRelease, validateMergedReleasePullRequest, validateOpenReleasePullRequestCollisions, validatePostActionPullRequestSnapshot, + validateNpmEnvironmentState, + validatePublishWorkflowContract, + validatePublishWorkflowDispatchTrigger, validatePublishWorkflowDispatchRecoveryTrigger, validatePublishRecoveryEvidence, + validateRegistryProvenance, + validateRegistryProvenanceInvocation, + validateRegistryStateBeforePublish, validateReleasePleaseCommitMessages, validateReleasePleaseCompletion, validateReleasePleaseMutationConfiguration, @@ -31,6 +41,12 @@ const RELEASE_BRANCH = "release-please--branches--main--components--cometapi"; const RELEASE_SHA = "a".repeat(40); const BRANCH_SHA = "b".repeat(40); const RUN_ID = 123456789; +const PUBLISH_WORKFLOW = parse( + readFileSync( + new URL("../.github/workflows/publish.yml", import.meta.url), + "utf8", + ), +); function releaseNotes(version = "0.1.1") { return `## [${version}](https://github.com/cometapi-dev/cometapi-node/compare/v0.1.0...v${version}) (2026-07-29) @@ -161,11 +177,135 @@ describe("Release Please generated files", () => { }); }); +describe("Release Please publication handoff", () => { + function handoffFixture({ uploadConclusion = "success" } = {}) { + const artifactName = `release-please-result-${RUN_ID}-1`; + return { + artifacts: + uploadConclusion === "success" + ? [ + { + expired: false, + name: artifactName, + workflow_run: { id: RUN_ID }, + }, + ] + : [], + jobs: [ + { + conclusion: "success", + name: "Prepare a reviewed release pull request or GitHub release", + run_attempt: 1, + run_id: RUN_ID, + status: "completed", + steps: [ + { + conclusion: uploadConclusion, + name: "Upload the exact Release Please result", + status: "completed", + }, + ], + }, + ], + runAttempt: 1, + runId: RUN_ID, + }; + } + + it("dispatches only a release run with its exact result artifact", () => { + expect(classifyReleasePleaseHandoff(handoffFixture())).toEqual({ + artifactName: `release-please-result-${RUN_ID}-1`, + hasResult: true, + }); + }); + + it("treats a successful preparation run as a release-inert handoff", () => { + expect( + classifyReleasePleaseHandoff( + handoffFixture({ uploadConclusion: "skipped" }), + ), + ).toEqual({ + artifactName: `release-please-result-${RUN_ID}-1`, + hasResult: false, + }); + }); + + it.each([ + ["missing release artifact", (value) => value.artifacts.splice(0)], + [ + "duplicate release artifact", + (value) => value.artifacts.push(value.artifacts[0]), + ], + [ + "expired release artifact", + (value) => (value.artifacts[0].expired = true), + ], + [ + "preparation artifact", + (value) => { + value.jobs[0].steps[0].conclusion = "skipped"; + }, + ], + [ + "failed upload step", + (value) => (value.jobs[0].steps[0].conclusion = "failure"), + ], + ])("rejects %s", (_name, mutate) => { + const fixture = handoffFixture(); + mutate(fixture); + expect(() => classifyReleasePleaseHandoff(fixture)).toThrow( + /release workflow/i, + ); + }); +}); + +describe("Release Please run-set freeze", () => { + function runFixture({ + id = RUN_ID, + runAttempt = 1, + status = "completed", + } = {}) { + return { + conclusion: status === "completed" ? "success" : null, + event: "push", + head_sha: RELEASE_SHA, + id, + name: "Release Please", + path: ".github/workflows/release-please.yml", + repository: { full_name: REPOSITORY }, + run_attempt: runAttempt, + status, + }; + } + + it("creates one deterministic snapshot only from completed runs", () => { + const older = runFixture({ id: RUN_ID - 1 }); + const current = runFixture(); + expect(snapshotReleasePleaseRuns([current, older])).toBe( + snapshotReleasePleaseRuns([older, current]), + ); + }); + + it("changes the snapshot when an old run is rerun", () => { + expect(snapshotReleasePleaseRuns([runFixture()])).not.toBe( + snapshotReleasePleaseRuns([runFixture({ runAttempt: 2 })]), + ); + }); + + it.each([ + ["active run", [runFixture({ status: "in_progress" })]], + ["duplicate run", [runFixture(), runFixture()]], + ])("rejects an %s", (_name, runs) => { + expect(() => snapshotReleasePleaseRuns(runs)).toThrow(/release workflow/i); + }); +}); + describe("Publish workflow dispatch recovery trigger", () => { const recoveryCommit = "c98b514227858cd183c781270a7f78f65b577e82"; - const controlParent = "8a80d8272a490ed6a7b47eede45aaeccae03c819"; + const controlParent = "5f493045a2205fe19904ca5be36f5bbf23378aec"; const recoveryFiles = [ ".github/workflows/publish.yml", + ".github/workflows/release-please.yml", "RELEASING.md", "scripts/release-workflow-validation.mjs", "tests/release-workflow-validation.test.mjs", @@ -177,11 +317,14 @@ describe("Publish workflow dispatch recovery trigger", () => { actor: "tensornull", changedFiles: recoveryFiles, controlCommit: BRANCH_SHA, + controlCommitInput: BRANCH_SHA, controlFirstParent: controlParent, eventName: "workflow_dispatch", - eventRef: "refs/tags/v0.1.1", - eventSha: recoveryCommit, + eventRef: "refs/heads/main", + eventSha: BRANCH_SHA, mainCommit: BRANCH_SHA, + operation: "recover-v0.1.1", + recoveryPolicyId: 60000000, releaseCommit: recoveryCommit, releaseTag: "v0.1.1", sourcePublishRunAttempt: 1, @@ -189,9 +332,9 @@ describe("Publish workflow dispatch recovery trigger", () => { sourceReleaseCommit: recoveryCommit, sourceRunAttempt: 1, sourceRunId: 30469181724, - task: "npm-publish-recovery", triggeringActor: "tensornull", workflowRunAttempt: 1, + workflowSha: BRANCH_SHA, ...overrides, }; } @@ -212,15 +355,19 @@ describe("Publish workflow dispatch recovery trigger", () => { ["actor", { actor: "github-actions[bot]" }], ["triggering actor", { triggeringActor: "other-maintainer" }], ["event", { eventName: "deployment" }], - ["ref", { eventRef: "refs/heads/main" }], + ["ref", { eventRef: "refs/tags/v0.1.1" }], ["event SHA", { eventSha: RELEASE_SHA }], + ["workflow SHA", { workflowSha: RELEASE_SHA }], ["control commit", { controlCommit: RELEASE_SHA }], + ["control input", { controlCommitInput: RELEASE_SHA }], + ["main commit", { mainCommit: RELEASE_SHA }], ["first parent", { controlFirstParent: RELEASE_SHA }], ["release input", { releaseCommit: RELEASE_SHA }], ["release tag input", { releaseTag: "v0.1.0" }], ["source Publish run", { sourcePublishRunId: 30471665744 }], ["source Publish attempt", { sourcePublishRunAttempt: 2 }], - ["task", { task: "publish" }], + ["operation", { operation: "release" }], + ["recovery policy", { recoveryPolicyId: 0 }], ["source commit", { sourceReleaseCommit: RELEASE_SHA }], ["source run ID", { sourceRunId: 30469181725 }], ["source attempt", { sourceRunAttempt: 2 }], @@ -243,6 +390,758 @@ describe("Publish workflow dispatch recovery trigger", () => { }); }); +describe("Publish tag dispatch trigger", () => { + function tagTrigger(overrides = {}) { + return { + actor: "github-actions[bot]", + controlCommit: RELEASE_SHA, + eventName: "workflow_dispatch", + eventRef: "refs/tags/v0.1.1", + eventSha: RELEASE_SHA, + operation: "release", + releaseCommit: RELEASE_SHA, + releaseTag: "v0.1.1", + sourceReleaseCommit: RELEASE_SHA, + sourceRunAttempt: 1, + sourceRunId: RUN_ID, + triggeringActor: "github-actions[bot]", + workflowRunAttempt: 1, + workflowSha: RELEASE_SHA, + ...overrides, + }; + } + + it("accepts a bot handoff bound to the exact stable tag", () => { + expect(validatePublishWorkflowDispatchTrigger(tagTrigger())).toEqual({ + releaseCommit: RELEASE_SHA, + releaseRunAttempt: 1, + releaseRunId: RUN_ID, + releaseTag: "v0.1.1", + }); + }); + + it.each([ + ["actor", { actor: "tensornull" }], + ["triggering actor", { triggeringActor: "tensornull" }], + ["event", { eventName: "push" }], + ["operation", { operation: "recover-v0.1.1" }], + ["ref", { eventRef: "refs/heads/main" }], + ["event SHA", { eventSha: BRANCH_SHA }], + ["workflow SHA", { workflowSha: BRANCH_SHA }], + ["control commit", { controlCommit: BRANCH_SHA }], + ["source commit", { sourceReleaseCommit: BRANCH_SHA }], + ["tag", { releaseTag: "v0.2.0", eventRef: "refs/tags/v0.2.0" }], + ["stable boundary", { releaseTag: "v0.1.0", eventRef: "refs/tags/v0.1.0" }], + ["source run", { sourceRunId: 0 }], + ["source attempt", { sourceRunAttempt: 0 }], + ["run attempt", { workflowRunAttempt: 0 }], + ])("rejects tag handoff drift in %s", (_name, overrides) => { + expect(() => + validatePublishWorkflowDispatchTrigger(tagTrigger(overrides)), + ).toThrow(/release workflow/i); + }); + + it("allows an idempotent rerun of the same tag handoff", () => { + expect( + validatePublishWorkflowDispatchTrigger( + tagTrigger({ workflowRunAttempt: 2 }), + ), + ).toMatchObject({ releaseRunId: RUN_ID }); + }); +}); + +describe("Publish workflow dispatch contract", () => { + function workflowContract(mutate) { + const workflow = JSON.parse(JSON.stringify(PUBLISH_WORKFLOW)); + mutate?.(workflow); + return workflow; + } + + it("requires the permanent tag-handoff inputs", () => { + expect(validatePublishWorkflowContract(workflowContract())).toEqual({ + supportsTagDispatch: true, + }); + }); + + it.each([ + [ + "optional control input", + (workflow) => { + workflow.on.workflow_dispatch.inputs.control_commit.required = false; + }, + ], + [ + "workflow_run trigger", + (workflow) => { + workflow.on.workflow_run.workflows = ["CI"]; + }, + ], + [ + "cancellable publish concurrency", + (workflow) => { + workflow.concurrency["cancel-in-progress"] = true; + }, + ], + [ + "missing recovery policy input", + (workflow) => { + delete workflow.on.workflow_dispatch.inputs.recovery_policy_id; + }, + ], + [ + "handoff environment", + (workflow) => { + workflow.jobs.handoff.environment = "npm"; + }, + ], + [ + "extra actions writer", + (workflow) => { + workflow.jobs.verify.permissions = { actions: "write" }; + }, + ], + [ + "explicit verify permissions", + (workflow) => { + workflow.jobs.verify.permissions = { contents: "read" }; + }, + ], + [ + "explicit live-smoke permissions", + (workflow) => { + workflow.jobs["live-smoke"].permissions = { contents: "write" }; + }, + ], + [ + "missing recovery annotation permission", + (workflow) => { + delete workflow.jobs.publish.permissions.checks; + }, + ], + [ + "missing Release Please run snapshot", + (workflow) => { + delete workflow.jobs.verify.outputs["release-please-snapshot"]; + }, + ], + [ + "unfrozen Release Please run snapshot", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.env.RELEASE_PLEASE_SNAPSHOT = "untrusted"; + }, + ], + [ + "unbound recovery policy ID", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.env.RECOVERY_POLICY_ID = "untrusted"; + }, + ], + [ + "repacked recovery artifact", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === "Download the prior live-verified release artifact", + ); + step.if = "inputs.publish_operation == 'release'"; + }, + ], + [ + "unchecked recovery artifact digest", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === "Download the prior live-verified release artifact", + ); + delete step.with["digest-mismatch"]; + }, + ], + [ + "different uploaded artifact", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Upload the verified release artifact", + ); + step.with.path = "release-artifacts/*.tgz"; + }, + ], + [ + "live-smoke dependency", + (workflow) => { + workflow.jobs["live-smoke"].needs = ["handoff", "verify"]; + }, + ], + [ + "elevated live-smoke permissions", + (workflow) => { + workflow.jobs["live-smoke"].permissions = { contents: "write" }; + }, + ], + [ + "missing bounded live smoke", + (workflow) => { + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Run the bounded live smoke", + ); + step.run = "true"; + }, + ], + [ + "tag dispatch gate", + (workflow) => { + workflow.jobs.verify.if = workflow.jobs.verify.if.replace( + "github.sha == inputs.release_commit", + "true", + ); + }, + ], + [ + "handoff dispatch ref", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => name === "Dispatch the exact immutable tag", + ); + step.run = step.run.replace('-f ref="$RELEASE_TAG"', '-f ref="main"'); + }, + ], + [ + "handoff run discovery", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => name === "Dispatch the exact immutable tag", + ); + step.run = step.run.replace( + "tag-dispatch-runs-before.json", + "untrusted-before.json", + ); + }, + ], + [ + "handoff waits for the queued tag run", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => name === "Dispatch the exact immutable tag", + ); + step.run += + '\ngh api "repos/${GITHUB_REPOSITORY}/actions/runs/${publish_run_id}/jobs"'; + }, + ], + [ + "preparation handoff gate", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => name === "Dispatch the exact immutable tag", + ); + delete step.if; + }, + ], + [ + "publication command", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Publish the exact artifact with provenance", + ); + step.run = "npm publish"; + }, + ], + [ + "unpaginated environment policies", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.run = step.run.replace( + 'gh api --paginate --slurp \\\n "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies?per_page=100"', + 'gh api "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies"', + ); + }, + ], + [ + "missing registry verification token", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", + ); + delete step.env.GH_TOKEN; + }, + ], + [ + "normal live-smoke condition", + (workflow) => { + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Run the bounded live smoke", + ); + step.if = "always()"; + }, + ], + [ + "normal live-smoke command", + (workflow) => { + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Run the bounded live smoke", + ); + step.run = "true"; + }, + ], + [ + "recovery live-smoke condition", + (workflow) => { + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Reuse the successful bounded live smoke", + ); + step.if = "always()"; + }, + ], + [ + "recovery artifact identity", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === "Download the prior live-verified release artifact", + ); + step.with["artifact-ids"] = "1"; + }, + ], + [ + "recovery artifact condition", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === "Download the prior live-verified release artifact", + ); + step.if = "always()"; + }, + ], + [ + "normal artifact condition", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Pack the exact release artifact", + ); + step.if = "always()"; + }, + ], + [ + "publication order", + (workflow) => { + const steps = workflow.jobs.publish.steps; + const publishIndex = steps.findIndex( + ({ name }) => name === "Publish the exact artifact with provenance", + ); + const verifyIndex = steps.findIndex( + ({ name }) => name === "Verify the public registry artifact", + ); + [steps[publishIndex], steps[verifyIndex]] = [ + steps[verifyIndex], + steps[publishIndex], + ]; + }, + ], + ])("rejects %s", (_name, mutate) => { + expect(() => + validatePublishWorkflowContract(workflowContract(mutate)), + ).toThrow(/release workflow/i); + }); +}); + +describe("npm publication state", () => { + function environmentFixture() { + return { + can_admins_bypass: false, + deployment_branch_policy: { + custom_branch_policies: true, + protected_branches: false, + }, + id: 18800205839, + name: "npm", + protection_rules: [ + { + prevent_self_review: false, + reviewers: [ + { + reviewer: { id: 129579691, login: "tensornull" }, + type: "User", + }, + ], + type: "required_reviewers", + }, + { type: "branch_policy" }, + ], + }; + } + const tagPolicy = { id: 55718965, name: "v*", type: "tag" }; + const mainPolicy = { id: 60000000, name: "main", type: "branch" }; + + it("accepts only tag policy for a normal release", () => { + expect( + validateNpmEnvironmentState({ + environment: environmentFixture(), + expectedPolicyIds: { "tag:v*": 55718965 }, + operation: "release", + policies: [tagPolicy], + }), + ).toEqual({ policies: ["tag:v*"] }); + }); + + it("accepts exactly the temporary main and permanent tag policies for recovery", () => { + expect( + validateNpmEnvironmentState({ + environment: environmentFixture(), + expectedPolicyIds: { + "branch:main": 60000000, + "tag:v*": 55718965, + }, + operation: "recover-v0.1.1", + policies: [tagPolicy, mainPolicy], + }), + ).toEqual({ policies: ["branch:main", "tag:v*"] }); + }); + + it("rejects a same-name recovery policy replacement", () => { + expect(() => + validateNpmEnvironmentState({ + environment: environmentFixture(), + expectedPolicyIds: { + "branch:main": 60000001, + "tag:v*": 55718965, + }, + operation: "recover-v0.1.1", + policies: [tagPolicy, mainPolicy], + }), + ).toThrow(/policy ID/i); + }); + + it.each([ + [ + "admin bypass", + { + environment: { ...environmentFixture(), can_admins_bypass: true }, + policies: [tagPolicy], + }, + ], + [ + "reviewer", + { + environment: { + ...environmentFixture(), + protection_rules: [{ type: "branch_policy" }], + }, + policies: [tagPolicy], + }, + ], + [ + "extra branch", + { environment: environmentFixture(), policies: [tagPolicy, mainPolicy] }, + ], + ])("rejects normal-release environment drift in %s", (_name, fixture) => { + expect(() => + validateNpmEnvironmentState({ + operation: "release", + ...fixture, + }), + ).toThrow(/release workflow/i); + }); + + it("accepts the unpublished candidate and preserves next", () => { + expect( + validateRegistryStateBeforePublish({ + exactVersion: null, + latestVersion: "0.1.0", + nextVersion: "0.1.0-alpha.3", + version: "0.1.1", + }), + ).toMatchObject({ previousVersion: "0.1.0" }); + }); + + it("accepts an idempotent replay only at the same exact version", () => { + expect( + validateRegistryStateBeforePublish({ + exactVersion: "0.1.1", + latestVersion: "0.1.1", + nextVersion: "0.1.0-alpha.3", + version: "0.1.1", + }), + ).toMatchObject({ exactVersion: "0.1.1" }); + }); + + it.each([ + [ + "wrong latest", + { + exactVersion: null, + latestVersion: "0.1.1", + nextVersion: "0.1.0-alpha.3", + version: "0.1.1", + }, + ], + [ + "changed next", + { + exactVersion: null, + latestVersion: "0.1.0", + nextVersion: "0.1.0-alpha.4", + version: "0.1.1", + }, + ], + [ + "wrong exact", + { + exactVersion: "0.1.2", + latestVersion: "0.1.0", + nextVersion: "0.1.0-alpha.3", + version: "0.1.1", + }, + ], + [ + "0.2 release", + { + exactVersion: null, + latestVersion: "0.1.1", + nextVersion: "0.1.0-alpha.3", + version: "0.2.0", + }, + ], + ])("rejects registry drift in %s", (_name, fixture) => { + expect(() => validateRegistryStateBeforePublish(fixture)).toThrow( + /release workflow/i, + ); + }); + + const provenanceDigest = "c".repeat(128); + + function provenanceFixture({ + commit = BRANCH_SHA, + mutate, + runAttempt = 1, + runId = RUN_ID, + workflowRef = "refs/heads/main", + } = {}) { + const statement = { + _type: "https://in-toto.io/Statement/v1", + predicateType: "https://slsa.dev/provenance/v1", + subject: [ + { + digest: { sha512: provenanceDigest }, + name: "pkg:npm/cometapi@0.1.1", + }, + ], + predicate: { + buildDefinition: { + buildType: + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + externalParameters: { + workflow: { + path: ".github/workflows/publish.yml", + ref: workflowRef, + repository: "https://github.com/cometapi-dev/cometapi-node", + }, + }, + internalParameters: { + github: { + event_name: "workflow_dispatch", + repository_id: "1307188651", + repository_owner_id: "225111184", + }, + }, + resolvedDependencies: [ + { + digest: { gitCommit: commit }, + uri: `git+https://github.com/cometapi-dev/cometapi-node@${workflowRef}`, + }, + ], + }, + runDetails: { + builder: { id: "https://github.com/actions/runner/github-hosted" }, + metadata: { + invocationId: `https://github.com/cometapi-dev/cometapi-node/actions/runs/${runId}/attempts/${runAttempt}`, + }, + }, + }, + }; + mutate?.(statement); + return { + attestations: { + attestations: [ + { + bundle: { + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(statement)).toString( + "base64", + ), + }, + }, + predicateType: "https://slsa.dev/provenance/v1", + }, + ], + }, + commit, + runAttempt, + runId, + sha512: provenanceDigest, + version: "0.1.1", + workflowRef, + }; + } + + it("binds the disclosed recovery provenance to main and its control commit", () => { + expect(validateRegistryProvenance(provenanceFixture())).toEqual({ + commit: BRANCH_SHA, + provenanceRunAttempt: 1, + provenanceRunId: RUN_ID, + version: "0.1.1", + workflowRef: "refs/heads/main", + }); + }); + + it("binds normal provenance to the immutable tag and release commit", () => { + expect( + validateRegistryProvenance( + provenanceFixture({ + commit: RELEASE_SHA, + workflowRef: "refs/tags/v0.1.1", + }), + ), + ).toMatchObject({ + commit: RELEASE_SHA, + workflowRef: "refs/tags/v0.1.1", + }); + }); + + it.each([ + [ + "workflow path", + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.path = + "/.github/workflows/other.yml"; + }, + ], + [ + "workflow ref", + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.ref = + "refs/heads/dev"; + }, + ], + [ + "source commit", + (statement) => { + statement.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = + RELEASE_SHA; + }, + ], + [ + "event", + (statement) => { + statement.predicate.buildDefinition.internalParameters.github.event_name = + "push"; + }, + ], + [ + "subject digest", + (statement) => { + statement.subject[0].digest.sha512 = "d".repeat(128); + }, + ], + [ + "invocation", + (statement) => { + statement.predicate.runDetails.metadata.invocationId = `https://github.com/other/repository/actions/runs/${RUN_ID}/attempts/2`; + }, + ], + ])("rejects provenance drift in %s", (_name, mutate) => { + expect(() => + validateRegistryProvenance(provenanceFixture({ mutate })), + ).toThrow(/release workflow/i); + }); + + function provenanceInvocation(overrides = {}) { + const run = { + conclusion: "failure", + event: "workflow_dispatch", + head_branch: "main", + head_sha: BRANCH_SHA, + id: RUN_ID, + name: "Publish", + path: ".github/workflows/publish.yml", + repository: { full_name: REPOSITORY }, + run_attempt: 1, + status: "completed", + }; + const jobs = [ + { + head_sha: BRANCH_SHA, + name: "Publish with npm Trusted Publishing", + run_attempt: 1, + run_id: RUN_ID, + runner_id: 42, + steps: [ + { + conclusion: "success", + name: "Publish the exact artifact with provenance", + status: "completed", + }, + ], + }, + ]; + return { + commit: BRANCH_SHA, + jobs, + run, + runAttempt: 1, + runId: RUN_ID, + workflowRef: "refs/heads/main", + ...overrides, + }; + } + + it("accepts a prior failed run only when its npm publish step succeeded", () => { + expect( + validateRegistryProvenanceInvocation(provenanceInvocation()), + ).toEqual({ + commit: BRANCH_SHA, + runAttempt: 1, + runId: RUN_ID, + workflowRef: "refs/heads/main", + }); + }); + + it("accepts the current in-progress run after its publish step succeeds", () => { + const fixture = provenanceInvocation(); + fixture.run.status = "in_progress"; + fixture.run.conclusion = null; + expect(validateRegistryProvenanceInvocation(fixture)).toMatchObject({ + runId: RUN_ID, + }); + }); + + it.each([ + ["run attempt", (fixture) => (fixture.run.run_attempt = 2)], + ["workflow", (fixture) => (fixture.run.path = ".github/workflows/ci.yml")], + ["ref", (fixture) => (fixture.run.head_branch = "dev")], + ["commit", (fixture) => (fixture.run.head_sha = RELEASE_SHA)], + ["runner", (fixture) => (fixture.jobs[0].runner_id = 0)], + [ + "publish step", + (fixture) => (fixture.jobs[0].steps[0].conclusion = "failure"), + ], + ])("rejects provenance invocation drift in %s", (_name, mutate) => { + const fixture = provenanceInvocation(); + mutate(fixture); + expect(() => validateRegistryProvenanceInvocation(fixture)).toThrow( + /release workflow/i, + ); + }); +}); + describe("Publish recovery source evidence", () => { const sourceCommit = "22c313d4f80c53ba01672dd35cc27b621d5ec9ce"; @@ -373,7 +1272,7 @@ describe("Release Please push classification", () => { }; } - it("ignores a metadata-only push whose current version remains published", () => { + it("prepares a source-only push whose current version remains published", () => { expect( classifyPushReleasePresence({ currentRelease: publishedCurrentRelease(), @@ -384,7 +1283,7 @@ describe("Release Please push classification", () => { previousVersion: "0.1.0", version: "0.1.0", }), - ).toEqual({ mode: "ignore", version: "0.1.0" }); + ).toEqual({ mode: "prepare", version: "0.1.0" }); }); it("classifies an exact next-patch push as a release", () => { @@ -408,7 +1307,6 @@ describe("Release Please push classification", () => { ], ["minor bump", { previousVersion: "0.1.0", version: "0.2.0" }], ["skipped patch", { previousVersion: "0.1.0", version: "0.1.2" }], - ["metadata-only package change", { packageChanged: true }], ["manifest drift", { manifestVersion: "0.1.1" }], [ "published-version tag without a Release", @@ -454,8 +1352,8 @@ describe("Release Please push classification", () => { ).toThrow(/release workflow/i); }); - it("rejects an unchanged-version rerun even if the historical tag exists", () => { - expect(() => + it("prepares a package metadata change without a premature version bump", () => { + expect( classifyPushReleasePresence({ currentRelease: publishedCurrentRelease(), currentTagCommit: RELEASE_SHA, @@ -465,7 +1363,7 @@ describe("Release Please push classification", () => { previousVersion: "0.1.0", version: "0.1.0", }), - ).toThrow(/package\.json push must change/i); + ).toEqual({ mode: "prepare", version: "0.1.0" }); }); }); @@ -850,6 +1748,19 @@ describe("release PR review validation", () => { ).toBeNull(); }); + it("allows an ordinary source fix push to prepare a release PR", () => { + expect( + selectPendingReleasePullRequest([], { + eventName: "push", + operation: "prepare", + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + releaseExists: false, + repository: REPOSITORY, + }), + ).toBeNull(); + }); + it("rejects a rerun attempt that would prepare a release PR", () => { expect(() => selectPendingReleasePullRequest([pullRequestFixture()], { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index f91b130..91b925a 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -1,10 +1,20 @@ -import { readFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { URL } from "node:url"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; +import { validatePublishWorkflowContract } from "../scripts/release-workflow-validation.mjs"; + const workflowNames = [ "ci.yml", "live-smoke.yml", @@ -130,7 +140,8 @@ describe("GitHub Actions workflow contract", () => { ); expect(publish).toContain("run: bash scripts/publish-artifact.sh"); expect(publishWorkflow).toContain("workflow_dispatch:"); - expect(publishWorkflow).toContain("inputs.recovery_task"); + expect(publishWorkflow).toContain("inputs.publish_operation"); + expect(publishWorkflow).toContain("inputs.control_commit"); expect(publishWorkflow).not.toContain("NPM_ALPHA1_BOOTSTRAP"); expect(publishWorkflow).not.toContain("recover-verify"); expect(publishWorkflow).not.toContain("recover-publish"); @@ -185,9 +196,25 @@ describe("GitHub Actions workflow contract", () => { const publishWorkflow = workflow("publish.yml"); expect(matches(publishWorkflow, /^\s+id-token: write$/gm)).toHaveLength(1); - expect(job(publishWorkflow, "verify")).not.toContain("id-token: write"); + expect(matches(publishWorkflow, /^\s+actions: write$/gm)).toHaveLength(1); + const handoff = job(publishWorkflow, "handoff"); + expect(handoff).toContain("actions: write"); + expect(handoff).not.toContain("id-token: write"); + expect(handoff).not.toMatch(/^ {4}environment:/m); + const verify = job(publishWorkflow, "verify"); + expect(verify).not.toContain("id-token: write"); + expect(verify).not.toMatch(/^ {4}permissions:/m); + expect(verify).toContain("Freeze the Release Please run set"); expect(job(publishWorkflow, "live-smoke")).not.toContain("id-token: write"); - expect(job(publishWorkflow, "publish")).toContain("id-token: write"); + expect(job(publishWorkflow, "live-smoke")).not.toMatch( + /^ {4}permissions:/m, + ); + const publish = job(publishWorkflow, "publish"); + expect(publish).toContain("checks: read"); + expect(publish).toContain("id-token: write"); + expect(publish).toContain( + "Release Please run set changed while publication awaited approval.", + ); }); it("uses Release Please for the reviewed patch PR and immutable release", () => { @@ -208,7 +235,6 @@ describe("GitHub Actions workflow contract", () => { expect(contents).not.toContain("token:"); expect(releasePleaseWorkflow.on.push).toEqual({ branches: ["main"], - paths: ["package.json"], }); expect(contents).toMatch(/^ {2}workflow_dispatch:$/m); expect(contents).toContain("group: release-please-main"); @@ -227,7 +253,7 @@ describe("GitHub Actions workflow contract", () => { releasePlease, /git fetch --no-tags origin \+refs\/heads\/main:refs\/remotes\/origin\/main/g, ), - ).toHaveLength(4); + ).toHaveLength(5); expect(releasePlease).toContain("validateMergedReleasePullRequest"); expect(releasePlease).toContain("validatePreparedReleasePullRequest"); expect(releasePlease).toContain("selectPendingReleasePullRequest"); @@ -285,6 +311,24 @@ describe("GitHub Actions workflow contract", () => { "Reconfirm the exact release state before mutation", ), ).toBeLessThan(releasePlease.indexOf("Run Release Please")); + expect(releasePlease).toContain( + "Require the exact current main commit immediately before mutation", + ); + expect(releasePlease).toContain( + "main moved immediately before the Release Please mutation.", + ); + const releasePleaseSteps = + releasePleaseWorkflow.jobs["release-please"].steps; + const finalMainCheck = releasePleaseSteps.findIndex( + ({ name }) => + name === + "Require the exact current main commit immediately before mutation", + ); + const releasePleaseAction = releasePleaseSteps.findIndex( + ({ name }) => name === "Run Release Please", + ); + expect(finalMainCheck).toBeGreaterThanOrEqual(0); + expect(releasePleaseAction).toBe(finalMainCheck + 1); expect(releasePleaseConfig).not.toHaveProperty("last-release-sha"); expect(releasePleaseConfig.label).toBe("autorelease: pending"); @@ -301,6 +345,11 @@ describe("GitHub Actions workflow contract", () => { }); }); + it("runs Release Please for source-only fix commits on main", () => { + expect(releasePleaseWorkflow.on.push).toEqual({ branches: ["main"] }); + expect(releasePleaseWorkflow.on.push).not.toHaveProperty("paths"); + }); + it("parses every inline Node workflow validator", () => { for (const name of ["release-please.yml", "publish.yml"]) { const blocks = matches( @@ -320,7 +369,46 @@ describe("GitHub Actions workflow contract", () => { } }); - it("starts publication from Release Please or the exact tag workflow dispatch recovery", () => { + it("loads the permanent dispatch contract from an actual tagged commit", () => { + const repository = mkdtempSync(join(tmpdir(), "cometapi-publish-tag-")); + try { + const workflowDirectory = join(repository, ".github", "workflows"); + mkdirSync(workflowDirectory, { recursive: true }); + writeFileSync( + join(workflowDirectory, "publish.yml"), + workflow("publish.yml"), + ); + for (const args of [ + ["init", "--quiet"], + ["config", "user.name", "CometAPI Test"], + ["config", "user.email", "test@cometapi.invalid"], + ["add", ".github/workflows/publish.yml"], + ["commit", "--quiet", "-m", "test: tagged publish workflow"], + ["tag", "v0.1.2"], + ]) { + const result = spawnSync("git", args, { + cwd: repository, + encoding: "utf8", + }); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + } + const tagged = spawnSync( + "git", + ["show", "v0.1.2:.github/workflows/publish.yml"], + { cwd: repository, encoding: "utf8" }, + ); + expect(tagged.stderr).toBe(""); + expect(tagged.status).toBe(0); + expect(validatePublishWorkflowContract(parse(tagged.stdout))).toEqual({ + supportsTagDispatch: true, + }); + } finally { + rmSync(repository, { force: true, recursive: true }); + } + }); + + it("hands Release Please releases to tag-bound publication with one exact main recovery", () => { const publish = workflow("publish.yml"); expect(publish).toMatch( /workflow_run:\n {4}workflows:\n {6}- Release Please\n {4}types:\n {6}- completed/, @@ -328,25 +416,84 @@ describe("GitHub Actions workflow contract", () => { expect(publish).not.toMatch(/^ {2}release:/m); expect(publishWorkflow.on.workflow_dispatch).toBeDefined(); expect(publishWorkflow.on.push).toBeUndefined(); + expect(publishWorkflow.on.workflow_dispatch.inputs).toMatchObject({ + control_commit: { required: true, type: "string" }, + publish_operation: { required: true, type: "string" }, + release_commit: { required: true, type: "string" }, + release_run_attempt: { required: true, type: "string" }, + release_run_id: { required: true, type: "string" }, + release_tag: { required: true, type: "string" }, + source_publish_run_attempt: { required: false, type: "string" }, + source_publish_run_id: { required: false, type: "string" }, + }); + + const handoff = job(publish, "handoff"); + expect(handoff).toContain( + "github.event.workflow_run.conclusion == 'success'", + ); + expect(handoff).toContain("github.event.workflow_run.event == 'push'"); + expect(handoff).toContain( + "github.event.workflow_run.head_branch == 'main'", + ); + expect(handoff).toContain("actions: write"); + expect(handoff).not.toContain("environment:"); + expect(handoff).not.toContain("id-token: write"); + expect(handoff).not.toContain("publish-artifact.sh"); + expect(handoff).toContain("Classify the exact Release Please handoff"); + expect(handoff).toContain("classifyReleasePleaseHandoff"); + expect( + matches(handoff, /if: steps\.result\.outputs\.has-result == 'true'/g), + ).toHaveLength(4); + expect(handoff).toContain("validateReleaseWorkflowRun"); + expect(handoff).toContain("validateReleasePleaseActionResult"); + expect(handoff).toContain("validateGitHubRelease"); + expect(handoff).toContain("validatePublishWorkflowContract"); + expect(handoff).toContain( + 'git show "refs/tags/${release_tag}:.github/workflows/publish.yml"', + ); + expect(handoff).toContain("actions/workflows/publish.yml/dispatches"); + expect(handoff).toContain('-f ref="$RELEASE_TAG"'); + expect(handoff).toContain('-f "inputs[publish_operation]=release"'); + expect(handoff).toContain('-f "inputs[control_commit]=$RELEASE_COMMIT"'); const verify = job(publish, "verify"); + expect(verify).toContain("github.event_name == 'workflow_dispatch'"); + expect(verify).not.toContain("github.event.workflow_run"); + expect(verify).toContain("inputs.publish_operation == 'release'"); + expect(verify).toContain("startsWith(github.ref, 'refs/tags/v0.1.')"); expect(verify).toContain( - "github.event.workflow_run.conclusion == 'success'", + "github.ref == format('refs/tags/{0}', inputs.release_tag)", ); + expect(verify).toContain("github.sha == inputs.release_commit"); + expect(verify).toContain("github.workflow_sha == inputs.control_commit"); + expect(verify).toContain("inputs.publish_operation == 'recover-v0.1.1'"); + expect(verify).toContain("github.ref == 'refs/heads/main'"); + expect(verify).toContain("github.sha == inputs.control_commit"); expect(verify).toContain("SOURCE_RELEASE_COMMIT:"); expect(verify).toContain("c98b514227858cd183c781270a7f78f65b577e82"); expect(verify).toContain("SOURCE_RELEASE_RUN_ID:"); expect(verify).toContain("30469181724"); expect(verify).toContain("SOURCE_RELEASE_RUN_ATTEMPT:"); - expect(verify).toContain("github.ref == 'refs/tags/v0.1.1'"); expect(verify).toContain( "inputs.release_commit == 'c98b514227858cd183c781270a7f78f65b577e82'", ); expect(verify).toContain("inputs.release_tag == 'v0.1.1'"); expect(verify).toContain("inputs.release_run_id == '30469181724'"); expect(verify).toContain("inputs.source_publish_run_id == '30471665743'"); + expect(verify).toContain("validatePublishWorkflowDispatchTrigger"); expect(verify).toContain("validatePublishWorkflowDispatchRecoveryTrigger"); expect(verify).toContain("validatePublishRecoveryEvidence"); + expect(verify).toContain( + "Download the prior live-verified release artifact", + ); + expect(verify).toContain( + "artifact-ids: ${{ steps.recovery-evidence.outputs.artifact-id }}", + ); + expect(verify).toContain( + "if: inputs.publish_operation == 'recover-v0.1.1'", + ); + expect(verify).toContain("if: inputs.publish_operation == 'release'"); + expect(verify).toContain("Select the exact release artifact"); expect(verify).toContain("github.triggering_actor"); expect(releaseWorkflowValidation).toContain("30471665743"); expect(releaseWorkflowValidation).toContain("8731956162"); @@ -358,8 +505,6 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain( "EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml", ); - expect(verify).toContain("github.event.workflow_run.head_sha"); - expect(verify).toContain("github.event.workflow_run.run_attempt"); expect(verify).toContain( "release-please-result-${{ env.SOURCE_RELEASE_RUN_ID }}-${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}", ); @@ -402,8 +547,81 @@ describe("GitHub Actions workflow contract", () => { "name: ${{ needs.verify.outputs.artifact-name }}", ); const liveSmoke = job(publish, "live-smoke"); + expect(publishWorkflow.jobs["live-smoke"].needs).toEqual(["verify"]); expect(liveSmoke).toContain("Reuse the successful bounded live smoke"); - expect(liveSmoke).toContain("if: github.event_name != 'workflow_dispatch'"); + expect(liveSmoke).toContain( + "if: needs.verify.outputs.publish-operation == 'recover-v0.1.1'", + ); + expect(liveSmoke).toContain( + "if: needs.verify.outputs.publish-operation == 'release'", + ); + expect(liveSmoke).toMatch( + /if: needs\.verify\.outputs\.publish-operation == 'release'\n[\s\S]*?run: npm run test:live/, + ); + + const publishJob = job(publish, "publish"); + expect(publishJob).not.toContain("github.event.workflow_run"); + expect(publishJob).toContain( + "Reconfirm protected state immediately before publication", + ); + expect(publishJob).toContain("validateNpmEnvironmentState"); + expect(publishJob).toContain("validateRegistryStateBeforePublish"); + expect( + matches(publish, /validatePublishRecoveryEvidence\(\{/g), + ).toHaveLength(2); + expect(publishJob).toContain( + "The bounded live evidence changed while publication awaited approval.", + ); + expect(publishJob).toContain("validateRegistryProvenance"); + expect(publishJob).toContain("validateRegistryProvenanceInvocation"); + expect(publishJob).toContain("WORKFLOW_REF: ${{ github.ref }}"); + expect(publishJob).toContain("provenance.provenanceRunId"); + expect(publishJob).toContain("PROVENANCE_RUN_ATTEMPT"); + expect(publishJob).toContain( + "for state in in_progress queued waiting requested pending", + ); + expect(publishJob).toContain( + 'npm view cometapi@next version)" != "0.1.0-alpha.3"', + ); + expect(publishJob).toContain("RELEASE_PLEASE_ENABLED"); + expect(publishJob).toContain( + "RELEASE_PLEASE_ENABLED: ${{ vars.RELEASE_PLEASE_ENABLED }}", + ); + expect(publishJob).not.toContain( + "actions/variables/RELEASE_PLEASE_ENABLED", + ); + expect(publishJob).toContain( + "RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }}", + ); + expect(publishJob).toContain("GH_TOKEN: ${{ github.token }}"); + expect(publishJob).toContain("status=${state}"); + expect(publishJob).toContain("client.chat.completions.create"); + expect(publishJob).toContain("client.responses.create"); + expect(publishJob).toContain("client.models.list"); + expect(publishJob).toContain('writeFileSync("consumer.mts"'); + expect(publishJob).toContain('writeFileSync("consumer.cts"'); + expect(publishJob).toContain("./node_modules/.bin/tsc --noEmit"); + expect(publishJob.indexOf("validateNpmEnvironmentState")).toBeLessThan( + publishJob.indexOf("Publish the exact artifact with provenance"), + ); + expect( + publishJob.indexOf( + "Reconfirm protected state immediately before publication", + ), + ).toBeLessThan( + publishJob.indexOf("Publish the exact artifact with provenance"), + ); + expect( + publishJob.indexOf("Publish the exact artifact with provenance"), + ).toBeLessThan(publishJob.indexOf("Verify the public registry artifact")); + + const releasePlease = job(workflow("release-please.yml"), "release-please"); + expect( + matches(releasePlease, /validatePublishWorkflowContract\(/g), + ).toHaveLength(2); + expect(releasePlease).toContain( + 'git show "refs/tags/${tag}:.github/workflows/publish.yml"', + ); expect(publish).not.toContain("github.event.deployment"); }); From dc8357a77043f581a6b991b15feba442f9f518b6 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 14:10:55 +0800 Subject: [PATCH 2/4] docs: close recovery dist-tag ambiguity --- RELEASING.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/RELEASING.md b/RELEASING.md index 75fcfc8..1aa0936 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -649,6 +649,22 @@ The repository maintains four independently auditable workflows: } EOF + dist_tags_ready="false" + for attempt in {1..12}; do + latest_version="$(npm view cometapi@latest version 2>/dev/null || true)" + next_version="$(npm view cometapi@next version 2>/dev/null || true)" + if [[ "$latest_version" == "0.1.1" && + "$next_version" == "0.1.0-alpha.3" ]]; then + dist_tags_ready="true" + break + fi + [[ "$attempt" == "12" ]] || sleep 10 + done + if [[ "$dist_tags_ready" != "true" ]]; then + echo "The required latest and next dist-tags did not converge." >&2 + exit 1 + fi + attestations_url="$(REGISTRY_DIST="$registry_dist" node -e \ 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')" attestations_file="$(mktemp)" From 553d320259cfaf71b84759adf509a39e487ed819 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 14:58:47 +0800 Subject: [PATCH 3/4] fix: bind publication to one current-main attempt --- .github/workflows/publish.yml | 18 +++++++++--------- RELEASING.md | 10 +++++++--- scripts/release-workflow-validation.mjs | 2 +- tests/release-workflow-validation.test.mjs | 9 +-------- tests/workflow-contract.test.mjs | 13 +++++++++++++ 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2defe69..5b27306 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -145,8 +145,8 @@ jobs: exit 1 fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then - echo "The release commit is no longer an ancestor of origin/main." >&2 + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then + echo "The release commit is no longer the exact origin/main tip." >&2 exit 1 fi @@ -244,7 +244,7 @@ jobs: run: | set -euo pipefail git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT" ]]; then echo "origin/main moved away from the release commit before handoff." >&2 exit 1 fi @@ -389,8 +389,8 @@ jobs: git diff --name-only "$CONTROL_FIRST_PARENT" "$CONTROL_COMMIT" > "$CHANGED_FILES" ;; release) - if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then - echo "The tag release commit is not an ancestor of origin/main." >&2 + if [[ "$MAIN_COMMIT" != "$RELEASE_COMMIT" ]]; then + echo "The tag release commit is no longer the exact origin/main tip." >&2 exit 1 fi CONTROL_FIRST_PARENT="" @@ -576,8 +576,8 @@ jobs: fi ;; release) - if ! git merge-base --is-ancestor "$WORKFLOW_SHA" refs/remotes/origin/main; then - echo "The release commit is not an ancestor of origin/main." >&2 + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then + echo "The release commit is no longer the exact origin/main tip." >&2 exit 1 fi ;; @@ -925,8 +925,8 @@ jobs: echo "The tag publication identity changed while awaiting approval." >&2 exit 1 fi - if ! git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main; then - echo "The release commit is no longer an ancestor of origin/main." >&2 + if [[ "$main_commit" != "$RELEASE_COMMIT" ]]; then + echo "The release commit is no longer the exact origin/main tip." >&2 exit 1 fi ;; diff --git a/RELEASING.md b/RELEASING.md index 1aa0936..7ad1af6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -280,8 +280,9 @@ The repository maintains four independently auditable workflows: job. That job has no Environment and no OIDC permission. It validates the exact source job and treats a preparation run whose result-upload step was skipped as release-inert. For a release run, it requires the unique - attempt-qualified result, immutable bot-authored Release, tag commit, `main` - ancestry, and the dispatch contract stored in the tag, then uses its sole + attempt-qualified result, immutable bot-authored Release, tag commit, exact + current `main` identity, and the dispatch contract stored in the tag, then + uses its sole `actions: write` permission to dispatch the same workflow with `ref=v`. The `verify`, `live-smoke`, and `publish` jobs accept only that tag-bound `workflow_dispatch`; they are unreachable from the original @@ -289,7 +290,8 @@ The repository maintains four independently auditable workflows: run is release-inert and cannot enter the handoff. The tag run independently revalidates the source run and result artifact, - exact tag and immutable Release, package metadata, and `main` ancestry. The + exact tag and immutable Release, package metadata, and exact current `main` + identity. The normal operation packs and tests one attempt-qualified artifact, runs a fresh bounded live smoke, and sends that same file to npm OIDC; the one-time recovery operation downloads and retests the already live-verified tarball @@ -573,6 +575,8 @@ The repository maintains four independently auditable workflows: immediately before registry mutation and fails if a run was created, rerun, or remains active while the recovery was waiting. This makes the operator freeze observable rather than relying only on timing. + The one-time recovery accepts only the first workflow attempt; a rerun or a + second dispatch is forbidden even when all other inputs match. If the npm publish request may have reached the registry but its response or the remaining workflow result was lost, do not infer success or a safe retry diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 2cfd2c5..7d7afcf 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -1158,7 +1158,7 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ PUBLISH_RECOVERY.dispatchTask, "publish recovery operation", ); - requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt"); + requireEqual(workflowRunAttempt, 1, "publish recovery run attempt"); requirePositiveInteger(recoveryPolicyId, "publish recovery policy ID"); if (!Array.isArray(changedFiles)) { fail("Release workflow publish recovery changed files must be an array."); diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 62b4daa..3377128 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -371,6 +371,7 @@ describe("Publish workflow dispatch recovery trigger", () => { ["source commit", { sourceReleaseCommit: RELEASE_SHA }], ["source run ID", { sourceRunId: 30469181725 }], ["source attempt", { sourceRunAttempt: 2 }], + ["workflow rerun", { workflowRunAttempt: 2 }], ["missing file", { changedFiles: recoveryFiles.slice(1) }], ["extra file", { changedFiles: [...recoveryFiles, "package.json"] }], ])("rejects recovery trigger drift in %s", (_name, overrides) => { @@ -380,14 +381,6 @@ describe("Publish workflow dispatch recovery trigger", () => { ), ).toThrow(/release workflow/i); }); - - it("accepts a rerun of the same immutable recovery event", () => { - expect( - validatePublishWorkflowDispatchRecoveryTrigger( - recoveryTrigger({ workflowRunAttempt: 2 }), - ), - ).toMatchObject({ releaseRunId: 30469181724 }); - }); }); describe("Publish tag dispatch trigger", () => { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 91b925a..4ab8da9 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -455,6 +455,12 @@ describe("GitHub Actions workflow contract", () => { expect(handoff).toContain('-f ref="$RELEASE_TAG"'); expect(handoff).toContain('-f "inputs[publish_operation]=release"'); expect(handoff).toContain('-f "inputs[control_commit]=$RELEASE_COMMIT"'); + expect(handoff).toContain( + '$(git rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT', + ); + expect(handoff).not.toContain( + 'git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main', + ); const verify = job(publish, "verify"); expect(verify).toContain("github.event_name == 'workflow_dispatch'"); @@ -494,6 +500,10 @@ describe("GitHub Actions workflow contract", () => { ); expect(verify).toContain("if: inputs.publish_operation == 'release'"); expect(verify).toContain("Select the exact release artifact"); + expect(verify).toContain('if [[ "$MAIN_COMMIT" != "$RELEASE_COMMIT" ]]'); + expect(verify).toContain( + 'if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]', + ); expect(verify).toContain("github.triggering_actor"); expect(releaseWorkflowValidation).toContain("30471665743"); expect(releaseWorkflowValidation).toContain("8731956162"); @@ -593,6 +603,9 @@ describe("GitHub Actions workflow contract", () => { expect(publishJob).toContain( "RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }}", ); + expect(publishJob).toContain( + 'if [[ "$main_commit" != "$RELEASE_COMMIT" ]]', + ); expect(publishJob).toContain("GH_TOKEN: ${{ github.token }}"); expect(publishJob).toContain("status=${state}"); expect(publishJob).toContain("client.chat.completions.create"); From 11871d9f2b22e66f443628596ebd5863b4ea3576 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 16:31:44 +0800 Subject: [PATCH 4/4] fix: reject duplicate recovery dispatches --- .github/workflows/publish.yml | 48 +++++ RELEASING.md | 97 ++++++--- scripts/release-workflow-validation.mjs | 153 ++++++++++++++ tests/release-workflow-validation.test.mjs | 220 +++++++++++++++++++++ tests/workflow-contract.test.mjs | 18 ++ 5 files changed, 507 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b27306..6fd2911 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -441,6 +441,31 @@ jobs: }); } EOF + - name: Require the only recovery dispatch for this control commit + if: inputs.publish_operation == 'recover-v0.1.1' + env: + CONTROL_COMMIT: ${{ inputs.control_commit }} + CURRENT_RUN_ID: ${{ github.run_id }} + GH_TOKEN: ${{ github.token }} + RECOVERY_DISPATCH_RUNS: ${{ runner.temp }}/publish-recovery-dispatch-runs.json + shell: bash + run: | + set -euo pipefail + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100" \ + > "$RECOVERY_DISPATCH_RUNS" + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateUniquePublishRecoveryRun } from "./scripts/release-workflow-validation.mjs"; + + validateUniquePublishRecoveryRun({ + controlCommit: process.env.CONTROL_COMMIT, + currentRunId: Number(process.env.CURRENT_RUN_ID), + responses: JSON.parse( + readFileSync(process.env.RECOVERY_DISPATCH_RUNS, "utf8"), + ), + }); + EOF - name: Freeze the Release Please run set id: release-please-snapshot env: @@ -875,6 +900,7 @@ jobs: env: CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs + CURRENT_RUN_ID: ${{ github.run_id }} ENVIRONMENT_FILE: ${{ runner.temp }}/npm-environment.json EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} @@ -883,6 +909,7 @@ jobs: POLICIES_FILE: ${{ runner.temp }}/npm-deployment-policies.json RECOVERY_ANNOTATIONS: ${{ runner.temp }}/publish-recovery-annotations-before-publish.json RECOVERY_ARTIFACTS: ${{ runner.temp }}/publish-recovery-artifacts-before-publish.json + RECOVERY_DISPATCH_RUNS: ${{ runner.temp }}/publish-recovery-dispatch-runs-before-publish.json RECOVERY_JOBS: ${{ runner.temp }}/publish-recovery-jobs-before-publish.json RECOVERY_LIVE_LOG: ${{ runner.temp }}/publish-recovery-live-before-publish.log RECOVERY_POLICY_ID: ${{ inputs.recovery_policy_id }} @@ -1127,6 +1154,27 @@ jobs: version, }); EOF + + if [[ "$OPERATION" == "recover-v0.1.1" ]]; then + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100" \ + > "$RECOVERY_DISPATCH_RUNS" + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + + const { validateUniquePublishRecoveryRun } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + validateUniquePublishRecoveryRun({ + controlCommit: process.env.CONTROL_COMMIT, + currentRunId: Number(process.env.CURRENT_RUN_ID), + responses: JSON.parse( + readFileSync(process.env.RECOVERY_DISPATCH_RUNS, "utf8"), + ), + }); + EOF + fi - name: Publish the exact artifact with provenance env: DIST_TAG: ${{ needs.verify.outputs.dist-tag }} diff --git a/RELEASING.md b/RELEASING.md index 7ad1af6..a03e8c2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -90,6 +90,26 @@ npm publication, and environment approvals require authorization from the current maintainer request. This document defines allowable mechanics but grants no standing remote-write permission. +## Pull-request review identity + +GitHub does not allow a pull-request author to approve that same pull request, +including when the author is an organization or repository administrator. The +repository setting `can_approve_pull_request_reviews=true` authorizes eligible +GitHub Actions workflows to submit approving pull-request reviews; it does not +override this author-self-approval restriction. Before requesting a review, +compare the PR author login with the intended reviewer login. + +If they are the same, never ask that reviewer to select the disabled `Approve` +action and never describe a `COMMENTED` review as `APPROVED`. A ruleset that +requires an approving review needs a different human reviewer. When the active +ruleset requires zero approvals and the release procedure asks only for an +owner's exact-head audit record, the author may submit a `Comment` review whose +body names the reviewed commit; verify its `user.login`, `state=COMMENTED`, and +`commit_id` through the reviews API before merge. Action-authored Release Please +PRs remain different: the release workflow requires a formal, exact-head +`APPROVED` review from a human repository administrator whose login differs +from the bot author. + ## Candidate verification gate Run from the repository root on a clean checkout: @@ -361,8 +381,7 @@ The repository maintains four independently auditable workflows: '{before_policies: $before_policies[0], main_policy_id: null, control_commit: null, - recovery_run_id: null, - before_publish_run_ids: null}' > "$recovery_state" + recovery_run_id: null}' > "$recovery_state" chmod 600 "$recovery_state" echo "Recovery state: $recovery_state" policy_file="$(mktemp)" @@ -430,7 +449,7 @@ The repository maintains four independently auditable workflows: ``` Set `RELEASE_PLEASE_ENABLED=true`, resolve the current reviewed `main` SHA as - `control_commit`, save the existing Publish run IDs, and dispatch: + `control_commit`, record it, and dispatch: ```bash set -euo pipefail @@ -439,14 +458,9 @@ The repository maintains four independently auditable workflows: "$recovery_state")" control_commit="$(gh api repos/cometapi-dev/cometapi-node/commits/main --jq '.sha')" [[ "$control_commit" =~ ^[0-9a-f]{40}$ ]] - before_runs="$(mktemp)" - gh api --paginate --slurp \ - 'repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100' \ - | jq '[.[].workflow_runs[].id]' > "$before_runs" state_next="${recovery_state}.next" - jq --arg control_commit "$control_commit" --slurpfile before_runs "$before_runs" \ - '.control_commit = $control_commit | - .before_publish_run_ids = $before_runs[0]' \ + jq --arg control_commit "$control_commit" \ + '.control_commit = $control_commit' \ "$recovery_state" > "$state_next" chmod 600 "$state_next" mv "$state_next" "$recovery_state" @@ -476,8 +490,11 @@ The repository maintains four independently auditable workflows: JSON ``` - The dispatch endpoint returns `204` without a run ID. Poll and subtract the - pre-dispatch set; never select a run merely because it is the latest: + The dispatch endpoint returns `204` without a run ID. Poll the exact + workflow/ref/SHA identity and require the API's unflattened `total_count` and + returned run set to both equal one; never select a run merely because it is + the latest. This exact filter and count check fail closed if GitHub truncates + a workflow-run search: ```bash set -euo pipefail @@ -485,26 +502,38 @@ The repository maintains four independently auditable workflows: control_commit="$(jq -er \ '.control_commit | select(type == "string" and test("^[0-9a-f]{40}$"))' \ "$recovery_state")" - before_runs="$(mktemp)" - jq -e '.before_publish_run_ids | type == "array"' "$recovery_state" >/dev/null - jq '.before_publish_run_ids' "$recovery_state" > "$before_runs" actor="$(gh api user --jq '.login')" [[ "$actor" == "tensornull" ]] recovery_run_id="" for poll in {1..12}; do - after_runs="$(mktemp)" + run_pages="$(mktemp)" gh api --paginate --slurp \ - 'repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100' \ - | jq '[.[].workflow_runs[]]' > "$after_runs" - recovery_run_id="$(jq -r \ - --arg actor "$actor" --arg control "$control_commit" \ - --slurpfile before "$before_runs" \ - '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | - select(.actor.login == $actor and .triggering_actor.login == $actor and - .event == "workflow_dispatch" and .head_branch == "main" and - .head_sha == $control and .run_attempt == 1)] | - if length == 1 then .[0].id else empty end' "$after_runs")" - [[ -n "$recovery_run_id" ]] && break + "repos/cometapi-dev/cometapi-node/actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${control_commit}&per_page=100" \ + > "$run_pages" + candidate_count="$(jq -er \ + '([.[].total_count] | unique) as $counts | + if ($counts | length) == 1 then $counts[0] else error("inconsistent total_count") end' \ + "$run_pages")" + if [[ "$candidate_count" -gt 1 ]]; then + echo "Multiple new Publish runs matched the recovery control commit." >&2 + exit 1 + fi + if [[ "$candidate_count" == "1" ]]; then + jq -e --arg actor "$actor" --arg control "$control_commit" \ + '([.[].workflow_runs[]]) as $runs | + ([.[].total_count] | unique) == [1] and ($runs | length) == 1 and + $runs[0].head_branch == "main" and $runs[0].head_sha == $control and + $runs[0].name == "Publish" and + $runs[0].path == ".github/workflows/publish.yml" and + $runs[0].repository.full_name == "cometapi-dev/cometapi-node" and + $runs[0].head_repository.full_name == "cometapi-dev/cometapi-node" and + $runs[0].event == "workflow_dispatch" and + $runs[0].run_attempt == 1 and $runs[0].actor.login == $actor and + $runs[0].triggering_actor.login == $actor' \ + "$run_pages" >/dev/null + recovery_run_id="$(jq -r '[.[].workflow_runs[]][0].id' "$run_pages")" + break + fi sleep 5 done [[ "$recovery_run_id" =~ ^[1-9][0-9]*$ ]] @@ -575,8 +604,18 @@ The repository maintains four independently auditable workflows: immediately before registry mutation and fails if a run was created, rerun, or remains active while the recovery was waiting. This makes the operator freeze observable rather than relying only on timing. - The one-time recovery accepts only the first workflow attempt; a rerun or a - second dispatch is forbidden even when all other inputs match. + The operator must create only one first-attempt recovery dispatch and must not + rerun or replace it. Both the unprivileged verify job and the protected + publish job read the paginated exact-branch/SHA Publish search, require its + reported total to equal the returned single run, and require that run to be + current. The Actions run API does not expose dispatch inputs, so an unknown + same-commit dispatch is conservatively a collision. GitHub does not offer an atomic + list-runs-and-publish operation: an administrator could create a dispatch + after the final list call. Repository-wide non-cancelling concurrency keeps + that later run behind the current run, and its own unique-run check rejects it + before publication. This guarantees at most one recovery publication attempt, + while the authorized operator protocol—not an impossible server-side + primitive—requires that only one dispatch be created. If the npm publish request may have reached the registry but its response or the remaining workflow result was lost, do not infer success or a safe retry diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 7d7afcf..2654815 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -297,6 +297,40 @@ export function validatePublishWorkflowContract(workflow) { "Freeze the Release Please run set", "Publish Release Please snapshot", ); + const uniqueRecoveryStep = requireUniqueStep( + jobs.verify, + "Require the only recovery dispatch for this control commit", + "Publish unique recovery dispatch", + ); + requireEqual( + uniqueRecoveryStep?.if, + "inputs.publish_operation == 'recover-v0.1.1'", + "Publish unique recovery dispatch gate", + ); + requireEqual( + uniqueRecoveryStep?.env?.CONTROL_COMMIT, + "${{ inputs.control_commit }}", + "Publish unique recovery control commit", + ); + requireEqual( + uniqueRecoveryStep?.env?.CURRENT_RUN_ID, + "${{ github.run_id }}", + "Publish unique recovery current run ID", + ); + for (const fragment of [ + "gh api --paginate --slurp", + "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", + "validateUniquePublishRecoveryRun", + ]) { + if ( + typeof uniqueRecoveryStep?.run !== "string" || + !uniqueRecoveryStep.run.includes(fragment) + ) { + fail( + `Release workflow Publish unique recovery dispatch must contain ${fragment}.`, + ); + } + } for (const fragment of [ "actions/workflows/release-please.yml/runs?per_page=100", "snapshotReleasePleaseRuns", @@ -486,11 +520,18 @@ export function validatePublishWorkflowContract(workflow) { "${{ vars.RELEASE_PLEASE_ENABLED }}", "Publish Release Please variable context", ); + requireEqual( + reconfirmStep?.env?.CURRENT_RUN_ID, + "${{ github.run_id }}", + "Publish recovery current run reconfirmation", + ); for (const fragment of [ "actions/workflows/release-please.yml/runs?per_page=100", + "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", 'gh api --paginate --slurp \\\n "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies?per_page=100"', "{branch_policies: [.[].branch_policies[]]}", "snapshotReleasePleaseRuns", + "validateUniquePublishRecoveryRun", "expectedPolicyIds", "if (digest !== process.env.RELEASE_PLEASE_SNAPSHOT)", ]) { @@ -503,6 +544,19 @@ export function validatePublishWorkflowContract(workflow) { ); } } + requireEqual( + reconfirmStep.run.split("validateUniquePublishRecoveryRun").length - 1, + 2, + "Publish recovery unique-run pre-publication reference count", + ); + if ( + reconfirmStep.run.indexOf("validateRegistryStateBeforePublish") >= + reconfirmStep.run.lastIndexOf("validateUniquePublishRecoveryRun") + ) { + fail( + "Release workflow Publish must revalidate the unique recovery run after registry state and before publication.", + ); + } const publishStep = requireUniqueStep( jobs.publish, "Publish the exact artifact with provenance", @@ -1177,6 +1231,105 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ }; } +export function validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses, +}) { + requireCommit(controlCommit, "publish recovery unique-run control commit"); + requirePositiveInteger( + currentRunId, + "publish recovery unique-run current run ID", + ); + if (!Array.isArray(responses) || responses.length === 0) { + fail( + "Release workflow publish recovery run responses must be a non-empty array.", + ); + } + + const runs = []; + let totalCount; + for (const response of responses) { + if ( + response === null || + typeof response !== "object" || + Array.isArray(response) + ) { + fail("Release workflow publish recovery run response must be an object."); + } + if (!Number.isInteger(response.total_count) || response.total_count < 0) { + fail( + "Release workflow publish recovery response total count must be a non-negative integer.", + ); + } + totalCount ??= response.total_count; + requireEqual( + response.total_count, + totalCount, + "publish recovery response total-count agreement", + ); + if (!Array.isArray(response.workflow_runs)) { + fail( + "Release workflow publish recovery response workflow runs must be an array.", + ); + } + runs.push(...response.workflow_runs); + } + requireEqual( + runs.length, + totalCount, + "publish recovery complete paginated run count", + ); + + const seen = new Set(); + for (const run of runs) { + requirePositiveInteger(run?.id, "publish recovery candidate run ID"); + if (seen.has(run.id)) { + fail( + "Release workflow publish recovery run set contains a duplicate run ID.", + ); + } + seen.add(run.id); + } + requireEqual(totalCount, 1, "publish recovery exact control-run count"); + + const candidates = runs.filter( + (run) => run?.head_branch === "main" && run?.head_sha === controlCommit, + ); + requireEqual(candidates.length, 1, "publish recovery matching run count"); + const run = candidates[0]; + requireEqual(run.id, currentRunId, "publish recovery current run ID"); + requireEqual(run.event, "workflow_dispatch", "publish recovery run event"); + requireEqual(run.name, "Publish", "publish recovery workflow name"); + requireEqual( + run.path, + ".github/workflows/publish.yml", + "publish recovery workflow path", + ); + requireEqual( + run.repository?.full_name, + "cometapi-dev/cometapi-node", + "publish recovery repository", + ); + requireEqual( + run.head_repository?.full_name, + "cometapi-dev/cometapi-node", + "publish recovery head repository", + ); + requireEqual( + run.actor?.login, + PUBLISH_RECOVERY.actor, + "publish recovery actor", + ); + requireEqual( + run.triggering_actor?.login, + PUBLISH_RECOVERY.actor, + "publish recovery triggering actor", + ); + requireEqual(run.run_attempt, 1, "publish recovery run attempt"); + return { runAttempt: 1, runId: currentRunId }; +} + function requireJob(job, { conclusion, id, name }) { requireEqual(job?.id, id, `${name} job ID`); requireEqual( diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 3377128..8ab852c 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -19,6 +19,7 @@ import { validatePublishWorkflowContract, validatePublishWorkflowDispatchTrigger, validatePublishWorkflowDispatchRecoveryTrigger, + validateUniquePublishRecoveryRun, validatePublishRecoveryEvidence, validateRegistryProvenance, validateRegistryProvenanceInvocation, @@ -383,6 +384,152 @@ describe("Publish workflow dispatch recovery trigger", () => { }); }); +describe("Unique Publish recovery run", () => { + const controlCommit = "b".repeat(40); + const currentRunId = 30500000000; + + function recoveryRun(overrides = {}) { + return { + actor: { login: "tensornull" }, + event: "workflow_dispatch", + head_branch: "main", + head_repository: { full_name: REPOSITORY }, + head_sha: controlCommit, + id: currentRunId, + name: "Publish", + path: ".github/workflows/publish.yml", + repository: { full_name: REPOSITORY }, + run_attempt: 1, + triggering_actor: { login: "tensornull" }, + ...overrides, + }; + } + + function recoveryResponses(runs, totalCount = runs.length) { + return [{ total_count: totalCount, workflow_runs: runs }]; + } + + it("accepts exactly one first-attempt recovery dispatch", () => { + expect( + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses: recoveryResponses([recoveryRun()]), + }), + ).toEqual({ runAttempt: 1, runId: currentRunId }); + }); + + it("rejects a second fresh dispatch for the same control commit", () => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses: recoveryResponses([ + recoveryRun(), + recoveryRun({ id: currentRunId + 1 }), + ]), + }), + ).toThrow(/control-run count/i); + }); + + it("rejects a rerun history plus a fresh replacement dispatch", () => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId: currentRunId + 1, + responses: recoveryResponses([ + recoveryRun({ run_attempt: 2 }), + recoveryRun({ id: currentRunId + 1 }), + ]), + }), + ).toThrow(/control-run count/i); + }); + + it("rejects duplicate run IDs from a paginated response", () => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses: recoveryResponses([recoveryRun(), recoveryRun()]), + }), + ).toThrow(/duplicate run ID/i); + }); + + it("rejects a rerun even when it is the only matching run", () => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses: recoveryResponses([recoveryRun({ run_attempt: 2 })]), + }), + ).toThrow(/run attempt/i); + }); + + it.each([ + ["non-array response set", { responses: null }], + [ + "malformed run ID", + { responses: recoveryResponses([recoveryRun({ id: 0 })]) }, + ], + [ + "missing matching run", + { + responses: recoveryResponses([ + recoveryRun({ head_sha: "a".repeat(40) }), + ]), + }, + ], + [ + "truncated search result", + { responses: recoveryResponses([recoveryRun()], 1001) }, + ], + [ + "inconsistent page totals", + { + responses: [ + { total_count: 1, workflow_runs: [recoveryRun()] }, + { total_count: 2, workflow_runs: [] }, + ], + }, + ], + ])("rejects %s", (_name, overrides) => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId, + responses: overrides.responses, + }), + ).toThrow(/release workflow/i); + }); + + it.each([ + ["current ID", { currentRunId: currentRunId + 1 }], + ["actor", { run: { actor: { login: "other-maintainer" } } }], + [ + "triggering actor", + { run: { triggering_actor: { login: "other-maintainer" } } }, + ], + ["event", { run: { event: "push" } }], + ["workflow", { run: { name: "Other" } }], + ["path", { run: { path: ".github/workflows/other.yml" } }], + ["repository", { run: { repository: { full_name: "other/repo" } } }], + [ + "head repository", + { run: { head_repository: { full_name: "other/repo" } } }, + ], + ["ref", { run: { head_branch: "dev" } }], + ["SHA", { run: { head_sha: "a".repeat(40) } }], + ])("rejects recovery run drift in %s", (_name, overrides) => { + expect(() => + validateUniquePublishRecoveryRun({ + controlCommit, + currentRunId: overrides.currentRunId ?? currentRunId, + responses: recoveryResponses([recoveryRun(overrides.run)]), + }), + ).toThrow(/release workflow/i); + }); +}); + describe("Publish tag dispatch trigger", () => { function tagTrigger(overrides = {}) { return { @@ -517,6 +664,38 @@ describe("Publish workflow dispatch contract", () => { delete workflow.jobs.verify.outputs["release-please-snapshot"]; }, ], + [ + "missing unique recovery dispatch gate", + (workflow) => { + workflow.jobs.verify.steps = workflow.jobs.verify.steps.filter( + ({ name }) => + name !== + "Require the only recovery dispatch for this control commit", + ); + }, + ], + [ + "unpaginated unique recovery dispatch query", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === + "Require the only recovery dispatch for this control commit", + ); + step.run = step.run.replace("gh api --paginate --slurp", "gh api"); + }, + ], + [ + "unbound unique recovery run ID", + (workflow) => { + const step = workflow.jobs.verify.steps.find( + ({ name }) => + name === + "Require the only recovery dispatch for this control commit", + ); + step.env.CURRENT_RUN_ID = "untrusted"; + }, + ], [ "unfrozen Release Please run snapshot", (workflow) => { @@ -537,6 +716,47 @@ describe("Publish workflow dispatch contract", () => { step.env.RECOVERY_POLICY_ID = "untrusted"; }, ], + [ + "missing pre-publication unique recovery check", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.run = step.run.replaceAll( + "validateUniquePublishRecoveryRun", + "removedUniquePublishRecoveryRun", + ); + }, + ], + [ + "wrong pre-publication recovery run endpoint", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.run = step.run.replace( + "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", + "actions/workflows/publish.yml/runs?per_page=1", + ); + }, + ], + [ + "late protected-state reconfirmation", + (workflow) => { + const steps = workflow.jobs.publish.steps; + const reconfirmIndex = steps.findIndex( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + const [reconfirm] = steps.splice(reconfirmIndex, 1); + const publishIndex = steps.findIndex( + ({ name }) => name === "Publish the exact artifact with provenance", + ); + steps.splice(publishIndex + 1, 0, reconfirm); + }, + ], [ "repacked recovery artifact", (workflow) => { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 4ab8da9..3b9fbfa 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -488,6 +488,15 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain("inputs.source_publish_run_id == '30471665743'"); expect(verify).toContain("validatePublishWorkflowDispatchTrigger"); expect(verify).toContain("validatePublishWorkflowDispatchRecoveryTrigger"); + expect(verify).toContain("validateUniquePublishRecoveryRun"); + expect(verify).toContain( + "Require the only recovery dispatch for this control commit", + ); + expect(verify).toContain("CURRENT_RUN_ID: ${{ github.run_id }}"); + expect(verify).toContain("gh api --paginate --slurp"); + expect(verify).toContain( + "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", + ); expect(verify).toContain("validatePublishRecoveryEvidence"); expect(verify).toContain( "Download the prior live-verified release artifact", @@ -579,6 +588,15 @@ describe("GitHub Actions workflow contract", () => { expect( matches(publish, /validatePublishRecoveryEvidence\(\{/g), ).toHaveLength(2); + expect( + matches(publish, /validateUniquePublishRecoveryRun\(\{/g), + ).toHaveLength(2); + expect(publishJob).toContain("validateUniquePublishRecoveryRun"); + expect(publishJob).toContain("CURRENT_RUN_ID: ${{ github.run_id }}"); + expect(publishJob).toContain( + "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", + ); + expect(publishJob).toContain("gh api --paginate --slurp"); expect(publishJob).toContain( "The bounded live evidence changed while publication awaited approval.", );