diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index e9fa9b4..78805d9 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -251,6 +251,7 @@ jobs: environment: npm-stage permissions: actions: read + contents: read id-token: write runs-on: ubuntu-latest timeout-minutes: 10 @@ -441,6 +442,65 @@ jobs: if (compare(current, latest) <= 0) { throw new Error(`Candidate ${expectedVersion} is not newer than npm latest ${latestValue}`); } + const priorTag = `v${latestValue}`; + const remoteTagLines = execute("git", [ + "ls-remote", + "--tags", + `https://github.com/${repository}.git`, + `refs/tags/${priorTag}`, + `refs/tags/${priorTag}^{}`, + ], `annotated tag ${priorTag}`).trim().split("\n"); + if (remoteTagLines.length !== 2) { + throw new Error(`npm latest ${latestValue} lacks one annotated Git tag`); + } + const tagIdentity = new Map(); + for (const line of remoteTagLines) { + const match = /^([0-9a-f]{40})\t(refs\/tags\/v[^\s^]+)(\^\{\})?$/u.exec(line); + if (match === null || match[2] !== `refs/tags/${priorTag}`) { + throw new Error(`npm latest ${latestValue} has a malformed Git tag identity`); + } + const field = match[3] === undefined ? "object" : "source"; + if (tagIdentity.has(field)) throw new Error(`npm latest ${latestValue} repeats its Git tag identity`); + tagIdentity.set(field, match[1]); + } + if ( + tagIdentity.get("object") === tagIdentity.get("source") + || typeof tagIdentity.get("source") !== "string" + ) { + throw new Error(`npm latest ${latestValue} is not bound by an annotated Git tag`); + } + const release = JSON.parse(execute("gh", [ + "api", + `repos/${repository}/releases/tags/${priorTag}`, + ], `immutable release ${priorTag}`)); + const latestRelease = JSON.parse(execute("gh", [ + "api", + `repos/${repository}/releases/latest`, + ], "latest immutable release")); + if ( + release?.tag_name !== priorTag + || release?.name !== `KB ${priorTag}` + || release?.draft !== false + || release?.prerelease !== false + || release?.immutable !== true + || release?.author?.id !== 41898282 + || release?.author?.login !== "github-actions[bot]" + || release?.author?.type !== "Bot" + || !Array.isArray(release?.assets) + || release.assets.length !== 0 + || latestRelease?.id !== release.id + || latestRelease?.tag_name !== priorTag + || latestRelease?.immutable !== true + ) { + throw new Error(`npm latest ${latestValue} lacks its exact immutable GitHub Release`); + } + const comparison = JSON.parse(execute("gh", [ + "api", + `repos/${repository}/compare/${tagIdentity.get("source")}...main`, + ], `main ancestry for ${priorTag}`)); + if (comparison?.status !== "ahead" && comparison?.status !== "identical") { + throw new Error(`npm latest ${latestValue} is not reachable from current main`); + } const intentRuns = new Map(); const resolutionCounts = new Map(); const increment = (map, version) => map.set(version, (map.get(version) ?? 0) + 1); @@ -1020,6 +1080,7 @@ jobs: EXPECTED_METADATA_SHA256: ${{ steps.artifact.outputs.metadata_sha256 }} EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} + GH_TOKEN: ${{ github.token }} METADATA: ${{ steps.artifact.outputs.metadata }} TARBALL: ${{ steps.artifact.outputs.tarball }} run: | @@ -1051,19 +1112,6 @@ jobs: echo "::error::$DEFAULT_BRANCH advanced to $current_default_sha after artifact verification" exit 1 fi - tag_lookup_output="$RUNNER_TEMP/kb-stage-tag-lookup.txt" - if git ls-remote --exit-code --refs \ - "https://github.com/$GITHUB_REPOSITORY.git" \ - "refs/tags/$release_tag" > "$tag_lookup_output"; then - echo "::error::Tag $release_tag was created after package verification" - exit 1 - else - tag_lookup_status=$? - if [[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]; then - echo "::error::Could not prove that tag $release_tag is still absent from origin" - exit 1 - fi - fi current_latest="$(npm view "@hraness/kb" dist-tags.latest \ --json \ --registry=https://registry.npmjs.org)" @@ -1123,6 +1171,157 @@ jobs: echo "::error::Pinned npm's clean default publication tag is not latest" exit 1 fi + git --git-dir="$current_main" fetch --quiet --no-tags --depth=1 \ + "https://github.com/$GITHUB_REPOSITORY.git" \ + "refs/heads/$DEFAULT_BRANCH" + final_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)" + final_latest="$(npm view "@hraness/kb" dist-tags.latest \ + --json \ + --registry=https://registry.npmjs.org)" + CURRENT_LATEST="$current_latest" FINAL_LATEST="$final_latest" node -e ' + const current = JSON.parse(process.env.CURRENT_LATEST ?? "null"); + const final = JSON.parse(process.env.FINAL_LATEST ?? "null"); + if (typeof current !== "string" || final !== current) { + throw new Error("Public npm latest changed immediately before staged publication"); + } + ' + if [[ "$final_default_sha" != "$current_default_sha" || \ + "$final_default_sha" != "$EXPECTED_SOURCE_SHA" ]]; then + echo "::error::$DEFAULT_BRANCH changed immediately before staged publication" + exit 1 + fi + prior_version="$(FINAL_LATEST="$final_latest" node -p ' + const value = JSON.parse(process.env.FINAL_LATEST ?? "null"); + if (typeof value !== "string") process.exit(1); + value; + ')" + prior_tag="v$prior_version" + prior_tag_identity="$RUNNER_TEMP/kb-final-prior-release-tag.txt" + prior_release_json="$RUNNER_TEMP/kb-final-prior-release.json" + latest_release_json="$RUNNER_TEMP/kb-final-latest-release.json" + prior_comparison_json="$RUNNER_TEMP/kb-final-prior-release-comparison.json" + git ls-remote --tags "https://github.com/$GITHUB_REPOSITORY.git" \ + "refs/tags/$prior_tag" "refs/tags/$prior_tag^{}" > "$prior_tag_identity" + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$prior_tag" > "$prior_release_json" + gh api "repos/$GITHUB_REPOSITORY/releases/latest" > "$latest_release_json" + prior_source="$(PRIOR_TAG="$prior_tag" TAG_IDENTITY="$prior_tag_identity" node <<'NODE' + const { readFileSync } = require("node:fs"); + const priorTag = process.env.PRIOR_TAG ?? ""; + const lines = readFileSync(process.env.TAG_IDENTITY, "utf8").trim().split("\n"); + if (lines.length !== 2) throw new Error(`npm latest ${priorTag.slice(1)} lacks one annotated Git tag`); + const identity = new Map(); + for (const line of lines) { + const match = /^([0-9a-f]{40})\t(refs\/tags\/v[^\s^]+)(\^\{\})?$/u.exec(line); + if (match === null || match[2] !== `refs/tags/${priorTag}`) { + throw new Error(`npm latest ${priorTag.slice(1)} has a malformed Git tag identity`); + } + const field = match[3] === undefined ? "object" : "source"; + if (identity.has(field)) throw new Error(`npm latest ${priorTag.slice(1)} repeats its Git tag identity`); + identity.set(field, match[1]); + } + if (identity.get("object") === identity.get("source") || typeof identity.get("source") !== "string") { + throw new Error(`npm latest ${priorTag.slice(1)} is not bound by an annotated Git tag`); + } + process.stdout.write(identity.get("source")); + NODE + )" + gh api "repos/$GITHUB_REPOSITORY/compare/$prior_source...$DEFAULT_BRANCH" > "$prior_comparison_json" + PRIOR_TAG="$prior_tag" \ + PRIOR_RELEASE_JSON="$prior_release_json" \ + LATEST_RELEASE_JSON="$latest_release_json" \ + PRIOR_COMPARISON_JSON="$prior_comparison_json" node <<'NODE' + const { readFileSync } = require("node:fs"); + const priorTag = process.env.PRIOR_TAG ?? ""; + const read = (name) => JSON.parse(readFileSync(process.env[name], "utf8")); + const release = read("PRIOR_RELEASE_JSON"); + const latestRelease = read("LATEST_RELEASE_JSON"); + const comparison = read("PRIOR_COMPARISON_JSON"); + if ( + release?.tag_name !== priorTag + || release?.name !== `KB ${priorTag}` + || release?.draft !== false + || release?.prerelease !== false + || release?.immutable !== true + || release?.author?.id !== 41898282 + || release?.author?.login !== "github-actions[bot]" + || release?.author?.type !== "Bot" + || !Array.isArray(release?.assets) + || release.assets.length !== 0 + || latestRelease?.id !== release.id + || latestRelease?.tag_name !== priorTag + || latestRelease?.immutable !== true + ) throw new Error(`npm latest ${priorTag.slice(1)} lacks its exact immutable GitHub Release`); + if (comparison?.status !== "ahead" && comparison?.status !== "identical") { + throw new Error(`npm latest ${priorTag.slice(1)} is not reachable from current main`); + } + NODE + terminal_latest="$(npm view "@hraness/kb" dist-tags.latest \ + --json \ + --registry=https://registry.npmjs.org)" + FINAL_LATEST="$final_latest" TERMINAL_LATEST="$terminal_latest" node -e ' + const final = JSON.parse(process.env.FINAL_LATEST ?? "null"); + const terminal = JSON.parse(process.env.TERMINAL_LATEST ?? "null"); + if (typeof final !== "string" || terminal !== final) { + throw new Error("Public npm latest changed during final release-closure verification"); + } + ' + terminal_refs_output="$RUNNER_TEMP/kb-final-stage-refs.txt" + git ls-remote --exit-code \ + "https://github.com/$GITHUB_REPOSITORY.git" \ + "refs/heads/$DEFAULT_BRANCH" \ + "refs/tags/$release_tag" \ + "refs/tags/$prior_tag" \ + "refs/tags/$prior_tag^{}" > "$terminal_refs_output" + DEFAULT_BRANCH="$DEFAULT_BRANCH" \ + EXPECTED_SOURCE_SHA="$EXPECTED_SOURCE_SHA" \ + PRIOR_TAG="$prior_tag" \ + PRIOR_TAG_IDENTITY="$prior_tag_identity" \ + RELEASE_TAG="$release_tag" \ + TERMINAL_REFS_OUTPUT="$terminal_refs_output" node <<'NODE' + const { readFileSync } = require("node:fs"); + const expectedHeadRef = `refs/heads/${process.env.DEFAULT_BRANCH ?? ""}`; + const expectedTagRef = `refs/tags/${process.env.RELEASE_TAG ?? ""}`; + const priorTagRef = `refs/tags/${process.env.PRIOR_TAG ?? ""}`; + const expectedSourceSha = process.env.EXPECTED_SOURCE_SHA ?? ""; + const parse = (path) => readFileSync(path, "utf8").trim().split("\n").map((line) => { + const match = /^([0-9a-f]{40})\t(refs\/(?:heads|tags)\/[^\s^]+)(\^\{\})?$/u.exec(line); + if (match === null) throw new Error("Final remote snapshot has malformed identity data"); + return { peeled: match[3] !== undefined, ref: match[2], sha: match[1] }; + }); + const priorEntries = parse(process.env.PRIOR_TAG_IDENTITY); + const entries = parse(process.env.TERMINAL_REFS_OUTPUT); + if (entries.some((entry) => entry.ref === expectedTagRef)) { + throw new Error(`Tag ${process.env.RELEASE_TAG} was created after package verification`); + } + const priorIdentity = (values) => { + const identity = new Map(); + for (const entry of values) { + if (entry.ref !== priorTagRef) continue; + const field = entry.peeled ? "source" : "object"; + if (identity.has(field)) throw new Error("Final remote snapshot repeats prior tag identity"); + identity.set(field, entry.sha); + } + return identity; + }; + const initialPriorIdentity = priorIdentity(priorEntries); + const terminalPriorIdentity = priorIdentity(entries); + if ( + initialPriorIdentity.size !== 2 + || terminalPriorIdentity.size !== 2 + || terminalPriorIdentity.get("object") !== initialPriorIdentity.get("object") + || terminalPriorIdentity.get("source") !== initialPriorIdentity.get("source") + ) { + throw new Error(`Prior tag ${process.env.PRIOR_TAG} changed during final release-closure verification`); + } + const headEntries = entries.filter((entry) => entry.ref === expectedHeadRef && !entry.peeled); + if ( + entries.length !== 3 + || headEntries.length !== 1 + || headEntries[0]?.sha !== expectedSourceSha + ) { + throw new Error(`Could not prove exact ${process.env.DEFAULT_BRANCH}, unchanged ${process.env.PRIOR_TAG}, and absent ${process.env.RELEASE_TAG} in one remote snapshot`); + } + NODE cd "$clean_npm_directory" npm stage publish "$TARBALL" \ --access public \ diff --git a/AGENTS.md b/AGENTS.md index b1dc48d..fd25893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ - Keep `portfolio-inventory.json` byte-canonical and consistent with the public package identity, version, repository, direct `@hraness/*` dependency edges, and Hraness-owned dependencies pinned by exact immutable GitHub specifiers. - Pair concrete behavior tests with property tests for parsing, resolution, ordering, path confinement, and round-trip laws. - Run `bun test src/benchmark.test.ts src/evaluation.test.ts src/evaluation-kb.test.ts src/search.test.ts src/sdk.test.ts` when changing rank fusion, retrieval defaults, frozen-corpus execution, or built-in evaluation adapters. The six-case synthetic rank-fusion fixture is a deterministic regression, not a retrieval-quality or performance benchmark. Keep real-corpus manifests versioned, judgments independent of rankings, raw lane evidence intact, and performance claims tied to named hardware and measured runs. Run `bun run check` before handing off a change; it must leave committed `dist/` and `bun.lock` unchanged. -- Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read` plus `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any unresolved version-bound Actions intent newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. Record a successful intent step immediately before mutation and scan every retained attempt so an ambiguous runner failure remains locked. Recognize attempted terminal mutation steps before trusting any job display name, and accept each mutation only when its single successful intent has the immediately preceding safe positive Actions step number. npm's short-lived trust assertion cannot list stages, so resolve provider state out of band; a failed, interrupted, or rejected stage may release only its exact durable intent through the exceptional owner-authorized `resolved_stage_version` input and matching successful resolution step. Leave that input empty normally and do not claim this workflow prevents out-of-band stages. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. +- Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read`, `contents: read`, and `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any unresolved version-bound Actions intent newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. Record a successful intent step immediately before mutation and scan every retained attempt so an ambiguous runner failure remains locked. Recognize attempted terminal mutation steps before trusting any job display name, and accept each mutation only when its single successful intent has the immediately preceding safe positive Actions step number. Public promotion clears that version's intent, but the next stage remains locked until public `latest` has its matching annotated tag, exact immutable bot-created Latest Release, and source reachable from current `main`; prove that closure both before intent history is accepted and immediately before mutation. npm's short-lived trust assertion cannot list stages, so resolve provider state out of band; a failed, interrupted, or rejected stage may release only its exact durable intent through the exceptional owner-authorized `resolved_stage_version` input and matching successful resolution step. Leave that input empty normally and do not claim this workflow prevents out-of-band stages. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. - Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Publish and verify the exact staged npm artifact first, approve its public promotion with human 2FA, then let the owner-authenticated operator create the exact annotated stable `v` tag on `main`. The protected tag workflow must bind the actor and event sender to owner `User` ID `894119` and public repository ID `1308971873` before checkout, then verify the tag, source, registry artifact, and immutable Latest Release. Run release verifiers from exact current `main`; require the tag-triggered Release and staging workflows to match current `main`, and revalidate the complete verifier closure immediately before mutation. Before any GitHub Release mutation, require exact npm `dist-tags.latest`, nonempty canonical registry signatures, and pinned npm `11.19.0` cryptographic verification of the exact publish and SLSA provenance attestations, including the registry tarball SHA-512, staging workflow identity, public repository and owner IDs, sole main source commit, `workflow_dispatch` event, GitHub-hosted builder, and canonical invocation. Accept an existing Release only when its exact title and source/run receipt match this workflow and its creator is immutable `github-actions[bot]` ID `41898282`. Never move a tag, republish npm, or start a second stable release before the first completes. diff --git a/docs/publishing.md b/docs/publishing.md index 5d8e9ac..e98cd00 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -120,7 +120,12 @@ Do not add an npm publishing token to GitHub. Preserve the runner disappeared during an ambiguous provider write. Workflow concurrency therefore cannot leave two independently approvable stable candidates after the first run ends. The same final boundary re-reads - `latest` and requires this candidate to be strictly newer. The sole + `latest` and requires this candidate to be strictly newer. Public promotion + clears its retained Actions intent, while the next stage + remains locked until that public `latest` has one matching annotated tag, + the exact bot-created immutable zero-asset Latest Release, and a source + commit reachable from current `main`. The initial history scan and final + mutation boundary both prove this completed-release closure. The sole successful pre-versioned stage record is sealed to run `33269920554`, attempt `1`, source `e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a`, and version `0.17.3`; every @@ -154,10 +159,12 @@ input records a durable resolution and releases only that matching Actions-history intent; leave it empty for all normal releases. The short-lived trusted-publishing assertion cannot run `npm stage list`, so first resolve the provider state through the authenticated npm stage UI/CLI and treat every -failed or interrupted mutation as ambiguous. Approval needs no override because -the promoted version becomes public `latest` and releases the lock -automatically. This serializes the canonical workflow authority; it is not a -claim that npm exposes or prevents an out-of-band concurrent stage. +failed or interrupted mutation as ambiguous. Approval needs no recovery input +because the promoted version becomes public `latest` and clears its matching +Actions intent. The next stage remains locked until the protected tag and +immutable Latest Release complete that version's release closure. This +serializes the canonical workflow authority; it is not a claim that npm exposes +or prevents an out-of-band concurrent stage. If candidate generation is missing or fails, dispatch **Stage npm package** from current `main` without the opt-in. That recovery remains build-only. Use @@ -171,8 +178,9 @@ and release ordering all fail closed beyond that boundary. The verification job checks out source, installs dependencies without lifecycle scripts, runs the complete gate, creates the three-file artifact, and smokes the exact tarball. Its dependent staging job is the only job with -OIDC authority. That job has only `actions: read` and `id-token: write`. The -exact `npm-stage` environment restricts deployments to `main` and has no +OIDC authority. That job has only `actions: read`, `contents: read`, and +`id-token: write`. The exact `npm-stage` environment restricts deployments to +`main` and has no required reviewers, so an explicitly opted-in staging job starts after verification without another GitHub approval. It checks out no source and runs no repository code. It rebinds diff --git a/scripts/check-workflow-yaml.test.ts b/scripts/check-workflow-yaml.test.ts index 13dfb08..7739e48 100644 --- a/scripts/check-workflow-yaml.test.ts +++ b/scripts/check-workflow-yaml.test.ts @@ -5,9 +5,16 @@ import { resolve } from "node:path"; import { validateNpmStageWorkflow, + validateReleaseWorkflow, validateWorkflowYaml, } from "./check-workflow-yaml.ts"; +function replaceLast(source: string, needle: string, replacement: string): string { + const index = source.lastIndexOf(needle); + if (index < 0) throw new Error(`Missing test fixture: ${needle}`); + return source.slice(0, index) + replacement + source.slice(index + needle.length); +} + describe("GitHub workflow YAML", () => { test("accepts commands with YAML-significant text inside block scalars", () => { expect(() => validateWorkflowYaml(` @@ -36,6 +43,25 @@ jobs: `, "workflow.yml")).toThrow("invalid YAML"); }); + test("locks workflow-level execution semantics outside jobs", async () => { + for (const [path, validate] of [ + ["../.github/workflows/npm-stage.yml", validateNpmStageWorkflow], + ["../.github/workflows/release.yml", validateReleaseWorkflow], + ] as const) { + const source = await readFile(resolve(import.meta.dir, path), "utf8"); + for (const injected of [ + 'env:\n NODE_OPTIONS: "--require ./hostile.cjs"', + "defaults:\n run:\n working-directory: scripts", + ]) { + const changed = source.replace("\non:\n", `\n${injected}\n\non:\n`); + expect(changed).not.toBe(source); + expect(() => validate(changed, path)).toThrow( + "exact reviewed workflow semantics", + ); + } + } + }); + test("requires a fresh default-branch HEAD guard at the final publication boundary", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); @@ -50,7 +76,229 @@ jobs: expect(() => validateNpmStageWorkflow( missingFinalGuard, "npm-stage.yml", - )).toThrow("must recheck current default-branch HEAD"); + )).toThrow("must re-read current main and npm latest at the final mutation boundary"); + }); + + test("requires the prior npm latest release closure at both staging boundaries", async () => { + const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); + const source = await readFile(path, "utf8"); + const marker = "lacks one annotated Git tag"; + const firstIndex = source.indexOf(marker); + const finalIndex = source.lastIndexOf(marker); + expect(firstIndex).toBeGreaterThan(-1); + expect(finalIndex).toBeGreaterThan(firstIndex); + for (const message of [ + marker, + "lacks its exact immutable GitHub Release", + "is not reachable from current main", + ]) { + expect(source.match(new RegExp(message, "gu")) ?? []).toHaveLength(2); + } + const withoutFirstClosure = + source.slice(0, firstIndex) + + "has no release tag" + + source.slice(firstIndex + marker.length); + const withoutFinalClosure = + source.slice(0, finalIndex) + + "has no release tag" + + source.slice(finalIndex + marker.length); + expect(() => validateNpmStageWorkflow(source, "npm-stage.yml")).not.toThrow(); + expect(() => validateNpmStageWorkflow( + withoutFirstClosure, + "npm-stage.yml", + )).toThrow("pending-stage guard must prove the prior npm latest release closure"); + expect(() => validateNpmStageWorkflow( + withoutFinalClosure, + "npm-stage.yml", + )).toThrow("staged-publication boundary must prove the prior npm latest release closure"); + + for (const [needle, replacement, message] of [ + [ + 'const priorTag = `v${latestValue}`;', + 'const priorTag = "v0.1.0";', + "pending-stage guard must prove", + ], + [ + 'prior_version="$(FINAL_LATEST="$final_latest" node -p', + 'prior_version="$(CURRENT_LATEST="$current_latest" node -p', + "must", + ], + [ + 'if (typeof current !== "string" || final !== current)', + "if (false)", + "must", + ], + [ + '"$final_default_sha" != "$EXPECTED_SOURCE_SHA"', + "false", + "must", + ], + ] as const) { + const weakened = needle.includes("final_") || needle.includes("FINAL_LATEST") + ? replaceLast(source, needle, replacement) + : source.replace(needle, replacement); + expect(weakened).not.toBe(source); + expect(() => validateNpmStageWorkflow(weakened, "npm-stage.yml")).toThrow(message); + } + + const weakenedFinalComparison = replaceLast( + source, + 'comparison?.status !== "ahead" && comparison?.status !== "identical"', + "false", + ); + expect(() => validateNpmStageWorkflow( + weakenedFinalComparison, + "npm-stage.yml", + )).toThrow("staged-publication boundary must prove"); + const weakenedFinalRelease = replaceLast( + source, + "release?.tag_name !== priorTag", + "false", + ); + expect(() => validateNpmStageWorkflow( + weakenedFinalRelease, + "npm-stage.yml", + )).toThrow("staged-publication boundary must prove"); + + for (const [needle, replacement, message] of [ + [ + 'if (typeof final !== "string" || terminal !== final)', + "if (false)", + "staged-publication boundary must prove", + ], + [ + "entries.length !== 3", + "entries.length < 0", + "staged-publication boundary must prove", + ], + [ + "headEntries[0]?.sha !== expectedSourceSha", + "false", + "staged-publication boundary must prove", + ], + [ + 'terminalPriorIdentity.get("source") !== initialPriorIdentity.get("source")', + "false", + "staged-publication boundary must prove", + ], + ] as const) { + const weakenedTerminalGuard = replaceLast(source, needle, replacement); + expect(() => validateNpmStageWorkflow( + weakenedTerminalGuard, + "npm-stage.yml", + )).toThrow(message); + } + const terminalRegistryDrift = source.replace( + 'terminal_latest="$(npm view "@hraness/kb" dist-tags.latest \\\n' + + " --json \\\n" + + " --registry=https://registry.npmjs.org)", + 'terminal_latest="$(npm view "@hraness/kb" dist-tags.latest \\\n' + + " --json \\\n" + + " --registry=https://registry.example.invalid)", + ); + expect(terminalRegistryDrift).not.toBe(source); + expect(() => validateNpmStageWorkflow( + terminalRegistryDrift, + "npm-stage.yml", + )).toThrow("terminal npm latest read to the canonical registry"); + const suppressedPeeledIdentity = source.replace( + " git ls-remote --exit-code \\\n", + " git ls-remote --exit-code --refs \\\n", + ); + expect(suppressedPeeledIdentity).not.toBe(source); + expect(() => validateNpmStageWorkflow( + suppressedPeeledIdentity, + "npm-stage.yml", + )).toThrow("must retain peeled annotated-tag identity"); + }); + + test("locks the OIDC staging job to its exact reviewed steps and sole mutation", async () => { + const source = await readFile( + resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"), + "utf8", + ); + const setupNode = " - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0"; + const insertedStep = replaceLast( + source, + setupNode, + " - name: Hidden registry mutation\n" + + " run: npm dist-tag add @hraness/kb@0.18.0 latest\n" + + setupNode, + ); + expect(() => validateNpmStageWorkflow(insertedStep, "npm-stage.yml")).toThrow( + "exact reviewed step sequence", + ); + const unpinnedAction = replaceLast( + source, + "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + "actions/setup-node@v7", + ); + expect(() => validateNpmStageWorkflow(unpinnedAction, "npm-stage.yml")).toThrow( + "exact reviewed step sequence", + ); + const bypassedReauthorization = source.replace( + " - name: Reauthorize current npm staging attempt", + " - name: Reauthorize current npm staging attempt\n continue-on-error: true", + ); + expect(() => validateNpmStageWorkflow(bypassedReauthorization, "npm-stage.yml")).toThrow( + "fail-closed step control flow", + ); + const unconditionalMutation = source.replace( + " - name: Revalidate current main and stage exact package", + " - name: Revalidate current main and stage exact package\n if: always()", + ); + expect(() => validateNpmStageWorkflow(unconditionalMutation, "npm-stage.yml")).toThrow( + "fail-closed step control flow", + ); + const extraMutation = source.replace( + ' npm stage publish "$TARBALL" \\', + ' npm publish "$TARBALL"\n npm stage publish "$TARBALL" \\', + ); + expect(extraMutation).not.toBe(source); + expect(() => validateNpmStageWorkflow(extraMutation, "npm-stage.yml")).toThrow( + "unexpected provider mutation command", + ); + for (const mutation of [ + "npm --registry=https://registry.npmjs.org publish hostile.tgz", + "git -c user.name=hostile push origin main", + "gh --repo hraness/kb release edit v0.18.0 --title hostile", + "GH_TOKEN=hostile gh release edit v0.18.0 --title hostile", + "gh api repos/hraness/kb --raw-field=hostile=true", + 'node -e \'execute("gh", ["api", "--method", "DELETE"])\'', + ] as const) { + const injectedMutation = source.replace( + ' npm stage publish "$TARBALL" \\', + ` ${mutation}\n npm stage publish "$TARBALL" \\`, + ); + expect(injectedMutation).not.toBe(source); + expect(() => validateNpmStageWorkflow(injectedMutation, "npm-stage.yml")).toThrow( + "unexpected provider mutation command", + ); + } + const wrappedMutation = source.replace( + ' npm stage publish "$TARBALL" \\', + " bash -c 'npm publish hostile.tgz'\n" + + ' npm stage publish "$TARBALL" \\', + ); + expect(() => validateNpmStageWorkflow(wrappedMutation, "npm-stage.yml")).toThrow( + "exact reviewed workflow semantics", + ); + for (const [needle, replacement] of [ + [" GH_TOKEN: ${{ github.token }}", " GH_TOKEN: ${{ secrets.ADMIN }}"], + [ + " EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }}", + " EXPECTED_SOURCE_SHA: ${{ github.sha }}", + ], + [ + " TARBALL: ${{ steps.artifact.outputs.tarball }}", + " TARBALL: ${{ steps.artifact.outputs.tarball }}\n EXTRA: hostile", + ], + ] as const) { + const weakenedEnvironment = replaceLast(source, needle, replacement); + expect(() => validateNpmStageWorkflow(weakenedEnvironment, "npm-stage.yml")).toThrow( + "exact reviewed environment", + ); + } }); test("inspects terminal npm mutations before trusting a stage-job display name", async () => { @@ -95,6 +343,7 @@ jobs: "environment: npm-stage", "if: inputs.publish_to_npm == true", "actions: read", + "contents: read", "id-token: write", "Reauthorize current npm staging attempt", "Reject unresolved stable-stage intent", @@ -167,9 +416,12 @@ jobs: "npm-stage.yml", )).toThrow("explicit publish_to_npm opt-in"); expect(() => validateNpmStageWorkflow( - source.replace(" actions: read\n id-token: write", " id-token: write"), + source.replace( + " actions: read\n contents: read\n id-token: write", + " contents: read\n id-token: write", + ), "npm-stage.yml", - )).toThrow("actions: read and id-token: write"); + )).toThrow("actions: read, contents: read, and id-token: write"); expect(() => validateNpmStageWorkflow( source.replace( "attempt.triggering_actor?.id !== actorId", @@ -188,6 +440,13 @@ jobs: ), "npm-stage.yml", )).toThrow("must reject unsafe stable-version components"); + expect(() => validateNpmStageWorkflow( + source.replace( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "actions/upload-artifact@v7", + ), + "npm-stage.yml", + )).toThrow("exact reviewed step sequence"); }); test("gates the immutable GitHub release on the exact public npm artifact", async () => { @@ -208,6 +467,104 @@ jobs: expect(source).toContain("scripts/package-smoke.ts"); }); + test("structurally binds release mutation to owner authorization and current controls", async () => { + const source = await readFile( + resolve(import.meta.dir, "../.github/workflows/release.yml"), + "utf8", + ); + expect(() => validateReleaseWorkflow(source, "release.yml")).not.toThrow(); + + const bypassedAuthorization = source.replace( + " - name: Verify immutable owner and public repository identity", + " - name: Verify immutable owner and public repository identity\n" + + " continue-on-error: true", + ); + expect(() => validateReleaseWorkflow(bypassedAuthorization, "release.yml")).toThrow( + "immutable owner and public repository", + ); + + for (const [needle, replacement, message] of [ + [ + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + "owner authorization", + ], + [ + " ref: main", + " ref: ${{ github.ref }}", + "current-main checkout", + ], + [ + 'run "$current_attestation"', + 'run "$current_identity"', + "npm attestation", + ], + [ + "attempt.triggering_actor?.id !== actorId", + "attempt.triggering_actor?.id !== 1", + "reauthorize", + ], + [ + 'final_default_sha="$(verify_current_release_controls)"', + 'final_default_sha="$current_default_sha"', + "current controls", + ], + ] as const) { + expect(source).toContain(needle); + expect(() => validateReleaseWorkflow( + source.replace(needle, replacement), + "release.yml", + )).toThrow(message); + } + + const publishMarker = " - name: Reauthorize current release attempt"; + const insertedPublishStep = source.replace( + publishMarker, + " - name: Hidden write\n run: git push origin main\n" + publishMarker, + ); + expect(() => validateReleaseWorkflow(insertedPublishStep, "release.yml")).toThrow( + "exact reviewed step sequence", + ); + const unpinnedPublishCheckout = replaceLast( + source, + "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "actions/checkout@v7", + ); + expect(() => validateReleaseWorkflow(unpinnedPublishCheckout, "release.yml")).toThrow( + "exact reviewed step sequence", + ); + const unpinnedVerificationAction = source.replace( + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + "oven-sh/setup-bun@v2", + ); + expect(() => validateReleaseWorkflow(unpinnedVerificationAction, "release.yml")).toThrow( + "exact reviewed step sequence", + ); + const bypassedVerification = source.replace( + " - name: Verify release identity", + " - name: Verify release identity\n continue-on-error: true", + ); + expect(() => validateReleaseWorkflow(bypassedVerification, "release.yml")).toThrow( + "fail-closed step control flow", + ); + const unconditionalRelease = source.replace( + " - name: Publish verified GitHub Release", + " - name: Publish verified GitHub Release\n if: always()", + ); + expect(() => validateReleaseWorkflow(unconditionalRelease, "release.yml")).toThrow( + "fail-closed step control flow", + ); + const extraReleaseMutation = source.replace( + ' if ! gh release create "$VERIFIED_TAG" \\', + ' gh release edit "$VERIFIED_TAG" --title hostile\n' + + ' if ! gh release create "$VERIFIED_TAG" \\', + ); + expect(extraReleaseMutation).not.toBe(source); + expect(() => validateReleaseWorkflow(extraReleaseMutation, "release.yml")).toThrow( + "unexpected provider mutation command", + ); + }); + test("pins publication to the canonical npm registry", async () => { const path = resolve(import.meta.dir, "../package.json"); const manifest = JSON.parse(await readFile(path, "utf8")) as { diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index 505e6e6..f84657e 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -30,10 +31,622 @@ function workflowRecord(source: string, label: string): Record return workflow; } +function validateReviewedWorkflowSemantics( + workflow: Record, + expectedSha256: string, + label: string, +): void { + const actual = createHash("sha256").update(JSON.stringify(workflow)).digest("hex"); + if (actual !== expectedSha256) { + throw new Error(`${label} must retain its exact reviewed workflow semantics`); + } +} + export function validateWorkflowYaml(source: string, label: string): void { workflowRecord(source, label); } +function jobSteps(job: Record, label: string): readonly Record[] { + if (!Array.isArray(job.steps) || job.steps.length === 0) { + throw new Error(`${label} steps must be a non-empty sequence`); + } + return job.steps.map((step, index) => record(step, `${label} step ${String(index + 1)}`)); +} + +type ExpectedStep = + | Readonly<{ if?: string; kind: "run"; name?: string }> + | Readonly<{ if?: string; kind: "uses"; name?: string; uses: string }>; + +function validateExactStepSequence( + steps: readonly Record[], + expected: readonly ExpectedStep[], + label: string, +): void { + if (steps.length !== expected.length) { + throw new Error(`${label} must retain its exact reviewed step sequence`); + } + for (const [index, expectedStep] of expected.entries()) { + const step = steps[index]!; + const name = typeof step.name === "string" ? step.name : undefined; + if (name !== expectedStep.name) { + throw new Error(`${label} must retain its exact reviewed step sequence`); + } + if ( + step.if !== expectedStep.if + || step["continue-on-error"] !== undefined + ) { + throw new Error(`${label} must retain fail-closed step control flow`); + } + if (expectedStep.kind === "run") { + if (typeof step.run !== "string" || step.uses !== undefined) { + throw new Error(`${label} must retain its exact reviewed step sequence`); + } + } else if (step.uses !== expectedStep.uses || step.run !== undefined) { + throw new Error(`${label} must retain its exact reviewed step sequence`); + } + } +} + +type ProviderExecutable = "curl" | "gh" | "git" | "npm" | "wget"; + +function shellTokens(source: string): readonly string[] { + const tokens: string[] = []; + const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|([^\s]+)/gu; + for (const match of source.matchAll(pattern)) { + tokens.push(match[1] ?? match[2] ?? match[3] ?? ""); + } + return tokens; +} + +function commandAfterGlobalOptions( + tokens: readonly string[], + optionsWithValues: ReadonlySet, +): Readonly<{ arguments: readonly string[]; command?: string }> { + let index = 0; + while (index < tokens.length) { + const token = tokens[index] ?? ""; + if (!token.startsWith("-")) { + return { arguments: tokens.slice(index + 1), command: token }; + } + if (optionsWithValues.has(token)) index += 1; + index += 1; + } + return { arguments: [] }; +} + +function isUnexpectedProviderInvocation( + executable: ProviderExecutable, + tokens: readonly string[], +): boolean { + if (executable === "curl" || executable === "wget") return true; + if (executable === "npm") { + const invocation = commandAfterGlobalOptions(tokens, new Set([ + "--auth-type", + "--cache", + "--globalconfig", + "--loglevel", + "--otp", + "--prefix", + "--registry", + "--scope", + "--userconfig", + "--workspace", + "-w", + ])); + return invocation.command !== undefined && new Set([ + "access", + "deprecate", + "dist-tag", + "owner", + "publish", + "stage", + "token", + "unpublish", + ]).has(invocation.command); + } + if (executable === "git") { + return commandAfterGlobalOptions(tokens, new Set([ + "--config-env", + "--git-dir", + "--namespace", + "--work-tree", + "-C", + "-c", + ])).command === "push"; + } + const invocation = commandAfterGlobalOptions(tokens, new Set([ + "--hostname", + "--repo", + "-R", + ])); + if (invocation.command === "release") return true; + if (invocation.command !== "api") return false; + return invocation.arguments.some((argument, index) => ( + new Set(["--field", "--input", "--raw-field", "-F", "-f"]).has(argument) + || /^(?:--field|--input|--raw-field|-F|-f)=/u.test(argument) + || ( + new Set(["--method", "-X"]).has(argument) + && new Set(["DELETE", "PATCH", "POST", "PUT"]) + .has((invocation.arguments[index + 1] ?? "").toUpperCase()) + ) + || /^(?:--method|-X)=(?:DELETE|PATCH|POST|PUT)$/iu.test(argument) + )); +} + +function containsUnexpectedProviderInvocation(commands: string): boolean { + const normalized = commands.replace(/\\\r?\n\s*/gu, " "); + const shellInvocation = /(?:^|&&|\|\||;|\$\()\s*(?:(?:do|elif|if|then|until|while)\s+)?!?\s*(?:command\s+)?(?:env\s+(?:-[^\s]+\s+)*)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*(?:\/(?:[^/\s]+\/)*)?(npm|gh|git|curl|wget)\b([^;&|]*)/gmu; + for (const match of normalized.matchAll(shellInvocation)) { + if (isUnexpectedProviderInvocation( + match[1] as ProviderExecutable, + shellTokens(match[2] ?? ""), + )) return true; + } + + const embeddedInvocation = /\b(?:execFileSync|execute|spawnSync)\(\s*["'](npm|gh|git|curl|wget)["']\s*,\s*\[([\s\S]*?)\]\s*(?:,|\))/gu; + for (const match of commands.matchAll(embeddedInvocation)) { + const arguments_ = [...(match[2] ?? "").matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'/gu)] + .map((argument) => argument[1] ?? argument[2] ?? ""); + if (isUnexpectedProviderInvocation( + match[1] as ProviderExecutable, + arguments_, + )) return true; + } + return false; +} + +function validateNoUnexpectedProviderMutations( + steps: readonly Record[], + allowedStepIndex: number, + allowedCommand: string, + label: string, +): void { + const commands = steps.map((step, index) => { + if (typeof step.run !== "string") return ""; + if (index !== allowedStepIndex) return step.run; + const occurrences = step.run.split(allowedCommand).length - 1; + if (occurrences !== 1) { + throw new Error(`${label} must contain its one reviewed terminal mutation`); + } + return step.run.replace(allowedCommand, ""); + }).join("\n"); + if ( + containsUnexpectedProviderInvocation(commands) + || /["'](?:POST|PUT|PATCH|DELETE)["']/u.test(commands) + || JSON.stringify(steps).includes("secrets.") + ) { + throw new Error(`${label} contains an unexpected provider mutation command`); + } +} + +function validatePinnedActionUses( + steps: readonly Record[], + expected: readonly string[], + label: string, +): void { + const actual = steps.flatMap((step) => typeof step.uses === "string" ? [step.uses] : []); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} must retain its exact pinned action sequence`); + } +} + +function joinedCommands(steps: readonly Record[]): string { + return steps + .map((step) => typeof step.run === "string" ? step.run : "") + .join("\n"); +} + +function validateOwnerTagAuthorization( + job: Record, + label: string, +): void { + if (Object.keys(record(job.permissions, `${label} permissions`)).length !== 0) { + throw new Error(`${label} must hold no token permissions`); + } + const steps = jobSteps(job, label); + if (steps.length !== 1 || JSON.stringify(job).includes("actions/checkout@")) { + throw new Error(`${label} must authorize the tag sender before checkout`); + } + const step = steps[0]!; + const environment = record(step.env, `${label} environment`); + const command = step.run; + if ( + step.if !== undefined + || step["continue-on-error"] !== undefined + || environment.EXPECTED_ACTOR_ID !== "894119" + || environment.EXPECTED_REPOSITORY !== "hraness/kb" + || environment.EXPECTED_REPOSITORY_ID !== "1308971873" + || environment.REF_PROTECTED !== "${{ github.ref_protected }}" + || typeof command !== "string" + ) { + throw new Error(`${label} must bind the immutable owner and public repository`); + } + for (const required of [ + '"$GITHUB_EVENT_NAME" != push', + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID"', + '"$REF_PROTECTED" != true', + "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", + 'event.sender?.type !== "User"', + "event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID)", + 'event.repository?.visibility !== "public"', + "event.repository?.private !== false", + 'event.repository?.default_branch !== "main"', + ]) { + if (!command.includes(required)) { + throw new Error(`${label} is missing ${required}`); + } + } +} + +export function validateReleaseWorkflow(source: string, label: string): void { + const workflow = workflowRecord(source, label); + const triggers = record(workflow.on, `${label} on`); + const push = record(triggers.push, `${label} push trigger`); + if ( + Object.keys(triggers).length !== 1 + || !Array.isArray(push.tags) + || JSON.stringify(push.tags) !== JSON.stringify(["v*", "!v*-beta.*"]) + ) { + throw new Error(`${label} must accept only stable version-tag pushes`); + } + const topPermissions = record(workflow.permissions, `${label} permissions`); + if (topPermissions.contents !== "read" || Object.keys(topPermissions).length !== 1) { + throw new Error(`${label} top-level permissions must be contents: read only`); + } + const concurrency = record(workflow.concurrency, `${label} concurrency`); + if (concurrency.group !== "stable-release" || concurrency["cancel-in-progress"] !== false) { + throw new Error(`${label} must serialize stable releases without cancellation`); + } + + const jobs = record(workflow.jobs, `${label} jobs`); + if (JSON.stringify(Object.keys(jobs).sort()) !== JSON.stringify(["authorize", "publish", "verify"])) { + throw new Error(`${label} must contain exactly authorize, verify, and publish jobs`); + } + const authorize = record(jobs.authorize, `${label} authorize job`); + const verify = record(jobs.verify, `${label} verify job`); + const publish = record(jobs.publish, `${label} publish job`); + if ([authorize, verify, publish].some((job) => ( + job.if !== undefined || job["continue-on-error"] !== undefined + ))) { + throw new Error(`${label} jobs must retain fail-closed control flow`); + } + validateOwnerTagAuthorization(authorize, `${label} owner authorization`); + + const verifyPermissions = record(verify.permissions, `${label} verify permissions`); + if ( + verify.needs !== "authorize" + || verifyPermissions.contents !== "read" + || Object.keys(verifyPermissions).length !== 1 + ) { + throw new Error(`${label} verification must follow authorization with contents: read only`); + } + const verifySteps = jobSteps(verify, `${label} verify`); + validateExactStepSequence(verifySteps, [ + { + kind: "uses", + uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + }, + { + kind: "uses", + uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + }, + { + kind: "uses", + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + }, + { kind: "run", name: "Pin npm" }, + { kind: "run", name: "Verify release identity" }, + { kind: "run", name: "Materialize exact tagged source" }, + { kind: "run", name: "Install tagged source" }, + { kind: "run", name: "Check tagged source" }, + { kind: "run", name: "Verify generated tagged tree" }, + { kind: "run", name: "Verify tagged package boundary" }, + { kind: "run", name: "Verify canonical npm delivery" }, + ], `${label} verification`); + const verifyCheckout = verifySteps[0]!; + const verifyCheckoutWith = record(verifyCheckout.with, `${label} verify checkout inputs`); + if ( + typeof verifyCheckout.uses !== "string" + || !verifyCheckout.uses.startsWith("actions/checkout@") + || verifyCheckoutWith["fetch-depth"] !== 0 + || verifyCheckoutWith["persist-credentials"] !== false + || verifyCheckoutWith.ref !== "main" + ) { + throw new Error(`${label} verification must begin from an uncredentialed full-history current-main checkout`); + } + const verifyCommands = joinedCommands(verifySteps); + let previousIndex = -1; + for (const required of [ + 'refs/heads/$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH', + 'checked_out_head="$(git rev-parse HEAD)"', + 'git merge-base --is-ancestor "$tag_commit" "$default_head"', + 'Tagged and current release workflow controls differ', + 'git worktree add --detach "$source_tree" "$SOURCE_SHA"', + 'current_attestation="$GITHUB_WORKSPACE/scripts/npm-release-attestation.ts"', + "npm audit signatures", + 'run "$current_attestation"', + ]) { + const index = verifyCommands.indexOf(required); + if (index <= previousIndex) { + throw new Error(`${label} must bind current controls, tagged source, and npm attestation in order`); + } + previousIndex = index; + } + + const publishPermissions = record(publish.permissions, `${label} publish permissions`); + if ( + publish.needs !== "verify" + || publishPermissions.actions !== "read" + || publishPermissions.contents !== "write" + || Object.keys(publishPermissions).length !== 2 + ) { + throw new Error(`${label} publication must follow verification with only actions: read and contents: write`); + } + const publishSteps = jobSteps(publish, `${label} publish`); + validateExactStepSequence(publishSteps, [ + { + kind: "uses", + uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + }, + { kind: "run", name: "Reauthorize current release attempt" }, + { kind: "run", name: "Publish verified GitHub Release" }, + ], `${label} publication`); + const publishCheckout = publishSteps[0]!; + const publishCheckoutWith = record(publishCheckout.with, `${label} publish checkout inputs`); + if ( + publishCheckout.uses !== "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" + || publishCheckoutWith["fetch-depth"] !== 0 + || publishCheckoutWith["persist-credentials"] !== false + || publishCheckoutWith.ref !== "main" + ) { + throw new Error(`${label} publication must begin from an uncredentialed full-history current-main checkout`); + } + const reauthorizeIndex = publishSteps.findIndex((step) => step.name === "Reauthorize current release attempt"); + const mutationIndex = publishSteps.findIndex((step) => + typeof step.run === "string" && step.run.includes('gh release create "$VERIFIED_TAG"')); + if (reauthorizeIndex !== 1 || mutationIndex <= reauthorizeIndex) { + throw new Error(`${label} must reauthorize the current attempt immediately before any release mutation boundary`); + } + const publishCommands = joinedCommands(publishSteps); + let publishGuardIndex = -1; + for (const required of [ + "attempt.triggering_actor?.id !== actorId", + "Current release attempt is not owner-authorized for this exact public workflow", + "verify_current_release_controls()", + 'scripts/npm-release-attestation.ts', + 'final_default_sha="$(verify_current_release_controls)"', + 'gh release create "$VERIFIED_TAG"', + 'release.immutable !== true', + 'author?.id !== Number(process.env.EXPECTED_ACTIONS_BOT_ID)', + ]) { + const index = publishCommands.indexOf(required); + if (index <= publishGuardIndex) { + throw new Error(`${label} must reauthorize and rebind current controls before immutable release publication`); + } + publishGuardIndex = index; + } + if ( + (source.match(/gh release create "\$VERIFIED_TAG"/gu) ?? []).length !== 1 + || source.includes("id-token: write") + ) { + throw new Error(`${label} must contain one tokenless GitHub Release mutation`); + } + validateNoUnexpectedProviderMutations( + publishSteps, + 2, + 'gh release create "$VERIFIED_TAG"', + `${label} publication`, + ); + validateReviewedWorkflowSemantics( + workflow, + "730142a72697531c636c0979d4499a3934277f5d0057d557ad8419b88a8dade3", + label, + ); +} + +function validatePendingStableReleaseClosure(command: string, label: string): void { + const latestIndex = command.indexOf('const latestValue = JSON.parse(execute("npm", ['); + const priorTagIndex = command.indexOf('const priorTag = `v${latestValue}`;', latestIndex + 1); + const tagLookupIndex = command.indexOf('const remoteTagLines = execute("git", [', priorTagIndex + 1); + const tagErrorIndex = command.indexOf("lacks one annotated Git tag", tagLookupIndex + 1); + const releaseIndex = command.indexOf('`repos/${repository}/releases/tags/${priorTag}`', tagLookupIndex + 1); + const latestReleaseIndex = command.indexOf("releases/latest", releaseIndex + 1); + const comparisonIndex = command.indexOf( + '`repos/${repository}/compare/${tagIdentity.get("source")}...main`', + latestReleaseIndex + 1, + ); + const releaseErrorIndex = command.indexOf( + "lacks its exact immutable GitHub Release", + latestReleaseIndex + 1, + ); + const comparisonErrorIndex = command.indexOf( + "is not reachable from current main", + comparisonIndex + 1, + ); + if ( + latestIndex < 0 + || priorTagIndex <= latestIndex + || tagLookupIndex <= priorTagIndex + || tagErrorIndex <= tagLookupIndex + || releaseIndex <= tagLookupIndex + || latestReleaseIndex <= releaseIndex + || comparisonIndex <= latestReleaseIndex + || releaseErrorIndex <= latestReleaseIndex + || comparisonErrorIndex <= comparisonIndex + ) { + throw new Error(`${label} must prove the prior npm latest release closure in order`); + } + for (const required of [ + '"dist-tags.latest",', + '"--registry=https://registry.npmjs.org",', + '`https://github.com/${repository}.git`,', + '`refs/tags/${priorTag}`,', + '`refs/tags/${priorTag}^{}`,', + "remoteTagLines.length !== 2", + 'match[2] !== `refs/tags/${priorTag}`', + 'tagIdentity.get("object") === tagIdentity.get("source")', + 'typeof tagIdentity.get("source") !== "string"', + "release?.tag_name !== priorTag", + 'release?.name !== `KB ${priorTag}`', + "release?.draft !== false", + "release?.prerelease !== false", + "release?.immutable !== true", + "release?.author?.id !== 41898282", + 'release?.author?.login !== "github-actions[bot]"', + 'release?.author?.type !== "Bot"', + "!Array.isArray(release?.assets)", + "release.assets.length !== 0", + "latestRelease?.id !== release.id", + "latestRelease?.tag_name !== priorTag", + "latestRelease?.immutable !== true", + 'comparison?.status !== "ahead" && comparison?.status !== "identical"', + ]) { + if (!command.includes(required)) { + throw new Error(`${label} must prove the prior npm latest release closure`); + } + } +} + +function validateFinalStableReleaseClosure(command: string, label: string): void { + const finalLatestGuardIndex = command.indexOf( + 'CURRENT_LATEST="$current_latest" FINAL_LATEST="$final_latest" node -e', + ); + const finalMainGuardIndex = command.indexOf( + '"$final_default_sha" != "$EXPECTED_SOURCE_SHA"', + finalLatestGuardIndex + 1, + ); + const priorVersionIndex = command.indexOf( + 'prior_version="$(FINAL_LATEST="$final_latest" node -p', + finalMainGuardIndex + 1, + ); + const priorTagIndex = command.indexOf('prior_tag="v$prior_version"', priorVersionIndex + 1); + const tagLookupIndex = command.indexOf( + 'git ls-remote --tags "https://github.com/$GITHUB_REPOSITORY.git"', + priorTagIndex + 1, + ); + const releaseIndex = command.indexOf( + 'gh api "repos/$GITHUB_REPOSITORY/releases/tags/$prior_tag"', + tagLookupIndex + 1, + ); + const latestReleaseIndex = command.indexOf( + 'gh api "repos/$GITHUB_REPOSITORY/releases/latest"', + releaseIndex + 1, + ); + const comparisonIndex = command.indexOf( + 'gh api "repos/$GITHUB_REPOSITORY/compare/$prior_source...$DEFAULT_BRANCH"', + latestReleaseIndex + 1, + ); + const comparisonGuardIndex = command.indexOf( + 'comparison?.status !== "ahead" && comparison?.status !== "identical"', + comparisonIndex + 1, + ); + const terminalLatestIndex = command.indexOf( + 'terminal_latest="$(npm view "@hraness/kb" dist-tags.latest', + comparisonGuardIndex + 1, + ); + const terminalLatestGuardIndex = command.indexOf( + 'FINAL_LATEST="$final_latest" TERMINAL_LATEST="$terminal_latest" node -e', + terminalLatestIndex + 1, + ); + const terminalRefsIndex = command.indexOf( + 'git ls-remote --exit-code', + terminalLatestGuardIndex + 1, + ); + const candidateTagGuardIndex = command.indexOf( + "was created after package verification", + terminalRefsIndex + 1, + ); + const priorTagGuardIndex = command.indexOf( + "changed during final release-closure verification", + candidateTagGuardIndex + 1, + ); + const terminalRefsGuardIndex = command.indexOf( + "Could not prove exact", + priorTagGuardIndex + 1, + ); + const publishIndex = command.indexOf('npm stage publish "$TARBALL"', terminalRefsGuardIndex + 1); + if ( + finalLatestGuardIndex < 0 + || finalMainGuardIndex <= finalLatestGuardIndex + || priorVersionIndex <= finalMainGuardIndex + || priorTagIndex <= priorVersionIndex + || tagLookupIndex <= priorTagIndex + || releaseIndex <= tagLookupIndex + || latestReleaseIndex <= releaseIndex + || comparisonIndex <= latestReleaseIndex + || comparisonGuardIndex <= comparisonIndex + || terminalLatestIndex <= comparisonGuardIndex + || terminalLatestGuardIndex <= terminalLatestIndex + || terminalRefsIndex <= terminalLatestGuardIndex + || candidateTagGuardIndex <= terminalRefsIndex + || priorTagGuardIndex <= candidateTagGuardIndex + || terminalRefsGuardIndex <= priorTagGuardIndex + || publishIndex <= terminalRefsGuardIndex + ) { + throw new Error(`${label} must prove the prior npm latest release closure at the final mutation boundary`); + } + const terminalLatestCommand = command.slice(terminalLatestIndex, terminalLatestGuardIndex); + if ( + !terminalLatestCommand.includes('terminal_latest="$(npm view "@hraness/kb" dist-tags.latest') + || !terminalLatestCommand.includes("--json") + || !terminalLatestCommand.includes("--registry=https://registry.npmjs.org") + ) { + throw new Error(`${label} must bind the terminal npm latest read to the canonical registry`); + } + const terminalRefsCommand = command.slice(terminalRefsIndex, candidateTagGuardIndex); + if (terminalRefsCommand.includes("--refs")) { + throw new Error(`${label} final remote snapshot must retain peeled annotated-tag identity`); + } + for (const required of [ + 'if (typeof current !== "string" || final !== current)', + 'const value = JSON.parse(process.env.FINAL_LATEST ?? "null")', + '"refs/tags/$prior_tag" "refs/tags/$prior_tag^{}"', + 'const priorTag = process.env.PRIOR_TAG ?? "";', + 'match[2] !== `refs/tags/${priorTag}`', + 'identity.get("object") === identity.get("source")', + 'typeof identity.get("source") !== "string"', + "release?.tag_name !== priorTag", + 'release?.name !== `KB ${priorTag}`', + "release?.draft !== false", + "release?.prerelease !== false", + "release?.immutable !== true", + "release?.author?.id !== 41898282", + 'release?.author?.login !== "github-actions[bot]"', + 'release?.author?.type !== "Bot"', + "!Array.isArray(release?.assets)", + "release.assets.length !== 0", + "latestRelease?.id !== release.id", + "latestRelease?.tag_name !== priorTag", + "latestRelease?.immutable !== true", + 'comparison?.status !== "ahead" && comparison?.status !== "identical"', + 'const terminal = JSON.parse(process.env.TERMINAL_LATEST ?? "null")', + 'if (typeof final !== "string" || terminal !== final)', + '"refs/heads/$DEFAULT_BRANCH"', + '"refs/tags/$release_tag"', + '"refs/tags/$prior_tag"', + '"refs/tags/$prior_tag^{}" > "$terminal_refs_output"', + 'const expectedHeadRef = `refs/heads/${process.env.DEFAULT_BRANCH ?? ""}`', + 'const expectedTagRef = `refs/tags/${process.env.RELEASE_TAG ?? ""}`', + 'const priorTagRef = `refs/tags/${process.env.PRIOR_TAG ?? ""}`', + "PRIOR_TAG_IDENTITY", + "entries.some((entry) => entry.ref === expectedTagRef)", + 'terminalPriorIdentity.get("object") !== initialPriorIdentity.get("object")', + 'terminalPriorIdentity.get("source") !== initialPriorIdentity.get("source")', + "entries.length !== 3", + "headEntries.length !== 1", + "headEntries[0]?.sha !== expectedSourceSha", + "Final remote snapshot has malformed identity data", + "lacks one annotated Git tag", + "lacks its exact immutable GitHub Release", + "is not reachable from current main", + ]) { + if (!command.includes(required)) { + throw new Error(`${label} must prove the prior npm latest release closure`); + } + } +} + export function validateNpmStageWorkflow(source: string, label: string): void { const workflow = workflowRecord(source, label); const triggers = record(workflow.on, `${label} on`); @@ -115,19 +728,65 @@ export function validateNpmStageWorkflow(source: string, label: string): void { } if ( stagePermissions.actions !== "read" + || stagePermissions.contents !== "read" || stagePermissions["id-token"] !== "write" - || Object.keys(stagePermissions).length !== 2 + || Object.keys(stagePermissions).length !== 3 ) { - throw new Error(`${label} staging must hold only actions: read and id-token: write`); + throw new Error(`${label} staging must hold only actions: read, contents: read, and id-token: write`); } if (stage.environment !== "npm-stage") { throw new Error(`${label} staging must use the exact npm-stage environment`); } + if ( + select.if !== undefined + || select["continue-on-error"] !== undefined + || verify["continue-on-error"] !== undefined + || stage["continue-on-error"] !== undefined + ) { + throw new Error(`${label} jobs must retain fail-closed control flow`); + } if (!Array.isArray(select.steps)) { throw new Error(`${label} select steps must be a sequence`); } const selectionSteps = select.steps.map((step, index) => record(step, `${label} select step ${String(index + 1)}`)); + validatePinnedActionUses(selectionSteps, [ + "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + ], `${label} selection`); + const verificationSteps = jobSteps(verify, `${label} verification`); + validateExactStepSequence(verificationSteps, [ + { + kind: "uses", + uses: "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", + }, + { + kind: "uses", + uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + }, + { + kind: "uses", + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + }, + { kind: "run", name: "Pin npm" }, + { kind: "run", name: "Require current default-branch head" }, + { kind: "run", name: "Verify package can be staged" }, + { kind: "run" }, + { kind: "run" }, + { kind: "run", name: "Verify generated tree" }, + { kind: "run", name: "Prepare and smoke exact npm artifact" }, + { + kind: "uses", + name: "Upload reviewed npm artifact", + uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + }, + ], `${label} verification`); + if ( + verificationSteps[6]?.run !== "bun install --frozen-lockfile --ignore-scripts" + || verificationSteps[7]?.run !== "bun run check" + ) { + throw new Error(`${label} verification must retain its exact package gate commands`); + } const selectionCommands = selectionSteps.filter((step) => typeof step.run === "string" && step.run.includes("scripts/npm-stage-selection.ts")); if (selectionCommands.length !== 1 || typeof selectionCommands[0]?.run !== "string") { @@ -149,6 +808,46 @@ export function validateNpmStageWorkflow(source: string, label: string): void { } const steps = stage.steps.map((step, index) => record(step, `${label} stage step ${String(index + 1)}`)); + validateExactStepSequence(steps, [ + { kind: "run", name: "Reauthorize current npm staging attempt" }, + { + kind: "uses", + uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + }, + { kind: "run", name: "Pin npm" }, + { kind: "run", name: "Reject unresolved stable-stage intent" }, + { + kind: "run", + if: "inputs.resolved_stage_version != ''", + name: "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}", + }, + { kind: "run", name: "Bind artifact reference" }, + { + kind: "uses", + name: "Download reviewed package", + uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + }, + { kind: "run", name: "Rebind downloaded package" }, + { kind: "run", name: "Record exclusive stable-stage intent" }, + { kind: "run", name: "Revalidate current main and stage exact package" }, + ], `${label} staging`); + const setupNodeWith = record(steps[1]?.with, `${label} stage setup-node inputs`); + if ( + setupNodeWith["node-version"] !== "24" + || setupNodeWith["package-manager-cache"] !== false + || setupNodeWith["registry-url"] !== "https://registry.npmjs.org" + || Object.keys(setupNodeWith).length !== 3 + ) { + throw new Error(`${label} staging must retain the exact reviewed setup-node inputs`); + } + const downloadWith = record(steps[6]?.with, `${label} stage download inputs`); + if ( + downloadWith.name !== "${{ needs.verify.outputs.artifact_name }}" + || downloadWith.path !== "${{ runner.temp }}/kb-npm-stage" + || Object.keys(downloadWith).length !== 2 + ) { + throw new Error(`${label} staging must retain the exact reviewed artifact download inputs`); + } const authorizationStep = steps[0]; if ( authorizationStep?.name !== "Reauthorize current npm staging attempt" @@ -262,6 +961,10 @@ export function validateNpmStageWorkflow(source: string, label: string): void { throw new Error(`${label} pending-stage guard is missing ${required}`); } } + validatePendingStableReleaseClosure( + pendingStageStep.run, + `${label} pending-stage guard`, + ); const terminalInspectionIndex = pendingStageStep.run.indexOf("const terminalWrites ="); const jobDisplayNameFilterIndex = pendingStageStep.run.indexOf( 'if (!job.name.startsWith("Stage exact package"))', @@ -318,20 +1021,23 @@ export function validateNpmStageWorkflow(source: string, label: string): void { throw new Error(`${label} stable-stage intent and resolution identities are incomplete`); } const environment = record(publicationStep.env, `${label} staged-publication environment`); - for (const name of [ - "DEFAULT_BRANCH", - "DIGEST", - "EXPECTED_ARCHIVE_SHA256", - "EXPECTED_DIGEST_SHA256", - "EXPECTED_METADATA_SHA256", - "EXPECTED_SOURCE_SHA", - "EXPECTED_VERSION", - "METADATA", - "TARBALL", - ]) { - if (typeof environment[name] !== "string") { - throw new Error(`${label} staged publication must bind ${name}`); - } + const expectedEnvironment = { + DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}", + DIGEST: "${{ steps.artifact.outputs.digest }}", + EXPECTED_ARCHIVE_SHA256: "${{ steps.artifact.outputs.archive_sha256 }}", + EXPECTED_DIGEST_SHA256: "${{ steps.artifact.outputs.digest_sha256 }}", + EXPECTED_METADATA_SHA256: "${{ steps.artifact.outputs.metadata_sha256 }}", + EXPECTED_SOURCE_SHA: "${{ needs.verify.outputs.source_sha }}", + EXPECTED_VERSION: "${{ needs.verify.outputs.package_version }}", + GH_TOKEN: "${{ github.token }}", + METADATA: "${{ steps.artifact.outputs.metadata }}", + TARBALL: "${{ steps.artifact.outputs.tarball }}", + } as const; + if ( + Object.keys(environment).length !== Object.keys(expectedEnvironment).length + || Object.entries(expectedEnvironment).some(([name, value]) => environment[name] !== value) + ) { + throw new Error(`${label} staged publication must bind its exact reviewed environment`); } const guardCommands = [ 'git init --quiet --bare "$current_main"', @@ -342,16 +1048,62 @@ export function validateNpmStageWorkflow(source: string, label: string): void { 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', "npm config get tag", + 'final_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)"', + 'final_latest="$(npm view "@hraness/kb" dist-tags.latest', + "Public npm latest changed immediately before staged publication", + "$DEFAULT_BRANCH changed immediately before staged publication", + 'prior_version="$(FINAL_LATEST="$final_latest" node -p', + 'git ls-remote --tags "https://github.com/$GITHUB_REPOSITORY.git"', + 'gh api "repos/$GITHUB_REPOSITORY/releases/tags/$prior_tag"', + 'gh api "repos/$GITHUB_REPOSITORY/releases/latest"', + 'gh api "repos/$GITHUB_REPOSITORY/compare/$prior_source...$DEFAULT_BRANCH"', + 'terminal_latest="$(npm view "@hraness/kb" dist-tags.latest', + "Public npm latest changed during final release-closure verification", + 'git ls-remote --exit-code', + '"refs/heads/$DEFAULT_BRANCH"', + '"refs/tags/$release_tag"', + '"refs/tags/$prior_tag"', + '"refs/tags/$prior_tag^{}"', + "changed during final release-closure verification", + "Could not prove exact", 'npm stage publish "$TARBALL"', ]; let previousIndex = -1; for (const command of guardCommands) { - const index = publicationStep.run.indexOf(command); + const index = publicationStep.run.indexOf(command, previousIndex + 1); if (index <= previousIndex) { throw new Error(`${label} must recheck current default-branch HEAD immediately before staged publication`); } previousIndex = index; } + const finalFetchIndex = publicationStep.run.lastIndexOf( + 'git --git-dir="$current_main" fetch', + ); + const npmConfigIndex = publicationStep.run.indexOf("npm config get tag"); + const finalDefaultIndex = publicationStep.run.indexOf( + 'final_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)"', + ); + if ( + finalFetchIndex <= npmConfigIndex + || finalFetchIndex >= finalDefaultIndex + || (publicationStep.run.match(/git --git-dir="\$current_main" fetch/gu) ?? []).length !== 2 + || (publicationStep.run.match(/npm view "@hraness\/kb" dist-tags\.latest/gu) ?? []).length !== 3 + ) { + throw new Error(`${label} must re-read current main and npm latest at the final mutation boundary`); + } + validateFinalStableReleaseClosure( + publicationStep.run, + `${label} staged-publication boundary`, + ); + for (const message of [ + "lacks one annotated Git tag", + "lacks its exact immutable GitHub Release", + "is not reachable from current main", + ]) { + if ((source.match(new RegExp(message, "gu")) ?? []).length !== 2) { + throw new Error(`${label} must repeat the prior npm latest release closure at both boundaries`); + } + } const publishIndex = publicationStep.run.indexOf('npm stage publish "$TARBALL"'); if (!publicationStep.run.slice(publishIndex).includes("--registry=https://registry.npmjs.org")) { throw new Error(`${label} staged publication must bind the canonical npm registry`); @@ -359,6 +1111,12 @@ export function validateNpmStageWorkflow(source: string, label: string): void { if (/--tag(?:=|\s)/u.test(publicationStep.run)) { throw new Error(`${label} must preserve pinned npm's default-tag monotonicity guard`); } + validateNoUnexpectedProviderMutations( + steps, + 9, + 'npm stage publish "$TARBALL"', + `${label} staging`, + ); const stageSource = JSON.stringify(stage); if (/\bbun\b/u.test(stageSource) || stageSource.includes("./scripts/")) { throw new Error(`${label} staging must not execute repository code`); @@ -366,13 +1124,23 @@ export function validateNpmStageWorkflow(source: string, label: string): void { if ((source.match(/id-token: write/gu) ?? []).length !== 1) { throw new Error(`${label} must grant OIDC authority to exactly one job`); } + validateReviewedWorkflowSemantics( + workflow, + "92d4df09713882861aaa5fdd9163792771d1c2f687e9fc946b661c740c1dd8e2", + label, + ); } if (import.meta.main) { const repositoryRoot = resolve(import.meta.dir, ".."); - for (const path of [".github/workflows/ci.yml", ".github/workflows/release.yml"]) { - validateWorkflowYaml(await readFile(resolve(repositoryRoot, path), "utf8"), path); - } + validateWorkflowYaml( + await readFile(resolve(repositoryRoot, ".github/workflows/ci.yml"), "utf8"), + ".github/workflows/ci.yml", + ); + validateReleaseWorkflow( + await readFile(resolve(repositoryRoot, ".github/workflows/release.yml"), "utf8"), + ".github/workflows/release.yml", + ); const npmStagePath = ".github/workflows/npm-stage.yml"; validateNpmStageWorkflow( await readFile(resolve(repositoryRoot, npmStagePath), "utf8"), diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 2aa39de..96ccc44 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -468,7 +468,7 @@ describe("npm release workflows", () => { "name: Stage exact package v${{ needs.verify.outputs.package_version }}", "if: inputs.publish_to_npm == true", "environment: npm-stage", - "permissions:\n actions: read\n id-token: write", + "permissions:\n actions: read\n contents: read\n id-token: write", "Reauthorize current npm staging attempt", 'EXPECTED_WORKFLOW_ID: "344070109"', 'PUBLISH_TO_NPM: ${{ inputs.publish_to_npm }}', @@ -507,11 +507,17 @@ describe("npm release workflows", () => { '"https://github.com/$GITHUB_REPOSITORY.git"', 'EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }}', 'release_tag="v$EXPECTED_VERSION"', - "git ls-remote --exit-code --refs", - '"refs/tags/$release_tag" > "$tag_lookup_output"', - 'tag_lookup_status=$?', - '[[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]', - "Could not prove that tag $release_tag is still absent from origin", + "git ls-remote --exit-code", + '"refs/heads/$DEFAULT_BRANCH"', + '"refs/tags/$release_tag"', + '"refs/tags/$prior_tag"', + '"refs/tags/$prior_tag^{}" > "$terminal_refs_output"', + "Final remote snapshot has malformed identity data", + "changed during final release-closure verification", + "Could not prove exact", + "lacks one annotated Git tag", + "lacks its exact immutable GitHub Release", + "is not reachable from current main", 'current_archive_sha256="$(sha256sum "$TARBALL"', 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', @@ -525,29 +531,47 @@ describe("npm release workflows", () => { `--registry=${npmRegistry}`, ] as const) expect(stageJob).toContain(required); expect(workflow.match(/id-token: write/gu) ?? []).toHaveLength(1); - expect(stageJob).not.toContain("contents: read"); expect(stageJob).not.toContain("actions/checkout@"); expect(stageJob).not.toContain("setup-bun@"); expect(stageJob).not.toMatch(/\bbun\b/u); expect(stageJob).not.toContain("./scripts/"); expect(stageJob.match(/npm stage publish/gu) ?? []).toHaveLength(1); + expect(stageJob.match(/lacks one annotated Git tag/gu) ?? []).toHaveLength(2); + expect(stageJob.match(/lacks its exact immutable GitHub Release/gu) ?? []).toHaveLength(2); + expect(stageJob.match(/is not reachable from current main/gu) ?? []).toHaveLength(2); expect(stageJob).not.toContain("--tag latest"); const authorizationIndex = stageJob.indexOf("Reauthorize current npm staging attempt"); const setupIndex = stageJob.indexOf("actions/setup-node@"); const pendingStageIndex = stageJob.indexOf("Reject unresolved stable-stage intent"); const intentIndex = stageJob.lastIndexOf("Record exclusive stable-stage intent"); - const fetchIndex = stageJob.lastIndexOf('git --git-dir="$current_main" fetch'); - const tagLookupIndex = stageJob.lastIndexOf("git ls-remote --exit-code --refs"); + const fetchIndex = stageJob.indexOf('git --git-dir="$current_main" fetch'); + const tagLookupIndex = stageJob.lastIndexOf("git ls-remote --exit-code"); + const priorTagLookupIndex = stageJob.lastIndexOf("git ls-remote --tags"); + const priorReleaseIndex = stageJob.lastIndexOf("releases/tags/$prior_tag"); + const priorComparisonIndex = stageJob.lastIndexOf("compare/$prior_source...$DEFAULT_BRANCH"); const rehashIndex = stageJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); + const finalFetchIndex = stageJob.lastIndexOf('git --git-dir="$current_main" fetch'); + const latestLookup = 'npm view "@hraness/kb" dist-tags.latest'; + const firstLatestIndex = stageJob.indexOf(latestLookup); + const finalLatestIndex = stageJob.indexOf(latestLookup, firstLatestIndex + 1); + const terminalLatestIndex = stageJob.lastIndexOf(latestLookup); const stageIndex = stageJob.indexOf('npm stage publish "$TARBALL"'); expect(authorizationIndex).toBeGreaterThan(-1); expect(authorizationIndex).toBeLessThan(setupIndex); expect(pendingStageIndex).toBeGreaterThan(setupIndex); expect(pendingStageIndex).toBeLessThan(stageIndex); expect(fetchIndex).toBeGreaterThan(-1); - expect(fetchIndex).toBeLessThan(tagLookupIndex); - expect(tagLookupIndex).toBeLessThan(rehashIndex); + expect(fetchIndex).toBeLessThan(rehashIndex); expect(rehashIndex).toBeLessThan(stageIndex); + expect(finalFetchIndex).toBeGreaterThan(rehashIndex); + expect(finalFetchIndex).toBeLessThan(finalLatestIndex); + expect(finalLatestIndex).toBeLessThan(priorTagLookupIndex); + expect(priorTagLookupIndex).toBeLessThan(priorReleaseIndex); + expect(priorReleaseIndex).toBeLessThan(priorComparisonIndex); + expect(priorComparisonIndex).toBeLessThan(terminalLatestIndex); + expect(terminalLatestIndex).toBeLessThan(tagLookupIndex); + expect(tagLookupIndex).toBeLessThan(stageIndex); + expect(finalLatestIndex).toBeLessThan(terminalLatestIndex); expect(intentIndex).toBeGreaterThan(pendingStageIndex); expect(intentIndex).toBeLessThan(stageIndex); expect(workflow).not.toContain("secrets.NPM_TOKEN"); @@ -564,7 +588,9 @@ describe("npm release workflows", () => { const authorizationIndex = stageJob.indexOf("Reauthorize current npm staging attempt"); const setupIndex = stageJob.indexOf("actions/setup-node@"); const mutationIndex = stageJob.indexOf('npm stage publish "$TARBALL"'); - expect(stageJob).toContain("permissions:\n actions: read\n id-token: write"); + expect(stageJob).toContain( + "permissions:\n actions: read\n contents: read\n id-token: write", + ); expect(authorizationIndex).toBeGreaterThan(-1); expect(authorizationIndex).toBeLessThan(setupIndex); expect(setupIndex).toBeLessThan(mutationIndex); @@ -738,6 +764,20 @@ describe("npm release workflows", () => { const currentJobsPath = join(directory, "current-jobs.json"); const runsPath = join(directory, "runs.json"); const jobsPath = join(directory, "jobs.json"); + const tagIdentityPath = join(directory, "tag-identity.txt"); + const releasePath = join(directory, "release.json"); + const latestReleasePath = join(directory, "latest-release.json"); + const comparisonPath = join(directory, "comparison.json"); + const completeRelease = { + assets: [], + author: { id: 41898282, login: "github-actions[bot]", type: "Bot" }, + draft: false, + id: 190, + immutable: true, + name: "KB v0.19.0", + prerelease: false, + tag_name: "v0.19.0", + } as const; try { await mkdir(binaryDirectory, { recursive: true }); await writeFile( @@ -748,12 +788,24 @@ describe("npm release workflows", () => { "printf '\"%s\"\\n' \"$MOCK_NPM_LATEST\"", ].join("\n"), ); + await writeFile( + join(binaryDirectory, "git"), + [ + "#!/bin/bash", + "set -euo pipefail", + '[[ "$*" == ls-remote\\ --tags* ]] || { echo "unexpected git request: $*" >&2; exit 2; }', + 'cat "$MOCK_TAG_IDENTITY"', + ].join("\n"), + ); await writeFile( join(binaryDirectory, "gh"), [ "#!/bin/bash", "set -euo pipefail", 'case "$*" in', + ' *"repos/hraness/kb/releases/tags/v0.19.0"*) cat "$MOCK_RELEASE_JSON" ;;', + ' *"repos/hraness/kb/releases/latest"*) cat "$MOCK_LATEST_RELEASE_JSON" ;;', + ' *"repos/hraness/kb/compare/2222222222222222222222222222222222222222...main"*) cat "$MOCK_COMPARISON_JSON" ;;', ' *"/actions/workflows/344070109/runs?"*) cat "$MOCK_RUNS_JSON" ;;', ' *"/actions/runs/67890/jobs?"*) cat "$MOCK_CURRENT_JOBS_JSON" ;;', ' *"/actions/runs/12345/jobs?"*|*"/actions/runs/33269920554/jobs?"*) cat "$MOCK_JOBS_JSON" ;;', @@ -763,7 +815,16 @@ describe("npm release workflows", () => { ); await Promise.all([ chmod(join(binaryDirectory, "npm"), 0o755), + chmod(join(binaryDirectory, "git"), 0o755), chmod(join(binaryDirectory, "gh"), 0o755), + writeFile( + tagIdentityPath, + "1111111111111111111111111111111111111111\trefs/tags/v0.19.0\n" + + "2222222222222222222222222222222222222222\trefs/tags/v0.19.0^{}\n", + ), + writeFile(releasePath, JSON.stringify(completeRelease)), + writeFile(latestReleasePath, JSON.stringify(completeRelease)), + writeFile(comparisonPath, JSON.stringify({ status: "ahead" })), writeFile(runsPath, JSON.stringify({ total_count: 1, workflow_runs: [{ @@ -790,9 +851,13 @@ describe("npm release workflows", () => { GITHUB_REPOSITORY: "hraness/kb", GITHUB_RUN_ID: "67890", MOCK_CURRENT_JOBS_JSON: currentJobsPath, + MOCK_COMPARISON_JSON: comparisonPath, + MOCK_LATEST_RELEASE_JSON: latestReleasePath, MOCK_NPM_LATEST: "0.19.0", + MOCK_RELEASE_JSON: releasePath, MOCK_RUNS_JSON: runsPath, MOCK_JOBS_JSON: jobsPath, + MOCK_TAG_IDENTITY: tagIdentityPath, RESOLVED_STAGE_VERSION: "", }; @@ -811,6 +876,28 @@ describe("npm release workflows", () => { const released = await runWorkflowScript(script, environment); expect(released.exitCode).toBe(0); + await writeFile(tagIdentityPath, "2222222222222222222222222222222222222222\trefs/tags/v0.19.0\n"); + const lightweightTag = await runWorkflowScript(script, environment); + expect(lightweightTag.exitCode).not.toBe(0); + expect(lightweightTag.stderr).toContain("lacks one annotated Git tag"); + await writeFile( + tagIdentityPath, + "1111111111111111111111111111111111111111\trefs/tags/v0.19.0\n" + + "2222222222222222222222222222222222222222\trefs/tags/v0.19.0^{}\n", + ); + + await writeFile(releasePath, JSON.stringify({ ...completeRelease, immutable: false })); + const mutableRelease = await runWorkflowScript(script, environment); + expect(mutableRelease.exitCode).not.toBe(0); + expect(mutableRelease.stderr).toContain("lacks its exact immutable GitHub Release"); + await writeFile(releasePath, JSON.stringify(completeRelease)); + + await writeFile(comparisonPath, JSON.stringify({ status: "diverged" })); + const divergedRelease = await runWorkflowScript(script, environment); + expect(divergedRelease.exitCode).not.toBe(0); + expect(divergedRelease.stderr).toContain("is not reachable from current main"); + await writeFile(comparisonPath, JSON.stringify({ status: "ahead" })); + await writeFile(jobsPath, JSON.stringify({ total_count: 1, jobs: [{ @@ -1013,7 +1100,225 @@ describe("npm release workflows", () => { } finally { await rm(directory, { recursive: true, force: true }); } - }); + }, 30_000); + + test("the terminal stage boundary freshly closes npm, Git tag, and GitHub Release state", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Revalidate current main and stage exact package"); + const directory = await mkdtemp(join(tmpdir(), "kb-final-stage-boundary-")); + const binaryDirectory = join(directory, "bin"); + const expectedSourceSha = "a".repeat(40); + const priorSourceSha = "b".repeat(40); + const completeRelease = { + assets: [], + author: { id: 41898282, login: "github-actions[bot]", type: "Bot" }, + draft: false, + id: 190, + immutable: true, + name: "KB v0.19.0", + prerelease: false, + tag_name: "v0.19.0", + } as const; + + try { + await mkdir(binaryDirectory, { recursive: true }); + await Promise.all([ + writeFile( + join(binaryDirectory, "git"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'git %s\\n\' "$*" >> "$MOCK_COMMAND_LOG"', + 'if [[ "$1" == check-ref-format || "$1" == init ]]; then exit 0; fi', + 'if [[ "$1" == --git-dir=* && "$2" == fetch ]]; then exit 0; fi', + 'if [[ "$1" == --git-dir=* && "$2" == rev-parse ]]; then printf \'%s\\n\' "$MOCK_SOURCE_SHA"; exit 0; fi', + 'if [[ "$1" == ls-remote && "$2" == --tags ]]; then cat "$MOCK_TAG_IDENTITY"; exit 0; fi', + 'if [[ "$1" == ls-remote && "$2" == --exit-code ]]; then', + ' printf \'%s\\trefs/heads/main\\n\' "$MOCK_TERMINAL_MAIN_SHA"', + ' if [[ "${MOCK_CANDIDATE_TAG_PRESENT:-false}" == true ]]; then', + ' printf \'%s\\trefs/tags/v0.19.1\\n\' "$MOCK_SOURCE_SHA"', + " fi", + ' if [[ "${MOCK_PRIOR_TAG_DRIFT:-false}" == true ]]; then', + ' printf \'%s\\trefs/tags/v0.19.0\\n\' "$MOCK_TERMINAL_PRIOR_OBJECT_SHA"', + ' printf \'%s\\trefs/tags/v0.19.0^{}\\n\' "$MOCK_TERMINAL_PRIOR_SOURCE_SHA"', + " else", + ' cat "$MOCK_TAG_IDENTITY"', + " fi", + " exit 0", + "fi", + 'echo "unexpected git request: $*" >&2', + "exit 3", + ].join("\n"), + ), + writeFile( + join(binaryDirectory, "npm"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'npm %s\\n\' "$*" >> "$MOCK_COMMAND_LOG"', + 'if [[ "$1" == view ]]; then', + ' count="$(cat "$MOCK_NPM_COUNT_FILE")"', + ' count=$((count + 1))', + ' printf \'%s\\n\' "$count" > "$MOCK_NPM_COUNT_FILE"', + ' if [[ "$count" -ge 3 ]]; then printf \'"%s"\\n\' "$MOCK_NPM_TERMINAL"; else printf \'"%s"\\n\' "$MOCK_NPM_LATEST"; fi', + " exit 0", + "fi", + 'if [[ "$1" == config && "$2" == get && "$3" == tag ]]; then printf \'latest\\n\'; exit 0; fi', + 'if [[ "$1" == stage && "$2" == publish ]]; then exit 0; fi', + 'echo "unexpected npm request: $*" >&2', + "exit 3", + ].join("\n"), + ), + writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'gh %s\\n\' "$*" >> "$MOCK_COMMAND_LOG"', + 'case "$2" in', + ' repos/hraness/kb/releases/tags/v0.19.0) cat "$MOCK_RELEASE_JSON" ;;', + ' repos/hraness/kb/releases/latest) cat "$MOCK_LATEST_RELEASE_JSON" ;;', + ' "repos/hraness/kb/compare/${MOCK_PRIOR_SOURCE}...main") cat "$MOCK_COMPARISON_JSON" ;;', + ' *) echo "unexpected gh request: $*" >&2; exit 3 ;;', + "esac", + ].join("\n"), + ), + ]); + await Promise.all([ + chmod(join(binaryDirectory, "git"), 0o755), + chmod(join(binaryDirectory, "npm"), 0o755), + chmod(join(binaryDirectory, "gh"), 0o755), + ]); + + const runBoundary = async (options: Readonly<{ + candidateTagPresent?: boolean; + comparisonStatus?: string; + release?: Readonly>; + terminalLatest?: string; + terminalMainSha?: string; + terminalPriorTagDrift?: boolean; + }> = {}) => { + const runDirectory = await mkdtemp(join(directory, "run-")); + const archive = Buffer.from("reviewed archive", "utf8"); + const metadata = Buffer.from("reviewed metadata", "utf8"); + const digest = Buffer.from("reviewed digest", "utf8"); + const archivePath = join(runDirectory, "hraness-kb-0.19.1.tgz"); + const metadataPath = join(runDirectory, "npm-pack.json"); + const digestPath = join(runDirectory, "npm-package.sha256"); + const tagIdentityPath = join(runDirectory, "tag-identity.txt"); + const releasePath = join(runDirectory, "release.json"); + const latestReleasePath = join(runDirectory, "latest-release.json"); + const comparisonPath = join(runDirectory, "comparison.json"); + const commandLog = join(runDirectory, "commands.log"); + const npmCountPath = join(runDirectory, "npm-count.txt"); + const release = options.release ?? completeRelease; + await Promise.all([ + writeFile(archivePath, archive), + writeFile(metadataPath, metadata), + writeFile(digestPath, digest), + writeFile( + tagIdentityPath, + `${"c".repeat(40)}\trefs/tags/v0.19.0\n${priorSourceSha}\trefs/tags/v0.19.0^{}\n`, + ), + writeFile(releasePath, JSON.stringify(release)), + writeFile(latestReleasePath, JSON.stringify(release)), + writeFile( + comparisonPath, + JSON.stringify({ status: options.comparisonStatus ?? "ahead" }), + ), + writeFile(commandLog, ""), + writeFile(npmCountPath, "0\n"), + ]); + const result = await runWorkflowScript(script, { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + DEFAULT_BRANCH: "main", + DIGEST: digestPath, + EXPECTED_ARCHIVE_SHA256: sha256(archive), + EXPECTED_DIGEST_SHA256: sha256(digest), + EXPECTED_METADATA_SHA256: sha256(metadata), + EXPECTED_SOURCE_SHA: expectedSourceSha, + EXPECTED_VERSION: "0.19.1", + GH_TOKEN: "test-token", + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_SHA: expectedSourceSha, + METADATA: metadataPath, + MOCK_CANDIDATE_TAG_PRESENT: options.candidateTagPresent === true ? "true" : "false", + MOCK_COMMAND_LOG: commandLog, + MOCK_COMPARISON_JSON: comparisonPath, + MOCK_LATEST_RELEASE_JSON: latestReleasePath, + MOCK_NPM_LATEST: "0.19.0", + MOCK_NPM_COUNT_FILE: npmCountPath, + MOCK_NPM_TERMINAL: options.terminalLatest ?? "0.19.0", + MOCK_PRIOR_TAG_DRIFT: options.terminalPriorTagDrift === true ? "true" : "false", + MOCK_PRIOR_SOURCE: priorSourceSha, + MOCK_RELEASE_JSON: releasePath, + MOCK_SOURCE_SHA: expectedSourceSha, + MOCK_TAG_IDENTITY: tagIdentityPath, + MOCK_TERMINAL_PRIOR_OBJECT_SHA: "d".repeat(40), + MOCK_TERMINAL_PRIOR_SOURCE_SHA: "e".repeat(40), + MOCK_TERMINAL_MAIN_SHA: options.terminalMainSha ?? expectedSourceSha, + RUNNER_TEMP: runDirectory, + TARBALL: archivePath, + }); + return { commandLog: await readFile(commandLog, "utf8"), result }; + }; + + const accepted = await runBoundary(); + expect(accepted.result.exitCode).toBe(0); + const commands = accepted.commandLog.trim().split("\n"); + const latestIndices = commands.flatMap((command, index) => + command.startsWith("npm view @hraness/kb dist-tags.latest") ? [index] : []); + const releaseIndex = commands.findIndex((command) => + command === "gh api repos/hraness/kb/releases/tags/v0.19.0"); + const candidateTagIndex = commands.findIndex((command) => + command.startsWith("git ls-remote --exit-code https://github.com/hraness/kb.git")); + const mutationIndex = commands.findIndex((command) => command.startsWith("npm stage publish")); + expect(latestIndices).toHaveLength(3); + expect(releaseIndex).toBeGreaterThan(latestIndices[1]!); + expect(latestIndices[2]!).toBeGreaterThan(releaseIndex); + expect(candidateTagIndex).toBeGreaterThan(latestIndices[2]!); + expect(mutationIndex).toBeGreaterThan(candidateTagIndex); + + const candidateCollision = await runBoundary({ candidateTagPresent: true }); + expect(candidateCollision.result.exitCode).not.toBe(0); + expect(candidateCollision.result.stderr).toContain("was created after package verification"); + expect(candidateCollision.commandLog).not.toContain("npm stage publish"); + + const npmDrift = await runBoundary({ terminalLatest: "0.19.1" }); + expect(npmDrift.result.exitCode).not.toBe(0); + expect(npmDrift.result.stderr).toContain( + "Public npm latest changed during final release-closure verification", + ); + expect(npmDrift.commandLog).not.toContain("npm stage publish"); + + const mainDrift = await runBoundary({ terminalMainSha: "d".repeat(40) }); + expect(mainDrift.result.exitCode).not.toBe(0); + expect(mainDrift.result.stderr).toContain("Could not prove exact main"); + expect(mainDrift.commandLog).not.toContain("npm stage publish"); + + const priorTagDrift = await runBoundary({ terminalPriorTagDrift: true }); + expect(priorTagDrift.result.exitCode).not.toBe(0); + expect(priorTagDrift.result.stderr).toContain( + "Prior tag v0.19.0 changed during final release-closure verification", + ); + expect(priorTagDrift.commandLog).not.toContain("npm stage publish"); + + const mutableRelease = await runBoundary({ + release: { ...completeRelease, immutable: false }, + }); + expect(mutableRelease.result.exitCode).not.toBe(0); + expect(mutableRelease.result.stderr).toContain("lacks its exact immutable GitHub Release"); + expect(mutableRelease.commandLog).not.toContain("npm stage publish"); + + const divergedRelease = await runBoundary({ comparisonStatus: "diverged" }); + expect(divergedRelease.result.exitCode).not.toBe(0); + expect(divergedRelease.result.stderr).toContain("is not reachable from current main"); + expect(divergedRelease.commandLog).not.toContain("npm stage publish"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, 30_000); test("the source-free staging boundary rejects npm's packed top-level tag override", async () => { const workflow = await readFile(stageWorkflowUrl, "utf8"); @@ -1545,7 +1850,9 @@ describe("npm release workflows", () => { "current attempt", "including every retained attempt", "successful version-bound intent step", - "`actions: read` and `id-token: write`", + "matching annotated tag", + "immutable zero-asset Latest Release", + "completed-release closure", "`Number.MAX_SAFE_INTEGER`", "`npm audit signatures --json", "`dist-tags.latest`", @@ -1563,6 +1870,9 @@ describe("npm release workflows", () => { expect(normalizedGuide).toContain( "selected branch `main` with type `branch`", ); + expect(normalizedGuide).toContain( + "only `actions: read`, `contents: read`, and `id-token: write`", + ); expect(guide).toMatch(/the only job with\s+OIDC authority/u); expect(guide).toMatch(/explicitly opted-in staging job\s+starts after verification/u); expect(guide).toMatch(/approve the staged package through npm with two-factor\s+authentication/u); @@ -1571,13 +1881,17 @@ describe("npm release workflows", () => { expect(guide).toMatch(/new bare\s+Git directory/u); expect(guide).toMatch(/do not import a\s+script from the tagged tree/u); expect(guide).toMatch(/trusted-publishing assertion cannot run\s+`npm stage list`/u); - expect(guide).toMatch(/not a\s+claim that npm exposes or prevents an out-of-band concurrent stage/u); + expect(normalizedGuide).toContain( + "not a claim that npm exposes or prevents an out-of-band concurrent stage", + ); expect(agents).toContain("Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission"); expect(agents).toContain("selected default branch `main`"); expect(agents).toContain("administrator bypass disabled"); expect(agents).toContain("public promotion remains human-gated by two-factor authentication"); expect(agents).toContain("boolean `publish_to_npm=true`"); - expect(agents).toContain("`actions: read` plus `id-token: write`"); + expect(agents).toContain("`actions: read`, `contents: read`, and `id-token: write`"); + expect(agents).toContain("the next stage remains locked until public `latest`"); + expect(agents).toContain("exact immutable bot-created Latest Release"); expect(agents).toContain("clean default `latest`"); expect(agents).toContain("pinned npm `11.19.0`"); expect(agents).toContain("Record a successful intent step immediately before mutation");