diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5fe7c7d..6fd2911 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 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 + + 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 rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT" ]]; 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,118 @@ 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 [[ "$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="" + : > "$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: 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: + 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 +576,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 +593,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 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 + ;; + *) 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 +648,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 +754,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 +818,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 +829,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 +871,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 +896,285 @@ 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 + CURRENT_RUN_ID: ${{ github.run_id }} + 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_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 }} + 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 [[ "$main_commit" != "$RELEASE_COMMIT" ]]; then + echo "The release commit is no longer the exact origin/main tip." >&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 + + 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 }} @@ -553,7 +1182,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 +1227,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 +1337,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..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: @@ -238,12 +258,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 +282,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 +295,38 @@ 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, 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 + 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 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 + 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 +341,144 @@ 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}' > "$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`, record it, 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}$ ]] + state_next="${recovery_state}.next" + jq --arg control_commit "$control_commit" \ + '.control_commit = $control_commit' \ + "$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 - < "$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]*$ ]] + 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. + 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 + 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 + + 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)" + 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..2654815 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,1041 @@ 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", + ); + 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", + "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", + ); + 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)", + ]) { + if ( + typeof reconfirmStep?.run !== "string" || + !reconfirmStep.run.includes(fragment) + ) { + fail( + `Release workflow Publish protected-state reconfirmation must contain ${fragment}.`, + ); + } + } + 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", + "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 +1134,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 +1147,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 +1207,13 @@ export function validatePublishWorkflowDispatchRecoveryTrigger({ PUBLISH_RECOVERY.sourcePublishRunAttempt, "publish recovery source Publish run attempt", ); - requireEqual(task, PUBLISH_RECOVERY.dispatchTask, "publish recovery task"); - requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt"); + requireEqual( + operation, + PUBLISH_RECOVERY.dispatchTask, + "publish recovery operation", + ); + 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."); } @@ -207,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( @@ -650,6 +1773,7 @@ export function selectPendingReleasePullRequest( pullRequests, { eventName, + operation = eventName === "workflow_dispatch" ? "prepare" : "release", releaseBranch, releaseCommit, releaseExists = false, @@ -663,6 +1787,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 +1802,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 +1839,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 +2398,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 +2429,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..8ab852c 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -1,18 +1,29 @@ +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, + validateUniquePublishRecoveryRun, validatePublishRecoveryEvidence, + validateRegistryProvenance, + validateRegistryProvenanceInvocation, + validateRegistryStateBeforePublish, validateReleasePleaseCommitMessages, validateReleasePleaseCompletion, validateReleasePleaseMutationConfiguration, @@ -31,6 +42,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 +178,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 +318,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 +333,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,18 +356,23 @@ 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 }], + ["workflow rerun", { workflowRunAttempt: 2 }], ["missing file", { changedFiles: recoveryFiles.slice(1) }], ["extra file", { changedFiles: [...recoveryFiles, "package.json"] }], ])("rejects recovery trigger drift in %s", (_name, overrides) => { @@ -233,13 +382,976 @@ describe("Publish workflow dispatch recovery trigger", () => { ), ).toThrow(/release workflow/i); }); +}); + +describe("Unique Publish recovery run", () => { + const controlCommit = "b".repeat(40); + const currentRunId = 30500000000; - it("accepts a rerun of the same immutable recovery event", () => { + 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( - validatePublishWorkflowDispatchRecoveryTrigger( - recoveryTrigger({ workflowRunAttempt: 2 }), + 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 { + 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"]; + }, + ], + [ + "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) => { + 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"; + }, + ], + [ + "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) => { + 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({ releaseRunId: 30469181724 }); + ).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, + ); }); }); @@ -373,7 +1485,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 +1496,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 +1520,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 +1565,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 +1576,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 +1961,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..3b9fbfa 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,103 @@ 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"'); + 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'"); + 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("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", + ); + 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('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"); @@ -358,8 +524,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 +566,93 @@ 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( + 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.", + ); + 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( + 'if [[ "$main_commit" != "$RELEASE_COMMIT" ]]', + ); + 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"); });