From c5566246abce065a39f1f66ed2b6e2d4bc89e62f Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 19:14:13 +0800 Subject: [PATCH] fix: remove the one-time release recovery path --- .github/workflows/publish.yml | 347 +--------- RELEASING.md | 626 ++--------------- scripts/release-workflow-validation.mjs | 768 +++++---------------- tests/release-workflow-validation.test.mjs | 652 +++++------------ tests/workflow-contract.test.mjs | 138 ++-- 5 files changed, 465 insertions(+), 2066 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6fd2911..ddc2fb9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,22 +32,9 @@ on: description: Successful Release Please run attempt. required: true type: string - source_publish_run_id: - description: Failed Publish run used only by an authorized recovery. - required: false - type: string - source_publish_run_attempt: - 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: actions: read - checks: read contents: read concurrency: @@ -309,23 +296,12 @@ jobs: if: >- vars.RELEASE_PLEASE_ENABLED == 'true' && 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' && - inputs.release_run_attempt == '1' && - inputs.source_publish_run_id == '30471665743' && - inputs.source_publish_run_attempt == '1')) + 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 runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -338,9 +314,7 @@ jobs: 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: - name: Check out the workflow control commit @@ -352,21 +326,15 @@ jobs: - 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 }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} WORKFLOW_SHA: ${{ github.workflow_sha }} @@ -374,37 +342,16 @@ jobs: run: | set -euo pipefail if [[ "$(git rev-parse HEAD)" != "$CONTROL_COMMIT" ]]; then - echo "The recovery control checkout does not match the triggering SHA." >&2 + echo "The tag dispatch checkout does not match the triggering SHA." >&2 exit 1 fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - MAIN_COMMIT="$(git rev-parse refs/remotes/origin/main)" - 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, - validatePublishWorkflowDispatchTrigger, - } from "./scripts/release-workflow-validation.mjs"; + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$RELEASE_COMMIT" ]]; then + echo "The tag release commit is no longer the exact origin/main tip." >&2 + exit 1 + fi + node --input-type=module <<'EOF' + import { validatePublishWorkflowDispatchTrigger } from "./scripts/release-workflow-validation.mjs"; const dispatchIdentity = { actor: process.env.ACTOR, @@ -415,7 +362,6 @@ jobs: operation: process.env.OPERATION, releaseCommit: process.env.RELEASE_COMMIT, releaseTag: process.env.RELEASE_TAG, - 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), @@ -423,48 +369,7 @@ jobs: 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"), - ), - }); + validatePublishWorkflowDispatchTrigger(dispatchIdentity); EOF - name: Freeze the Release Please run set id: release-please-snapshot @@ -488,62 +393,6 @@ jobs: 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: inputs.publish_operation == 'recover-v0.1.1' - env: - GH_TOKEN: ${{ github.token }} - RECOVERY_ANNOTATIONS: ${{ runner.temp }}/publish-recovery-annotations.json - RECOVERY_ARTIFACTS: ${{ runner.temp }}/publish-recovery-artifacts.json - RECOVERY_JOBS: ${{ runner.temp }}/publish-recovery-jobs.json - RECOVERY_LIVE_LOG: ${{ runner.temp }}/publish-recovery-live.log - RECOVERY_RUN: ${{ runner.temp }}/publish-recovery-run.json - SOURCE_PUBLISH_ATTEMPT: ${{ inputs.source_publish_run_attempt }} - SOURCE_PUBLISH_RUN_ID: ${{ inputs.source_publish_run_id }} - shell: bash - run: | - set -euo pipefail - 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_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 source run no longer proves the exact bounded live smoke." >&2 - exit 1 - fi - - node --input-type=module <<'EOF' - import { appendFileSync, readFileSync } from "node:fs"; - import { validatePublishRecoveryEvidence } from "./scripts/release-workflow-validation.mjs"; - - const result = 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")), - }); - appendFileSync( - process.env.GITHUB_OUTPUT, - [ - `artifact-id=${result.artifactId}`, - `artifact-name=${result.artifactName}`, - `live-job-id=${result.liveJobId}`, - "reuse-live-smoke=true", - "", - ].join("\n"), - ); - EOF - name: Check out the exact release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -576,8 +425,6 @@ 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: ${{ 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 }} @@ -593,21 +440,10 @@ jobs: fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - 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)" != "$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"; @@ -754,19 +590,7 @@ 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 - if: inputs.publish_operation == 'release' shell: bash run: | set -euo pipefail @@ -817,37 +641,21 @@ jobs: # without required reviewers and add COMETAPI_KEY before publishing a release. environment: live-smoke steps: - - name: Reuse the successful bounded live smoke - if: needs.verify.outputs.publish-operation == 'recover-v0.1.1' - env: - REUSE_LIVE_SMOKE: ${{ needs.verify.outputs.reuse-live-smoke }} - shell: bash - run: | - set -euo pipefail - if [[ "$REUSE_LIVE_SMOKE" != "true" ]]; then - echo "The exact recovery run did not validate bounded live evidence." >&2 - exit 1 - fi - name: Check out the verified release tag - 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: 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: needs.verify.outputs.publish-operation == 'release' run: npm ci - name: Build the release tag - if: needs.verify.outputs.publish-operation == 'release' run: npm run build - name: Run the bounded live smoke - if: needs.verify.outputs.publish-operation == 'release' env: COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }} COMETAPI_LIVE_SMOKE: "1" @@ -872,7 +680,6 @@ jobs: url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }} permissions: actions: read - checks: read contents: read deployments: read id-token: write @@ -900,20 +707,11 @@ jobs: env: CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs - CURRENT_RUN_ID: ${{ github.run_id }} ENVIRONMENT_FILE: ${{ runner.temp }}/npm-environment.json EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} 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 }} @@ -923,8 +721,6 @@ jobs: 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 @@ -943,33 +739,17 @@ jobs: 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 + 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 git show "${CONTROL_COMMIT}:scripts/release-workflow-validation.mjs" \ > "$CONTROL_VALIDATOR" @@ -988,44 +768,6 @@ jobs: 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" \ @@ -1135,14 +877,8 @@ jobs: 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, + expectedPolicyIds: { "tag:v*": 55718965 }, + operation: "release", policies: JSON.parse( readFileSync(process.env.POLICIES_FILE, "utf8"), ).branch_policies, @@ -1154,27 +890,6 @@ jobs: version, }); EOF - - if [[ "$OPERATION" == "recover-v0.1.1" ]]; then - gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100" \ - > "$RECOVERY_DISPATCH_RUNS" - node --input-type=module <<'EOF' - import { readFileSync } from "node:fs"; - import { pathToFileURL } from "node:url"; - - const { validateUniquePublishRecoveryRun } = await import( - pathToFileURL(process.env.CONTROL_VALIDATOR) - ); - validateUniquePublishRecoveryRun({ - controlCommit: process.env.CONTROL_COMMIT, - currentRunId: Number(process.env.CURRENT_RUN_ID), - responses: JSON.parse( - readFileSync(process.env.RECOVERY_DISPATCH_RUNS, "utf8"), - ), - }); - EOF - fi - name: Publish the exact artifact with provenance env: DIST_TAG: ${{ needs.verify.outputs.dist-tag }} diff --git a/RELEASING.md b/RELEASING.md index a03e8c2..610f605 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -265,32 +265,22 @@ The repository maintains four independently auditable workflows: 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 - even when Release Please returns no PR output. The preparation run succeeds - only after independently verifying the canonical branch, title, - machine-readable body, pending label, four expected release files, and 0.1.x - patch versions. Before a post-merge `push` may create a Release, the workflow - scans every merged `main` PR carrying `autorelease: pending`, rejects legacy, - alternate, fork, older, or multiple candidates, and requires an - administrator's human approval on the exact final head. A same-run push retry - may proceed only for the same run ID, SHA, candidate, and review. An existing - tag and Release are accepted only as the exact bot-authored immutable Release - whose publication time falls within exactly one executed Release Please step - 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. 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. - This flow does not introduce a PAT or GitHub App credential. + manual preparation dispatch is attempt-1-only, skips GitHub Release creation, + and prepares or revalidates exactly one action-authored patch PR. The + preparation run verifies the canonical branch, title, machine-readable body, + pending label, four expected release files, and 0.1.x patch versions. Before a + post-merge `push` may create a Release, the workflow rejects legacy, + alternate, fork, older, or multiple candidates and requires a formal human + administrator approval on the exact final head. Release Please creates the + immutable tag and GitHub Release, verifies the release notes byte-for-byte + against the normalized `CHANGELOG` entry, reconciles release labels, and + uploads one schema-v2 attempt-qualified result artifact. An existing Release + may be reconciled only by a later attempt of that same run after its exact + commit, tag, author, immutable state, notes, and creation window are proven. + The triggering SHA must remain the fetched `main` tip before every mutation. + Before and after tag creation, the workflow also validates the permanent + publication contract stored at the exact release commit. This flow does not + introduce a PAT or GitHub App credential. Component identity remains internal to release discovery: `include-component-in-tag=false` requires the exact public `v` tag. The workflow has contents, pull-request, and issue permissions only for those @@ -311,563 +301,39 @@ The repository maintains four independently auditable workflows: 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) - validated the immutable tag, Release, and Release Please result, then failed - before packing, live smoke, OIDC, or npm because the downloaded runtime result - JSON was inside the workspace scanned by Prettier. Recovery run - [30471665743](https://github.com/cometapi-dev/cometapi-node/actions/runs/30471665743) - then validated the exact Release Please result, immutable Release, package, - and artifact and passed the only authorized three-request live smoke. Its npm - job was rejected before runner allocation because a `main` push produces a - `main` deployment while the protected npm environment accepts only `v*` tags. - 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 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 - < "$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`. + identity. It freezes the Release Please run set, packs and tests one fresh + attempt-qualified artifact, runs a fresh bounded live smoke, and sends that + same file to the protected npm Environment. Immediately after approval and + directly before registry mutation, it rechecks the source run, enable + variable, Release, tag, current `main`, active Publish runs, Release Please run + snapshot, npm Environment reviewer and branch policies, public versions, and + dist-tags. The Environment must contain exactly its permanent `tag:v*` + deployment policy. Registry token credentials are rejected; only the publish + job receives `id-token: write`. Provenance must bind the package to the exact + stable `v0.1.x` tag and release commit. Replays are integrity-idempotent, not + exactly-once: an existing version is accepted only when its registry integrity + matches the downloaded artifact, after which the bounded registry, signature, + and provenance checks run again. + +The one-time `0.1.1` main-context publication exception is historical evidence, +not a reusable release route. Its dispatch inputs, fixed evidence identifiers, +prior-artifact reuse, live-evidence reuse, and temporary `main` policy handling +must remain absent from executable workflow code. No later release may add a +`main` publication policy, dispatch publication from a branch, fabricate an +auxiliary tag, or choose a different patch to bypass a failed release. + +The permanent npm Environment deployment-policy set is exactly one `tag:v*` +policy. Any `branch:main` policy or other additional policy is configuration +drift and stops publication. Environment reviewers, the Trusted Publisher +tuple, repository variables, protections, secrets, and npm ownership are +external release prerequisites; the workflow validates the state it can read +and never changes those settings. 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 -only to the publish job. Publication cannot run from an arbitrary branch or an -unreviewed commit. +only to the publish job, and `actions: write` belongs only to the unprivileged +handoff. Publication cannot run from an arbitrary branch or an unreviewed +commit. The supported package `engines` range contains Node.js 22 and 24 only. Node.js 26 remains an advisory workflow target until it enters LTS; Node.js 18 and 20 diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 2654815..61280a9 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + const STABLE_VERSION_PATTERN = /^0\.1\.(0|[1-9]\d*)$/; const RELEASE_PR_FILES = [ ".release-please-manifest.json", @@ -12,34 +14,72 @@ 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: - "sha256:567b00f1ec32168d5c5be7d0b553542441920d3bb401959bcc2d6e157f35d08b", - artifactId: 8731956162, - 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: "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, - verifyJobId: 90643169818, -}); +const NPM_TAG_POLICY_ID = 55718965; +const PUBLISH_WORKFLOW_CONTRACT_SHA256 = + "43f70219c4b8deed5a68a7a369821cc18f891119b373134fda8ca46fc7080e24"; +const PUBLISH_HANDOFF_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'"; +const PUBLISH_RESULT_IF = "steps.result.outputs.has-result == 'true'"; +const PUBLISH_CONDITIONAL_STEPS = new Set([ + "handoff:Install validation dependencies without lifecycle scripts", + "handoff:Download the exact Release Please result", + "handoff:Validate the exact release and tag dispatch contract", + "handoff:Dispatch the exact immutable tag", +]); +const PUBLISH_VERIFY_IF = + "vars.RELEASE_PLEASE_ENABLED == 'true' && 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"; +const PUBLISH_VERIFY_ENV = { + SOURCE_RELEASE_COMMIT: "${{ inputs.release_commit }}", + SOURCE_RELEASE_RUN_ATTEMPT: "${{ inputs.release_run_attempt }}", + SOURCE_RELEASE_RUN_ID: "${{ inputs.release_run_id }}", +}; +const PUBLISH_DISPATCH_VALIDATION_ENV = { + ACTOR: "${{ github.actor }}", + CONTROL_COMMIT: "${{ github.workflow_sha }}", + EVENT_NAME: "${{ github.event_name }}", + EVENT_REF: "${{ github.ref }}", + EVENT_SHA: "${{ github.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 }}", + TRIGGERING_ACTOR: "${{ github.triggering_actor }}", + WORKFLOW_RUN_ATTEMPT: "${{ github.run_attempt }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", +}; +const PUBLISH_DISPATCH_VALIDATION_RUN = `set -euo pipefail +if [[ "$(git rev-parse HEAD)" != "$CONTROL_COMMIT" ]]; then + echo "The tag dispatch checkout does not match the triggering 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)" != "$RELEASE_COMMIT" ]]; then + echo "The tag release commit is no longer the exact origin/main tip." >&2 + exit 1 +fi +node --input-type=module <<'EOF' +import { validatePublishWorkflowDispatchTrigger } from "./scripts/release-workflow-validation.mjs"; + +const dispatchIdentity = { + actor: process.env.ACTOR, + controlCommit: process.env.CONTROL_COMMIT, + eventName: process.env.EVENT_NAME, + eventRef: process.env.EVENT_REF, + eventSha: process.env.EVENT_SHA, + operation: process.env.OPERATION, + releaseCommit: process.env.RELEASE_COMMIT, + releaseTag: process.env.RELEASE_TAG, + sourceReleaseCommit: process.env.SOURCE_RELEASE_COMMIT, + sourceRunAttempt: Number(process.env.SOURCE_RELEASE_RUN_ATTEMPT), + sourceRunId: Number(process.env.SOURCE_RELEASE_RUN_ID), + triggeringActor: process.env.TRIGGERING_ACTOR, + workflowRunAttempt: Number(process.env.WORKFLOW_RUN_ATTEMPT), + workflowSha: process.env.WORKFLOW_SHA, +}; +validatePublishWorkflowDispatchTrigger(dispatchIdentity); +EOF +`; function fail(message) { throw new Error(message); @@ -91,6 +131,14 @@ function stablePatch(version, label) { return Number(match[1]); } +function 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]; +} + export function validatePublishWorkflowContract(workflow) { if ( workflow === null || @@ -106,25 +154,23 @@ export function validatePublishWorkflowContract(workflow) { "Release workflow Publish workflow_dispatch inputs must be an object.", ); } - for (const name of [ + const requiredInputs = [ "publish_operation", "control_commit", "release_commit", "release_tag", "release_run_id", "release_run_attempt", - ]) { + ]; + requireEqual( + JSON.stringify(Object.keys(inputs).sort()), + JSON.stringify([...requiredInputs].sort()), + "Publish workflow_dispatch input set", + ); + for (const name of requiredInputs) { 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"] }), @@ -132,7 +178,7 @@ export function validatePublishWorkflowContract(workflow) { ); requireEqual( JSON.stringify(workflow.permissions), - JSON.stringify({ actions: "read", checks: "read", contents: "read" }), + JSON.stringify({ actions: "read", contents: "read" }), "Publish default permissions", ); requireEqual( @@ -149,6 +195,32 @@ export function validatePublishWorkflowContract(workflow) { JSON.stringify(["handoff", "live-smoke", "publish", "verify"]), "Publish job set", ); + for (const [jobName, job] of Object.entries(jobs)) { + if (!Array.isArray(job?.steps)) { + fail(`Release workflow Publish ${jobName} steps must be an array.`); + } + for (const step of job.steps) { + const stepLabel = `${jobName} ${String(step?.name)}`; + requireEqual( + step?.["continue-on-error"], + undefined, + `Publish ${stepLabel} continue-on-error`, + ); + requireEqual( + step?.["working-directory"], + undefined, + `Publish ${stepLabel} working directory`, + ); + const conditionalKey = `${jobName}:${String(step?.name)}`; + requireEqual( + step?.if, + PUBLISH_CONDITIONAL_STEPS.has(conditionalKey) + ? PUBLISH_RESULT_IF + : undefined, + `Publish ${stepLabel} gate`, + ); + } + } requireEqual( JSON.stringify(jobs.handoff?.permissions), JSON.stringify({ actions: "write", contents: "read" }), @@ -159,18 +231,7 @@ export function validatePublishWorkflowContract(workflow) { 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}.`); - } - } + requireEqual(jobs.handoff?.if, PUBLISH_HANDOFF_IF, "Publish handoff gate"); const dispatchStep = jobs.handoff?.steps?.find( (step) => step?.name === "Dispatch the exact immutable tag", ); @@ -240,21 +301,37 @@ export function validatePublishWorkflowContract(workflow) { 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}.`); - } - } + requireEqual(jobs.verify?.if, PUBLISH_VERIFY_IF, "Publish verify gate"); + requireEqual( + JSON.stringify(jobs.verify?.env), + JSON.stringify(PUBLISH_VERIFY_ENV), + "Publish verify source identity environment", + ); + const dispatchValidationStep = requireUniqueStep( + jobs.verify, + "Validate the exact workflow dispatch", + "Publish dispatch validation", + ); + requireEqual( + JSON.stringify(Object.keys(dispatchValidationStep).sort()), + JSON.stringify(["env", "name", "run", "shell"]), + "Publish dispatch validation key set", + ); + requireEqual( + JSON.stringify(dispatchValidationStep?.env), + JSON.stringify(PUBLISH_DISPATCH_VALIDATION_ENV), + "Publish dispatch validation environment", + ); + requireEqual( + dispatchValidationStep?.shell, + "bash", + "Publish dispatch validation shell", + ); + requireEqual( + dispatchValidationStep?.run, + PUBLISH_DISPATCH_VALIDATION_RUN, + "Publish dispatch validation command", + ); for (const [name, job] of Object.entries(jobs)) { if ( name !== "handoff" && @@ -280,13 +357,6 @@ export function validatePublishWorkflowContract(workflow) { 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 }}", @@ -297,40 +367,6 @@ export function validatePublishWorkflowContract(workflow) { "Freeze the Release Please run set", "Publish Release Please snapshot", ); - const uniqueRecoveryStep = requireUniqueStep( - jobs.verify, - "Require the only recovery dispatch for this control commit", - "Publish unique recovery dispatch", - ); - requireEqual( - uniqueRecoveryStep?.if, - "inputs.publish_operation == 'recover-v0.1.1'", - "Publish unique recovery dispatch gate", - ); - requireEqual( - uniqueRecoveryStep?.env?.CONTROL_COMMIT, - "${{ inputs.control_commit }}", - "Publish unique recovery control commit", - ); - requireEqual( - uniqueRecoveryStep?.env?.CURRENT_RUN_ID, - "${{ github.run_id }}", - "Publish unique recovery current run ID", - ); - for (const fragment of [ - "gh api --paginate --slurp", - "actions/workflows/publish.yml/runs?branch=main&event=workflow_dispatch&head_sha=${CONTROL_COMMIT}&per_page=100", - "validateUniquePublishRecoveryRun", - ]) { - if ( - typeof uniqueRecoveryStep?.run !== "string" || - !uniqueRecoveryStep.run.includes(fragment) - ) { - fail( - `Release workflow Publish unique recovery dispatch must contain ${fragment}.`, - ); - } - } for (const fragment of [ "actions/workflows/release-please.yml/runs?per_page=100", "snapshotReleasePleaseRuns", @@ -343,41 +379,12 @@ export function validatePublishWorkflowContract(workflow) { 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", - ); + requireEqual(packArtifactStep?.if, undefined, "Publish artifact gate"); if ( typeof packArtifactStep?.run !== "string" || !packArtifactStep.run.includes( @@ -430,32 +437,25 @@ export function validatePublishWorkflowContract(workflow) { "${{ 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", - ); + for (const stepName of [ + "Check out the verified release tag", + "Set up Node.js 24", + "Install locked dependencies", + "Build the release tag", + "Run the bounded live smoke", + ]) { + const step = requireUniqueStep( + jobs["live-smoke"], + stepName, + `Publish ${stepName}`, + ); + requireEqual(step?.if, undefined, `Publish ${stepName} gate`); + } requireEqual( boundedLiveStep?.run, "npm run test:live", @@ -484,7 +484,6 @@ export function validatePublishWorkflowContract(workflow) { JSON.stringify(jobs.publish?.permissions), JSON.stringify({ actions: "read", - checks: "read", contents: "read", deployments: "read", "id-token": "write", @@ -510,28 +509,16 @@ export function validatePublishWorkflowContract(workflow) { "${{ 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)", ]) { @@ -544,19 +531,6 @@ export function validatePublishWorkflowContract(workflow) { ); } } - requireEqual( - reconfirmStep.run.split("validateUniquePublishRecoveryRun").length - 1, - 2, - "Publish recovery unique-run pre-publication reference count", - ); - if ( - reconfirmStep.run.indexOf("validateRegistryStateBeforePublish") >= - reconfirmStep.run.lastIndexOf("validateUniquePublishRecoveryRun") - ) { - fail( - "Release workflow Publish must revalidate the unique recovery run after registry state and before publication.", - ); - } const publishStep = requireUniqueStep( jobs.publish, "Publish the exact artifact with provenance", @@ -586,6 +560,11 @@ export function validatePublishWorkflowContract(workflow) { "bash scripts/publish-artifact.sh", "npm publication command", ); + requireEqual( + createHash("sha256").update(JSON.stringify(workflow)).digest("hex"), + PUBLISH_WORKFLOW_CONTRACT_SHA256, + "Publish executable definition digest", + ); return { supportsTagDispatch: true }; } @@ -820,15 +799,8 @@ export function validateNpmEnvironmentState({ 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(operation, PUBLISH_OPERATION, "npm environment operation"); + const expected = ["tag:v*"]; requireEqual( JSON.stringify(actual), JSON.stringify(expected), @@ -849,7 +821,7 @@ export function validateNpmEnvironmentState({ ); requireEqual( expectedPolicyIds["tag:v*"], - PUBLISH_RECOVERY.permanentTagPolicyId, + NPM_TAG_POLICY_ID, "permanent npm tag policy ID", ); for (const policy of policies) { @@ -907,9 +879,9 @@ export function validateRegistryProvenance({ } if ( typeof workflowRef !== "string" || - !/^refs\/(heads\/main|tags\/v0\.1\.[1-9]\d*)$/.test(workflowRef) + !/^refs\/tags\/v0\.1\.[1-9]\d*$/.test(workflowRef) ) { - fail("Release workflow provenance ref must be main or a stable 0.1.x tag."); + fail("Release workflow provenance ref must be a stable 0.1.x tag."); } const entries = Array.isArray(attestations?.attestations) ? attestations.attestations @@ -1029,6 +1001,14 @@ export function validateRegistryProvenanceInvocation({ workflowRef, }) { requireCommit(commit, "provenance invocation commit"); + if ( + typeof workflowRef !== "string" || + !/^refs\/tags\/v0\.1\.[1-9]\d*$/.test(workflowRef) + ) { + fail( + "Release workflow provenance invocation ref must be a stable 0.1.x tag.", + ); + } requirePositiveInteger(runId, "provenance invocation run ID"); requirePositiveInteger(runAttempt, "provenance invocation run attempt"); requireEqual(run?.id, runId, "provenance invocation run ID"); @@ -1104,402 +1084,6 @@ export function validateRegistryProvenanceInvocation({ 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, - triggeringActor, - workflowRunAttempt, - workflowSha, -}) { - requireEqual(actor, PUBLISH_RECOVERY.actor, "publish recovery actor"); - requireEqual( - triggeringActor, - PUBLISH_RECOVERY.actor, - "publish recovery triggering actor", - ); - requireEqual(eventName, "workflow_dispatch", "publish recovery event"); - requireEqual(eventRef, "refs/heads/main", "publish recovery ref"); - requireCommit(eventSha, "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"); - requireEqual( - controlCommit, - 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, - "publish recovery control first parent", - ); - requireEqual( - releaseCommit, - PUBLISH_RECOVERY.releaseCommit, - "publish recovery input release commit", - ); - requireEqual( - releaseTag, - PUBLISH_RECOVERY.releaseTag, - "publish recovery input release tag", - ); - requireEqual( - sourceReleaseCommit, - PUBLISH_RECOVERY.releaseCommit, - "publish recovery source release commit", - ); - requirePositiveInteger(sourceRunId, "publish recovery source run ID"); - requireEqual( - sourceRunId, - PUBLISH_RECOVERY.releaseRunId, - "publish recovery source run ID", - ); - requirePositiveInteger( - sourceRunAttempt, - "publish recovery source run attempt", - ); - requireEqual( - sourceRunAttempt, - PUBLISH_RECOVERY.releaseRunAttempt, - "publish recovery source run attempt", - ); - requirePositiveInteger( - sourcePublishRunId, - "publish recovery source Publish run ID", - ); - requireEqual( - sourcePublishRunId, - PUBLISH_RECOVERY.sourcePublishRunId, - "publish recovery source Publish run ID", - ); - requirePositiveInteger( - sourcePublishRunAttempt, - "publish recovery source Publish run attempt", - ); - requireEqual( - sourcePublishRunAttempt, - PUBLISH_RECOVERY.sourcePublishRunAttempt, - "publish recovery source Publish 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."); - } - requireEqual( - JSON.stringify([...changedFiles].sort()), - JSON.stringify([...PUBLISH_RECOVERY.changedFiles].sort()), - "publish recovery changed files", - ); - return { - releaseCommit: PUBLISH_RECOVERY.releaseCommit, - releaseRunAttempt: PUBLISH_RECOVERY.releaseRunAttempt, - releaseRunId: PUBLISH_RECOVERY.releaseRunId, - sourcePublishRunAttempt: PUBLISH_RECOVERY.sourcePublishRunAttempt, - sourcePublishRunId: PUBLISH_RECOVERY.sourcePublishRunId, - }; -} - -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( - job?.run_id, - PUBLISH_RECOVERY.sourcePublishRunId, - `${name} run ID`, - ); - requireEqual( - job?.run_attempt, - PUBLISH_RECOVERY.sourcePublishRunAttempt, - `${name} run attempt`, - ); - requireEqual(job?.name, name, `${name} job name`); - requireEqual(job?.status, "completed", `${name} job status`); - requireEqual(job?.conclusion, conclusion, `${name} job conclusion`); - requireEqual( - job?.head_sha, - PUBLISH_RECOVERY.sourcePublishCommit, - `${name} job head SHA`, - ); -} - -function requireSuccessfulStep(job, stepName) { - const steps = Array.isArray(job?.steps) ? job.steps : []; - const matching = steps.filter((step) => step?.name === stepName); - requireEqual(matching.length, 1, `${stepName} step count`); - requireEqual(matching[0].status, "completed", `${stepName} step status`); - requireEqual( - matching[0].conclusion, - "success", - `${stepName} step conclusion`, - ); -} - -export function validatePublishRecoveryEvidence({ - annotations, - artifacts, - jobs, - run, -}) { - requireEqual( - run?.id, - PUBLISH_RECOVERY.sourcePublishRunId, - "recovery source run ID", - ); - requireEqual( - run?.run_attempt, - PUBLISH_RECOVERY.sourcePublishRunAttempt, - "recovery source run attempt", - ); - requireEqual(run?.name, "Publish", "recovery source workflow name"); - requireEqual( - run?.path, - ".github/workflows/publish.yml", - "recovery source workflow path", - ); - requireEqual(run?.event, "push", "recovery source event"); - requireEqual(run?.head_branch, "main", "recovery source head branch"); - requireEqual( - run?.head_sha, - PUBLISH_RECOVERY.sourcePublishCommit, - "recovery source head SHA", - ); - requireEqual( - run?.repository?.full_name, - "cometapi-dev/cometapi-node", - "recovery source repository", - ); - requireEqual( - run?.actor?.login, - PUBLISH_RECOVERY.actor, - "recovery source actor", - ); - requireEqual( - run?.triggering_actor?.login, - PUBLISH_RECOVERY.actor, - "recovery source triggering actor", - ); - requireEqual(run?.status, "completed", "recovery source status"); - requireEqual(run?.conclusion, "failure", "recovery source conclusion"); - - if (!Array.isArray(jobs)) { - fail("Release workflow recovery source jobs must be an array."); - } - requireEqual(jobs.length, 3, "recovery source job count"); - const verify = jobs.find((job) => job?.id === PUBLISH_RECOVERY.verifyJobId); - const live = jobs.find((job) => job?.id === PUBLISH_RECOVERY.liveJobId); - const publish = jobs.find( - (job) => job?.id === PUBLISH_RECOVERY.failedPublishJobId, - ); - requireJob(verify, { - conclusion: "success", - id: PUBLISH_RECOVERY.verifyJobId, - name: "Verify the immutable release artifact", - }); - requireSuccessfulStep(verify, "Run release checks"); - requireSuccessfulStep(verify, "Pack the exact release artifact"); - requireSuccessfulStep( - verify, - "Test consumers against the exact release artifact", - ); - requireSuccessfulStep(verify, "Upload the verified release artifact"); - requireJob(live, { - conclusion: "success", - id: PUBLISH_RECOVERY.liveJobId, - name: "Verify the release tag against CometAPI", - }); - requireSuccessfulStep(live, "Run the bounded live smoke"); - requireJob(publish, { - conclusion: "failure", - id: PUBLISH_RECOVERY.failedPublishJobId, - name: "Publish with npm Trusted Publishing", - }); - requireEqual(publish?.runner_id, 0, "failed publish runner ID"); - requireEqual(publish?.steps?.length, 0, "failed publish step count"); - - if (!Array.isArray(annotations)) { - fail("Release workflow failed publish annotations must be an array."); - } - const branchRejections = annotations.filter( - (annotation) => - annotation?.annotation_level === "failure" && - annotation?.message === - 'Branch "main" is not allowed to deploy to npm due to environment protection rules.', - ); - requireEqual( - branchRejections.length, - 1, - "failed publish branch-policy annotation count", - ); - - if (!Array.isArray(artifacts)) { - fail("Release workflow recovery source artifacts must be an array."); - } - requireEqual(artifacts.length, 1, "recovery source artifact count"); - const artifact = artifacts[0]; - requireEqual( - artifact?.id, - PUBLISH_RECOVERY.artifactId, - "recovery artifact ID", - ); - requireEqual( - artifact?.name, - PUBLISH_RECOVERY.artifactName, - "recovery artifact name", - ); - requireEqual( - artifact?.digest, - PUBLISH_RECOVERY.artifactDigest, - "recovery artifact digest", - ); - requireEqual(artifact?.expired, false, "recovery artifact expired state"); - requireEqual( - artifact?.workflow_run?.id, - PUBLISH_RECOVERY.sourcePublishRunId, - "recovery artifact run ID", - ); - requireEqual( - artifact?.workflow_run?.head_sha, - PUBLISH_RECOVERY.sourcePublishCommit, - "recovery artifact head SHA", - ); - - return { - artifactId: PUBLISH_RECOVERY.artifactId, - artifactName: PUBLISH_RECOVERY.artifactName, - liveJobId: PUBLISH_RECOVERY.liveJobId, - }; -} - function releaseTitle(version) { return `chore(main): release ${version}`; } diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 8ab852c..23ce811 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -18,9 +18,6 @@ import { validateNpmEnvironmentState, validatePublishWorkflowContract, validatePublishWorkflowDispatchTrigger, - validatePublishWorkflowDispatchRecoveryTrigger, - validateUniquePublishRecoveryRun, - validatePublishRecoveryEvidence, validateRegistryProvenance, validateRegistryProvenanceInvocation, validateRegistryStateBeforePublish, @@ -301,235 +298,6 @@ describe("Release Please run-set freeze", () => { }); }); -describe("Publish workflow dispatch recovery trigger", () => { - const recoveryCommit = "c98b514227858cd183c781270a7f78f65b577e82"; - 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", - "tests/workflow-contract.test.mjs", - ]; - - function recoveryTrigger(overrides = {}) { - return { - actor: "tensornull", - changedFiles: recoveryFiles, - controlCommit: BRANCH_SHA, - controlCommitInput: BRANCH_SHA, - controlFirstParent: controlParent, - eventName: "workflow_dispatch", - 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, - sourcePublishRunId: 30471665743, - sourceReleaseCommit: recoveryCommit, - sourceRunAttempt: 1, - sourceRunId: 30469181724, - triggeringActor: "tensornull", - workflowRunAttempt: 1, - workflowSha: BRANCH_SHA, - ...overrides, - }; - } - - it("accepts only the reviewed one-cycle recovery merge", () => { - expect( - validatePublishWorkflowDispatchRecoveryTrigger(recoveryTrigger()), - ).toEqual({ - releaseCommit: recoveryCommit, - releaseRunAttempt: 1, - releaseRunId: 30469181724, - sourcePublishRunAttempt: 1, - sourcePublishRunId: 30471665743, - }); - }); - - it.each([ - ["actor", { actor: "github-actions[bot]" }], - ["triggering actor", { triggeringActor: "other-maintainer" }], - ["event", { eventName: "deployment" }], - ["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 }], - ["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) => { - expect(() => - validatePublishWorkflowDispatchRecoveryTrigger( - recoveryTrigger(overrides), - ), - ).toThrow(/release workflow/i); - }); -}); - -describe("Unique Publish recovery run", () => { - const controlCommit = "b".repeat(40); - const currentRunId = 30500000000; - - function recoveryRun(overrides = {}) { - return { - actor: { login: "tensornull" }, - event: "workflow_dispatch", - head_branch: "main", - head_repository: { full_name: REPOSITORY }, - head_sha: controlCommit, - id: currentRunId, - name: "Publish", - path: ".github/workflows/publish.yml", - repository: { full_name: REPOSITORY }, - run_attempt: 1, - triggering_actor: { login: "tensornull" }, - ...overrides, - }; - } - - function recoveryResponses(runs, totalCount = runs.length) { - return [{ total_count: totalCount, workflow_runs: runs }]; - } - - it("accepts exactly one first-attempt recovery dispatch", () => { - expect( - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId, - responses: recoveryResponses([recoveryRun()]), - }), - ).toEqual({ runAttempt: 1, runId: currentRunId }); - }); - - it("rejects a second fresh dispatch for the same control commit", () => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId, - responses: recoveryResponses([ - recoveryRun(), - recoveryRun({ id: currentRunId + 1 }), - ]), - }), - ).toThrow(/control-run count/i); - }); - - it("rejects a rerun history plus a fresh replacement dispatch", () => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId: currentRunId + 1, - responses: recoveryResponses([ - recoveryRun({ run_attempt: 2 }), - recoveryRun({ id: currentRunId + 1 }), - ]), - }), - ).toThrow(/control-run count/i); - }); - - it("rejects duplicate run IDs from a paginated response", () => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId, - responses: recoveryResponses([recoveryRun(), recoveryRun()]), - }), - ).toThrow(/duplicate run ID/i); - }); - - it("rejects a rerun even when it is the only matching run", () => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId, - responses: recoveryResponses([recoveryRun({ run_attempt: 2 })]), - }), - ).toThrow(/run attempt/i); - }); - - it.each([ - ["non-array response set", { responses: null }], - [ - "malformed run ID", - { responses: recoveryResponses([recoveryRun({ id: 0 })]) }, - ], - [ - "missing matching run", - { - responses: recoveryResponses([ - recoveryRun({ head_sha: "a".repeat(40) }), - ]), - }, - ], - [ - "truncated search result", - { responses: recoveryResponses([recoveryRun()], 1001) }, - ], - [ - "inconsistent page totals", - { - responses: [ - { total_count: 1, workflow_runs: [recoveryRun()] }, - { total_count: 2, workflow_runs: [] }, - ], - }, - ], - ])("rejects %s", (_name, overrides) => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId, - responses: overrides.responses, - }), - ).toThrow(/release workflow/i); - }); - - it.each([ - ["current ID", { currentRunId: currentRunId + 1 }], - ["actor", { run: { actor: { login: "other-maintainer" } } }], - [ - "triggering actor", - { run: { triggering_actor: { login: "other-maintainer" } } }, - ], - ["event", { run: { event: "push" } }], - ["workflow", { run: { name: "Other" } }], - ["path", { run: { path: ".github/workflows/other.yml" } }], - ["repository", { run: { repository: { full_name: "other/repo" } } }], - [ - "head repository", - { run: { head_repository: { full_name: "other/repo" } } }, - ], - ["ref", { run: { head_branch: "dev" } }], - ["SHA", { run: { head_sha: "a".repeat(40) } }], - ])("rejects recovery run drift in %s", (_name, overrides) => { - expect(() => - validateUniquePublishRecoveryRun({ - controlCommit, - currentRunId: overrides.currentRunId ?? currentRunId, - responses: recoveryResponses([recoveryRun(overrides.run)]), - }), - ).toThrow(/release workflow/i); - }); -}); - describe("Publish tag dispatch trigger", () => { function tagTrigger(overrides = {}) { return { @@ -564,7 +332,7 @@ describe("Publish tag dispatch trigger", () => { ["actor", { actor: "tensornull" }], ["triggering actor", { triggeringActor: "tensornull" }], ["event", { eventName: "push" }], - ["operation", { operation: "recover-v0.1.1" }], + ["operation", { operation: "unexpected" }], ["ref", { eventRef: "refs/heads/main" }], ["event SHA", { eventSha: BRANCH_SHA }], ["workflow SHA", { workflowSha: BRANCH_SHA }], @@ -623,9 +391,12 @@ describe("Publish workflow dispatch contract", () => { }, ], [ - "missing recovery policy input", + "extra workflow dispatch input", (workflow) => { - delete workflow.on.workflow_dispatch.inputs.recovery_policy_id; + workflow.on.workflow_dispatch.inputs.untrusted = { + required: false, + type: "string", + }; }, ], [ @@ -653,167 +424,194 @@ describe("Publish workflow dispatch contract", () => { }, ], [ - "missing recovery annotation permission", + "extra default permission", (workflow) => { - delete workflow.jobs.publish.permissions.checks; + workflow.permissions.checks = "read"; }, ], [ - "missing Release Please run snapshot", + "extra publish permission", (workflow) => { - delete workflow.jobs.verify.outputs["release-please-snapshot"]; + workflow.jobs.publish.permissions.checks = "read"; }, ], [ - "missing unique recovery dispatch gate", + "missing Release Please run snapshot", (workflow) => { - workflow.jobs.verify.steps = workflow.jobs.verify.steps.filter( - ({ name }) => - name !== - "Require the only recovery dispatch for this control commit", - ); + delete workflow.jobs.verify.outputs["release-please-snapshot"]; }, ], [ - "unpaginated unique recovery dispatch query", + "unfrozen Release Please run snapshot", (workflow) => { - const step = workflow.jobs.verify.steps.find( + const step = workflow.jobs.publish.steps.find( ({ name }) => - name === - "Require the only recovery dispatch for this control commit", + name === "Reconfirm protected state immediately before publication", ); - step.run = step.run.replace("gh api --paginate --slurp", "gh api"); + step.env.RELEASE_PLEASE_SNAPSHOT = "untrusted"; }, ], [ - "unbound unique recovery run ID", + "late protected-state reconfirmation", (workflow) => { - const step = workflow.jobs.verify.steps.find( + const steps = workflow.jobs.publish.steps; + const reconfirmIndex = steps.findIndex( ({ name }) => - name === - "Require the only recovery dispatch for this control commit", + 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", ); - step.env.CURRENT_RUN_ID = "untrusted"; + steps.splice(publishIndex + 1, 0, reconfirm); }, ], [ - "unfrozen Release Please run snapshot", + "skipped protected-state reconfirmation", (workflow) => { const step = workflow.jobs.publish.steps.find( ({ name }) => name === "Reconfirm protected state immediately before publication", ); - step.env.RELEASE_PLEASE_SNAPSHOT = "untrusted"; + step.if = "${{ false }}"; }, ], [ - "unbound recovery policy ID", + "ignored protected-state reconfirmation failure", (workflow) => { const step = workflow.jobs.publish.steps.find( ({ name }) => name === "Reconfirm protected state immediately before publication", ); - step.env.RECOVERY_POLICY_ID = "untrusted"; + step["continue-on-error"] = true; }, ], [ - "missing pre-publication unique recovery check", + "neutralized protected-state reconfirmation failures", (workflow) => { const step = workflow.jobs.publish.steps.find( ({ name }) => name === "Reconfirm protected state immediately before publication", ); - step.run = step.run.replaceAll( - "validateUniquePublishRecoveryRun", - "removedUniquePublishRecoveryRun", - ); + step.run = step.run + .replace("set -euo pipefail", "set +e") + .replaceAll("exit 1", "true"); }, ], [ - "wrong pre-publication recovery run endpoint", + "different uploaded artifact", (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", + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Upload the verified release artifact", ); + step.with.path = "release-artifacts/*.tgz"; }, ], [ - "late protected-state reconfirmation", + "live-smoke dependency", (workflow) => { - const steps = workflow.jobs.publish.steps; - const reconfirmIndex = steps.findIndex( - ({ name }) => - name === "Reconfirm protected state immediately before publication", + 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", ); - const [reconfirm] = steps.splice(reconfirmIndex, 1); - const publishIndex = steps.findIndex( - ({ name }) => name === "Publish the exact artifact with provenance", + step.run = "true"; + }, + ], + [ + "tag dispatch gate", + (workflow) => { + workflow.jobs.verify.if = workflow.jobs.verify.if.replace( + "github.sha == inputs.release_commit", + "true", ); - steps.splice(publishIndex + 1, 0, reconfirm); }, ], [ - "repacked recovery artifact", + "unguarded tag dispatch", (workflow) => { - const step = workflow.jobs.verify.steps.find( - ({ name }) => - name === "Download the prior live-verified release artifact", + workflow.jobs.verify.if += " || true"; + }, + ], + [ + "main dispatch alternative", + (workflow) => { + workflow.jobs.verify.if += " || github.ref == 'refs/heads/main'"; + }, + ], + [ + "unguarded handoff", + (workflow) => { + workflow.jobs.handoff.if += " || true"; + }, + ], + [ + "missing runtime dispatch validation", + (workflow) => { + workflow.jobs.verify.steps = workflow.jobs.verify.steps.filter( + ({ name }) => name !== "Validate the exact workflow dispatch", ); - step.if = "inputs.publish_operation == 'release'"; }, ], [ - "unchecked recovery artifact digest", + "untrusted runtime dispatch ref", (workflow) => { const step = workflow.jobs.verify.steps.find( - ({ name }) => - name === "Download the prior live-verified release artifact", + ({ name }) => name === "Validate the exact workflow dispatch", ); - delete step.with["digest-mismatch"]; + step.env.EVENT_REF = "refs/heads/main"; }, ], [ - "different uploaded artifact", + "bypassed runtime dispatch validation", (workflow) => { const step = workflow.jobs.verify.steps.find( - ({ name }) => name === "Upload the verified release artifact", + ({ name }) => name === "Validate the exact workflow dispatch", ); - step.with.path = "release-artifacts/*.tgz"; + step.run = "true"; }, ], [ - "live-smoke dependency", + "skipped runtime dispatch validation", (workflow) => { - workflow.jobs["live-smoke"].needs = ["handoff", "verify"]; + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Validate the exact workflow dispatch", + ); + step.if = "${{ false }}"; }, ], [ - "elevated live-smoke permissions", + "ignored runtime dispatch validation failure", (workflow) => { - workflow.jobs["live-smoke"].permissions = { contents: "write" }; + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Validate the exact workflow dispatch", + ); + step["continue-on-error"] = true; }, ], [ - "missing bounded live smoke", + "redirected runtime dispatch validation", (workflow) => { - const step = workflow.jobs["live-smoke"].steps.find( - ({ name }) => name === "Run the bounded live smoke", + const step = workflow.jobs.verify.steps.find( + ({ name }) => name === "Validate the exact workflow dispatch", ); - step.run = "true"; + step["working-directory"] = "untrusted"; }, ], [ - "tag dispatch gate", + "untrusted source release commit", (workflow) => { - workflow.jobs.verify.if = workflow.jobs.verify.if.replace( - "github.sha == inputs.release_commit", - "true", - ); + workflow.jobs.verify.env.SOURCE_RELEASE_COMMIT = "untrusted"; }, ], [ @@ -847,6 +645,16 @@ describe("Publish workflow dispatch contract", () => { '\ngh api "repos/${GITHUB_REPOSITORY}/actions/runs/${publish_run_id}/jobs"'; }, ], + [ + "ignored handoff contract validation failure", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => + name === "Validate the exact release and tag dispatch contract", + ); + step["continue-on-error"] = true; + }, + ], [ "preparation handoff gate", (workflow) => { @@ -888,54 +696,52 @@ describe("Publish workflow dispatch contract", () => { }, ], [ - "normal live-smoke condition", + "skipped registry verification", (workflow) => { - const step = workflow.jobs["live-smoke"].steps.find( - ({ name }) => name === "Run the bounded live smoke", + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", ); - step.if = "always()"; + step.if = "${{ false }}"; }, ], [ - "normal live-smoke command", + "ignored registry verification failure", (workflow) => { - const step = workflow.jobs["live-smoke"].steps.find( - ({ name }) => name === "Run the bounded live smoke", + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", ); - step.run = "true"; + step["continue-on-error"] = true; }, ], [ - "recovery live-smoke condition", + "normal live-smoke condition", (workflow) => { const step = workflow.jobs["live-smoke"].steps.find( - ({ name }) => name === "Reuse the successful bounded live smoke", + ({ name }) => name === "Run the bounded live smoke", ); step.if = "always()"; }, ], [ - "recovery artifact identity", + "conditional live-smoke setup", (workflow) => { - const step = workflow.jobs.verify.steps.find( - ({ name }) => - name === "Download the prior live-verified release artifact", + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Set up Node.js 24", ); - step.with["artifact-ids"] = "1"; + step.if = "always()"; }, ], [ - "recovery artifact condition", + "normal live-smoke command", (workflow) => { - const step = workflow.jobs.verify.steps.find( - ({ name }) => - name === "Download the prior live-verified release artifact", + const step = workflow.jobs["live-smoke"].steps.find( + ({ name }) => name === "Run the bounded live smoke", ); - step.if = "always()"; + step.run = "true"; }, ], [ - "normal artifact condition", + "conditional artifact pack", (workflow) => { const step = workflow.jobs.verify.steps.find( ({ name }) => name === "Pack the exact release artifact", @@ -1005,30 +811,13 @@ describe("npm publication state", () => { ).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", () => { + it("rejects a replacement for the permanent tag policy", () => { expect(() => validateNpmEnvironmentState({ environment: environmentFixture(), - expectedPolicyIds: { - "branch:main": 60000001, - "tag:v*": 55718965, - }, - operation: "recover-v0.1.1", - policies: [tagPolicy, mainPolicy], + expectedPolicyIds: { "tag:v*": 55718966 }, + operation: "release", + policies: [tagPolicy], }), ).toThrow(/policy ID/i); }); @@ -1132,11 +921,11 @@ describe("npm publication state", () => { const provenanceDigest = "c".repeat(128); function provenanceFixture({ - commit = BRANCH_SHA, + commit = RELEASE_SHA, mutate, runAttempt = 1, runId = RUN_ID, - workflowRef = "refs/heads/main", + workflowRef = "refs/tags/v0.1.1", } = {}) { const statement = { _type: "https://in-toto.io/Statement/v1", @@ -1205,28 +994,25 @@ describe("npm publication state", () => { }; } - it("binds the disclosed recovery provenance to main and its control commit", () => { + it("binds normal provenance to the immutable tag and release commit", () => { expect(validateRegistryProvenance(provenanceFixture())).toEqual({ - commit: BRANCH_SHA, + commit: RELEASE_SHA, provenanceRunAttempt: 1, provenanceRunId: RUN_ID, version: "0.1.1", - workflowRef: "refs/heads/main", + workflowRef: "refs/tags/v0.1.1", }); }); - it("binds normal provenance to the immutable tag and release commit", () => { - expect( + it("rejects provenance from main", () => { + expect(() => validateRegistryProvenance( provenanceFixture({ - commit: RELEASE_SHA, - workflowRef: "refs/tags/v0.1.1", + commit: BRANCH_SHA, + workflowRef: "refs/heads/main", }), ), - ).toMatchObject({ - commit: RELEASE_SHA, - workflowRef: "refs/tags/v0.1.1", - }); + ).toThrow(/stable 0\.1\.x tag/i); }); it.each([ @@ -1248,7 +1034,7 @@ describe("npm publication state", () => { "source commit", (statement) => { statement.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = - RELEASE_SHA; + BRANCH_SHA; }, ], [ @@ -1280,8 +1066,8 @@ describe("npm publication state", () => { const run = { conclusion: "failure", event: "workflow_dispatch", - head_branch: "main", - head_sha: BRANCH_SHA, + head_branch: "v0.1.1", + head_sha: RELEASE_SHA, id: RUN_ID, name: "Publish", path: ".github/workflows/publish.yml", @@ -1291,7 +1077,7 @@ describe("npm publication state", () => { }; const jobs = [ { - head_sha: BRANCH_SHA, + head_sha: RELEASE_SHA, name: "Publish with npm Trusted Publishing", run_attempt: 1, run_id: RUN_ID, @@ -1306,12 +1092,12 @@ describe("npm publication state", () => { }, ]; return { - commit: BRANCH_SHA, + commit: RELEASE_SHA, jobs, run, runAttempt: 1, runId: RUN_ID, - workflowRef: "refs/heads/main", + workflowRef: "refs/tags/v0.1.1", ...overrides, }; } @@ -1320,10 +1106,10 @@ describe("npm publication state", () => { expect( validateRegistryProvenanceInvocation(provenanceInvocation()), ).toEqual({ - commit: BRANCH_SHA, + commit: RELEASE_SHA, runAttempt: 1, runId: RUN_ID, - workflowRef: "refs/heads/main", + workflowRef: "refs/tags/v0.1.1", }); }); @@ -1336,11 +1122,19 @@ describe("npm publication state", () => { }); }); + it("rejects a provenance invocation from main", () => { + const fixture = provenanceInvocation({ workflowRef: "refs/heads/main" }); + fixture.run.head_branch = "main"; + expect(() => validateRegistryProvenanceInvocation(fixture)).toThrow( + /stable 0\.1\.x tag/i, + ); + }); + 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)], + ["commit", (fixture) => (fixture.run.head_sha = BRANCH_SHA)], ["runner", (fixture) => (fixture.jobs[0].runner_id = 0)], [ "publish step", @@ -1355,124 +1149,6 @@ describe("npm publication state", () => { }); }); -describe("Publish recovery source evidence", () => { - const sourceCommit = "22c313d4f80c53ba01672dd35cc27b621d5ec9ce"; - - function successfulStep(name) { - return { conclusion: "success", name, status: "completed" }; - } - - function sourceEvidence() { - const commonJob = { - head_sha: sourceCommit, - run_attempt: 1, - run_id: 30471665743, - status: "completed", - }; - return { - annotations: [ - { - annotation_level: "failure", - message: - 'Branch "main" is not allowed to deploy to npm due to environment protection rules.', - }, - ], - artifacts: [ - { - digest: - "sha256:567b00f1ec32168d5c5be7d0b553542441920d3bb401959bcc2d6e157f35d08b", - expired: false, - id: 8731956162, - name: "npm-package-0.1.1-30471665743-1", - workflow_run: { head_sha: sourceCommit, id: 30471665743 }, - }, - ], - jobs: [ - { - ...commonJob, - conclusion: "success", - id: 90643169818, - name: "Verify the immutable release artifact", - steps: [ - successfulStep("Run release checks"), - successfulStep("Pack the exact release artifact"), - successfulStep("Test consumers against the exact release artifact"), - successfulStep("Upload the verified release artifact"), - ], - }, - { - ...commonJob, - conclusion: "success", - id: 90643725110, - name: "Verify the release tag against CometAPI", - steps: [successfulStep("Run the bounded live smoke")], - }, - { - ...commonJob, - conclusion: "failure", - id: 90643868523, - name: "Publish with npm Trusted Publishing", - runner_id: 0, - steps: [], - }, - ], - run: { - actor: { login: "tensornull" }, - conclusion: "failure", - event: "push", - head_branch: "main", - head_sha: sourceCommit, - id: 30471665743, - name: "Publish", - path: ".github/workflows/publish.yml", - repository: { full_name: REPOSITORY }, - run_attempt: 1, - status: "completed", - triggering_actor: { login: "tensornull" }, - }, - }; - } - - it("accepts the exact failed publish run after verified artifact and live jobs", () => { - expect(validatePublishRecoveryEvidence(sourceEvidence())).toEqual({ - artifactId: 8731956162, - artifactName: "npm-package-0.1.1-30471665743-1", - liveJobId: 90643725110, - }); - }); - - it.each([ - ["run conclusion", (evidence) => (evidence.run.conclusion = "success")], - ["run SHA", (evidence) => (evidence.run.head_sha = RELEASE_SHA)], - [ - "triggering actor", - (evidence) => (evidence.run.triggering_actor.login = "other"), - ], - ["verify job", (evidence) => (evidence.jobs[0].conclusion = "failure")], - ["live job", (evidence) => (evidence.jobs[1].conclusion = "failure")], - [ - "publish steps", - (evidence) => evidence.jobs[2].steps.push(successfulStep("Set up job")), - ], - ["annotation", (evidence) => (evidence.annotations = [])], - ["artifact ID", (evidence) => (evidence.artifacts[0].id += 1)], - [ - "artifact digest", - (evidence) => (evidence.artifacts[0].digest = `sha256:${"0".repeat(64)}`), - ], - [ - "artifact expiration", - (evidence) => (evidence.artifacts[0].expired = true), - ], - ])("rejects drift in %s", (_name, mutate) => { - const evidence = sourceEvidence(); - mutate(evidence); - expect(() => validatePublishRecoveryEvidence(evidence)).toThrow( - /release workflow/i, - ); - }); -}); - describe("Release Please push classification", () => { function publishedCurrentRelease(overrides = {}) { return { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 3b9fbfa..9fce7b1 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -180,7 +180,7 @@ describe("GitHub Actions workflow contract", () => { expect(workflow(name)).toMatch(/^permissions:\n {2}contents: read$/m); } expect(workflow("publish.yml")).toMatch( - /^permissions:\n {2}actions: read\n {2}checks: read\n {2}contents: read$/m, + /^permissions:\n {2}actions: read\n {2}contents: read$/m, ); const ci = workflow("ci.yml"); @@ -210,7 +210,7 @@ describe("GitHub Actions workflow contract", () => { /^ {4}permissions:/m, ); const publish = job(publishWorkflow, "publish"); - expect(publish).toContain("checks: read"); + expect(publish).not.toContain("checks: read"); expect(publish).toContain("id-token: write"); expect(publish).toContain( "Release Please run set changed while publication awaited approval.", @@ -408,24 +408,29 @@ describe("GitHub Actions workflow contract", () => { } }); - it("hands Release Please releases to tag-bound publication with one exact main recovery", () => { + it("hands Release Please releases only to immutable-tag publication", () => { const publish = workflow("publish.yml"); expect(publish).toMatch( /workflow_run:\n {4}workflows:\n {6}- Release Please\n {4}types:\n {6}- completed/, ); - expect(publish).not.toMatch(/^ {2}release:/m); + expect(publish).not.toMatch(/^ {2}(push|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" }, - }); + expect( + Object.keys(publishWorkflow.on.workflow_dispatch.inputs).sort(), + ).toEqual([ + "control_commit", + "publish_operation", + "release_commit", + "release_run_attempt", + "release_run_id", + "release_tag", + ]); + for (const input of Object.values( + publishWorkflow.on.workflow_dispatch.inputs, + )) { + expect(input).toMatchObject({ required: true, type: "string" }); + } const handoff = job(publish, "handoff"); expect(handoff).toContain( @@ -458,9 +463,6 @@ describe("GitHub Actions workflow contract", () => { 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'"); @@ -472,53 +474,26 @@ describe("GitHub Actions workflow contract", () => { ); 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( - "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("inputs.control_commit == inputs.release_commit"); 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( + expect(publishWorkflow.jobs.verify.if).not.toContain("refs/heads/main"); + expect(verify).not.toContain("recover-v0.1.1"); + expect(verify).not.toContain("source_publish"); + expect(verify).not.toContain("recovery_policy"); + expect(verify).not.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("Pack the exact release artifact"); + expect( + publishWorkflow.jobs.verify.steps.find( + ({ name }) => name === "Pack the exact release artifact", + )?.if, + ).toBeUndefined(); 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"); - expect(verify).toContain( - "Live smoke passed 3 sequential requests with a 16-token output cap.", - ); expect(verify).toContain("ref: ${{ env.SOURCE_RELEASE_COMMIT }}"); expect(verify).toContain("EXPECTED_WORKFLOW: Release Please"); expect(verify).toContain( @@ -538,12 +513,12 @@ describe("GitHub Actions workflow contract", () => { ); expect(resultDownload).not.toContain("pattern:"); expect(resultDownload).not.toContain("merge-multiple:"); - expect(resultDownload).not.toContain("release-please-result-1"); expect(resultDownload).not.toContain("github.run_attempt"); expect(verify).toContain("validateReleasePleaseActionResult"); expect(verify).toContain("validateGitHubRelease"); expect(verify).toContain("runAttempt: run.runAttempt"); expect(verify).toContain("runId: run.runId"); + expect(releaseWorkflowValidation).toContain("run?.run_attempt"); expect(releaseWorkflowValidation).toContain( 'requireEqual(result.schemaVersion, 2, "result schema version")', @@ -555,6 +530,9 @@ describe("GitHub Actions workflow contract", () => { ); expect(releaseWorkflowValidation).toContain("release?.immutable"); expect(releaseWorkflowValidation).toContain("release?.target_commitish"); + expect(releaseWorkflowValidation).not.toContain("recover-v0.1.1"); + expect(releaseWorkflowValidation).not.toContain("30471665743"); + expect(releaseWorkflowValidation).not.toContain("8731956162"); expect(verify).toContain( "artifact-name: ${{ steps.artifact-name.outputs.name }}", @@ -565,18 +543,14 @@ describe("GitHub Actions workflow contract", () => { expect(job(publish, "publish")).toContain( "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: 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/, - ); + expect(liveSmoke).not.toContain("Reuse the successful bounded live smoke"); + for (const step of publishWorkflow.jobs["live-smoke"].steps) { + expect(step.if).toBeUndefined(); + } + expect(liveSmoke).toContain("run: npm run test:live"); const publishJob = job(publish, "publish"); expect(publishJob).not.toContain("github.event.workflow_run"); @@ -585,21 +559,10 @@ describe("GitHub Actions workflow contract", () => { ); 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('expectedPolicyIds: { "tag:v*": 55718965 }'); + expect(publishJob).not.toContain("branch:main"); + expect(publishJob).not.toContain("RECOVERY_"); + expect(publishJob).not.toContain("source_publish"); expect(publishJob).toContain("validateRegistryProvenance"); expect(publishJob).toContain("validateRegistryProvenanceInvocation"); expect(publishJob).toContain("WORKFLOW_REF: ${{ github.ref }}"); @@ -611,16 +574,12 @@ describe("GitHub Actions workflow contract", () => { 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" ]]', ); @@ -632,9 +591,6 @@ describe("GitHub Actions workflow contract", () => { 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", @@ -654,8 +610,10 @@ describe("GitHub Actions workflow contract", () => { 'git show "refs/tags/${tag}:.github/workflows/publish.yml"', ); expect(publish).not.toContain("github.event.deployment"); + expect(publish).not.toContain("recover-v0.1.1"); + expect(publish).not.toContain("recovery_policy_id"); + expect(publish).not.toContain("source_publish_run"); }); - it("rejects an unrelated divergent Release Please branch", () => { const releasePlease = job(workflow("release-please.yml"), "release-please"); expect(releasePlease).toContain(