diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index cce6810..e85d65c 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -363,7 +363,7 @@ jobs: --registry=https://registry.npmjs.org test "$(npm --version)" = "11.19.0" [[ "$(node --version)" == v24.* ]] - - name: Reject another pending stable stage + - name: Reject unresolved stable-stage intent env: EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} EXPECTED_WORKFLOW_ID: "344070109" @@ -413,9 +413,13 @@ jobs: } return result.stdout; }; + const workflowNumber = Number(workflowId); + const currentRunNumber = Number(currentRunId); if ( !/^[1-9][0-9]*$/u.test(workflowId) || !/^[1-9][0-9]*$/u.test(currentRunId) + || !Number.isSafeInteger(workflowNumber) + || !Number.isSafeInteger(currentRunNumber) || repository !== "hraness/kb" ) throw new Error("Stable-stage history identity is invalid"); const current = parseVersion(expectedVersion, "Candidate version"); @@ -434,6 +438,96 @@ jobs: if (compare(current, latest) <= 0) { throw new Error(`Candidate ${expectedVersion} is not newer than npm latest ${latestValue}`); } + const intentRuns = new Map(); + const resolutionCounts = new Map(); + const increment = (map, version) => map.set(version, (map.get(version) ?? 0) + 1); + const reserve = (version, runId) => { + parseVersion(version, `Reserved version from run ${runId}`); + const runs = intentRuns.get(version) ?? []; + runs.push(runId); + intentRuns.set(version, runs); + }; + const inspectRunJobs = (runId) => { + const jobsPayload = JSON.parse(execute("gh", [ + "api", + "--method", "GET", + `/repos/${repository}/actions/runs/${runId}/jobs?filter=all&per_page=100`, + ], `jobs for npm-stage run ${runId}`)); + if ( + !jobsPayload + || typeof jobsPayload !== "object" + || !Number.isSafeInteger(jobsPayload.total_count) + || jobsPayload.total_count < 0 + || jobsPayload.total_count > 100 + || !Array.isArray(jobsPayload.jobs) + || jobsPayload.jobs.length !== jobsPayload.total_count + ) throw new Error(`npm-stage run ${runId} exceeds the reviewed 100-job bound`); + for (const job of jobsPayload.jobs) { + if ( + !job + || typeof job !== "object" + || typeof job.name !== "string" + || !Array.isArray(job.steps) + || job.steps.length > 100 + ) { + throw new Error(`npm-stage run ${runId} contains an invalid job`); + } + if (!job.name.startsWith("Stage exact package")) continue; + const intents = job.steps.filter((step) => ( + step?.name === "Record exclusive stable-stage intent" + && step?.conclusion === "success" + )); + const resolutions = job.steps.filter((step) => ( + typeof step?.name === "string" + && step.name.startsWith("Record cleared stable-stage intent") + && step?.conclusion === "success" + )); + if (intents.length > 1 || resolutions.length > 1) { + throw new Error(`npm-stage run ${runId} has ambiguous intent history`); + } + const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); + if (intents.length === 0 && resolutions.length === 0) { + const legacy = legacyStages.get(String(runId)); + if ( + job.conclusion === "success" + && job.name === "Stage exact package" + ) { + if ( + legacy === undefined + || job.head_sha !== legacy.headSha + || job.run_attempt !== legacy.runAttempt + ) { + throw new Error(`Successful npm-stage run ${runId} lacks a version-bound intent`); + } + reserve(legacy.version, runId); + continue; + } + const legacyMutation = job.steps.filter((step) => ( + step?.name === "Revalidate current main and stage exact package" + && (step?.conclusion === "success" || step?.conclusion === "failure") + )); + if (match !== null && legacyMutation.length === 1) { + reserve(match[1], runId); + } + continue; + } + if (match === null) { + throw new Error(`npm-stage run ${runId} lacks a version-bound stage job`); + } + if (intents.length === 1) reserve(match[1], runId); + if (resolutions.length === 1) { + const resolutionMatch = /^Record cleared stable-stage intent v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(resolutions[0].name); + if (resolutionMatch === null) { + throw new Error(`npm-stage run ${runId} has an invalid cleared-intent identity`); + } + parseVersion(resolutionMatch[1], `Cleared version from run ${runId}`); + increment(resolutionCounts, resolutionMatch[1]); + } + } + }; + // A rerun is in progress and therefore absent from the completed-run + // query. Inspect all attempts of this run before completed dispatches. + inspectRunJobs(currentRunNumber); const runsPayload = JSON.parse(execute("gh", [ "api", "--method", "GET", @@ -448,7 +542,6 @@ jobs: || !Array.isArray(runsPayload.workflow_runs) || runsPayload.workflow_runs.length !== runsPayload.total_count ) throw new Error("Completed npm-stage history exceeds the reviewed 100-run bound"); - let resolvedStageSeen = false; for (const run of runsPayload.workflow_runs) { if ( !run @@ -456,66 +549,50 @@ jobs: || !Number.isSafeInteger(run.id) || run.id <= 0 || String(run.id) === currentRunId - || run.workflow_id !== Number(workflowId) + || run.workflow_id !== workflowNumber || run.event !== "workflow_dispatch" || run.head_branch !== "main" || run.status !== "completed" ) throw new Error("Completed npm-stage history contains an invalid run"); - const jobsPayload = JSON.parse(execute("gh", [ - "api", - "--method", "GET", - `/repos/${repository}/actions/runs/${run.id}/jobs?filter=all&per_page=100`, - ], `jobs for npm-stage run ${run.id}`)); + inspectRunJobs(run.id); + } + for (const [version, count] of resolutionCounts) { + if (count > (intentRuns.get(version)?.length ?? 0)) { + throw new Error(`Retained history has a cleared ${version} intent without its matching reservation`); + } + } + if (resolved !== null) { + const reservations = intentRuns.get(resolvedStageVersion)?.length ?? 0; + const resolutions = resolutionCounts.get(resolvedStageVersion) ?? 0; if ( - !jobsPayload - || typeof jobsPayload !== "object" - || !Number.isSafeInteger(jobsPayload.total_count) - || jobsPayload.total_count < 0 - || jobsPayload.total_count > 100 - || !Array.isArray(jobsPayload.jobs) - || jobsPayload.jobs.length !== jobsPayload.total_count - ) throw new Error(`npm-stage run ${run.id} exceeds the reviewed 100-job bound`); - for (const job of jobsPayload.jobs) { - if (!job || typeof job !== "object" || typeof job.name !== "string") { - throw new Error(`npm-stage run ${run.id} contains an invalid job`); - } - if (job.conclusion !== "success" || !job.name.startsWith("Stage exact package")) continue; - const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); - let stagedVersion; - if (match !== null) { - stagedVersion = match[1]; - } else { - const legacy = legacyStages.get(String(run.id)); - if ( - job.name !== "Stage exact package" - || legacy === undefined - || job.head_sha !== legacy.headSha - || job.run_attempt !== legacy.runAttempt - ) { - throw new Error(`Successful npm-stage run ${run.id} lacks a version-bound stage job`); - } - stagedVersion = legacy.version; - } - const staged = parseVersion(stagedVersion, `Staged version from run ${run.id}`); - if (compare(staged, latest) > 0) { - if ( - resolved !== null - && stagedVersion === resolvedStageVersion - && compare(staged, current) <= 0 - ) { - resolvedStageSeen = true; - continue; - } - throw new Error( - `Refusing to stage ${expectedVersion}: run ${run.id} already staged pending ${stagedVersion}`, - ); - } + reservations <= resolutions + || compare(resolved, latest) <= 0 + || compare(resolved, current) > 0 + ) { + throw new Error(`Resolved prior stage ${resolvedStageVersion} does not identify a blocking intent`); } + increment(resolutionCounts, resolvedStageVersion); } - if (resolved !== null && !resolvedStageSeen) { - throw new Error(`Resolved prior stage ${resolvedStageVersion} does not identify a blocking stage`); + for (const [version, runs] of intentRuns) { + const outstanding = runs.length - (resolutionCounts.get(version) ?? 0); + if (outstanding > 0 && compare(parseVersion(version, "Reserved stage"), latest) > 0) { + throw new Error( + `Refusing to stage ${expectedVersion}: run ${runs[runs.length - 1]} already reserved stable stage ${version}`, + ); + } } NODE + - name: Record cleared stable-stage intent v${{ inputs.resolved_stage_version }} + if: inputs.resolved_stage_version != '' + env: + RESOLVED_STAGE_VERSION: ${{ inputs.resolved_stage_version }} + run: | + set -euo pipefail + if [[ ! "$RESOLVED_STAGE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Cleared stable-stage intent identity is invalid" + exit 1 + fi + echo "Recorded the cleared retained-history intent for @hraness/kb@$RESOLVED_STAGE_VERSION" - name: Bind artifact reference env: ARTIFACT_NAME: ${{ needs.verify.outputs.artifact_name }} @@ -896,6 +973,18 @@ jobs: >> "$GITHUB_OUTPUT" printf 'tarball=%s\nmetadata=%s\ndigest=%s\n' \ "$tarball" "$metadata" "$digest" >> "$GITHUB_OUTPUT" + - name: Record exclusive stable-stage intent + env: + EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} + run: | + set -euo pipefail + if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ || \ + ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]*$ || \ + ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Stable-stage intent identity is invalid" + exit 1 + fi + echo "Reserved the exclusive retained-history intent for @hraness/kb@$EXPECTED_VERSION" - name: Revalidate current main and stage exact package env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} diff --git a/AGENTS.md b/AGENTS.md index 070105c..baecabf 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 successful version-bound Actions stage 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. A rejected npm stage may release only its exact durable history lock through the exceptional owner-authorized `resolved_stage_version` input; leave that input empty normally. 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` 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. 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. 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 d425d95..dd5cf63 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -112,16 +112,20 @@ Do not add an npm publishing token to GitHub. Preserve exact verified source SHA, and the explicit true input. A collaborator rerun, a missing or false input, a push, another branch, or a stale commit cannot reach npm. - Before mutation, the job also reads the bounded completed-run history for - this exact workflow. Every successful staging job carries its stable - version in the provider-owned job record. If any such version is newer than - public `dist-tags.latest`, the new run stops, so workflow concurrency 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 successful pre-versioned stage record is + Before mutation, the job reads bounded Actions history for this exact + workflow, including every retained attempt of the current run and completed + `main` dispatches. A successful version-bound intent step is recorded + immediately before the npm mutation. Any intent newer than public + `dist-tags.latest` stops a later run even when the original job failed or + 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 + successful pre-versioned stage record is sealed to run `33269920554`, attempt `1`, source `e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a`, and version `0.17.3`; every - later successful stage must carry its version in the Actions job name. + later mutation attempt must carry its version in the Actions job name and + its successful reservation step in the provider-owned job record. 4. Batch the unavoidable human gate into an intentional stable release, then inspect and approve the staged package through npm with two-factor authentication. @@ -146,9 +150,14 @@ If npm rejects a candidate, reject that exact staged version through npm first (npm requires two-factor authentication for rejection). Then dispatch the replacement from current `main` with `publish_to_npm=true` and `resolved_stage_version=`. This owner-authorized exceptional -input releases only that matching Actions-history lock; leave it empty for all -normal releases. Approval needs no override because the promoted version -becomes public `latest` and releases the lock automatically. +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. 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 +180,8 @@ identity, filename, inventory, count, modes, sizes, SHA-1, SHA-512, and the independent SHA-256 manifest before mutation. Immediately before staging, it independently parses the packed manifest, rejects npm's top-level `tag` -override, rejects an unresolved prior successful stage from the durable -Actions run history, fetches current `main` into a new bare Git directory, +override, rejects an unresolved prior mutation intent from durable all-attempt +Actions history, fetches current `main` into a new bare Git directory, then rehashes all three files and invokes only `npm stage publish` against `https://registry.npmjs.org`. It rejects ambient tag configuration, runs from an empty directory with empty user/global npm config, and proves pinned npm's diff --git a/scripts/check-workflow-yaml.test.ts b/scripts/check-workflow-yaml.test.ts index fa58757..edf1646 100644 --- a/scripts/check-workflow-yaml.test.ts +++ b/scripts/check-workflow-yaml.test.ts @@ -80,7 +80,9 @@ jobs: "actions: read", "id-token: write", "Reauthorize current npm staging attempt", - "Reject another pending stable stage", + "Reject unresolved stable-stage intent", + "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}", + "Record exclusive stable-stage intent", "Verified package version components exceed Number.MAX_SAFE_INTEGER", 'EXPECTED_WORKFLOW_ID: "344070109"', "attempt.triggering_actor?.id !== actorId", diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index 6e90895..e9bd51c 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -222,9 +222,9 @@ export function validateNpmStageWorkflow(source: string, label: string): void { throw new Error(`${label} ${stepName} must reject unsafe stable-version components`); } } - const pendingStageStep = steps.find((step) => step.name === "Reject another pending stable stage"); + const pendingStageStep = steps.find((step) => step.name === "Reject unresolved stable-stage intent"); if (pendingStageStep === undefined || typeof pendingStageStep.run !== "string") { - throw new Error(`${label} must reject another unresolved successful stage`); + throw new Error(`${label} must reject another unresolved stage intent`); } if (!pendingStageStep.run.includes("BigInt(Number.MAX_SAFE_INTEGER)")) { throw new Error(`${label} pending-stage guard must reject unsafe stable-version components`); @@ -244,8 +244,11 @@ export function validateNpmStageWorkflow(source: string, label: string): void { for (const required of [ "Completed npm-stage history exceeds the reviewed 100-run bound", "Stage exact package v", - "already staged pending", - "does not identify a blocking stage", + "already reserved stable stage", + "does not identify a blocking intent", + "Record exclusive stable-stage intent", + "Record cleared stable-stage intent", + "jobs?filter=all&per_page=100", 'execute("npm", [', "dist-tags.latest", ]) { @@ -267,6 +270,36 @@ export function validateNpmStageWorkflow(source: string, label: string): void { if (publicationStep === undefined || typeof publicationStep.run !== "string") { throw new Error(`${label} staged-publication command is missing`); } + const intentSteps = steps.filter((step) => step.name === "Record exclusive stable-stage intent"); + const resolutionSteps = steps.filter((step) => + step.name === "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}"); + if ( + intentSteps.length !== 1 + || resolutionSteps.length !== 1 + || steps.indexOf(intentSteps[0]!) !== steps.indexOf(publicationStep) - 1 + || steps.indexOf(resolutionSteps[0]!) <= steps.indexOf(pendingStageStep) + || steps.indexOf(resolutionSteps[0]!) >= steps.indexOf(intentSteps[0]!) + ) { + throw new Error(`${label} must persist resolution and exclusive intent immediately before mutation`); + } + const intentStep = intentSteps[0]!; + const resolutionStep = resolutionSteps[0]!; + const intentEnvironment = record(intentStep.env, `${label} stable-stage intent environment`); + const resolutionEnvironment = record( + resolutionStep.env, + `${label} stable-stage resolution environment`, + ); + if ( + typeof intentStep.run !== "string" + || intentEnvironment.EXPECTED_VERSION !== "${{ needs.verify.outputs.package_version }}" + || !intentStep.run.includes("$GITHUB_RUN_ID") + || !intentStep.run.includes("$GITHUB_RUN_ATTEMPT") + || resolutionStep.if !== "inputs.resolved_stage_version != ''" + || typeof resolutionStep.run !== "string" + || resolutionEnvironment.RESOLVED_STAGE_VERSION !== "${{ inputs.resolved_stage_version }}" + ) { + 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", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 20791dd..7925068 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -402,10 +402,13 @@ describe("npm release workflows", () => { 'PUBLISH_TO_NPM: ${{ inputs.publish_to_npm }}', 'REF_PROTECTED: ${{ github.ref_protected }}', "attempt.triggering_actor?.id !== actorId", - "Reject another pending stable stage", + "Reject unresolved stable-stage intent", "Completed npm-stage history exceeds the reviewed 100-run bound", - "already staged pending", + "already reserved stable stage", "RESOLVED_STAGE_VERSION: ${{ inputs.resolved_stage_version }}", + "Record cleared stable-stage intent v${{ inputs.resolved_stage_version }}", + "Record exclusive stable-stage intent", + "jobs?filter=all&per_page=100", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "Downloaded npm artifact must contain exactly the tarball, npm-pack.json, and npm-package.sha256", 'expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz"', @@ -452,7 +455,8 @@ describe("npm release workflows", () => { 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 another pending stable stage"); + 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 rehashIndex = stageJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); @@ -465,6 +469,8 @@ describe("npm release workflows", () => { expect(fetchIndex).toBeLessThan(tagLookupIndex); expect(tagLookupIndex).toBeLessThan(rehashIndex); expect(rehashIndex).toBeLessThan(stageIndex); + expect(intentIndex).toBeGreaterThan(pendingStageIndex); + expect(intentIndex).toBeLessThan(stageIndex); expect(workflow).not.toContain("secrets.NPM_TOKEN"); expect(workflow).not.toContain("NODE_AUTH_TOKEN"); expect(workflow).not.toMatch(/\bnpm publish\b/u); @@ -645,11 +651,12 @@ describe("npm release workflows", () => { } }); - test("a completed stage remains a durable lock until that version is public latest", async () => { + test("a successful intent remains a durable lock across failed jobs and reruns", async () => { const workflow = await readFile(stageWorkflowUrl, "utf8"); - const script = workflowStepScript(workflow, "Reject another pending stable stage"); + const script = workflowStepScript(workflow, "Reject unresolved stable-stage intent"); const directory = await mkdtemp(join(tmpdir(), "kb-stage-history-")); const binaryDirectory = join(directory, "bin"); + const currentJobsPath = join(directory, "current-jobs.json"); const runsPath = join(directory, "runs.json"); const jobsPath = join(directory, "jobs.json"); try { @@ -669,6 +676,7 @@ describe("npm release workflows", () => { "set -euo pipefail", 'case "$*" in', ' *"/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" ;;', ' *) echo "unexpected gh request: $*" >&2; exit 2 ;;', "esac", @@ -687,6 +695,14 @@ describe("npm release workflows", () => { status: "completed", }], })), + writeFile(currentJobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + conclusion: null, + name: "Stage exact package v0.19.2", + steps: [], + }], + })), ]); const environment = { PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, @@ -694,6 +710,7 @@ describe("npm release workflows", () => { EXPECTED_WORKFLOW_ID: "344070109", GITHUB_REPOSITORY: "hraness/kb", GITHUB_RUN_ID: "67890", + MOCK_CURRENT_JOBS_JSON: currentJobsPath, MOCK_NPM_LATEST: "0.19.0", MOCK_RUNS_JSON: runsPath, MOCK_JOBS_JSON: jobsPath, @@ -702,19 +719,30 @@ describe("npm release workflows", () => { await writeFile(jobsPath, JSON.stringify({ total_count: 1, - jobs: [{ name: "Stage exact package v0.19.0", conclusion: "success" }], + jobs: [{ + name: "Stage exact package v0.19.0", + conclusion: "success", + steps: [{ name: "Record exclusive stable-stage intent", conclusion: "success" }], + }], })); const released = await runWorkflowScript(script, environment); expect(released.exitCode).toBe(0); await writeFile(jobsPath, JSON.stringify({ total_count: 1, - jobs: [{ name: "Stage exact package v0.19.1", conclusion: "success" }], + jobs: [{ + name: "Stage exact package v0.19.1", + conclusion: "failure", + steps: [ + { name: "Record exclusive stable-stage intent", conclusion: "success" }, + { name: "Revalidate current main and stage exact package", conclusion: "failure" }, + ], + }], })); const pending = await runWorkflowScript(script, environment); expect(pending.exitCode).not.toBe(0); expect(pending.stderr).toContain( - "run 12345 already staged pending 0.19.1", + "run 12345 already reserved stable stage 0.19.1", ); const rejectedInNpm = await runWorkflowScript(script, { @@ -723,13 +751,31 @@ describe("npm release workflows", () => { }); expect(rejectedInNpm.exitCode).toBe(0); + await writeFile(jobsPath, JSON.stringify({ + total_count: 2, + jobs: [{ + name: "Stage exact package v0.19.2", + conclusion: "failure", + steps: [ + { name: "Record cleared stable-stage intent v0.19.1", conclusion: "success" }, + { name: "Record exclusive stable-stage intent", conclusion: "skipped" }, + ], + }, { + name: "Stage exact package v0.19.1", + conclusion: "failure", + steps: [{ name: "Record exclusive stable-stage intent", conclusion: "success" }], + }], + })); + const durableResolution = await runWorkflowScript(script, environment); + expect(durableResolution.exitCode).toBe(0); + await writeFile(jobsPath, JSON.stringify({ total_count: 1, - jobs: [{ name: "Stage exact package", conclusion: "success" }], + jobs: [{ name: "Stage exact package", conclusion: "success", steps: [] }], })); const unboundHistory = await runWorkflowScript(script, environment); expect(unboundHistory.exitCode).not.toBe(0); - expect(unboundHistory.stderr).toContain("lacks a version-bound stage job"); + expect(unboundHistory.stderr).toContain("lacks a version-bound intent"); await Promise.all([ writeFile(runsPath, JSON.stringify({ @@ -749,11 +795,36 @@ describe("npm release workflows", () => { conclusion: "success", head_sha: "e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a", run_attempt: 1, + steps: [], }], })), ]); const sealedLegacyStage = await runWorkflowScript(script, environment); expect(sealedLegacyStage.exitCode).toBe(0); + + await Promise.all([ + writeFile(runsPath, JSON.stringify({ total_count: 0, workflow_runs: [] })), + writeFile(currentJobsPath, JSON.stringify({ + total_count: 2, + jobs: [{ + conclusion: "failure", + name: "Stage exact package v0.19.1", + steps: [ + { name: "Record exclusive stable-stage intent", conclusion: "success" }, + { name: "Revalidate current main and stage exact package", conclusion: "failure" }, + ], + }, { + conclusion: null, + name: "Stage exact package v0.19.2", + steps: [], + }], + })), + ]); + const sameRunRerun = await runWorkflowScript(script, environment); + expect(sameRunRerun.exitCode).not.toBe(0); + expect(sameRunRerun.stderr).toContain( + "run 67890 already reserved stable stage 0.19.1", + ); } finally { await rm(directory, { recursive: true, force: true }); } @@ -1251,6 +1322,8 @@ describe("npm release workflows", () => { "allows only `main`", "original actor and triggering actor", "current attempt", + "including every retained attempt", + "successful version-bound intent step", "`actions: read` and `id-token: write`", "`Number.MAX_SAFE_INTEGER`", "`npm audit signatures --json", @@ -1274,6 +1347,8 @@ describe("npm release workflows", () => { expect(guide).toMatch(/exactly the tarball,\s+`npm-pack\.json`, and `npm-package\.sha256`/u); 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(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"); @@ -1282,6 +1357,10 @@ describe("npm release workflows", () => { expect(agents).toContain("`actions: read` plus `id-token: write`"); 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"); + expect(agents).toContain("scan every retained attempt"); + expect(agents).toContain("cannot list stages"); + expect(agents).toContain("do not claim this workflow prevents out-of-band stages"); expect(agents).toContain("sole main source commit"); expect(agents).toContain("The protected tag workflow must bind the actor and event sender"); expect(agents).toContain("public repository ID `1308971873`");