diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7de370a..b160fb7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,7 @@ name: Publish on: - push: - branches: - - main - paths: - - .github/workflows/publish.yml + deployment: workflow_run: workflows: - Release Please @@ -14,6 +10,7 @@ on: permissions: actions: read + checks: read contents: read concurrency: @@ -29,18 +26,23 @@ jobs: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') || - (github.event_name == 'push' && github.ref == 'refs/heads/main')) + (github.event_name == 'deployment' && + github.event.deployment.task == 'npm-publish-recovery' && + github.event.deployment.environment == 'npm' && + github.event.deployment.ref == 'v0.1.1' && + github.event.deployment.sha == 'c98b514227858cd183c781270a7f78f65b577e82')) runs-on: ubuntu-latest timeout-minutes: 30 env: - SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'push' && 'c98b514227858cd183c781270a7f78f65b577e82' || github.event.workflow_run.head_sha }} - SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'push' && '1' || github.event.workflow_run.run_attempt }} - SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'push' && '30469181724' || github.event.workflow_run.id }} + SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'deployment' && 'c98b514227858cd183c781270a7f78f65b577e82' || github.event.workflow_run.head_sha }} + SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'deployment' && '1' || github.event.workflow_run.run_attempt }} + SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'deployment' && '30469181724' || github.event.workflow_run.id }} outputs: artifact-name: ${{ steps.artifact-name.outputs.name }} dist-tag: ${{ steps.version.outputs.dist-tag }} release-commit: ${{ steps.trust.outputs.release-commit }} release-tag: ${{ steps.trust.outputs.release-tag }} + reuse-live-smoke: ${{ steps.recovery-evidence.outputs.reuse-live-smoke }} version: ${{ steps.version.outputs.version }} steps: - name: Check out the workflow control commit @@ -48,53 +50,135 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.sha }} - - name: Validate the one-cycle exact release recovery - if: github.event_name == 'push' + ref: ${{ github.event_name == 'deployment' && github.workflow_sha || github.sha }} + - name: Validate the exact tag deployment recovery + if: github.event_name == 'deployment' env: ACTOR: ${{ github.actor }} CHANGED_FILES: ${{ runner.temp }}/publish-recovery-files - EVENT_AFTER: ${{ github.event.after }} - EVENT_BEFORE: ${{ github.event.before }} + CONTROL_COMMIT: ${{ github.workflow_sha }} + DEPLOYMENT_CREATOR: ${{ github.event.deployment.creator.login }} + DEPLOYMENT_ENVIRONMENT: ${{ github.event.deployment.environment }} + DEPLOYMENT_ID: ${{ github.event.deployment.id }} + DEPLOYMENT_REF: ${{ github.event.deployment.ref }} + DEPLOYMENT_RELEASE_COMMIT: ${{ github.event.deployment.payload.release_commit }} + DEPLOYMENT_RELEASE_TAG: ${{ github.event.deployment.payload.release_tag }} + DEPLOYMENT_SHA: ${{ github.event.deployment.sha }} + DEPLOYMENT_SOURCE_RUN_ATTEMPT: ${{ github.event.deployment.payload.source_run_attempt }} + DEPLOYMENT_SOURCE_RUN_ID: ${{ github.event.deployment.payload.source_run_id }} + DEPLOYMENT_TASK: ${{ github.event.deployment.task }} EVENT_NAME: ${{ github.event_name }} EVENT_REF: ${{ github.ref }} - MAIN_COMMIT: ${{ github.sha }} + EVENT_SHA: ${{ github.sha }} + MAIN_COMMIT: ${{ github.workflow_sha }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} shell: bash run: | set -euo pipefail - if [[ "$(git rev-parse HEAD)" != "$MAIN_COMMIT" ]]; then + if [[ "$(git rev-parse HEAD)" != "$CONTROL_COMMIT" ]]; then echo "The recovery control 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)" != "$MAIN_COMMIT" ]]; then + MAIN_COMMIT="$(git rev-parse refs/remotes/origin/main)" + if [[ "$MAIN_COMMIT" != "$CONTROL_COMMIT" ]]; then echo "main moved after the publish recovery was triggered." >&2 exit 1 fi - git diff --name-only "$EVENT_BEFORE" "$MAIN_COMMIT" > "$CHANGED_FILES" - MAIN_FIRST_PARENT="$(git rev-parse "${MAIN_COMMIT}^1")" \ + CONTROL_FIRST_PARENT="$(git rev-parse "${CONTROL_COMMIT}^1")" + git diff --name-only "$CONTROL_FIRST_PARENT" "$CONTROL_COMMIT" > "$CHANGED_FILES" + MAIN_COMMIT="$MAIN_COMMIT" CONTROL_FIRST_PARENT="$CONTROL_FIRST_PARENT" \ node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; - import { validatePublishRecoveryTrigger } from "./scripts/release-workflow-validation.mjs"; + import { validatePublishDeploymentRecoveryTrigger } from "./scripts/release-workflow-validation.mjs"; - validatePublishRecoveryTrigger({ + validatePublishDeploymentRecoveryTrigger({ actor: process.env.ACTOR, changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8") .split("\n") .filter((file) => file !== ""), - eventAfter: process.env.EVENT_AFTER, - eventBefore: process.env.EVENT_BEFORE, + controlCommit: process.env.CONTROL_COMMIT, + controlFirstParent: process.env.CONTROL_FIRST_PARENT, + deploymentCreator: process.env.DEPLOYMENT_CREATOR, + deploymentEnvironment: process.env.DEPLOYMENT_ENVIRONMENT, + deploymentId: Number(process.env.DEPLOYMENT_ID), + deploymentRef: process.env.DEPLOYMENT_REF, + deploymentReleaseCommit: process.env.DEPLOYMENT_RELEASE_COMMIT, + deploymentReleaseTag: process.env.DEPLOYMENT_RELEASE_TAG, + deploymentSha: process.env.DEPLOYMENT_SHA, + deploymentSourceRunAttempt: Number( + process.env.DEPLOYMENT_SOURCE_RUN_ATTEMPT, + ), + deploymentSourceRunId: Number(process.env.DEPLOYMENT_SOURCE_RUN_ID), + deploymentTask: process.env.DEPLOYMENT_TASK, eventName: process.env.EVENT_NAME, eventRef: process.env.EVENT_REF, + eventSha: process.env.EVENT_SHA, mainCommit: process.env.MAIN_COMMIT, - mainFirstParent: process.env.MAIN_FIRST_PARENT, 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), }); EOF + - name: Validate the prior artifact and bounded live evidence + id: recovery-evidence + if: github.event_name == 'deployment' + 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: ${{ github.event.deployment.payload.source_run_attempt }} + SOURCE_PUBLISH_RUN_ID: ${{ github.event.deployment.payload.source_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: @@ -127,7 +211,7 @@ jobs: EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git EXPECTED_WORKFLOW: Release Please EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml - CONTROL_SHA: ${{ github.sha }} + CONTROL_SHA: ${{ github.event_name == 'deployment' && github.workflow_sha || github.sha }} EVENT_NAME: ${{ github.event_name }} RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json @@ -146,7 +230,7 @@ jobs: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main case "$EVENT_NAME" in workflow_run) expected_main="$WORKFLOW_SHA" ;; - push) expected_main="$CONTROL_SHA" ;; + deployment) expected_main="$CONTROL_SHA" ;; *) echo "Publish received an unsupported event." >&2; exit 1 ;; esac if [[ "$(git rev-parse refs/remotes/origin/main)" != "$expected_main" ]]; then @@ -362,21 +446,37 @@ jobs: # without required reviewers and add COMETAPI_KEY before publishing a release. environment: live-smoke steps: + - name: Reuse the successful bounded live smoke + if: github.event_name == 'deployment' + 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: github.event_name != 'deployment' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.verify.outputs.release-commit }} - name: Set up Node.js 24 + if: github.event_name != 'deployment' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x cache: npm - name: Install locked dependencies + if: github.event_name != 'deployment' run: npm ci - name: Build the release tag + if: github.event_name != 'deployment' run: npm run build - name: Run the bounded live smoke + if: github.event_name != 'deployment' env: COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }} COMETAPI_LIVE_SMOKE: "1" diff --git a/RELEASING.md b/RELEASING.md index 2d9eee1..5b6235b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -295,15 +295,26 @@ The repository maintains four independently auditable workflows: [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. One reviewed recovery merge - temporarily adds an automatic `publish.yml`-only `main` push path. It accepts - only human actor `tensornull`, exact Release Please run `30469181724` attempt - 1, release commit `c98b514227858cd183c781270a7f78f65b577e82`, a direct - first-parent recovery merge, and the five recorded repair files. The workflow - then checks out and rebuilds the immutable release commit, downloads runtime - evidence under `runner.temp`, and uses the unchanged live, OIDC, artifact, and - registry gates. The recovery trigger and constants must be removed in the - post-release evidence PR; the `runner.temp` isolation remains permanent. + 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. + + The replacement one-cycle recovery uses a human-created GitHub deployment for + the existing immutable `v0.1.1` tag. It accepts only actor and triggering actor + `tensornull`, exact release commit + `c98b514227858cd183c781270a7f78f65b577e82`, Release Please run + `30469181724` attempt 1, failed Publish run `30471665743` attempt 1, the exact + first-parent control merge, and the recorded repair files. It revalidates the + successful source verify job, artifact ID and digest, branch-policy failure, + and the log evidence for exactly three sequential live requests. The new tag + run repeats offline package and exact-artifact gates but does not spend another + live request budget. The protected npm environment and OIDC gate remain + unchanged. The deployment trigger and exact recovery constants must be removed + in the post-release evidence PR; the `runner.temp` isolation remains permanent. Third-party actions are pinned to full commit SHAs. Workflow permissions remain read-only except where a documented job requires more; `id-token: write` belongs diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 3566dbc..f5d479b 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -13,6 +13,10 @@ const RELEASE_WORKFLOW_JOB = const RELEASE_WORKFLOW_STEP = "Run Release Please"; 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", "RELEASING.md", @@ -20,9 +24,19 @@ const PUBLISH_RECOVERY = Object.freeze({ "tests/release-workflow-validation.test.mjs", "tests/workflow-contract.test.mjs", ]), + controlParent: "22c313d4f80c53ba01672dd35cc27b621d5ec9ce", + deploymentEnvironment: "npm", + deploymentTask: "npm-publish-recovery", + failedPublishJobId: 90643868523, + liveJobId: 90643725110, releaseCommit: "c98b514227858cd183c781270a7f78f65b577e82", releaseRunAttempt: 1, releaseRunId: 30469181724, + releaseTag: "v0.1.1", + sourcePublishCommit: "22c313d4f80c53ba01672dd35cc27b621d5ec9ce", + sourcePublishRunAttempt: 1, + sourcePublishRunId: 30471665743, + verifyJobId: 90643169818, }); function fail(message) { @@ -75,42 +89,62 @@ function stablePatch(version, label) { return Number(match[1]); } -export function validatePublishRecoveryTrigger({ +export function validatePublishDeploymentRecoveryTrigger({ actor, changedFiles, - eventAfter, - eventBefore, + controlCommit, + controlFirstParent, + deploymentCreator, + deploymentEnvironment, + deploymentId, + deploymentRef, + deploymentReleaseCommit, + deploymentReleaseTag, + deploymentSha, + deploymentSourceRunAttempt, + deploymentSourceRunId, + deploymentTask, eventName, eventRef, + eventSha, mainCommit, - mainFirstParent, sourceReleaseCommit, sourceRunAttempt, sourceRunId, + triggeringActor, workflowRunAttempt, }) { requireEqual(actor, PUBLISH_RECOVERY.actor, "publish recovery actor"); - requireEqual(eventName, "push", "publish recovery event"); - requireEqual(eventRef, "refs/heads/main", "publish recovery ref"); - requireCommit(eventAfter, "publish recovery event after SHA"); - requireCommit(eventBefore, "publish recovery event before SHA"); - requireCommit(mainCommit, "publish recovery main commit"); - requireCommit(mainFirstParent, "publish recovery main first parent"); - requireCommit(sourceReleaseCommit, "publish recovery source release commit"); requireEqual( - eventAfter, - mainCommit, - "publish recovery event and main commit agreement", + triggeringActor, + PUBLISH_RECOVERY.actor, + "publish recovery triggering actor", ); + requireEqual(eventName, "deployment", "publish recovery event"); requireEqual( - eventBefore, - PUBLISH_RECOVERY.releaseCommit, - "publish recovery event before SHA", + eventRef, + `refs/tags/${PUBLISH_RECOVERY.releaseTag}`, + "publish recovery ref", ); + requireCommit(eventSha, "publish recovery event SHA"); requireEqual( - mainFirstParent, + eventSha, PUBLISH_RECOVERY.releaseCommit, - "publish recovery main first parent", + "publish recovery event SHA", + ); + requireCommit(controlCommit, "publish recovery 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( + controlFirstParent, + PUBLISH_RECOVERY.controlParent, + "publish recovery control first parent", ); requireEqual( sourceReleaseCommit, @@ -132,6 +166,65 @@ export function validatePublishRecoveryTrigger({ PUBLISH_RECOVERY.releaseRunAttempt, "publish recovery source run attempt", ); + requirePositiveInteger(deploymentId, "publish recovery deployment ID"); + requireEqual( + deploymentCreator, + PUBLISH_RECOVERY.actor, + "publish recovery deployment creator", + ); + requireEqual( + deploymentEnvironment, + PUBLISH_RECOVERY.deploymentEnvironment, + "publish recovery deployment environment", + ); + requireEqual( + deploymentRef, + PUBLISH_RECOVERY.releaseTag, + "publish recovery deployment ref", + ); + requireCommit(deploymentSha, "publish recovery deployment SHA"); + requireEqual( + deploymentSha, + PUBLISH_RECOVERY.releaseCommit, + "publish recovery deployment SHA", + ); + requireEqual( + deploymentTask, + PUBLISH_RECOVERY.deploymentTask, + "publish recovery deployment task", + ); + requireCommit( + deploymentReleaseCommit, + "publish recovery deployment payload release commit", + ); + requireEqual( + deploymentReleaseCommit, + PUBLISH_RECOVERY.releaseCommit, + "publish recovery deployment payload release commit", + ); + requireEqual( + deploymentReleaseTag, + PUBLISH_RECOVERY.releaseTag, + "publish recovery deployment payload release tag", + ); + requirePositiveInteger( + deploymentSourceRunId, + "publish recovery deployment payload source run ID", + ); + requireEqual( + deploymentSourceRunId, + PUBLISH_RECOVERY.sourcePublishRunId, + "publish recovery deployment payload source run ID", + ); + requirePositiveInteger( + deploymentSourceRunAttempt, + "publish recovery deployment payload source run attempt", + ); + requireEqual( + deploymentSourceRunAttempt, + PUBLISH_RECOVERY.sourcePublishRunAttempt, + "publish recovery deployment payload source run attempt", + ); requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt"); if (!Array.isArray(changedFiles)) { fail("Release workflow publish recovery changed files must be an array."); @@ -145,6 +238,178 @@ export function validatePublishRecoveryTrigger({ releaseCommit: PUBLISH_RECOVERY.releaseCommit, releaseRunAttempt: PUBLISH_RECOVERY.releaseRunAttempt, releaseRunId: PUBLISH_RECOVERY.releaseRunId, + sourcePublishRunAttempt: PUBLISH_RECOVERY.sourcePublishRunAttempt, + sourcePublishRunId: PUBLISH_RECOVERY.sourcePublishRunId, + }; +} + +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, }; } diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index dfdca52..07ebd2a 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -11,7 +11,8 @@ import { validateMergedReleasePullRequest, validateOpenReleasePullRequestCollisions, validatePostActionPullRequestSnapshot, - validatePublishRecoveryTrigger, + validatePublishDeploymentRecoveryTrigger, + validatePublishRecoveryEvidence, validateReleasePleaseCommitMessages, validateReleasePleaseCompletion, validateReleasePleaseMutationConfiguration, @@ -160,8 +161,9 @@ describe("Release Please generated files", () => { }); }); -describe("Publish recovery trigger", () => { +describe("Publish deployment recovery trigger", () => { const recoveryCommit = "c98b514227858cd183c781270a7f78f65b577e82"; + const controlParent = "22c313d4f80c53ba01672dd35cc27b621d5ec9ce"; const recoveryFiles = [ ".github/workflows/publish.yml", "RELEASING.md", @@ -174,35 +176,60 @@ describe("Publish recovery trigger", () => { return { actor: "tensornull", changedFiles: recoveryFiles, - eventAfter: BRANCH_SHA, - eventBefore: recoveryCommit, - eventName: "push", - eventRef: "refs/heads/main", + controlCommit: BRANCH_SHA, + controlFirstParent: controlParent, + deploymentCreator: "tensornull", + deploymentEnvironment: "npm", + deploymentId: 456789, + deploymentRef: "v0.1.1", + deploymentReleaseCommit: recoveryCommit, + deploymentReleaseTag: "v0.1.1", + deploymentSha: recoveryCommit, + deploymentSourceRunAttempt: 1, + deploymentSourceRunId: 30471665743, + deploymentTask: "npm-publish-recovery", + eventName: "deployment", + eventRef: "refs/tags/v0.1.1", + eventSha: recoveryCommit, mainCommit: BRANCH_SHA, - mainFirstParent: recoveryCommit, sourceReleaseCommit: recoveryCommit, sourceRunAttempt: 1, sourceRunId: 30469181724, + triggeringActor: "tensornull", workflowRunAttempt: 1, ...overrides, }; } it("accepts only the reviewed one-cycle recovery merge", () => { - expect(validatePublishRecoveryTrigger(recoveryTrigger())).toEqual({ - releaseCommit: recoveryCommit, - releaseRunAttempt: 1, - releaseRunId: 30469181724, - }); + expect(validatePublishDeploymentRecoveryTrigger(recoveryTrigger())).toEqual( + { + releaseCommit: recoveryCommit, + releaseRunAttempt: 1, + releaseRunId: 30469181724, + sourcePublishRunAttempt: 1, + sourcePublishRunId: 30471665743, + }, + ); }); it.each([ ["actor", { actor: "github-actions[bot]" }], - ["event", { eventName: "workflow_dispatch" }], - ["ref", { eventRef: "refs/heads/dev" }], - ["before SHA", { eventBefore: RELEASE_SHA }], - ["after SHA", { eventAfter: RELEASE_SHA }], - ["first parent", { mainFirstParent: RELEASE_SHA }], + ["triggering actor", { triggeringActor: "other-maintainer" }], + ["event", { eventName: "push" }], + ["ref", { eventRef: "refs/heads/main" }], + ["event SHA", { eventSha: RELEASE_SHA }], + ["control commit", { controlCommit: RELEASE_SHA }], + ["first parent", { controlFirstParent: RELEASE_SHA }], + ["deployment creator", { deploymentCreator: "github-actions[bot]" }], + ["deployment environment", { deploymentEnvironment: "production" }], + ["deployment ref", { deploymentRef: "main" }], + ["deployment SHA", { deploymentSha: RELEASE_SHA }], + ["deployment task", { deploymentTask: "deploy" }], + ["payload release commit", { deploymentReleaseCommit: RELEASE_SHA }], + ["payload release tag", { deploymentReleaseTag: "v0.1.0" }], + ["payload source run", { deploymentSourceRunId: 30471665744 }], + ["payload source attempt", { deploymentSourceRunAttempt: 2 }], ["source commit", { sourceReleaseCommit: RELEASE_SHA }], ["source run ID", { sourceRunId: 30469181725 }], ["source attempt", { sourceRunAttempt: 2 }], @@ -210,19 +237,137 @@ describe("Publish recovery trigger", () => { ["extra file", { changedFiles: [...recoveryFiles, "package.json"] }], ])("rejects recovery trigger drift in %s", (_name, overrides) => { expect(() => - validatePublishRecoveryTrigger(recoveryTrigger(overrides)), + validatePublishDeploymentRecoveryTrigger(recoveryTrigger(overrides)), ).toThrow(/release workflow/i); }); it("accepts a rerun of the same immutable recovery event", () => { expect( - validatePublishRecoveryTrigger( + validatePublishDeploymentRecoveryTrigger( recoveryTrigger({ workflowRunAttempt: 2 }), ), ).toMatchObject({ releaseRunId: 30469181724 }); }); }); +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 58f39fb..c6a2cd6 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -122,7 +122,7 @@ describe("GitHub Actions workflow contract", () => { ); }); - it("publishes only through OIDC and has no manual recovery path", () => { + it("publishes only through OIDC and has no token recovery path", () => { const publishWorkflow = workflow("publish.yml"); const publish = job(publishWorkflow, "publish"); expect(publish).toContain( @@ -168,7 +168,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}contents: read$/m, + /^permissions:\n {2}actions: read\n {2}checks: read\n {2}contents: read$/m, ); const ci = workflow("ci.yml"); @@ -301,33 +301,33 @@ describe("GitHub Actions workflow contract", () => { }); it("parses every inline Node workflow validator", () => { - const blocks = matches( - workflow("release-please.yml"), - /node --input-type=module <<'EOF'\n([\s\S]*?)\n\s+EOF/g, - ); - expect(blocks.length).toBeGreaterThan(0); - for (const block of blocks) { - const checked = spawnSync( - process.execPath, - ["--input-type=module", "--check", "-"], - { encoding: "utf8", input: block[1] }, + for (const name of ["release-please.yml", "publish.yml"]) { + const blocks = matches( + workflow(name), + /node --input-type=module <<'EOF'\n([\s\S]*?)\n\s+EOF/g, ); - expect(checked.stderr).toBe(""); - expect(checked.status).toBe(0); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + const checked = spawnSync( + process.execPath, + ["--input-type=module", "--check", "-"], + { encoding: "utf8", input: block[1] }, + ); + expect(checked.stderr).toBe(""); + expect(checked.status).toBe(0); + } } }); - it("starts publication from Release Please or the exact one-cycle recovery", () => { + it("starts publication from Release Please or the exact tag deployment recovery", () => { 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}workflow_dispatch:/m); - expect(publishWorkflow.on.push).toEqual({ - branches: ["main"], - paths: [".github/workflows/publish.yml"], - }); + expect(publishWorkflow.on.deployment).toBeNull(); + expect(publishWorkflow.on.push).toBeUndefined(); const verify = job(publish, "verify"); expect(verify).toContain( @@ -338,7 +338,18 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain("SOURCE_RELEASE_RUN_ID:"); expect(verify).toContain("30469181724"); expect(verify).toContain("SOURCE_RELEASE_RUN_ATTEMPT:"); - expect(verify).toContain("validatePublishRecoveryTrigger"); + expect(verify).toContain("github.event.deployment.ref == 'v0.1.1'"); + expect(verify).toContain( + "github.event.deployment.sha == 'c98b514227858cd183c781270a7f78f65b577e82'", + ); + expect(verify).toContain("validatePublishDeploymentRecoveryTrigger"); + expect(verify).toContain("validatePublishRecoveryEvidence"); + 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( @@ -387,6 +398,9 @@ describe("GitHub Actions workflow contract", () => { expect(job(publish, "publish")).toContain( "name: ${{ needs.verify.outputs.artifact-name }}", ); + const liveSmoke = job(publish, "live-smoke"); + expect(liveSmoke).toContain("Reuse the successful bounded live smoke"); + expect(liveSmoke).toContain("if: github.event_name != 'deployment'"); }); it("rejects an unrelated divergent Release Please branch", () => {