diff --git a/.codex/skills/release-oliphaunt/SKILL.md b/.codex/skills/release-oliphaunt/SKILL.md index 4d4d3277..20ab923b 100644 --- a/.codex/skills/release-oliphaunt/SKILL.md +++ b/.codex/skills/release-oliphaunt/SKILL.md @@ -25,9 +25,9 @@ and do not treat target/ecosystem carriers as additional products. 2. For registry/GitHub setup, identity bootstrap, or trusted-publisher work, also read `docs/maintainers/release-setup.md`. 3. For a failed or partially public release, also read `references/recovery.md` before changing state. -4. Record the candidate commit with `git rev-parse HEAD`; keep that SHA unchanged through qualification, lock creation, and publish. +4. Record the candidate commit with `git rev-parse HEAD`; keep that SHA unchanged through qualification, lock creation, and publish. The only exception is the documented same-version control recovery: its later current-main controller receives fresh qualification, while the original release commit/tree remains the immutable publication source and retains its byte-identical approved lock. 5. Inspect `git status`, product versions, existing product tags/releases, registry identities, and the latest exact-SHA CI run. Report any public collision before attempting a mutation. -6. Run `tools/dev/bun.sh tools/release/audit-github-release-controls.mjs` with the truthful credential lifecycle before any external mutation. Use `--governance solo --bootstrap-state idle` for history repair, qualification, release-PR preparation, and dry-run while bootstrap tokens are absent. Rerun with `--bootstrap-state ready` only for an imminent first-identity bootstrap after every reviewed short-lived token required by the approved lock is installed (one registry or both; the current first release needs both); use `retired` after trusted publishers are configured and every provisioned token is revoked. Select `team` only with an independent maintainer. Treat `FAIL` as a blocker; report but do not promote `WARN` to a solo-release blocker. +6. Run `tools/dev/bun.sh tools/release/audit-github-release-controls.mjs` with the truthful credential lifecycle before any external mutation. Use `--governance solo --bootstrap-state idle` for history repair, qualification, release-PR preparation, and dry-run while bootstrap tokens are absent. Rerun with `--bootstrap-state ready` only for an imminent first-identity bootstrap after every reviewed short-lived token required by the approved lock is installed (one registry or both). If exact inventory proves that all selected Cargo/npm identities already match, keep the credential lifecycle `idle` and provision neither token. Use `retired` after trusted publishers are configured and every provisioned token is revoked. Select `team` only with an independent maintainer. Treat `FAIL` as a blocker; report but do not promote `WARN` to a solo-release blocker. 7. Generate trusted-publisher work from the approved publication lock with `tools/dev/bun.sh tools/release/trusted-publisher-config.mjs`. Its default mode is offline/read-only. Use authenticated `--audit` before considering `--apply`; mutation additionally requires the exact printed lock digest. Run npm audit and apply directly in a terminal because each classification pass starts with a discarded read-only TTY authentication warm-up before the bounded captured reads, and supply a fresh `--output` path for the atomically created mode-`0600` JSON evidence. Configure the direct workflow `release.yml` and `release-publish` environment. Keep release credentials only in their protected environments; do not add repository-level copies or a reusable-workflow secret bridge. 8. On a generated release PR, treat Release Please as the direct-candidate authority and `sync-release-pr.mjs` as the deterministic dependent-candidate @@ -51,7 +51,11 @@ Treat `.github/workflows/release.yml` as the sole release workflow. Its credential-bearing jobs directly select their protected environments. Its environment-free, secret-free continuation dispatchers consume only the typed outputs of `publish-bootstrap` or `publish-registry` and dispatch the sealed -exact-parent pointer; never route a continuation around that DAG. +exact-parent pointer; never route a continuation around that DAG. Continuations +are available only to normal single-identity publication, where source equals +controller. Same-version dual-identity recovery rejects bootstrap and +continuation inputs and resumes only through an idempotent root `publish` +rerun. A root `publish-bootstrap` or `publish` dispatch must run from the qualified current `main` commit. At the mutation boundary the transport helper first @@ -75,7 +79,16 @@ another repository mutation. - Prepare: synchronize release-owned files, run release checks, create the generated release PR, and stop for review. - Bootstrap: use the dedicated bootstrap environment only for identities that cannot use trusted publishing until their first package exists, including generated part identities introduced by a future lock. For npm, require a short-lived granular token with explicit `@oliphaunt` scope selection, Packages and scopes `Read and write`, and 2FA bypass, owned by a 2FA-enabled actor with scope write access; an ordinary token can authenticate yet fail the noninteractive publish with `EOTP`. Require one successful exact-SHA dry-run containing both `oliphaunt-publication-lock` and `oliphaunt-bootstrap-capsule`; select one run ID, verify the capsule's embedded lock against the separately downloaded lock, and publish only those frozen Cargo/npm bytes without rebuilding. Inventory the exact lock first. Model crates.io's documented token bucket; never accept an unverifiable numeric capacity assertion. Execute one sequential Cargo lane and one sequential npm lane, overlap only independent carriers, and preserve every lock dependency as a barrier. If one hosted job cannot finish, flush and upload the canonical hash-chained checkpoint before a separate credential-free job dispatches a bounded exact-parent continuation. Bind it to release/lock/package identity and exact artifact ID/digest/size, and permit zero-progress recursion only for explicitly typed, finite-budget rate-limit/deadline continuations. A valid `429 Retry-After` may defer; ambiguous uploads, timeouts, integrity mismatches, malformed responses, and checkpoint failures remain hard failures. After every identity has a receipt, use that exact lock with `tools/release/trusted-publisher-config.mjs`: its default plan has no network access, `--audit` is read-only, and mutation requires both `--apply` and the exact `--confirm-lock-digest`. Run npm audit/apply in a real TTY and retain each fresh `--output` JSON report, never the discarded authentication warm-up display. Require workflow `release.yml`, environment `release-publish`, and npm publish-only permission; reject extra or mismatched configurations. Revoke long-lived credentials, then resume normal publish. - Publish: require a successful exact-SHA `Qualified` gate, complete artifact set with binary compatibility-floor evidence, current full-lifecycle WASIX evidence when selected, frozen publication lock, the exact Release Please PR markability proof, and the all-registry rate-aware admission preflight. Before crossing the mutation boundary, build and validate the complete signed Maven Central bundle locally (including sources/javadocs and the strict size ceiling), and prove the lock-derived Swift semantic tag is absent or already resolves to the exact deterministic manifest commit. At that boundary, admit the root through the immutable transport rule above before any product tag, draft, asset, or registry mutation. Normal publish remains logically ordered as stage GitHub drafts/assets/attestations, publish the exact registry topology, then verify public consumers and promote. A registry continuation may span hosted jobs but must reuse the original exact stage handoff and immutable checkpoint; finalization is disabled until receipts are exhaustive. Every normal continuation must also carry the latest root-lineage-bound GitHub content-write pacer and core-request journal, merge the child's pre-install reads monotonically, and reject reset, replay, or substitution. Transfer state only through manifest-exact artifacts downloaded by immutable ID: reuse the approved Cargo/npm capsule rather than retransferring its carriers, send only required non-capsule registry inputs in the stage handoff, and send receipts only to finalization. Reuse a complete verified bootstrap ledger rather than serially reproving its Cargo/npm identities. Execute one bounded sequential lane per registry, overlap independent lanes, honor cross-registry DAG barriers, and assemble callback-returned receipts into an exhaustive exact-lock receipt set (an empty topology is valid for source-only products). Model crates.io's version token bucket with upload work overlapping refill and treat only valid server `Retry-After` state as authoritative; never require an unverifiable capacity secret. After receipt verification, run the lock-derived anonymous public Cargo/npm/Maven/JSR and Git/Swift consumer lanes concurrently from fresh caches under one shared deadline; resolve each entry root independently, require its platform-independent resolver lock to cover the complete frozen dependency closure, retry only transient visibility/network failures in a new cache, preserve deterministic receipt-bound evidence, and then reassert Release Please markability, promote GitHub drafts, add the tagged label, and remove the pending label as the literal final step. The additive/removal label operations are idempotent and preserve unrelated labels. Distinguish resolver coverage from host-installed/fetched payloads; never relabel a missing lock dependency as receipt-only. Treat Swift as a public source-tag/manifest proof before promotion, never as a claim that draft binary-target assets are anonymously downloadable. npm's trusted credential cannot move dist-tags, so each exact npm version receives its normal tag during publication. -- Recover: inventory external state first. Resume idempotently from the publication ledger; never delete or overwrite immutable public versions. +- Recover: inventory external state first. For ordinary single-identity + recovery, resume idempotently from the publication ledger. For an authorized + same-version control recovery, keep the original commit/tree, pinned complete + payload CI inventory, approved lock/capsule, and terminal ledger as the + publication source; require fresh full CI and approved control equivalence on + the later controller; require lock replay to be byte-identical including + `source` and `lockDigest`; keep tags/releases/assets source-bound; disable + bootstrap and continuations; and rerun root `publish` to reconcile missing + state. Never delete, overwrite, or republish a matching immutable public + version. - History repair: use only before any affected product tag/package is public. Follow `references/recovery.md` and require explicit maintainer authorization for protection changes or force-push. ## Local gates @@ -99,8 +112,13 @@ When a shared packager, archive encoder, carrier generator, or public target contract changes, update its exact product ownership in `tools/release/release-semantic-inputs.toml`, run the synchronizer with `--write`, and inspect the product-local fingerprint diff before `--check`. -Workflow, validation, registry-transport, test, and documentation-only files -must remain outside that ownership map. +Pure control-plane workflow, validation, registry-transport, test, and +documentation inputs remain outside that ownership map. Compiler, SDK, linker, +build-command, source-selection, target, and packaging choices are +product-semantic and must be owned even when a workflow or local action +currently carries them. Until such a choice has been extracted into an owned +canonical input, treat a change to it as a release blocker rather than a +zero-owner CI-only change. For a normalized generated release PR, also run the synchronizer in write mode and immediately in check mode. It follows only Moon production/peer edges, diff --git a/.codex/skills/release-oliphaunt/references/invariants.md b/.codex/skills/release-oliphaunt/references/invariants.md index e5c47412..2647ef80 100644 --- a/.codex/skills/release-oliphaunt/references/invariants.md +++ b/.codex/skills/release-oliphaunt/references/invariants.md @@ -7,13 +7,30 @@ member paths/checksums inside each target carrier. External extensions own independent packaging SemVer and record their upstream version/commit separately. -- The release commit, qualified workflow head, artifact attestations, publication lock source SHA/tree, and product tags must agree exactly. +- Normally the generated release-bump commit, qualified workflow head, artifact + attestations, publication lock source SHA/tree, and product tags agree + exactly. The sole post-publication control-recovery exception splits those + identities: the original release-bump commit/tree remains the immutable + publication source, while a later current-main commit is only the freshly + qualified workflow controller. Never rewrite the lock source or relabel + product evidence as controller output. +- Same-version recovery selects the complete original payload CI inventory, + approved lock/capsule, and terminal bootstrap ledger by exact committed + run/artifact ID, digest, and size. Its replayed publication lock must be + byte-identical to the approved original, including `source` and `lockDigest`. + The current first-release recovery requires all 73 recorded CI artifacts. +- Product tags/releases/assets, Swift source publication, registry receipts, + and consumer-facing provenance remain publication-source-bound. Workflow + code, the transport tag, OIDC claims, request journals, and pacing are + controller-bound. Dual-identity recovery evidence must bind both. - Extension evidence runs are immutable observations. Claim regeneration never changes them, and current WASIX support is qualified only by the full lifecycle collector running against same-workflow exact-SHA artifacts and recording that commit/tree/run identity. - The publication lock is exhaustive: reject undeclared and missing packages/assets as well as hash, size, dependency, target, or version drift. - Every shared published-byte producer or public target contract has exactly one declarative ownership rule and content-addressed Release Please - fingerprints under every affected product root; policy and transport-only - edits do not create product releases. + fingerprints under every affected product root. Pure control-plane policy + and transport-only edits do not create product releases; compiler, SDK, + build, source-selection, target, and packaging changes remain + product-semantic even when implemented in a workflow or local action. - Generate the lock after artifact assembly. Freeze it before any external write. Preserve it with the release ledger. - Publish leaves/parts before aggregators, target carriers before façades, runtime artifacts before SDKs, and packages before public GitHub release promotion. - Every extension Cargo `*-wasix` portable carrier and each of its dynamic payload parts records the explicit canonical target `wasix-portable`; portable extension targets are never inferred from a null target. @@ -24,4 +41,15 @@ - Before promotion, derive every applicable public consumer surface and dependency closure from the exact lock; probe each anonymous Cargo/npm/Maven/JSR entry independently plus Git/Swift in fresh caches under one deadline, require every resolver lock to contain its complete frozen closure, retry only transient visibility failures, and preserve deterministic evidence bound to both immutable receipt sets. Never hide a missing lock dependency in a receipt-only category. A macOS host install does not prove every OS carrier, and a pre-promotion Swift probe proves the public source tag/manifest rather than draft binary-target availability. - Normal npm and JSR publication uses GitHub-hosted OIDC. Normal Cargo publication exchanges OIDC for a fresh temporary token per bounded carrier batch and revokes it in `finally`; Maven credentials remain protected environment secrets. Bootstrap credentials are short-lived, isolated, and revoked after exact trusted-publisher configuration is audited; npm bootstrap specifically requires a granular `@oliphaunt` read/write token with 2FA bypass from a 2FA-enabled actor. - A pure version/changelog update may change the package envelope and lock, but must not change the WASIX binary-semantic input fingerprint. -- Never reuse a public version, move a public product tag, or force-push a history containing affected public releases. +- Never upload an immutable public version twice, move a public product tag, or + force-push a history containing affected public releases. A same-version + control recovery may only checksum/SRI-reconcile an already-public carrier; + one byte of drift fails closed and requires a new version. +- Same-version recovery cannot run bootstrap or any continuation. Resume only + through an idempotent root `publish` rerun that verifies the original + terminal ledger and reconciles every exact immutable identity before writes. +- With a clean release state, a pure zero-owner control-plane, workflow, + validator, registry-transport, test, or documentation change creates no + release PR and performs no publication. Semantic ownership, not a `ci:` + subject, decides; compiler, SDK, build, source-selection, target, and + packaging changes require releases. diff --git a/.codex/skills/release-oliphaunt/references/recovery.md b/.codex/skills/release-oliphaunt/references/recovery.md index 8cd4aeae..68ee6038 100644 --- a/.codex/skills/release-oliphaunt/references/recovery.md +++ b/.codex/skills/release-oliphaunt/references/recovery.md @@ -5,10 +5,88 @@ 1. Freeze further publication and save the workflow URL, candidate SHA, publication lock, complete checkpoint chain, and registry responses. 2. Validate every checkpoint digest and previous-checkpoint link. Confirm the source SHA/tree, lock/catalog digests, package envelope, and selected products are unchanged. Never hand-edit or truncate the chain. 3. Query every expected identity and GitHub tag/release. Classify it as absent, present-and-byte-matching, or conflicting. An existence-only response is not matching evidence. -4. Fix only the failed phase. Requalify a new commit if repository code/configuration changes; never attach old artifacts or a prior ledger to it. +4. Fix only the failed phase. A product-semantic code/configuration change + requires a new version, new source commit, and new qualification. A + zero-owner control-only fix may use the explicit dual-identity exception + below; outside that exception, never attach old artifacts or a prior ledger + to a new commit. 5. Resume in dependency order from the newest validated checkpoint. Re-inventory the complete exact lock first: a carrier accepted before an ambiguous response or checkpoint interruption is recovery input, not permission to upload again. Skip it only after its registry checksum/SRI/payload/file manifest matches the frozen bytes. Bootstrap resumes with one sequential Cargo lane and one sequential npm lane, preserves cross-lane dependency barriers, and serializes canonical checkpoint appends. 6. Seal bootstrap only after every expected identity has a receipt. Promote draft GitHub releases only after the final all-registry receipt proof and exact-lock anonymous public-consumer probes produce their deterministic receipt-bound evidence. Retry a transient visibility failure only from a fresh cache under the original shared deadline; do not retry an exact identity/source/closure mismatch. Swift remains a source-tag/manifest probe before promotion because draft binary-target assets are not anonymously public. npm's normal tag is attached by its immutable version publish because OIDC does not authorize a later dist-tag mutation. +### Same-version control recovery after partial publication + +Use this path only when at least one immutable carrier is already public, no +product tag/release has been promoted, and the required repository fix has no +release-semantic product owner. + +1. Keep the original release-bump commit and public history immutable. Every + recovery commit must be a linear descendant with subject + `fix(release): ...` and exactly one + `Oliphaunt-Release-Recovery-Of: ` trailer naming + the same original generated release-bump commit. +2. `verify-publication-candidate.mjs` must prove the anchor is the valid + release-bump commit for the exact selected products, every intervening + commit has the same authorization, release metadata and versions are + unchanged, the authoritative base/head release plan selects zero products, + and every changed shared path has zero owners in + `release-semantic-inputs.toml`. Do not use this path for a source, carrier, + compatibility, target-support, version, changelog, or owned byte-producer + change. +3. Name the identities explicitly. The trailer target is the immutable + **publication source**: it owns the original commit/tree, product bytes, + versions, approved publication lock/capsule, terminal bootstrap ledger, + product tags/releases/assets, Swift source tag, registry receipts, and + consumer-facing provenance. The later current-main recovery head is only the + **controller**: it owns workflow code, its fresh CI/run identity, the release + transport tag, OIDC claims, request journals, and pacing. +4. Run fresh complete CI on the controller. Then run `publish-dry-run` on that + controller to produce and approve recovery-control equivalence evidence. + This dry-run must not upload a replacement publication lock or bootstrap + capsule. +5. Resolve the committed immutable recovery record. Select the original source + SHA/tree, complete payload CI run, approved dry-run lock/capsule, and terminal + bootstrap ledger only by the exact recorded workflow run and artifact + ID/digest/size. For the current first-release recovery, compare the complete + observed CI inventory with all 73 recorded artifacts. Do not select “latest,” + fall back to artifact name alone, or accept a merely same-SHA run. +6. Reassemble only from those pinned original payload artifacts. Replay + publication-lock construction at the original source and require the + resulting file to be byte-identical to the approved original lock, including + the `source` object and `lockDigest`. Preserve the controller/source + equivalence receipt. A lock rebound to the controller is a provenance + mismatch even when every package-envelope byte is equal. +7. Verify the pinned terminal source-bound bootstrap ledger against the + original lock. Recovery bootstrap is disabled: do not create a new + controller-bound ledger, request bootstrap credentials, or invoke + `publish-bootstrap`. +8. Derive the exhaustive Cargo/npm/Maven/JSR inventory from the original frozen + lock so generated payload-part carriers cannot disappear behind the static + catalog. A matching public identity is a read-only recovery skip and must + never reach a publisher. Publish an absent exact identity once from the + frozen source payload. Any byte conflict stops the release and requires a + new version. +9. Stage and finalize every product tag, GitHub release, asset, Swift source + tag, registry receipt, product subject/source field, and public-consumer + proof at the original publication source. Use the controller-issued custom + recovery predicate to bind the fresh controller CI and approved control + equivalence to that frozen source evidence; never pretend the original + payload was built by the controller. Complete the original Release Please PR + lifecycle named by the trailer. +10. Bootstrap and publish continuations are disabled for dual-identity + recovery. If any recovery job is interrupted, rerun root `publish` on the + current controller. It must reselect the same pinned source evidence, + byte-reconcile already completed immutable state, and write only identities + that remain absent. + +This is not the normal response to a CI-only change. Before a release PR, pure +control-plane workflow, test, documentation, and transport-only commits select +no products; Release Please creates no release and no registry operation runs. +A workflow or action change that alters compiler, SDK, build, source-selection, +target, or packaging semantics is product-semantic regardless of its `ci:` +subject and requires the affected product versions to advance. The exception +exists only to finish an already-partial immutable release without fabricating +a duplicate version. + ## Pre-publication main-history repair This path is forbidden after any affected product tag/package is public. diff --git a/.github/scripts/check-release-intent.sh b/.github/scripts/check-release-intent.sh index 1a35230e..0879f613 100755 --- a/.github/scripts/check-release-intent.sh +++ b/.github/scripts/check-release-intent.sh @@ -415,6 +415,16 @@ if [[ "${is_release_pr}" == true ]]; then --head-ref "${head_ref}" fi +# A same-version partial-publication recovery is not a product release, but it +# must fail before expensive planning unless its exact original release, +# linear trailer chain, zero-product impact, and unchanged metadata all verify. +if git show -s --format=%B "${head_ref}^{commit}" | + grep -qi "^Oliphaunt-Release-Recovery-Of:"; then + tools/dev/bun.sh tools/release/verify-publication-candidate.mjs \ + --derive-products \ + --head-ref "${head_ref}" +fi + release_plan="$(tools/dev/bun.sh tools/release/release_plan.mjs --base-ref "${base_ref}" --head-ref "${head_ref}" --format json)" release_products="$( bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log((data.releaseProducts ?? []).join("\n"));' <<< "${release_plan}" diff --git a/.github/scripts/download-bootstrap-ledger.test.mjs b/.github/scripts/download-bootstrap-ledger.test.mjs index 7f99a866..33f48137 100644 --- a/.github/scripts/download-bootstrap-ledger.test.mjs +++ b/.github/scripts/download-bootstrap-ledger.test.mjs @@ -14,6 +14,7 @@ import { import os from "node:os"; import path from "node:path"; import { spawnSync } from "../../tools/test/fd-backed-spawn-sync.mjs"; +import { isolatedGitHubTestEnvironment } from "../../tools/test/isolated-github-test-environment.mjs"; import test from "node:test"; import { @@ -226,8 +227,7 @@ function invoke(fixture, { return spawnSync("node", [SCRIPT], { encoding: "utf8", timeout: BOOTSTRAP_LEDGER_PROCESS_TIMEOUT_MS, - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${fixture.bin}${path.delimiter}${process.env.PATH}`, BOOTSTRAP_LEDGER_PATH: fixture.destination, FAKE_ARTIFACTS_BY_RUN: JSON.stringify(artifactsByRun), @@ -239,13 +239,16 @@ function invoke(fixture, { FAKE_RUNS: JSON.stringify(runs), FAKE_ZIPS_BY_ARTIFACT: JSON.stringify(zipsByArtifact), GH_REPO: "f0rr0/oliphaunt", + GH_TOKEN: "test-token", GITHUB_OUTPUT: fixture.output, + GITHUB_REPOSITORY: "f0rr0/oliphaunt", GITHUB_RUN_ATTEMPT: String(attempt), GITHUB_RUN_ID: "900", + GITHUB_SHA: SHA, RELEASE_HEAD_SHA: SHA, OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), }); } diff --git a/.github/scripts/download-normal-publication-checkpoint.mjs b/.github/scripts/download-normal-publication-checkpoint.mjs index e530fedf..74034215 100644 --- a/.github/scripts/download-normal-publication-checkpoint.mjs +++ b/.github/scripts/download-normal-publication-checkpoint.mjs @@ -49,6 +49,7 @@ import { stableJson, validateReleaseContinuationPointer, } from "../../tools/release/release-continuation-contract.mjs"; +import { resolveReleaseSourceCommit } from "../../tools/release/release-source-identity.mjs"; import { captureCommandOutput } from "../../tools/dev/capture-command-output.mjs"; import { openContinuationEnvelope } from "./release-continuation-artifact.mjs"; @@ -364,7 +365,17 @@ export async function main() { const repo = required("GH_REPO"); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo)) fail("GH_REPO must be owner/repository"); const sha = required("RELEASE_HEAD_SHA"); - if (!/^[0-9a-f]{40}$/u.test(sha)) fail("RELEASE_HEAD_SHA must be a full lowercase commit SHA"); + let sourceSha; + try { + sourceSha = resolveReleaseSourceCommit({ + controlCommit: sha, + sourceCommit: process.env.RELEASE_SOURCE_SHA, + }, { prefix: "download-normal-publication-checkpoint" }); + } catch (cause) { + fail(cause instanceof Error + ? cause.message.replace(/^download-normal-publication-checkpoint:\s*/u, "") + : String(cause)); + } const currentRunId = positiveInteger(required("GITHUB_RUN_ID"), "GITHUB_RUN_ID"); const destination = path.resolve( ROOT, @@ -373,7 +384,7 @@ export async function main() { const lockFile = path.resolve(ROOT, required("PUBLICATION_LOCK_PATH")); const products = productsFromEnvironment(); const lock = loadPublicationLock(lockFile); - assertPublicationLockSource(lock, sha); + assertPublicationLockSource(lock, sourceSha); const plan = normalPublicationPlan(lock, products); const rawPointer = process.env.RELEASE_CONTINUATION_POINTER?.trim() ?? ""; if (rawPointer !== "") { diff --git a/.github/scripts/download-wasix-runtime-build-artifacts.mjs b/.github/scripts/download-wasix-runtime-build-artifacts.mjs index 2c3db4e9..e9360d2c 100644 --- a/.github/scripts/download-wasix-runtime-build-artifacts.mjs +++ b/.github/scripts/download-wasix-runtime-build-artifacts.mjs @@ -27,9 +27,13 @@ function run(command, args) { } requireEnv("GITHUB_TOKEN"); -const releaseSha = process.env.RELEASE_HEAD_SHA ?? process.env.GITHUB_SHA ?? ""; +const releaseSha = + process.env.RELEASE_ARTIFACT_SHA + ?? process.env.RELEASE_HEAD_SHA + ?? process.env.GITHUB_SHA + ?? ""; if (releaseSha === "") { - fail("RELEASE_HEAD_SHA or GITHUB_SHA is required", 2); + fail("RELEASE_ARTIFACT_SHA, RELEASE_HEAD_SHA, or GITHUB_SHA is required", 2); } // Installs the portable and AOT WASIX runtime outputs from the selected release diff --git a/.github/scripts/manage-release-drafts.mjs b/.github/scripts/manage-release-drafts.mjs index a40296fe..15293ade 100644 --- a/.github/scripts/manage-release-drafts.mjs +++ b/.github/scripts/manage-release-drafts.mjs @@ -10,6 +10,11 @@ import { createGitHubOperationBudget, exactReleaseMetadata, exactTagRefPayload, + GitHubReleaseSnapshotRaceError, + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS, + GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS, + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_WINDOW_MS, readReleaseByTagSync, readReleaseMapSync, readTagRefSync, @@ -18,7 +23,14 @@ import { remainingGitHubReadOptions, runGitHubMutationSync, } from "../../tools/release/github-release-mutations.mjs"; +import { + RELEASE_FINALIZATION_STEP_TIMEOUT_MINUTES, +} from "../../tools/release/release-finalization-budget.mjs"; import { loadGraph } from "../../tools/release/release-graph.mjs"; +import { + RELEASE_PLEASE_ASSERT_MARKABLE_WINDOW_MS, + RELEASE_PLEASE_MARK_TAGGED_WINDOW_MS, +} from "../../tools/release/release-please-pr-lifecycle.mjs"; import { DEFAULT_PUBLICATION_LOCK, loadPublicationLock, @@ -27,6 +39,16 @@ import { const FULL_SHA = /^[0-9a-f]{40}$/u; const DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS = 60_000; const DEFAULT_FAST_MUTATION_TIMEOUT_MS = 60_000; +export const GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS = 10_000; +export const GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS = 30_000; +const GITHUB_RELEASE_PROMOTION_LIFECYCLE_MARGIN_MS = 30_000; +const GITHUB_RELEASE_PROMOTION_STEP_WINDOW_MS = + RELEASE_FINALIZATION_STEP_TIMEOUT_MINUTES.promoteDrafts * 60_000; +export const GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS = + GITHUB_RELEASE_PROMOTION_STEP_WINDOW_MS + - RELEASE_PLEASE_ASSERT_MARKABLE_WINDOW_MS + - RELEASE_PLEASE_MARK_TAGGED_WINDOW_MS + - GITHUB_RELEASE_PROMOTION_LIFECYCLE_MARGIN_MS; export { assertResumableReleaseMetadata, @@ -41,7 +63,7 @@ function error(message, options = {}) { function usageError() { return error( - "usage: manage-release-drafts.mjs " + "usage: manage-release-drafts.mjs " + "--products-json JSON --head-ref SHA [--state draft|public|staged]", ); } @@ -67,7 +89,7 @@ function selectedPublicationLock(command, products, headRef, environment) { ?? DEFAULT_PUBLICATION_LOCK, ); if (!existsSync(file)) { - if (command === "preflight") return null; + if (command === "preflight" || command === "recovery-preflight") return null; throw error(`${command} requires the frozen publication lock: ${file}`); } const lock = loadPublicationLock(file); @@ -234,6 +256,117 @@ function validateExistingReleases(selected, releasesByTag) { } } +function sleepSync(milliseconds) { + if (milliseconds <= 0) return; + const cell = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); + Atomics.wait(cell, 0, 0, milliseconds); +} + +function pendingRequiredReleases(selected, releasesByTag, requiredState) { + return selected.flatMap(({ tag }) => { + const release = releasesByTag.get(tag); + if (release === undefined) return [`${tag} (missing)`]; + if (requiredState === "public" && release.draft) return [`${tag} (still draft)`]; + if (requiredState === "draft" && !release.draft) return [`${tag} (already public)`]; + return []; + }); +} + +function validateExpectedReleaseIds(selected, releasesByTag, expectedReleaseIds) { + if (expectedReleaseIds === undefined) return; + if (!(expectedReleaseIds instanceof Map)) { + throw error("expected release identities must be a Map"); + } + for (const { tag } of selected) { + const expectedId = expectedReleaseIds.get(tag); + if (!Number.isSafeInteger(expectedId) || expectedId <= 0) { + throw error(`expected release identity for ${tag} must be a positive integer`); + } + const release = releasesByTag.get(tag); + if (release !== undefined && release.id !== expectedId) { + throw error(`${tag} release id changed from ${expectedId} to ${release.id}`); + } + } +} + +function readRequiredReleaseMapSync({ + budget, + expectedReleaseIds, + readReleaseMap, + requiredState, + selected, + sleep = sleepSync, +}) { + if (!new Set(["draft", "public", "staged"]).has(requiredState)) { + throw error("required release snapshot state must be draft, public, or staged"); + } + if (typeof readReleaseMap !== "function" || typeof sleep !== "function") { + throw error("required release snapshot reader and sleep callback are required"); + } + let lastTransientSnapshotError = null; + for ( + let attempt = 0; + attempt <= GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.length; + attempt += 1 + ) { + let pending; + try { + const releasesByTag = readReleaseMap(); + validateExistingReleases(selected, releasesByTag); + validateExpectedReleaseIds(selected, releasesByTag, expectedReleaseIds); + pending = pendingRequiredReleases(selected, releasesByTag, requiredState); + if (pending.length === 0) return releasesByTag; + lastTransientSnapshotError = null; + } catch (cause) { + if (!(cause instanceof GitHubReleaseSnapshotRaceError)) throw cause; + if (cause.observedRelease !== undefined) { + const observedReleaseMap = + new Map([[cause.observedRelease.tag_name, cause.observedRelease]]); + validateExistingReleases(selected, observedReleaseMap); + validateExpectedReleaseIds(selected, observedReleaseMap, expectedReleaseIds); + } + lastTransientSnapshotError = cause; + pending = selected.map(({ tag }) => `${tag} (inconsistent paginated snapshot)`); + } + if (attempt === GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.length) { + throw error( + `GitHub release list did not converge to ${requiredState} state within ` + + `${GITHUB_RELEASE_SNAPSHOT_VISIBILITY_WINDOW_MS}ms: ${pending.join(", ")}`, + { cause: lastTransientSnapshotError ?? undefined }, + ); + } + const remainingVisibilityWindowMs = GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS + .slice(attempt) + .reduce((total, delay) => total + delay, 0); + if (budget.deadlineMs - budget.now() < remainingVisibilityWindowMs) { + throw error( + `GitHub operation lacks the complete ${remainingVisibilityWindowMs}ms release-list ` + + `visibility window required for: ${pending.join(", ")}`, + { cause: lastTransientSnapshotError ?? undefined }, + ); + } + sleep(GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS[attempt]); + } + throw error("required release snapshot loop ended unexpectedly"); +} + +function boundedReleaseSnapshotReadOptions(budget) { + const startedAtMs = budget.now(); + const snapshotBudget = { + ...budget, + deadlineMs: Math.min( + budget.deadlineMs, + startedAtMs + GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS, + ), + }; + return remainingGitHubReadOptions(snapshotBudget, { + attemptTimeoutMs: 4_000, + baseDelayMs: 500, + maxAttempts: GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + maxDelayMs: 500, + }); +} + function requireExactTags(selected, repo, headRef, budget) { for (const { product, tag } of selected) { const ref = readTagRefSync(repo, tag, remainingGitHubReadOptions(budget)); @@ -350,7 +483,18 @@ export function readSelectedRemoteTagMapSync(repo, selected, options = {}) { GIT_TERMINAL_PROMPT: "0", SSH_ASKPASS: "", }; - const timeout = Math.max(1, Math.min(DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS, remainingMs)); + const requestedTimeoutMs = + options.timeoutMs ?? DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS; + if ( + !Number.isSafeInteger(requestedTimeoutMs) + || requestedTimeoutMs < 1 + || requestedTimeoutMs > DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS + ) { + throw error( + `remote tag snapshot timeout must be between 1 and ${DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS}ms`, + ); + } + const timeout = Math.max(1, Math.min(requestedTimeoutMs, remainingMs)); const result = options.spawn === undefined ? captureCommandOutput("git", gitArgs, { allowEmptyOutput: true, @@ -407,15 +551,15 @@ function requireCollisionFreeTagSnapshot(selected, tagsByName, headRef) { } } -function fastMutationTimeout(budget) { +function fastMutationTimeout(budget, requiredTimeoutMs = DEFAULT_FAST_MUTATION_TIMEOUT_MS) { const remainingMs = budget.deadlineMs - budget.now(); - if (remainingMs < DEFAULT_FAST_MUTATION_TIMEOUT_MS) { + if (remainingMs < requiredTimeoutMs) { throw error( - `GitHub operation requires a complete ${DEFAULT_FAST_MUTATION_TIMEOUT_MS}ms mutation timeout; ` + `GitHub operation requires a complete ${requiredTimeoutMs}ms mutation timeout; ` + `${Math.max(0, remainingMs)}ms remains`, ); } - return DEFAULT_FAST_MUTATION_TIMEOUT_MS; + return requiredTimeoutMs; } function defaultTagMutation({ deadlineMs, environment, headRef, now, repo, tag, timeoutMs }) { @@ -495,17 +639,31 @@ function stageMissingReleaseFromSnapshot(context, dependencies) { exactReleaseFromMutation(output, context.metadata, { draft: true }); return { mutationAttempts: 1, recovered: false }; } catch (cause) { - const result = stageExactDraftReleaseSync(context, { - createRelease: ({ deadlineMs, now, timeoutMs }) => mutateRelease({ - ...context, - deadlineMs, - now, - timeoutMs, - }), - mutationOptions: dependencies.mutationOptions, - readRelease: dependencies.readRelease, - }); - return { ...result, fastMutationError: cause }; + let releasesByTag; + try { + releasesByTag = readRequiredReleaseMapSync({ + budget: context.budget, + readReleaseMap: dependencies.readReleaseMap, + requiredState: "staged", + selected: [{ metadata: context.metadata, tag: context.tag }], + sleep: dependencies.releaseSnapshotSleep, + }); + } catch (observationCause) { + const mutationDetail = redactGitHubReadDetail( + cause instanceof Error ? cause.message : String(cause), + context.environment, + ); + throw error( + `${observationCause instanceof Error ? observationCause.message : String(observationCause)}; ` + + `original draft mutation failure: ${mutationDetail || "unknown failure"}`, + { cause }, + ); + } + return { + fastMutationError: cause, + mutationAttempts: 1, + recovered: releasesByTag.has(context.tag), + }; } } @@ -516,22 +674,23 @@ function promoteReleaseFromSnapshot(context, dependencies) { ...context, deadlineMs: context.budget.deadlineMs, now: context.budget.now, - timeoutMs: fastMutationTimeout(context.budget), + timeoutMs: fastMutationTimeout( + context.budget, + GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS, + ), }); exactReleaseFromMutation(output, context.metadata, { draft: false, expectedId: context.expectedId }); return { mutationAttempts: 1, recovered: false }; } catch (cause) { - const result = promoteExactReleaseSync(context, { - mutationOptions: dependencies.mutationOptions, - promoteRelease: ({ deadlineMs, now, timeoutMs }) => mutatePromotion({ - ...context, - deadlineMs, - now, - timeoutMs, - }), - readRelease: dependencies.readRelease, - }); - return { ...result, fastMutationError: cause }; + // PATCH is idempotent, but replay is unnecessary and makes the bounded + // finalization proof depend on an error-shaped number of writes. Observe + // the whole selected batch once below; a rerun safely resumes any draft + // whose first PATCH was definitely not applied. + return { + fastMutationError: cause, + mutationAttempts: 1, + recovered: false, + }; } } @@ -539,10 +698,17 @@ export function reconcileSelectedReleasesSync( { budget, command, environment, expectedState, headRef, repo, selected }, dependencies = {}, ) { - const readReleaseMap = dependencies.readReleaseMap ?? ((targetRepo) => - readReleaseMapSync(targetRepo, remainingGitHubReadOptions(budget))); - const readTagMap = dependencies.readTagMap ?? ((targetRepo, targetSelected) => - readSelectedRemoteTagMapSync(targetRepo, targetSelected, { budget, environment })); + const readReleaseMap = dependencies.readReleaseMap ?? readReleaseMapSync; + const snapshotReleaseMap = () => + readReleaseMap(repo, boundedReleaseSnapshotReadOptions(budget)); + const readTagMap = dependencies.readTagMap ?? readSelectedRemoteTagMapSync; + const snapshotTagMap = () => readTagMap(repo, selected, { + budget, + environment, + timeoutMs: command === "promote" + ? GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS + : DEFAULT_GIT_SNAPSHOT_TIMEOUT_MS, + }); const perTagDependencies = { ...dependencies, readTagRef: dependencies.readTagRef ?? ((tag) => @@ -553,16 +719,50 @@ export function reconcileSelectedReleasesSync( readRelease: dependencies.readRelease ?? ((tag) => readReleaseByTagSync(repo, tag, remainingGitHubReadOptions(budget))), }; + const releaseSnapshotSleep = dependencies.releaseSnapshotSleep ?? sleepSync; + const requiredReleaseMap = (requiredState, { expectedReleaseIds } = {}) => + readRequiredReleaseMapSync({ + budget, + expectedReleaseIds, + readReleaseMap: snapshotReleaseMap, + requiredState, + selected, + sleep: releaseSnapshotSleep, + }); - let releasesByTag = readReleaseMap(repo); - validateExistingReleases(selected, releasesByTag); - let tagsByName = readTagMap(repo, selected); + let releasesByTag; + if (command === "verify") { + releasesByTag = requiredReleaseMap(expectedState); + } else if (command === "promote") { + // No mutation has happened yet. A missing/stale precondition can fail and + // be rerun safely, so it does not need the post-mutation visibility wait. + releasesByTag = snapshotReleaseMap(); + validateExistingReleases(selected, releasesByTag); + } else { + releasesByTag = snapshotReleaseMap(); + validateExistingReleases(selected, releasesByTag); + } + let tagsByName = snapshotTagMap(); requireCollisionFreeTagSnapshot(selected, tagsByName, headRef); if (command === "preflight") { console.log(`${selected.length} selected product tag/release names are absent or exact-SHA resumable`); return; } + if (command === "recovery-preflight") { + const existing = selected.filter(({ tag }) => + releasesByTag.has(tag) || tagsByName.get(tag) !== null); + if (existing.length > 0) { + throw error( + "same-version recovery requires every selected product tag and GitHub release " + + `to be absent; found ${existing.map(({ tag }) => tag).join(", ")}`, + ); + } + console.log( + `${selected.length} selected product tag/release names are absent for same-version recovery`, + ); + return; + } if (command === "stage") { for (const { product, tag } of selected) { @@ -576,7 +776,7 @@ export function reconcileSelectedReleasesSync( ); if (result.mutationAttempts > 0) console.log(`reconciled exact-SHA tag ${tag} for ${product}`); } - tagsByName = readTagMap(repo, selected); + tagsByName = snapshotTagMap(); requireExactTagSnapshot(selected, tagsByName, headRef); for (const { metadata, tag } of selected) { if (releasesByTag.has(tag)) continue; @@ -584,14 +784,15 @@ export function reconcileSelectedReleasesSync( { budget, environment, metadata, repo, tag }, { ...perReleaseDependencies, + readReleaseMap: snapshotReleaseMap, readRelease: () => perReleaseDependencies.readRelease(tag), + releaseSnapshotSleep, }, ); if (result.mutationAttempts > 0) console.log(`reconciled draft GitHub release ${tag}`); } - releasesByTag = readReleaseMap(repo); - validateExistingReleases(selected, releasesByTag); - tagsByName = readTagMap(repo, selected); + releasesByTag = requiredReleaseMap("staged"); + tagsByName = snapshotTagMap(); requireExactTagSnapshot(selected, tagsByName, headRef); } else { requireExactTagSnapshot(selected, tagsByName, headRef); @@ -602,6 +803,10 @@ export function reconcileSelectedReleasesSync( } if (command === "promote") { + const promotionFailures = []; + const expectedReleaseIds = new Map( + selected.map(({ tag }) => [tag, releasesByTag.get(tag).id]), + ); for (const { metadata, tag } of selected) { const release = releasesByTag.get(tag); if (!release.draft) continue; @@ -619,11 +824,30 @@ export function reconcileSelectedReleasesSync( readRelease: () => perReleaseDependencies.readRelease(tag), }, ); + if (result.fastMutationError !== undefined) { + promotionFailures.push({ cause: result.fastMutationError, tag }); + } if (result.mutationAttempts > 0) console.log(`reconciled promotion of ${tag}`); } - releasesByTag = readReleaseMap(repo); - validateExistingReleases(selected, releasesByTag); - tagsByName = readTagMap(repo, selected); + try { + releasesByTag = requiredReleaseMap("public", { expectedReleaseIds }); + } catch (observationCause) { + if (promotionFailures.length === 0) throw observationCause; + const firstFailure = promotionFailures[0]; + const mutationDetail = redactGitHubReadDetail( + firstFailure.cause instanceof Error + ? firstFailure.cause.message + : String(firstFailure.cause), + environment, + ); + throw error( + `${observationCause instanceof Error ? observationCause.message : String(observationCause)}; ` + + `${promotionFailures.length} promotion mutation failure(s); first failure for ` + + `${firstFailure.tag}: ${mutationDetail || "unknown failure"}`, + { cause: firstFailure.cause }, + ); + } + tagsByName = snapshotTagMap(); requireExactTagSnapshot(selected, tagsByName, headRef); } @@ -639,14 +863,33 @@ function defaultWindowForCommand(command) { if (command === "stage") return 30 * 60_000; // Promotion count is release-plan-derived. Keep the command inside the // mandatory finalization reserve while leaving a bounded contingency margin. - if (command === "promote") return 12 * 60_000; + if (command === "promote") return GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS; return 5 * 60_000; } +export function createReleaseDraftOperationBudget( + command, + { environment = process.env, now = Date.now } = {}, +) { + const defaultWindowMs = defaultWindowForCommand(command); + const budget = createGitHubOperationBudget({ + defaultWindowMs, + environment, + now, + }); + if (command !== "promote") return budget; + const maximumDeadlineMs = budget.startedAtMs + defaultWindowMs; + if (budget.deadlineMs <= maximumDeadlineMs) return budget; + return Object.freeze({ + ...budget, + deadlineMs: maximumDeadlineMs, + }); +} + export function main(argv, { environment = process.env, now = Date.now } = {}) { const { command, values } = parseArgs([...argv]); - if (!["preflight", "stage", "verify", "promote"].includes(command)) { - throw error("command must be preflight, stage, verify, or promote"); + if (!["preflight", "recovery-preflight", "stage", "verify", "promote"].includes(command)) { + throw error("command must be preflight, recovery-preflight, stage, verify, or promote"); } const repo = environment.GITHUB_REPOSITORY?.trim(); if (!repo || !environment.GH_TOKEN) { @@ -678,15 +921,11 @@ export function main(argv, { environment = process.env, now = Date.now } = {}) { } const selected = selectedReleases(command, products, headRef, environment); - const budget = createGitHubOperationBudget({ - defaultWindowMs: defaultWindowForCommand(command), - environment, - now, - }); + const budget = createReleaseDraftOperationBudget(command, { environment, now }); reconcileSelectedReleasesSync({ budget, command, - environment, + environment: budget.environment, expectedState, headRef, repo, diff --git a/.github/scripts/require-workflow-success.sh b/.github/scripts/require-workflow-success.sh index 26b6f9df..2e85066d 100644 --- a/.github/scripts/require-workflow-success.sh +++ b/.github/scripts/require-workflow-success.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -workflow="${1:?usage: require-workflow-success.sh [timeout-seconds] [--job ...] [--artifact ...] [--event ...]}" -sha="${2:?usage: require-workflow-success.sh [timeout-seconds] [--job ...] [--artifact ...] [--event ...]}" +workflow="${1:?usage: require-workflow-success.sh [timeout-seconds] [--job ...] [--artifact ...] [--gate-artifact ...] [--event ...]}" +sha="${2:?usage: require-workflow-success.sh [timeout-seconds] [--job ...] [--artifact ...] [--gate-artifact ...] [--event ...]}" timeout="${3:-7200}" if [[ $# -ge 3 ]]; then shift 3 @@ -11,10 +11,13 @@ else fi required_artifacts=() +gate_artifacts=() required_jobs=() required_events=() expected_run_id="" selected_artifacts_json='[]' +selected_gate_artifacts_json='[]' +selected_run_attempt='' while [[ $# -gt 0 ]]; do case "$1" in --run-id) @@ -29,6 +32,10 @@ while [[ $# -gt 0 ]]; do required_artifacts+=("${2:?--artifact requires a name}") shift 2 ;; + --gate-artifact) + gate_artifacts+=("${2:?--gate-artifact requires a name}") + shift 2 + ;; --event) required_events+=("${2:?--event requires an event name}") shift 2 @@ -72,7 +79,9 @@ emit_run_id() { if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { echo "run_id=$run_id" + echo "run_attempt=$selected_run_attempt" echo "artifact_metadata_json=$selected_artifacts_json" + echo "gate_artifact_metadata_json=$selected_gate_artifacts_json" } >> "$GITHUB_OUTPUT" fi echo "selected $workflow run $run_id" @@ -84,15 +93,15 @@ run_matches_request() { row="$( github_read "$workflow run $run_id metadata" \ api "repos/$GH_REPO/actions/runs/$run_id" \ - --jq '[.head_sha, .workflow_id, .event, .status, (.conclusion // "")] | @tsv' + --jq '[.head_sha, .workflow_id, .event, .status, (.conclusion // ""), .run_attempt] | @tsv' )" || { status=$? echo "failed to inspect $workflow run $run_id" >&2 return "$status" } - local run_sha workflow_id run_event run_status run_conclusion workflow_name - IFS=$'\t' read -r run_sha workflow_id run_event run_status run_conclusion <<< "$row" + local run_sha workflow_id run_event run_status run_conclusion run_attempt workflow_name + IFS=$'\t' read -r run_sha workflow_id run_event run_status run_conclusion run_attempt <<< "$row" if [[ "$(printf '%s' "$run_sha" | normalize_sha)" != "$(printf '%s' "$sha" | normalize_sha)" ]]; then echo "$workflow run $run_id belongs to $run_sha, not $sha" >&2 return 1 @@ -129,12 +138,18 @@ run_matches_request() { echo "$workflow run $run_id is $run_status/${run_conclusion:-}, not completed/success" >&2 return 1 fi + if [[ ! "$run_attempt" =~ ^[1-9][0-9]*$ ]]; then + echo "$workflow run $run_id has an invalid run attempt" >&2 + return 64 + fi + selected_run_attempt="$run_attempt" } required_artifacts_present() { local run_id="$1" - if [[ "${#required_artifacts[@]}" -eq 0 ]]; then + if [[ "${#required_artifacts[@]}" -eq 0 && "${#gate_artifacts[@]}" -eq 0 ]]; then selected_artifacts_json='[]' + selected_gate_artifacts_json='[]' return 0 fi @@ -150,14 +165,31 @@ required_artifacts_present() { return "$status" } local required_json - required_json="$(printf '%s\n' "${required_artifacts[@]}" | bun -e ' + if [[ "${#required_artifacts[@]}" -eq 0 ]]; then + required_json='[]' + else + required_json="$(printf '%s\n' "${required_artifacts[@]}" | bun -e ' +const names = (await Bun.stdin.text()).split(/\r?\n/u).filter(Boolean); +process.stdout.write(JSON.stringify(names)); +')" + fi + local gate_json + if [[ "${#gate_artifacts[@]}" -eq 0 ]]; then + gate_json='[]' + else + gate_json="$(printf '%s\n' "${gate_artifacts[@]}" | bun -e ' const names = (await Bun.stdin.text()).split(/\r?\n/u).filter(Boolean); process.stdout.write(JSON.stringify(names)); ')" - local selected status + fi + local selection status # shellcheck disable=SC2016 - if selected="$(REQUIRED_ARTIFACTS_JSON="$required_json" bun -e ' + if selection="$( + REQUIRED_ARTIFACTS_JSON="$required_json" \ + GATE_ARTIFACTS_JSON="$gate_json" \ + bun -e ' const expected = JSON.parse(process.env.REQUIRED_ARTIFACTS_JSON); +const gates = JSON.parse(process.env.GATE_ARTIFACTS_JSON); let records; try { records = JSON.parse(await Bun.stdin.text()); @@ -165,7 +197,13 @@ try { console.error(`artifact inventory is not valid JSON: ${cause.message}`); process.exit(64); } -if (!Array.isArray(expected) || expected.length === 0 || new Set(expected).size !== expected.length) { +if ( + !Array.isArray(expected) + || !Array.isArray(gates) + || expected.length + gates.length === 0 + || [...expected, ...gates].some((name) => typeof name !== "string" || name.length === 0) + || new Set([...expected, ...gates]).size !== expected.length + gates.length +) { console.error("required artifact identity list is malformed"); process.exit(64); } @@ -180,22 +218,43 @@ if (!Array.isArray(records) || records.some((entry) => process.exit(64); } const selected = []; -for (const name of expected) { +const selectedGates = []; +for (const name of [...expected, ...gates]) { const matches = records.filter((entry) => entry.name === name && entry.expired === false); if (matches.length !== 1) { console.error(`expected exactly one non-expired artifact named ${name}; found ${matches.length}`); process.exit(1); } const [entry] = matches; - selected.push({ digest: entry.digest, id: entry.id, name: entry.name, size: entry.size_in_bytes }); + const record = { + digest: entry.digest, + id: entry.id, + name: entry.name, + size: entry.size_in_bytes, + }; + (expected.includes(name) ? selected : selectedGates).push(record); } selected.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); -process.stdout.write(JSON.stringify(selected)); -' <<< "$artifacts_json")"; then - selected_artifacts_json="$selected" +selectedGates.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); +process.stdout.write(JSON.stringify({ selected, selectedGates })); +' <<< "$artifacts_json" + )"; then + selected_artifacts_json="$( + SELECTION_JSON="$selection" bun -e ' +const value = JSON.parse(process.env.SELECTION_JSON); +process.stdout.write(JSON.stringify(value.selected)); +' + )" + selected_gate_artifacts_json="$( + SELECTION_JSON="$selection" bun -e ' +const value = JSON.parse(process.env.SELECTION_JSON); +process.stdout.write(JSON.stringify(value.selectedGates)); +' + )" else status=$? selected_artifacts_json='[]' + selected_gate_artifacts_json='[]' return "$status" fi } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76d32685..aa40263c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,14 +46,14 @@ env: REGISTRY_MUTATION_WINDOW_SECONDS: 19800 GITHUB_STAGE_JOB_HARD_WINDOW_SECONDS: 21000 REGISTRY_JOB_HARD_WINDOW_SECONDS: 21000 - FINALIZE_JOB_HARD_WINDOW_SECONDS: 6840 + FINALIZE_JOB_HARD_WINDOW_SECONDS: 7080 NORMAL_REGISTRY_MUTATION_WINDOW_SECONDS: 11400 REGISTRY_EVIDENCE_HANDOFF_RESERVE_SECONDS: 900 - # Finalization has its own two-hour job. Its entry gate requires 48 intact - # minutes: every finalization step's 45-minute hard bound plus a separate + # Finalization has its own 124-minute job. Its entry gate requires 52 intact + # minutes: every finalization step's 49-minute hard bound plus a separate # three-minute runner/action transition margin. - RELEASE_FINALIZATION_RESERVE_SECONDS: 3000 - RELEASE_MINIMUM_FINALIZATION_SECONDS: 2880 + RELEASE_FINALIZATION_RESERVE_SECONDS: 3240 + RELEASE_MINIMUM_FINALIZATION_SECONDS: 3120 RELEASE_FINALIZATION_CLEANUP_MARGIN_SECONDS: 180 # Public consumer lanes run concurrently under one 13-minute internal # deadline and stop with ten minutes preserved for evidence, lock proof, and @@ -386,14 +386,74 @@ jobs: RELEASE_CONTINUATION_POINTER: ${{ inputs.continuation_pointer }} run: bun .github/scripts/verify-github-oidc-identity.mjs - - name: Prove workflow HEAD is the release-bump commit + - name: Prove workflow HEAD is a release or same-version recovery commit + id: verify_publication_candidate if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + timeout-minutes: 2 env: PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} run: | - tools/dev/bun.sh tools/release/verify-release-commit.mjs \ + tools/dev/bun.sh tools/release/verify-publication-candidate.mjs \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$RELEASE_HEAD_SHA" \ + --github-output "$GITHUB_OUTPUT" + + - name: Resolve exact release source and controller identities + id: release_identity + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + env: + CANDIDATE_MODE: ${{ steps.verify_publication_candidate.outputs.mode }} + CONTROL_SHA: ${{ steps.release_head.outputs.sha }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + source_sha="$CONTROL_SHA" + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + source_sha="$RECOVERY_RELEASE_SHA" + fi + for entry in "controller:$CONTROL_SHA" "source:$source_sha"; do + label="${entry%%:*}" + sha="${entry#*:}" + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Resolved release $label SHA is not a lowercase full commit SHA" >&2 + exit 1 + fi + git cat-file -e "$sha^{commit}" + done + controller_tree="$(git show -s --format=%T "$CONTROL_SHA^{commit}")" + if [[ ! "$controller_tree" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Resolved release controller tree is not a lowercase full Git tree SHA' >&2 + exit 1 + fi + { + echo "controller_sha=$CONTROL_SHA" + echo "controller_tree=$controller_tree" + echo "source_sha=$source_sha" + } >> "$GITHUB_OUTPUT" + { + echo "RELEASE_CONTROL_SHA=$CONTROL_SHA" + echo "RELEASE_SOURCE_SHA=$source_sha" + } >> "$GITHUB_ENV" + + - name: Resolve pinned same-version recovery provenance + id: recovery_source + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 1 + run: | + tools/dev/bun.sh tools/release/same-version-recovery-source.mjs \ + --release-sha "$RELEASE_SOURCE_SHA" \ + --github-output "$GITHUB_OUTPUT" + + - name: Prove same-version recovery has no staged GitHub release state + id: verify_release_recovery_github_absence + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish-dry-run' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: | + bun .github/scripts/manage-release-drafts.mjs recovery-preflight \ + --products-json "$PRODUCTS_JSON" \ + --head-ref "$RELEASE_SOURCE_SHA" - name: Prove Release Please PR can complete after publication id: assert_release_please_markable @@ -404,7 +464,7 @@ jobs: run: | tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs \ assert-markable \ - --release-sha "$RELEASE_HEAD_SHA" \ + --release-sha "${{ steps.verify_publication_candidate.outputs.release_sha }}" \ --base main - name: Preflight selected product tag and release collisions @@ -416,11 +476,11 @@ jobs: gh auth setup-git tools/dev/bun.sh tools/release/verify_product_tags.mjs \ --products-json "$PRODUCTS_JSON" \ - --target "$RELEASE_HEAD_SHA" \ + --target "$RELEASE_SOURCE_SHA" \ --allow-missing bun .github/scripts/manage-release-drafts.mjs preflight \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$RELEASE_SOURCE_SHA" - name: Check publish environment if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} @@ -468,7 +528,7 @@ jobs: fi qualification_args=( CI - "$RELEASE_HEAD_SHA" + "$RELEASE_CONTROL_SHA" "$qualification_timeout" --event push --event workflow_dispatch @@ -486,6 +546,90 @@ jobs: fi bash .github/scripts/require-workflow-success.sh "${qualification_args[@]}" + - name: Require qualified frozen-payload CI run for same-version recovery + id: recovery_payload_ci_qualification + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PINNED_ARTIFACT_METADATA_JSON: ${{ steps.recovery_source.outputs.payload_ci_artifact_metadata_json }} + PINNED_PAYLOAD_CI_RUN_ID: ${{ steps.recovery_source.outputs.payload_ci_run_id }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + qualification_args=( + CI + "$RECOVERY_RELEASE_SHA" + 0 + --run-id "$PINNED_PAYLOAD_CI_RUN_ID" + --event push + --event workflow_dispatch + --job Builds + --job Required + --job Qualified + ) + while IFS= read -r artifact; do + qualification_args+=(--artifact "$artifact") + done < <( + PINNED_ARTIFACT_METADATA_JSON="$PINNED_ARTIFACT_METADATA_JSON" bun -e ' + const rows = JSON.parse(process.env.PINNED_ARTIFACT_METADATA_JSON); + for (const row of rows) console.log(row.name); + ' + ) + gate_output="$RUNNER_TEMP/recovery-payload-ci-gate.out" + GITHUB_OUTPUT="$gate_output" \ + bash .github/scripts/require-workflow-success.sh "${qualification_args[@]}" + observed="$(sed -n 's/^artifact_metadata_json=//p' "$gate_output")" + if [[ -z "$observed" || "$(grep -c '^artifact_metadata_json=' "$gate_output")" != 1 ]]; then + echo 'Frozen-payload qualification did not emit exactly one artifact inventory.' >&2 + exit 1 + fi + PINNED_ARTIFACT_METADATA_JSON="$PINNED_ARTIFACT_METADATA_JSON" \ + OBSERVED_ARTIFACT_METADATA_JSON="$observed" \ + bun -e ' + const canonical = (value) => Array.isArray(value) + ? value.map(canonical) + : value !== null && typeof value === "object" + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) + : value; + const expected = JSON.stringify(canonical(JSON.parse(process.env.PINNED_ARTIFACT_METADATA_JSON))); + const actual = JSON.stringify(canonical(JSON.parse(process.env.OBSERVED_ARTIFACT_METADATA_JSON))); + if (actual !== expected) { + console.error("Frozen-payload CI artifact inventory differs from the pinned recovery provenance."); + process.exit(1); + } + ' + cat "$gate_output" >> "$GITHUB_OUTPUT" + + - name: Resolve exact release artifact source + id: release_artifact_source + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + env: + CANDIDATE_MODE: ${{ steps.verify_publication_candidate.outputs.mode }} + CONTROL_CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + PUBLICATION_SHA: ${{ steps.release_identity.outputs.source_sha }} + RECOVERY_PAYLOAD_CI_RUN_ID: ${{ steps.recovery_source.outputs.payload_ci_run_id }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + artifact_sha="$PUBLICATION_SHA" + artifact_ci_run_id="$CONTROL_CI_RUN_ID" + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + artifact_sha="$RECOVERY_RELEASE_SHA" + artifact_ci_run_id="$RECOVERY_PAYLOAD_CI_RUN_ID" + fi + if [[ ! "$artifact_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Resolved release artifact SHA is not a lowercase full commit SHA' >&2 + exit 1 + fi + if [[ ! "$artifact_ci_run_id" =~ ^[1-9][0-9]*$ ]]; then + echo 'Resolved release artifact CI run ID is not a positive integer' >&2 + exit 1 + fi + git cat-file -e "$artifact_sha^{commit}" + { + echo "sha=$artifact_sha" + echo "ci_run_id=$artifact_ci_run_id" + } >> "$GITHUB_OUTPUT" + - name: Download exact-SHA qualification record if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: @@ -495,7 +639,7 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_CONTROL_SHA" \ target/release-candidate \ --run-id "$CI_RUN_ID" \ --job Qualified \ @@ -510,7 +654,7 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_CONTROL_SHA" \ target/release-candidate/affected-plan \ --run-id "$CI_RUN_ID" \ --job Plan \ @@ -525,7 +669,7 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_CONTROL_SHA" \ target/release-candidate/wasix-evidence \ --run-id "$CI_RUN_ID" \ --job "E2E / WASIX release regression" \ @@ -544,20 +688,197 @@ jobs: --wasix-evidence-required "$WASIX_EVIDENCE_REQUIRED" \ --wasix-evidence-root target/release-candidate/wasix-evidence + - name: Download frozen-payload qualification record + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_ARTIFACT_CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} + run: | + node .github/scripts/download-build-artifacts.mjs \ + CI \ + "$RELEASE_ARTIFACT_SHA" \ + target/recovery-payload-candidate \ + --run-id "$RELEASE_ARTIFACT_CI_RUN_ID" \ + --job Qualified \ + --artifact oliphaunt-release-candidate + + - name: Download frozen-payload affected plan + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_ARTIFACT_CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} + run: | + node .github/scripts/download-build-artifacts.mjs \ + CI \ + "$RELEASE_ARTIFACT_SHA" \ + target/recovery-payload-candidate/affected-plan \ + --run-id "$RELEASE_ARTIFACT_CI_RUN_ID" \ + --job Plan \ + --artifact artifact-build-plan + + - name: Download frozen-payload WASIX evidence + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' && steps.release_plan.outputs.requires_wasix_release_regression_evidence == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_ARTIFACT_CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} + run: | + node .github/scripts/download-build-artifacts.mjs \ + CI \ + "$RELEASE_ARTIFACT_SHA" \ + target/recovery-payload-candidate/wasix-evidence \ + --run-id "$RELEASE_ARTIFACT_CI_RUN_ID" \ + --job "E2E / WASIX release regression" \ + --artifact wasix-release-regression-evidence + + - name: Verify frozen-payload qualification record + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + env: + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_HEAD_SHA: ${{ steps.release_artifact_source.outputs.sha }} + WASIX_EVIDENCE_REQUIRED: ${{ steps.release_plan.outputs.requires_wasix_release_regression_evidence }} + run: | + node .github/scripts/verify-release-candidate.mjs \ + target/recovery-payload-candidate/oliphaunt-release-candidate.json \ + --plan target/recovery-payload-candidate/affected-plan/ci-plan.json \ + --wasix-evidence-required "$WASIX_EVIDENCE_REQUIRED" \ + --wasix-evidence-root target/recovery-payload-candidate/wasix-evidence + + - name: Select original approved lock for same-version recovery + id: recovery_original_publication_lock + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PINNED_ARTIFACT_METADATA_JSON: ${{ steps.recovery_source.outputs.approved_dry_run_artifact_metadata_json }} + PINNED_DRY_RUN_ID: ${{ steps.recovery_source.outputs.approved_dry_run_id }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + gate_output="$RUNNER_TEMP/recovery-original-dry-run-gate.out" + GITHUB_OUTPUT="$gate_output" \ + bash .github/scripts/require-workflow-success.sh \ + Release \ + "$RECOVERY_RELEASE_SHA" \ + 0 \ + --run-id "$PINNED_DRY_RUN_ID" \ + --event workflow_dispatch \ + --artifact oliphaunt-publication-lock \ + --artifact oliphaunt-bootstrap-capsule + observed="$(sed -n 's/^artifact_metadata_json=//p' "$gate_output")" + if [[ -z "$observed" || "$(grep -c '^artifact_metadata_json=' "$gate_output")" != 1 ]]; then + echo 'Original dry-run qualification did not emit exactly one artifact inventory.' >&2 + exit 1 + fi + PINNED_ARTIFACT_METADATA_JSON="$PINNED_ARTIFACT_METADATA_JSON" \ + OBSERVED_ARTIFACT_METADATA_JSON="$observed" \ + bun -e ' + const canonical = (value) => Array.isArray(value) + ? value.map(canonical) + : value !== null && typeof value === "object" + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) + : value; + const expected = JSON.stringify(canonical(JSON.parse(process.env.PINNED_ARTIFACT_METADATA_JSON))); + const actual = JSON.stringify(canonical(JSON.parse(process.env.OBSERVED_ARTIFACT_METADATA_JSON))); + if (actual !== expected) { + console.error("Original dry-run artifact inventory differs from the pinned recovery provenance."); + process.exit(1); + } + ' + cat "$gate_output" >> "$GITHUB_OUTPUT" + + - name: Download original approved lock for same-version recovery + id: download_recovery_original_publication_lock + if: ${{ steps.recovery_original_publication_lock.outcome == 'success' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RECOVERY_LOCK_RUN_ID: ${{ steps.recovery_original_publication_lock.outputs.run_id }} + RECOVERY_LOCK_ARTIFACT_METADATA_JSON: ${{ steps.recovery_original_publication_lock.outputs.artifact_metadata_json }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + node .github/scripts/download-build-artifacts.mjs \ + Release \ + "$RECOVERY_RELEASE_SHA" \ + "$RUNNER_TEMP/recovery-original-publication-lock" \ + --run-id "$RECOVERY_LOCK_RUN_ID" \ + --artifact-metadata-json "$RECOVERY_LOCK_ARTIFACT_METADATA_JSON" \ + --artifact oliphaunt-publication-lock + - name: Require one approved dry-run lock and capsule id: approved_publication_lock if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + CANDIDATE_MODE: ${{ steps.verify_publication_candidate.outputs.mode }} + PINNED_ARTIFACT_METADATA_JSON: ${{ steps.recovery_source.outputs.approved_dry_run_artifact_metadata_json }} + PINNED_DRY_RUN_ID: ${{ steps.recovery_source.outputs.approved_dry_run_id }} run: | + approved_artifacts=( + --artifact oliphaunt-publication-lock + --artifact oliphaunt-bootstrap-capsule + ) + approval_sha="$RELEASE_SOURCE_SHA" + approval_timeout=300 + pinned_run_args=() + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + approval_timeout=0 + pinned_run_args=(--run-id "$PINNED_DRY_RUN_ID") + fi + gate_output="$RUNNER_TEMP/approved-publication-inputs-gate.out" + GITHUB_OUTPUT="$gate_output" \ bash .github/scripts/require-workflow-success.sh \ Release \ - "$RELEASE_HEAD_SHA" \ + "$approval_sha" \ + "$approval_timeout" \ + "${pinned_run_args[@]}" \ + --event workflow_dispatch \ + "${approved_artifacts[@]}" + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + observed="$(sed -n 's/^artifact_metadata_json=//p' "$gate_output")" + if [[ -z "$observed" || "$(grep -c '^artifact_metadata_json=' "$gate_output")" != 1 ]]; then + echo 'Approved recovery inputs did not emit exactly one artifact inventory.' >&2 + exit 1 + fi + PINNED_ARTIFACT_METADATA_JSON="$PINNED_ARTIFACT_METADATA_JSON" \ + OBSERVED_ARTIFACT_METADATA_JSON="$observed" \ + bun -e ' + const canonical = (value) => Array.isArray(value) + ? value.map(canonical) + : value !== null && typeof value === "object" + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) + : value; + const expected = JSON.stringify(canonical(JSON.parse(process.env.PINNED_ARTIFACT_METADATA_JSON))); + const actual = JSON.stringify(canonical(JSON.parse(process.env.OBSERVED_ARTIFACT_METADATA_JSON))); + if (actual !== expected) { + console.error("Approved recovery inputs differ from the pinned recovery provenance."); + process.exit(1); + } + ' + fi + cat "$gate_output" >> "$GITHUB_OUTPUT" + + - name: Require approved same-version recovery control evidence + id: approved_recovery_control + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + bash .github/scripts/require-workflow-success.sh \ + Release \ + "$RELEASE_CONTROL_SHA" \ 300 \ --event workflow_dispatch \ - --artifact oliphaunt-publication-lock \ - --artifact oliphaunt-bootstrap-capsule + --artifact oliphaunt-release-recovery-equivalence - name: Download prior approved publication lock if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} @@ -569,25 +890,59 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ Release \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_SOURCE_SHA" \ target/approved-publication-lock \ --run-id "$RELEASE_LOCK_RUN_ID" \ --artifact-metadata-json "$APPROVED_ARTIFACT_METADATA_JSON" \ --artifact oliphaunt-publication-lock + - name: Download approved same-version recovery control evidence + id: download_approved_recovery_control + if: ${{ steps.approved_recovery_control.outcome == 'success' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RECOVERY_APPROVAL_RUN_ID: ${{ steps.approved_recovery_control.outputs.run_id }} + RECOVERY_APPROVAL_ARTIFACT_METADATA_JSON: ${{ steps.approved_recovery_control.outputs.artifact_metadata_json }} + run: | + node .github/scripts/download-build-artifacts.mjs \ + Release \ + "$RELEASE_CONTROL_SHA" \ + "$RUNNER_TEMP/approved-recovery-control" \ + --run-id "$RECOVERY_APPROVAL_RUN_ID" \ + --artifact-metadata-json "$RECOVERY_APPROVAL_ARTIFACT_METADATA_JSON" \ + --artifact oliphaunt-release-recovery-equivalence + - name: Validate product versions and registry state + id: validate_release_registry_state if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/dev/bun.sh tools/release/release-check-registries.mjs --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" + run: | + registry_args=( + --products-json "$PRODUCTS_JSON" + --head-ref "$RELEASE_SOURCE_SHA" + ) + if [[ "${{ steps.verify_publication_candidate.outputs.mode }}" == release-recovery ]]; then + # Dynamic payload-part carriers do not exist until the exhaustive + # publication lock is frozen. Validate version/dependency state + # here, then inventory every exact carrier from that lock below. + tools/dev/bun.sh tools/release/check_release_versions.mjs \ + "${registry_args[@]}" + else + tools/dev/bun.sh tools/release/release-check-registries.mjs \ + "${registry_args[@]}" + fi - name: Download WASIX runtime build artifacts if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: bun .github/scripts/download-wasix-runtime-build-artifacts.mjs - name: Download WASIX release assets @@ -595,11 +950,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ target/oliphaunt-wasix/release-assets \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -610,11 +966,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ target/extension-artifacts \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -626,7 +983,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | download_sdk_artifact() { local product="$1" @@ -636,7 +994,7 @@ jobs: done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --family sdk-package --format lines) node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ "target/sdk-artifacts/$product" \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -651,11 +1009,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ target/liboliphaunt/release-assets \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -684,7 +1043,8 @@ jobs: GH_REPO: ${{ github.repository }} PRODUCT_OLIPHAUNT_BROKER: ${{ steps.release_plan.outputs.product_oliphaunt_broker }} PRODUCT_OLIPHAUNT_NODE_DIRECT: ${{ steps.release_plan.outputs.product_oliphaunt_node_direct }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | download_helper_artifacts() { local product="$1" @@ -696,7 +1056,7 @@ jobs: done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product "$product" --kind "$kind" --family release-assets --format lines) node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ "$destination" \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -720,7 +1080,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CI_RUN_ID: ${{ steps.release_artifact_source.outputs.ci_run_id }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | artifact_args=() while IFS= read -r artifact; do @@ -728,7 +1089,7 @@ jobs: done < <(tools/dev/bun.sh tools/release/release_graph_query.mjs ci-artifact-names --product oliphaunt-node-direct --kind node-direct-addon --family npm-package --format lines) node .github/scripts/download-build-artifacts.mjs \ CI \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_ARTIFACT_SHA" \ target/oliphaunt-node-direct/npm-packages \ --run-id "$CI_RUN_ID" \ --job Builds \ @@ -906,13 +1267,21 @@ jobs: id: freeze_publication_lock if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: + CANDIDATE_MODE: ${{ steps.verify_publication_candidate.outputs.mode }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + RELEASE_ARTIFACT_SHA: ${{ steps.release_artifact_source.outputs.sha }} run: | + lock_output=target/release/publication-lock.json + lock_source="$RELEASE_SOURCE_SHA" + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + lock_output=target/release/replayed-publication-lock.json + lock_source="$RELEASE_ARTIFACT_SHA" + fi lock_args=( create --products-json "$PRODUCTS_JSON" - --head-ref "$RELEASE_HEAD_SHA" - --output target/release/publication-lock.json + --head-ref "$lock_source" + --output "$lock_output" ) for artifact_root in \ target/release \ @@ -942,10 +1311,74 @@ jobs: --products-json "$PRODUCTS_JSON" \ --format lines) tools/dev/bun.sh tools/release/publication-lock.mjs "${lock_args[@]}" + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + original_lock="$RUNNER_TEMP/recovery-original-publication-lock/publication-lock.json" + if ! cmp -s "$original_lock" "$lock_output"; then + echo 'Replayed recovery lock differs from the original approved publication lock.' >&2 + diff --unified "$original_lock" "$lock_output" || true + exit 1 + fi + cp "$original_lock" "$PUBLICATION_LOCK_PATH" + fi tools/dev/bun.sh tools/release/publication-lock.mjs \ verify \ --lock target/release/publication-lock.json \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$lock_source" + + - name: Prove same-version recovery byte envelope is unchanged + id: verify_release_recovery_lock + if: ${{ steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 2 + env: + RECOVERY_LOCK_ARTIFACT_METADATA_JSON: ${{ steps.recovery_source.outputs.approved_lock_artifact_metadata_json }} + RECOVERY_LOCK_RUN_ID: ${{ steps.recovery_original_publication_lock.outputs.run_id }} + RECOVERY_RELEASE_SHA: ${{ steps.verify_publication_candidate.outputs.release_sha }} + run: | + tools/dev/bun.sh tools/release/verify-release-recovery-lock.mjs \ + --original-lock "$RUNNER_TEMP/recovery-original-publication-lock/publication-lock.json" \ + --replay-lock target/release/replayed-publication-lock.json \ + --release-sha "$RECOVERY_RELEASE_SHA" \ + --controller-sha "$RELEASE_CONTROL_SHA" \ + --original-run-id "$RECOVERY_LOCK_RUN_ID" \ + --original-artifact-metadata-json "$RECOVERY_LOCK_ARTIFACT_METADATA_JSON" \ + --output target/release/recovery-evidence/lock-equivalence.json + if [[ "${{ inputs.operation }}" == publish ]]; then + approved="$RUNNER_TEMP/approved-recovery-control/lock-equivalence.json" + if ! cmp -s "$approved" target/release/recovery-evidence/lock-equivalence.json; then + echo 'Fresh recovery lock replay evidence differs from the approved control dry-run.' >&2 + diff --unified "$approved" target/release/recovery-evidence/lock-equivalence.json || true + exit 1 + fi + fi + + - name: Inventory exact frozen registry state for same-version recovery + id: inventory_release_recovery_registries + if: ${{ steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 20 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLIPHAUNT_PUBLICATION_LOCK: ${{ env.PUBLICATION_LOCK_PATH }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: | + tools/dev/bun.sh tools/release/release-check-registries.mjs \ + --products-json "$PRODUCTS_JSON" \ + --head-ref "$RELEASE_SOURCE_SHA" \ + --registry-inventory-output target/release/recovery-registry-inventory.json + + - name: Prove same-version recovery follows a partial immutable publication + id: verify_release_recovery_publication + if: ${{ steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 20 + env: + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: | + tools/dev/bun.sh tools/release/verify-release-recovery-publication.mjs \ + --lock "$PUBLICATION_LOCK_PATH" \ + --inventory target/release/recovery-registry-inventory.json \ + --products-json "$PRODUCTS_JSON" \ + --output target/release/recovery-evidence/publication-state.json \ + --github-output "$GITHUB_OUTPUT" - name: Match prior approved publication lock if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} @@ -971,7 +1404,7 @@ jobs: tools/dev/bun.sh tools/release/preflight-maven-central-bundle.mjs \ --publication-lock "$PUBLICATION_LOCK_PATH" \ --products-json "$PRODUCTS_JSON" \ - --release-commit "$RELEASE_HEAD_SHA" + --release-commit "$RELEASE_SOURCE_SHA" - name: Prove the exact SwiftPM source tag is remotely collision-free id: preflight_swift_source_tag @@ -980,7 +1413,7 @@ jobs: run: | tools/dev/bun.sh tools/release/preflight-swiftpm-source-tag.mjs \ --publication-lock "$PUBLICATION_LOCK_PATH" \ - --release-commit "$RELEASE_HEAD_SHA" + --release-commit "$RELEASE_SOURCE_SHA" - name: Admit the complete paced GitHub release request envelope id: github_request_budget @@ -1024,7 +1457,7 @@ jobs: run: | tools/dev/bun.sh tools/release/release-phase-handoff.mjs audit \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --approved-run-id "$APPROVED_RUN_ID" \ --approved-artifacts-json "$APPROVED_ARTIFACT_METADATA_JSON" \ --github-output "$GITHUB_OUTPUT" @@ -1046,19 +1479,31 @@ jobs: if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && (steps.registry_needs.outputs.needs_cargo == 'true' || steps.registry_needs.outputs.needs_npm == 'true') }} env: PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + RELEASE_HEAD_SHA: ${{ steps.release_identity.outputs.source_sha }} run: bun .github/scripts/registry-bootstrap-ledger-state.mjs - name: Download immutable registry bootstrap ledger if: ${{ steps.bootstrap_ledger_state.outputs.needs_ledger == 'true' }} env: + CANDIDATE_MODE: ${{ steps.verify_publication_candidate.outputs.mode }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + PINNED_LEDGER_ARTIFACT_METADATA_JSON: ${{ steps.recovery_source.outputs.bootstrap_ledger_artifact_metadata_json }} + PINNED_LEDGER_RUN_ID: ${{ steps.recovery_source.outputs.bootstrap_ledger_run_id }} run: | - node .github/scripts/download-build-artifacts.mjs \ + ledger_args=( Release \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_SOURCE_SHA" \ "$BOOTSTRAP_LEDGER_PATH" \ --artifact oliphaunt-bootstrap-ledger + ) + if [[ "$CANDIDATE_MODE" == release-recovery ]]; then + ledger_args+=( + --run-id "$PINNED_LEDGER_RUN_ID" + --artifact-metadata-json "$PINNED_LEDGER_ARTIFACT_METADATA_JSON" + ) + fi + node .github/scripts/download-build-artifacts.mjs "${ledger_args[@]}" - name: Verify immutable bootstrap ledger and registry existence if: ${{ steps.bootstrap_ledger_state.outputs.needs_ledger == 'true' }} @@ -1073,19 +1518,19 @@ jobs: - name: Freeze bootstrap publication capsule id: freeze_bootstrap_capsule - if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' }} + if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' }} env: PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} run: | tools/dev/bun.sh tools/release/bootstrap-publication-capsule.mjs pack \ --lock "$PUBLICATION_LOCK_PATH" \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --output target/release/oliphaunt-bootstrap-capsule.tar - name: Upload frozen publication lock id: preserve_publication_lock - if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' }} + if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: oliphaunt-publication-lock @@ -1096,7 +1541,7 @@ jobs: - name: Upload frozen bootstrap publication capsule id: preserve_bootstrap_capsule - if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' }} + if: ${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: oliphaunt-bootstrap-capsule @@ -1105,6 +1550,18 @@ jobs: overwrite: true retention-days: 90 + - name: Upload same-version recovery equivalence evidence + id: preserve_release_recovery_equivalence + if: ${{ inputs.operation == 'publish-dry-run' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-release-recovery-equivalence + path: target/release/recovery-evidence + if-no-files-found: error + overwrite: true + retention-days: 90 + - name: Upload publication lock audit evidence if: ${{ inputs.operation == 'publish' && steps.release_plan.outputs.has_release_changes == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -1144,7 +1601,7 @@ jobs: RELEASE_OPERATION: publish RELEASE_CONTINUATION_POINTER: ${{ inputs.continuation_pointer }} RELEASE_TRANSPORT_CONTENT_WRITE_ADMISSION: pre-reserved - run: node .github/scripts/release-transport-ref.mjs ensure "$RELEASE_HEAD_SHA" + run: node .github/scripts/release-transport-ref.mjs ensure "$RELEASE_CONTROL_SHA" - name: Stage exact-SHA product tags and draft releases id: stage_github_releases @@ -1156,7 +1613,7 @@ jobs: run: | bun .github/scripts/manage-release-drafts.mjs stage \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --state staged - name: Verify exact product tags @@ -1166,7 +1623,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/dev/bun.sh tools/release/verify_product_tags.mjs --products-json "${PRODUCTS_JSON}" --target "$RELEASE_HEAD_SHA" + run: tools/dev/bun.sh tools/release/verify_product_tags.mjs --products-json "${PRODUCTS_JSON}" --target "$RELEASE_SOURCE_SHA" - name: Verify exact-SHA GitHub release staging id: verify_github_staging @@ -1175,7 +1632,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: bun .github/scripts/manage-release-drafts.mjs verify --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_HEAD_SHA" --state staged + run: bun .github/scripts/manage-release-drafts.mjs verify --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_SOURCE_SHA" --state staged - name: Publish all selected GitHub release asset sets concurrently id: publish_github_assets @@ -1185,11 +1642,60 @@ jobs: GITHUB_RELEASE_ASSET_UPLOAD_REPORT_PATH: ${{ runner.temp }}/oliphaunt-github-release-asset-upload-report.json GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} - run: tools/dev/bun.sh tools/release/release-publish.mjs publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_HEAD_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref "$RELEASE_SOURCE_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" + + - name: Prepare same-version recovery promotion attestation + id: recovery_promotion_attestation + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }} + timeout-minutes: 2 + env: + CONTROLLER_APPROVAL_ARTIFACTS_JSON: ${{ steps.approved_recovery_control.outputs.artifact_metadata_json }} + CONTROLLER_APPROVAL_RUN_ATTEMPT: ${{ steps.approved_recovery_control.outputs.run_attempt }} + CONTROLLER_APPROVAL_RUN_ID: ${{ steps.approved_recovery_control.outputs.run_id }} + CONTROLLER_CI_ARTIFACTS_JSON: ${{ steps.ci_qualification.outputs.artifact_metadata_json }} + CONTROLLER_CI_RUN_ATTEMPT: ${{ steps.ci_qualification.outputs.run_attempt }} + CONTROLLER_CI_RUN_ID: ${{ steps.ci_qualification.outputs.run_id }} + CONTROLLER_TREE: ${{ steps.release_identity.outputs.controller_tree }} + run: | + tools/dev/bun.sh tools/release/recovery-promotion-attestation.mjs prepare \ + --lock "$PUBLICATION_LOCK_PATH" \ + --provenance tools/release/same-version-recovery-sources.json \ + --approval target/release/recovery-evidence/lock-equivalence.json \ + --approval-run-id "$CONTROLLER_APPROVAL_RUN_ID" \ + --approval-run-attempt "$CONTROLLER_APPROVAL_RUN_ATTEMPT" \ + --approval-artifacts-json "$CONTROLLER_APPROVAL_ARTIFACTS_JSON" \ + --controller-sha "$RELEASE_CONTROL_SHA" \ + --controller-tree "$CONTROLLER_TREE" \ + --qualification-run-id "$CONTROLLER_CI_RUN_ID" \ + --qualification-run-attempt "$CONTROLLER_CI_RUN_ATTEMPT" \ + --qualification-artifacts-json "$CONTROLLER_CI_ARTIFACTS_JSON" \ + --promotion-run-id "$GITHUB_RUN_ID" \ + --promotion-run-attempt "$GITHUB_RUN_ATTEMPT" \ + --controller-output target/release/recovery-evidence/promotion-controller.json \ + --subjects-output target/release/recovery-evidence/promotion-subjects.json \ + --predicate-output target/release/recovery-evidence/promotion-predicate.json \ + --checksums-output target/release/recovery-evidence/promotion-subjects.sha256 \ + --github-output "$GITHUB_OUTPUT" + + - name: Reserve same-version recovery promotion attestation content write + if: ${{ steps.recovery_promotion_attestation.outcome == 'success' }} + run: | + tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "same-version recovery promotion attestation" + tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "same-version recovery promotion attestation API attempt" + + - name: Attest same-version recovery promotion + id: attest_recovery_promotion + if: ${{ steps.recovery_promotion_attestation.outcome == 'success' }} + timeout-minutes: 5 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d + with: + subject-checksums: ${{ steps.recovery_promotion_attestation.outputs.checksums_path }} + predicate-type: ${{ steps.recovery_promotion_attestation.outputs.predicate_type }} + predicate-path: ${{ steps.recovery_promotion_attestation.outputs.predicate_path }} - name: Resolve exact selected extension attestation subjects id: extension_attestation_subjects - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.has_extension_products == 'true' }} env: EXTENSION_PRODUCTS_JSON: ${{ steps.release_plan.outputs.extension_products_json }} run: | @@ -1199,42 +1705,42 @@ jobs: --github-output "$GITHUB_OUTPUT" - name: Reserve extension attestation content write (shard 1) - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_1 == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_1 == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "extension attestation shard 1" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "extension attestation shard 1 API attempt" - name: Attest selected extension release assets (shard 1) id: attest_extensions_1 - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_1 == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_1 == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: subject-path: ${{ steps.extension_attestation_subjects.outputs.paths_1 }} - name: Reserve extension attestation content write (shard 2) - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_2 == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_2 == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "extension attestation shard 2" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "extension attestation shard 2 API attempt" - name: Attest selected extension release assets (shard 2) id: attest_extensions_2 - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_2 == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.has_extension_products == 'true' && steps.extension_attestation_subjects.outputs.nonempty_2 == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: subject-path: ${{ steps.extension_attestation_subjects.outputs.paths_2 }} - name: Reserve liboliphaunt attestation content write - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "liboliphaunt native attestation" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "liboliphaunt native attestation API attempt" - name: Attest liboliphaunt release assets id: attest_liboliphaunt_native - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: @@ -1251,17 +1757,17 @@ jobs: timeout-minutes: 6 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-swift --step github-release --head-ref "$RELEASE_HEAD_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" + run: tools/dev/bun.sh tools/release/release-publish.mjs publish --product oliphaunt-swift --step github-release --head-ref "$RELEASE_SOURCE_SHA" --publication-lock "$PUBLICATION_LOCK_PATH" - name: Reserve broker attestation content write - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "broker attestation" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "broker attestation API attempt" - name: Attest broker release assets id: attest_broker - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: @@ -1271,14 +1777,14 @@ jobs: target/oliphaunt-broker/release-assets/*.sha256 - name: Reserve Node direct attestation content write - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "Node direct attestation" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "Node direct attestation API attempt" - name: Attest Node direct release assets id: attest_node_direct - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: @@ -1288,14 +1794,14 @@ jobs: target/oliphaunt-node-direct/release-assets/*.sha256 - name: Reserve WASIX attestation content write - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} run: | tools/dev/bun.sh tools/release/github-content-write-pacer.mjs reserve --label "WASIX attestation" tools/dev/bun.sh tools/release/github-core-request-journal.mjs reserve --label "WASIX attestation API attempt" - name: Attest WASIX release assets id: attest_wasix - if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} timeout-minutes: 5 uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 with: @@ -1316,6 +1822,7 @@ jobs: LIBOLIPHAUNT_NATIVE_ATTESTATION_BUNDLE: ${{ steps.attest_liboliphaunt_native.outputs.bundle-path }} BROKER_ATTESTATION_BUNDLE: ${{ steps.attest_broker.outputs.bundle-path }} NODE_DIRECT_ATTESTATION_BUNDLE: ${{ steps.attest_node_direct.outputs.bundle-path }} + RECOVERY_PROMOTION_ATTESTATION_BUNDLE: ${{ steps.attest_recovery_promotion.outputs.bundle-path }} WASIX_ATTESTATION_BUNDLE: ${{ steps.attest_wasix.outputs.bundle-path }} run: | bundle_args=() @@ -1325,17 +1832,27 @@ jobs: "$LIBOLIPHAUNT_NATIVE_ATTESTATION_BUNDLE" \ "$BROKER_ATTESTATION_BUNDLE" \ "$NODE_DIRECT_ATTESTATION_BUNDLE" \ + "$RECOVERY_PROMOTION_ATTESTATION_BUNDLE" \ "$WASIX_ATTESTATION_BUNDLE" do if [[ -n "$bundle" ]]; then bundle_args+=(--attestation-bundle "$bundle") fi done + recovery_args=() + if [[ "${{ steps.verify_publication_candidate.outputs.mode }}" == release-recovery ]]; then + recovery_args=( + --recovery-controller target/release/recovery-evidence/promotion-controller.json + --recovery-provenance tools/release/same-version-recovery-sources.json + --recovery-approval target/release/recovery-evidence/lock-equivalence.json + ) + fi tools/dev/bun.sh tools/release/verify_github_release_attestations.mjs pre-mutation \ --publication-lock "$PUBLICATION_LOCK_PATH" \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --output target/release/github-release-attestation-receipt.json \ + "${recovery_args[@]}" \ "${bundle_args[@]}" - name: Seal immutable GitHub-stage handoff @@ -1350,7 +1867,7 @@ jobs: tools/dev/bun.sh tools/release/release-phase-handoff.mjs seal \ --phase github-staged \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --runner-root "$RUNNER_TEMP" \ --approved-run-id "$APPROVED_RUN_ID" \ --approved-artifacts-json "$APPROVED_ARTIFACT_METADATA_JSON" \ @@ -1400,6 +1917,7 @@ jobs: environment: release-publish permissions: actions: read + artifact-metadata: write attestations: write contents: write id-token: write @@ -1407,7 +1925,9 @@ jobs: outputs: has_release_changes: ${{ steps.release_plan.outputs.has_release_changes }} products_json: ${{ steps.release_plan.outputs.products_json }} - release_head_sha: ${{ steps.release_head.outputs.sha }} + release_head_sha: ${{ steps.release_identity.outputs.controller_sha }} + release_control_sha: ${{ steps.release_identity.outputs.controller_sha }} + release_source_sha: ${{ steps.release_identity.outputs.source_sha }} approved_run_id: ${{ steps.approved_publication_lock.outputs.run_id }} approved_artifact_metadata_json: ${{ steps.approved_publication_lock.outputs.artifact_metadata_json }} stage_handoff_artifact_id: ${{ steps.preserve_github_stage_handoff.outputs.artifact-id }} @@ -1430,6 +1950,8 @@ jobs: outputs: publication_complete: ${{ steps.require_registry_execution_decision.outputs.complete }} release_head_sha: ${{ steps.registry_release_head.outputs.sha }} + release_control_sha: ${{ steps.registry_release_identity.outputs.controller_sha }} + release_source_sha: ${{ steps.registry_release_identity.outputs.source_sha }} products_json: ${{ steps.registry_inputs.outputs.products_json }} approved_run_id: ${{ steps.registry_inputs.outputs.approved_run_id }} approved_artifact_metadata_json: ${{ steps.registry_inputs.outputs.approved_artifact_metadata_json }} @@ -1498,6 +2020,57 @@ jobs: --format github-output \ >> "$GITHUB_OUTPUT" + - name: Reject same-version recovery continuation + if: ${{ inputs.continuation_pointer != '' }} + timeout-minutes: 2 + env: + PRODUCTS_JSON: ${{ steps.continued_release_plan.outputs.products_json }} + run: | + candidate_output="$RUNNER_TEMP/continued-publication-candidate.out" + tools/dev/bun.sh tools/release/verify-publication-candidate.mjs \ + --products-json "$PRODUCTS_JSON" \ + --head-ref "$RELEASE_HEAD_SHA" \ + --github-output "$candidate_output" + mode="$(sed -n 's/^mode=//p' "$candidate_output")" + if [[ -z "$mode" || "$(grep -c '^mode=' "$candidate_output")" != 1 ]]; then + echo 'Continued publication candidate did not emit exactly one mode.' >&2 + exit 1 + fi + if [[ "$mode" == release-recovery ]]; then + echo 'Same-version recovery continuations are disabled; restart the idempotent root publish so every existing carrier is reverified.' >&2 + exit 1 + fi + + - name: Resolve exact registry source and controller identities + id: registry_release_identity + timeout-minutes: 1 + env: + CONTINUATION_POINTER: ${{ inputs.continuation_pointer }} + CONTROL_SHA: ${{ steps.registry_release_head.outputs.sha }} + ROOT_SOURCE_SHA: ${{ needs.publish.outputs.release_source_sha }} + run: | + source_sha="$ROOT_SOURCE_SHA" + if [[ -n "$CONTINUATION_POINTER" ]]; then + source_sha="$CONTROL_SHA" + fi + for entry in "controller:$CONTROL_SHA" "source:$source_sha"; do + label="${entry%%:*}" + sha="${entry#*:}" + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Resolved registry $label SHA is not a lowercase full commit SHA" >&2 + exit 1 + fi + git cat-file -e "$sha^{commit}" + done + { + echo "controller_sha=$CONTROL_SHA" + echo "source_sha=$source_sha" + } >> "$GITHUB_OUTPUT" + { + echo "RELEASE_CONTROL_SHA=$CONTROL_SHA" + echo "RELEASE_SOURCE_SHA=$source_sha" + } >> "$GITHUB_ENV" + - name: Inspect exact parent continuation id: inspect_registry_continuation if: ${{ inputs.continuation_pointer != '' }} @@ -1593,7 +2166,7 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ Release \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_SOURCE_SHA" \ "$RUNNER_TEMP/approved-dry-run" \ --run-id "$APPROVED_RUN_ID" \ --artifact-metadata-json "$APPROVED_ARTIFACT_METADATA_JSON" \ @@ -1622,7 +2195,7 @@ jobs: run: | node .github/scripts/download-build-artifacts.mjs \ Release \ - "$RELEASE_HEAD_SHA" \ + "$RELEASE_CONTROL_SHA" \ "$RUNNER_TEMP/github-stage-handoff" \ --run-id "$STAGE_HANDOFF_RUN_ID" \ --artifact-metadata-json "$STAGE_HANDOFF_ARTIFACT_METADATA_JSON" \ @@ -1649,12 +2222,12 @@ jobs: --transport "$RUNNER_TEMP/approved-dry-run/oliphaunt-bootstrap-capsule.tar" \ --approved-lock "$RUNNER_TEMP/approved-dry-run/publication-lock.json" \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --workspace-root "$GITHUB_WORKSPACE" tools/dev/bun.sh tools/release/release-phase-handoff.mjs install \ --phase github-staged \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --runner-root "$RUNNER_TEMP" \ --approved-run-id "$APPROVED_RUN_ID" \ --approved-artifacts-json "$APPROVED_ARTIFACT_METADATA_JSON" \ @@ -1747,10 +2320,10 @@ jobs: run: | tools/dev/bun.sh tools/release/verify_product_tags.mjs \ --products-json "$PRODUCTS_JSON" \ - --target "$RELEASE_HEAD_SHA" + --target "$RELEASE_SOURCE_SHA" bun .github/scripts/manage-release-drafts.mjs verify \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --state staged - name: Restore exact-SHA normal-publication checkpoint @@ -1762,6 +2335,7 @@ jobs: PUBLICATION_LOCK_PATH: target/release/publication-lock.json NORMAL_PUBLICATION_CHECKPOINT_PATH: target/release/normal-publication-checkpoint.json PRODUCTS_JSON: ${{ steps.registry_inputs.outputs.products_json }} + RELEASE_SOURCE_SHA: ${{ steps.registry_release_identity.outputs.source_sha }} RELEASE_CONTINUATION_POINTER: ${{ inputs.continuation_pointer }} RELEASE_CONTINUATION_ARCHIVE: ${{ runner.temp }}/release-continuation.zip run: tools/dev/bun.sh .github/scripts/download-normal-publication-checkpoint.mjs @@ -1813,6 +2387,7 @@ jobs: CAPACITY_NOT_BEFORE_EPOCH: ${{ steps.registry_mutation_deadline.outputs.not_before_epoch || steps.reprove_registry_capacity.outputs.not_before_epoch }} CAPACITY_REQUIRED_WINDOW_SECONDS: ${{ steps.reprove_registry_capacity.outputs.required_window_seconds }} PRE_MUTATION_DEFERRAL_MODE: ${{ steps.registry_mutation_deadline.outputs.admission == 'defer' && 'pre-mutation-deadline' || '' }} + RELEASE_SOURCE_SHA: ${{ steps.registry_release_identity.outputs.source_sha }} run: tools/dev/bun.sh tools/release/record-normal-publication-capacity-deferral.mjs - name: Publish exact-lock registry topology @@ -1834,7 +2409,7 @@ jobs: --registry-plan \ --registry-admission "$ADMISSION_FILE" \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --publication-lock "$PUBLICATION_LOCK_PATH" - name: Require a typed registry execution decision @@ -1850,6 +2425,8 @@ jobs: PROGRESS_COUNT: ${{ steps.exact_registry_publish.outputs.progress_count || steps.record_registry_capacity_deferral.outputs.progress_count }} REMAINING_COUNT: ${{ steps.exact_registry_publish.outputs.remaining_count || steps.record_registry_capacity_deferral.outputs.remaining_count }} NOT_BEFORE_EPOCH: ${{ steps.exact_registry_publish.outputs.not_before_epoch || steps.record_registry_capacity_deferral.outputs.not_before_epoch }} + RELEASE_CONTROL_SHA: ${{ steps.registry_release_identity.outputs.controller_sha }} + RELEASE_SOURCE_SHA: ${{ steps.registry_release_identity.outputs.source_sha }} run: | if [[ "$COMPLETE" == true && "$DEFERRED" == false ]]; then if [[ "$ADMISSION" != execute || -n "$DEFERRAL_MODE" || "$REMAINING_COUNT" != 0 ]]; then @@ -1889,6 +2466,10 @@ jobs: echo 'registry publisher must emit exactly one of complete or deferred' >&2 exit 1 fi + if [[ "$DEFERRED" == true && "$RELEASE_CONTROL_SHA" != "$RELEASE_SOURCE_SHA" ]]; then + echo 'Same-version recovery cannot create a continuation: rerun the idempotent root publish so every existing carrier is reverified.' >&2 + exit 1 + fi { echo "complete=$COMPLETE" echo "deferred=$DEFERRED" @@ -1900,7 +2481,7 @@ jobs: - name: Seal exact normal-publication continuation contract id: prepare_registry_continuation - if: ${{ steps.require_registry_execution_decision.outputs.deferred == 'true' }} + if: ${{ steps.require_registry_execution_decision.outputs.deferred == 'true' && steps.registry_release_identity.outputs.controller_sha == steps.registry_release_identity.outputs.source_sha }} timeout-minutes: 3 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1921,7 +2502,7 @@ jobs: - name: Preserve immutable deferred normal-publication continuation id: preserve_deferred_registry_recovery - if: ${{ steps.require_registry_execution_decision.outputs.deferred == 'true' }} + if: ${{ steps.require_registry_execution_decision.outputs.deferred == 'true' && steps.registry_release_identity.outputs.controller_sha == steps.registry_release_identity.outputs.source_sha }} timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -1983,7 +2564,7 @@ jobs: tools/dev/bun.sh tools/release/release-phase-handoff.mjs seal \ --phase registry-published \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --runner-root "$RUNNER_TEMP" \ --approved-run-id "$APPROVED_RUN_ID" \ --approved-artifacts-json "$APPROVED_ARTIFACT_METADATA_JSON" \ @@ -2008,7 +2589,7 @@ jobs: - validate-inputs - publish-registry runs-on: macos-26 - timeout-minutes: 120 + timeout-minutes: 124 if: ${{ always() && inputs.operation == 'publish' && needs.validate-inputs.result == 'success' && needs.publish-registry.result == 'success' && needs.publish-registry.outputs.publication_complete == 'true' }} environment: release-publish permissions: @@ -2045,6 +2626,31 @@ jobs: INPUT_RELEASE_COMMIT: ${{ needs.publish-registry.outputs.release_head_sha }} run: .github/scripts/resolve-release-head.sh + - name: Resolve exact finalization source and controller identities + id: finalize_release_identity + timeout-minutes: 1 + env: + CONTROL_SHA: ${{ steps.finalize_release_head.outputs.sha }} + SOURCE_SHA: ${{ needs.publish-registry.outputs.release_source_sha }} + run: | + for entry in "controller:$CONTROL_SHA" "source:$SOURCE_SHA"; do + label="${entry%%:*}" + sha="${entry#*:}" + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Resolved finalization $label SHA is not a lowercase full commit SHA" >&2 + exit 1 + fi + git cat-file -e "$sha^{commit}" + done + { + echo "controller_sha=$CONTROL_SHA" + echo "source_sha=$SOURCE_SHA" + } >> "$GITHUB_OUTPUT" + { + echo "RELEASE_CONTROL_SHA=$CONTROL_SHA" + echo "RELEASE_SOURCE_SHA=$SOURCE_SHA" + } >> "$GITHUB_ENV" + - name: Set up pinned Node.js id: setup_finalize_node timeout-minutes: 5 @@ -2071,6 +2677,17 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} + - name: Re-prove release lifecycle identity + id: finalize_publication_candidate + timeout-minutes: 1 + env: + PRODUCTS_JSON: ${{ needs.publish-registry.outputs.products_json }} + run: | + tools/dev/bun.sh tools/release/verify-publication-candidate.mjs \ + --products-json "$PRODUCTS_JSON" \ + --head-ref "$RELEASE_HEAD_SHA" \ + --github-output "$GITHUB_OUTPUT" + - name: Install and verify exact registry-published handoff id: install_registry_handoff timeout-minutes: 5 @@ -2083,7 +2700,7 @@ jobs: tools/dev/bun.sh tools/release/release-phase-handoff.mjs install \ --phase registry-published \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --runner-root "$RUNNER_TEMP" \ --approved-run-id "$APPROVED_RUN_ID" \ --approved-artifacts-json "$APPROVED_ARTIFACT_METADATA_JSON" \ @@ -2126,10 +2743,10 @@ jobs: run: | tools/dev/bun.sh tools/release/verify_product_tags.mjs \ --products-json "$PRODUCTS_JSON" \ - --target "$RELEASE_HEAD_SHA" + --target "$RELEASE_SOURCE_SHA" bun .github/scripts/manage-release-drafts.mjs verify \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --state staged - name: Enter bounded release finalization @@ -2160,7 +2777,7 @@ jobs: git fetch --force --tags origin tools/dev/bun.sh tools/release/release-verify.mjs \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" \ + --head-ref "$RELEASE_SOURCE_SHA" \ --publication-lock "$PUBLICATION_LOCK_PATH" \ --registry-receipts target/release/registry-integrity-receipts.json \ --github-release-receipt target/release/github-release-attestation-receipt.json @@ -2196,7 +2813,7 @@ jobs: tools/dev/bun.sh tools/release/publication-lock.mjs \ verify \ --lock "$PUBLICATION_LOCK_PATH" \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$RELEASE_SOURCE_SHA" - name: Preserve pre-promotion GitHub mutation evidence id: preserve_pre_promotion_evidence @@ -2220,21 +2837,21 @@ jobs: - name: Promote verified GitHub release drafts id: promote_github_releases - timeout-minutes: 12 + timeout-minutes: 16 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PRODUCTS_JSON: ${{ needs.publish-registry.outputs.products_json }} run: | tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs \ assert-markable \ - --release-sha "$RELEASE_HEAD_SHA" \ + --release-sha "${{ steps.finalize_publication_candidate.outputs.release_sha }}" \ --base main bun .github/scripts/manage-release-drafts.mjs promote \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$RELEASE_SOURCE_SHA" tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs \ mark-tagged \ - --release-sha "$RELEASE_HEAD_SHA" \ + --release-sha "${{ steps.finalize_publication_candidate.outputs.release_sha }}" \ --base main publish-bootstrap: @@ -2349,14 +2966,23 @@ jobs: RELEASE_CONTINUATION_POINTER: ${{ inputs.continuation_pointer }} run: bun .github/scripts/verify-github-oidc-identity.mjs - - name: Prove workflow HEAD is the release-bump commit + - name: Prove workflow HEAD is a release or same-version recovery commit + id: verify_bootstrap_publication_candidate if: ${{ steps.bootstrap_scope.outputs.required == 'true' }} + timeout-minutes: 2 env: PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} run: | - tools/dev/bun.sh tools/release/verify-release-commit.mjs \ + tools/dev/bun.sh tools/release/verify-publication-candidate.mjs \ --products-json "$PRODUCTS_JSON" \ - --head-ref "$RELEASE_HEAD_SHA" + --head-ref "$RELEASE_HEAD_SHA" \ + --github-output "$GITHUB_OUTPUT" + + - name: Reject same-version recovery bootstrap mutation + if: ${{ steps.bootstrap_scope.outputs.required == 'true' && steps.verify_bootstrap_publication_candidate.outputs.mode == 'release-recovery' }} + run: | + echo 'Same-version recovery cannot run publish-bootstrap. Reuse and verify the pinned terminal bootstrap ledger through the idempotent root publish.' >&2 + exit 1 - name: Prove Release Please PR can complete after bootstrap publication id: assert_bootstrap_release_please_markable @@ -2367,7 +2993,7 @@ jobs: run: | tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs \ assert-markable \ - --release-sha "$RELEASE_HEAD_SHA" \ + --release-sha "${{ steps.verify_bootstrap_publication_candidate.outputs.release_sha }}" \ --base main - name: Preflight selected product tag and release collisions @@ -2488,13 +3114,16 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | + approved_artifacts=( + --artifact oliphaunt-publication-lock + --artifact oliphaunt-bootstrap-capsule + ) bash .github/scripts/require-workflow-success.sh \ Release \ "$RELEASE_HEAD_SHA" \ 0 \ --event workflow_dispatch \ - --artifact oliphaunt-publication-lock \ - --artifact oliphaunt-bootstrap-capsule + "${approved_artifacts[@]}" - name: Download approved lock and capsule from one dry-run if: ${{ steps.bootstrap_scope.outputs.required == 'true' }} @@ -2553,21 +3182,36 @@ jobs: run: node .github/scripts/download-bootstrap-ledger.mjs - name: Require bootstrap credentials before mutation + id: require_bootstrap_credentials if: ${{ steps.bootstrap_scope.outputs.required == 'true' }} env: CRATES_IO_BOOTSTRAP_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} NPM_BOOTSTRAP_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }} - NEEDS_CARGO: ${{ steps.registry_needs.outputs.needs_cargo }} - NEEDS_NPM: ${{ steps.registry_needs.outputs.needs_npm }} + SELECTED_NEEDS_CARGO: ${{ steps.registry_needs.outputs.needs_cargo }} + SELECTED_NEEDS_NPM: ${{ steps.registry_needs.outputs.needs_npm }} run: | - if [[ "$NEEDS_CARGO" == true && -z "$CRATES_IO_BOOTSTRAP_TOKEN" ]]; then + needs_cargo="$SELECTED_NEEDS_CARGO" + needs_npm="$SELECTED_NEEDS_NPM" + if [[ "$needs_cargo" != true && "$needs_cargo" != false ]]; then + echo 'bootstrap Cargo credential decision is not boolean' >&2 + exit 1 + fi + if [[ "$needs_npm" != true && "$needs_npm" != false ]]; then + echo 'bootstrap npm credential decision is not boolean' >&2 + exit 1 + fi + if [[ "$needs_cargo" == true && -z "$CRATES_IO_BOOTSTRAP_TOKEN" ]]; then echo 'selected products require CRATES_IO_BOOTSTRAP_TOKEN in the release-bootstrap environment' >&2 exit 1 fi - if [[ "$NEEDS_NPM" == true && -z "$NPM_BOOTSTRAP_TOKEN" ]]; then + if [[ "$needs_npm" == true && -z "$NPM_BOOTSTRAP_TOKEN" ]]; then echo 'selected products require NPM_BOOTSTRAP_TOKEN in the release-bootstrap environment' >&2 exit 1 fi + { + echo "needs_cargo_token=$needs_cargo" + echo "needs_npm_token=$needs_npm" + } >> "$GITHUB_OUTPUT" - name: Admit or recover exact immutable release transport ref id: ensure_bootstrap_transport_ref @@ -2603,7 +3247,8 @@ jobs: echo "Bootstrap registry mutation must stop before Unix time $deadline." - name: Configure npm identity-bootstrap authentication - if: ${{ steps.bootstrap_scope.outputs.required == 'true' && steps.registry_needs.outputs.needs_npm == 'true' }} + id: configure_bootstrap_npm_auth + if: ${{ steps.bootstrap_scope.outputs.required == 'true' && steps.require_bootstrap_credentials.outputs.needs_npm_token == 'true' }} env: NPM_BOOTSTRAP_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }} run: | @@ -2615,7 +3260,7 @@ jobs: id: bootstrap_registry_identities if: ${{ steps.bootstrap_scope.outputs.required == 'true' }} env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ steps.require_bootstrap_credentials.outputs.needs_cargo_token == 'true' && secrets.CRATES_IO_BOOTSTRAP_TOKEN || '' }} NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/oliphaunt-bootstrap.npmrc PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} REGISTRY_BOOTSTRAP_CARGO_SECONDS_PER_CARRIER: ${{ vars.REGISTRY_BOOTSTRAP_CARGO_SECONDS_PER_CARRIER || '30' }} @@ -2789,7 +3434,7 @@ jobs: dispatch-publish-continuation: name: Dispatch verified registry continuation needs: publish-registry - if: ${{ needs.publish-registry.outputs.continuation_required == 'true' }} + if: ${{ needs.publish-registry.outputs.continuation_required == 'true' && needs.publish-registry.outputs.release_control_sha == needs.publish-registry.outputs.release_source_sha }} runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67ca1555..81f2cced 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,9 @@ moon run :test The runtime smoke starts embedded Postgres and is intentionally slower than unit tests. The protected `publish-dry-run` operation is a release-candidate check: run it from the GitHub `Release` workflow after the exact release-bump commit has a -successful `Qualified` CI record. It is not a routine source-PR check. +successful `Qualified` CI record. The documented same-version control recovery +uses a separately qualified linear recovery head bound to that original +release-bump commit. It is not a routine source-PR check. Install local hooks with: @@ -53,3 +55,9 @@ owns exact-SHA product tags and draft GitHub releases. Product-local release metadata owns publish targets and artifact shape; Moon dependency scopes provide release coupling. See `docs/maintainers/release.md` for release intent, trusted publishing, and workflow details. + +A pure control-plane `ci:` change has no release-semantic owners, so preparing +a release after only that change creates no release PR and performs no registry +operation. A workflow or action change that alters compiler, SDK, build, +source-selection, target, or packaging behavior is product-semantic regardless +of its commit label and must advance the affected product versions. diff --git a/README.md b/README.md index 845d13c1..b5c5a5ff 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ PostgreSQL model. Applications own their database roots, choose an honest runtime mode for their platform, and package only the exact PostgreSQL extensions they select. -> **Release status:** this source tree is preparing Oliphaunt's first -> independently versioned public releases. Source versions intentionally remain -> `0.0.0` until the generated release PR advances them. Package names and -> install examples describe the release contract; they are not evidence that a -> registry package has already been published. +> **Release availability:** Oliphaunt products are independently versioned and +> can become visible on different registries at different times during a +> publication transaction. Release Please-generated version metadata in the +> exact source tree is the version authority; an install example is a contract, +> while the corresponding registry entry or promoted GitHub product tag is the +> availability evidence. ## Product model @@ -65,7 +66,7 @@ and physical-iOS boundaries. ## SDK entry points -The planned public entry points are: +The declared public entry points are: | App surface | Package entry point | Distribution boundary | | --- | --- | --- | diff --git a/docs/maintainers/release-setup.md b/docs/maintainers/release-setup.md index 06dabf83..b18cd255 100644 --- a/docs/maintainers/release-setup.md +++ b/docs/maintainers/release-setup.md @@ -1,6 +1,6 @@ # Release setup -Status: normative external-setup guide. Last verified: 2026-07-28. Owner: repository maintainers. +Status: normative external-setup guide. Last verified: 2026-07-30. Owner: repository maintainers. This document covers state that cannot live in the repository. The executable contract is the direct least-privilege workflow in @@ -161,9 +161,9 @@ authorization shortcut: - `ready` is valid only after every reviewed short-lived Cargo/npm token required by the approved lock has been installed for an imminent `publish-bootstrap` dispatch; it accepts either registry token or both, and - requires at least one. The current first release requires both registries; - a later lock that introduces identities in only one registry must not force - provisioning an unrelated credential; and + requires at least one. Provision only the registries whose exact locked + identities remain absent. A recovery in which every selected Cargo/npm + version already matches stays `idle` and requires neither token; and - `retired` is valid only after bootstrap sealed, trusted publishers were configured, and both tokens were revoked and removed; it also requires the token names to be absent. @@ -181,7 +181,7 @@ release. ## Registry ownership -The publication catalog is the identity inventory. Generate/query it rather than maintaining a package list in this document. Bootstrap accepts exactly two registry states: the locked first version is already public with lock-matching bytes (a recovery skip), or the package name is wholly absent (a first-version mutation). An existing name without the locked exact version is a hard blocker and must use a later normal release; it is never treated as bootstrap work. A resumed run reconciles and checkpoints every matching public version, then invokes publishers only for names that remain absent. A conflicting public identity is a blocker, not a reason to rename an artifact silently. +The publication catalog defines stable carrier topology; the frozen publication lock is the exhaustive candidate identity inventory, including generated payload parts. Generate/query them rather than maintaining a package list in this document. Bootstrap accepts exactly two registry states: the locked first version is already public with lock-matching bytes (a recovery skip), or the package name is wholly absent (a first-version mutation). An existing name without the locked exact version is a hard blocker and must use a later normal release; it is never treated as bootstrap work. A resumed run reconciles and checkpoints every matching public version, then invokes publishers only for names that remain absent. A conflicting public identity is a blocker, not a reason to rename an artifact silently. ### crates.io @@ -392,7 +392,7 @@ undeclared Kotlin Multiplatform/JVM root module. Product tags use `-v`. SwiftPM additionally consumes an unscoped semantic tag; because legacy unscoped tags occupy versions through `0.5.1`, the first Oliphaunt Swift version is `0.6.0`. Release Please owns product versions, changelogs, and the generated release PR. -The root protected publish job first reads +On the normal single-identity path, the root protected publish job first reads `oliphaunt-release-transport/`. When it is absent, or the job is on its first run attempt, the helper validates current `main` before creating or accepting the exact direct-commit tag. Only a genuine GitHub rerun @@ -483,16 +483,32 @@ exact, still-unpublished first-release rollback qualification transport in step 2 above; it is a direct child of the displaced bump solely to prove the corrected unreleased introduction tree. -`release_commit` is only an equality assertion for the workflow commit; it +On the normal single-identity path, `release_commit` is only an equality +assertion for the workflow commit; it cannot select an older commit. On a root dispatch, the workflow ref must be `main`; on a continuation it must be `oliphaunt-release-transport/`. Release tooling fixes create a -new candidate SHA and require new qualification. There is no temporary Release -Please target branch. +new candidate SHA and require new qualification. The same-version dual-identity +exception below still uses the current controller as `release_commit`; it +resolves the older publication source only through the immutable recovery +record. There is no temporary Release Please target branch. ## Recovery -Publishing is resumable but not cross-registry atomic. On failure, preserve and validate the complete content-addressed checkpoint chain, then inventory all selected identities against the frozen lock. Matching immutable versions may be skipped only after registry bytes are proved; a mismatched version/tag/asset or ledger checkpoint stops the release. Repository changes require a new version and new exact-SHA qualification. Use `.codex/skills/release-oliphaunt/references/recovery.md` for the recovery and pre-publication history-repair procedure. +Publishing is resumable but not cross-registry atomic. On failure, preserve and validate the complete content-addressed checkpoint chain, then inventory all selected identities against the frozen lock. Matching immutable versions may be skipped only after registry bytes are proved; a mismatched version/tag/asset or ledger checkpoint stops the release. Product-semantic repository changes require a new version and new exact-SHA qualification. + +A zero-owner release-control/test fix after partial immutable publication may +instead use the documented same-version recovery. It keeps the original +release commit/tree, exact pinned payload CI inventory, approved lock/capsule, +and terminal ledger as the publication source. A later current-main controller +receives fresh full CI and an approved control-equivalence dry-run, but supplies +only workflow/transport/OIDC/pacing code. Lock replay must be byte-identical to +the original, including source and lock digest; tags/releases/assets remain +source-bound. Recovery bootstrap and continuations are disabled, and an +interruption is resumed through an idempotent root `publish` rerun. No matching +public immutable identity is uploaded again. Use +`.codex/skills/release-oliphaunt/references/recovery.md` for that recovery and +the separate pre-publication history-repair procedure. ## External readiness checklist diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 35720a0b..0fb1cc03 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -4,7 +4,7 @@ Windows publishers must also follow the [Visual C++ runtime release contract](./windows-vc-runtime.md); it defines redistributable provenance, extension-provider ownership, app-local placement, and receipt evidence. -Status: normative operation guide. Last verified: 2026-07-28. Owner: repository maintainers. +Status: normative operation guide. Last verified: 2026-07-30. Owner: repository maintainers. Oliphaunt releases independent products from one monorepo. There is no repository-wide product version. @@ -113,17 +113,29 @@ It is an admission optimization, not a substitute for the full write/check, metadata, asset, extension, and package gates on the normalized head. Shared code that can change published bytes or a declared public target must -have one exact ownership rule in `release-semantic-inputs.toml`. The generated -`.release-semantic-inputs.json` file under each affected product root gives -Release Please a content-addressed trigger without treating workflow, -validator, registry-transport, test, or documentation edits as product -releases. After changing an owned shared input or its ownership, run +have one exact ownership rule in `release-semantic-inputs.toml`. This includes +compiler, SDK, linker, build-command, source-selection, and packaging choices +even when their current implementation lives in a workflow or local action. +The generated `.release-semantic-inputs.json` file under each affected product +root gives Release Please a content-addressed trigger without treating pure +control-plane orchestration, validators, registry transport, tests, or +documentation as product releases. After changing an owned shared input or its ownership, run `tools/dev/bun.sh tools/release/sync-release-semantic-inputs.mjs --write` and then `--check`; do not hand-edit the fingerprints. On a generated release PR, use `sync-release-pr.mjs` instead: it refreshes these files only after its final derived semantic input has converged, so interruption recovery and a second write/check pass are idempotent. +Conversely, a pure control-plane workflow, validator, registry-transport, test, +or documentation change with zero semantic owners does not bump a product. +Running `prepare-release-pr` after only those changes reports no releasable +products, creates no release PR, and performs no registry publication. This is +true regardless of a `ci:` commit subject: ownership follows changed semantics, +not the commit label. If another unreleased product change is already present, +Release Please may still prepare that product's release. A manually constructed +`chore(release):` commit without an actual manifest/version/changelog +transition is rejected by the structured release-commit verifier. + ### Generated dependent candidates Release Please remains the sole authority for direct candidates and their @@ -180,6 +192,15 @@ was not closed; it is never reported as “no releasable changes.” Root publication admission accepts only a current-main candidate with one non-cancelled CI run whose `head_sha` is exact and whose `Qualified` gate succeeded. That record covers required checks, tests, builds, policy, selected E2E, and named build artifacts. A successful `Builds` job alone is insufficient. After the root job pins the immutable release transport tag, downstream phases continue that exact transaction without re-evaluating the moving main branch. +Normal publication has one identity: that qualified commit is both the +workflow controller and the immutable publication source. The narrowly scoped +same-version control recovery described below has two identities instead. Its +current-main controller must receive fresh complete CI, while the publication +source remains the original release-bump commit and tree. Controller +qualification proves the repaired workflow, policy, transport, OIDC, and +pacing implementation; it does not replace or relabel the qualified product +payload. + The `macos-26` publication runner is ARM64, but its current runner-image contract exposes the installed Java 17 path as `JAVA_HOME_17_arm64` (including that lowercase suffix). Release setup uses that exact variable first, permits @@ -188,7 +209,9 @@ work unless the selected path contains an executable `bin/java`. Do not invent the variable name from the architecture or rely on the image's moving default Java version for Maven or Gradle publication. -The publish workflow downloads artifacts by that run id and SHA, verifies their attestations/qualification record, assembles the selected product carriers, then freezes a publication lock containing: +For a normal publication, the publish workflow downloads artifacts by that run +id and SHA, verifies their attestations/qualification record, assembles the +selected product carriers, then freezes a publication lock containing: - source commit/tree and catalog digest; - product/version and every actual registry identity; @@ -197,6 +220,16 @@ The publish workflow downloads artifacts by that run id and SHA, verifies their Missing and extra identities both fail. Publish commands reverify the lock immediately before writes. +A same-version control recovery does not perform that assembly from newly +built controller artifacts. It resolves the committed immutable recovery +record and selects the original payload CI run, approved dry-run lock and +capsule, and terminal bootstrap ledger by their exact run and artifact +IDs/digests/sizes. For the current first-release recovery, the record enumerates +all 73 original CI artifacts; any missing, extra, expired, or metadata-mismatched +artifact fails closed. The controller replays publication-lock construction +from those frozen inputs, and the result must be byte-identical to the approved +original lock, including its original `source` object and `lockDigest`. + ## Operations Run local metadata gates before dispatching: @@ -229,10 +262,17 @@ The `Release` workflow has four operations: 1. `prepare-release-pr` — run from current `main`; creates/updates the single generated release PR and syncs derived files. 2. `publish-dry-run` — downloads exact-SHA CI artifacts, performs package/registry preflight and clean-consumer checks, freezes/verifies the lock, and emits the lock-bound Cargo/npm bootstrap capsule without write credentials. -3. `publish-bootstrap` — creation of missing npm/crates identities only, from the already-approved capsule in bounded resumable Linux jobs. npm requires a short-lived granular `@oliphaunt` read/write token with 2FA bypass for this noninteractive first publication; the exact operator checklist is in `release-setup.md`. Configure trusted publishers and revoke every provisioned bootstrap token immediately after the chain seals. The current first release needs both Cargo and npm credentials, while a future single-registry identity addition must provision only that registry's token. +3. `publish-bootstrap` — creation of missing npm/crates identities only, from the already-approved capsule in bounded resumable Linux jobs. npm requires a short-lived granular `@oliphaunt` read/write token with 2FA bypass only when an npm identity is absent; the exact operator checklist is in `release-setup.md`. Configure trusted publishers and revoke every provisioned bootstrap token immediately after the chain seals. Provision only credentials required by the exact missing-identity inventory; a recovery in which every Cargo/npm version already matches requires neither token. 4. `publish` — normal trusted release. It uses short-lived Cargo/npm/JSR credentials, Maven protected secrets, the frozen lock, and idempotent publication checks. -Only a successful `publish-dry-run` uploads the canonical +Same-version control recovery disables `publish-bootstrap` and every bootstrap +or normal-publish continuation. It consumes the exact terminal original +bootstrap ledger and must be driven by a root `publish` dispatch on the current +controller. If a recovery run is interrupted, rerun that same root operation; +the frozen source lock and byte-verifying registry inventory make the rerun +idempotent. + +On the normal path, only a successful `publish-dry-run` uploads the canonical `oliphaunt-publication-lock` and `oliphaunt-bootstrap-capsule` approval artifacts. Bootstrap selects one successful same-SHA dry-run that contains both artifacts, downloads both by that one run ID, verifies the embedded lock is @@ -243,6 +283,12 @@ artifacts, downloads the approved lock, and byte-compares the two locks before publication. A mutating run therefore cannot approve itself or silently combine artifacts from different dry-runs. +A same-version recovery dry-run is an approval of the current controller, not +a new product-payload approval. It selects the original approved lock and +capsule by exact recorded metadata, verifies the byte-identical replay, and +uploads only the recovery-control equivalence evidence. It never emits a +replacement lock or capsule with the controller SHA. + `.github/workflows/release.yml` is the one directly dispatched release workflow. Its operation jobs declare their own least-privilege permissions and protected environments: dry-run is repository-read-only, bootstrap adds OIDC @@ -313,7 +359,7 @@ or non-hosted use, binds HEAD to `RELEASE_HEAD_SHA`, and reruns the fixed candidate/plan/WASIX-evidence verifier before omitting mutation tests. Workflow policy rejects extra full invocations or replay before candidate verification. -`release_commit`, when supplied, is an assertion that must equal the workflow +On the normal path, `release_commit`, when supplied, is an assertion that must equal the workflow commit. It cannot select historical code. A tooling fix is a new candidate and must pass new qualification. At the mutation boundary, a root `publish-bootstrap` or `publish` run first reads the lightweight @@ -331,6 +377,15 @@ than from moving `main`. Registry and finalization jobs remain exact-SHA and lock/handoff bound; they deliberately do not require `main` to remain frozen after the first mutation. +During same-version control recovery, `release_commit` still names the current +controller and the transport tag still points directly to that controller. +The separately verified publication source remains the original release +commit/tree from the immutable recovery record. Product tags, draft/final +GitHub releases, release assets, Swift source publication, registry receipts, +and consumer evidence are source-bound. The controller supplies only workflow +code and its transport/OIDC/pacing identity. Recovery provenance and handoffs +bind both identities so neither can be substituted for the other. + ## Publish order Cross-registry publication cannot be atomic, so the workflow is resumable and state-driven: @@ -363,6 +418,11 @@ dispatch only the immutable exact-parent pointer emitted by its direct parent. It dispatches `release.yml` at the SHA-derived transport tag; the child input gate rejects `main`, another tag, or a tag name derived from any other SHA. +That continuation protocol is available only when normal publication has one +source/controller identity. Same-version control recovery rejects continuation +inputs and deferred continuation results; recovery always resumes by +idempotently rerunning the root `publish` operation on the controller. + The workflow does not encode a second product/ecosystem publish order. Before the first mutation it writes `normal-publication-plan.json` directly from the approved lock. The normal registry executor consumes that same plan and rejects @@ -455,7 +515,7 @@ left on a previous runner. Bootstrap and normal registry publication each have an independent six-hour hosted-job envelope. The normal GitHub-staging job has a 350-minute hard window plus ten minutes for cleanup; the registry job also has a 350-minute hard window plus ten minutes for cleanup; finalization has a -114-minute hard window plus six minutes for cleanup. The executable phase-budget table accounts for setup, exact-ID +118-minute hard window plus six minutes for cleanup. The executable phase-budget table accounts for setup, exact-ID transfer, validation, mutation, evidence/handoff, and cleanup and requires a strictly positive margin in every phase. A bootstrap root starts its registry window only after qualification, capsule/lock verification, checkpoint restoration, and the @@ -473,7 +533,7 @@ checkout, handoff, tag, draft, and publication-lock proofs. Registry publication rate-aware mutation allowance plus positive margin before the protected 15-minute receipt/recovery handoff; a shortened residual window cannot admit a partial planned run unless the executor can close a dependency-safe checkpoint -and issue an exact-parent continuation. Finalization refuses to start unless at least 48 minutes +and issue an exact-parent continuation. Finalization refuses to start unless at least 52 minutes remain on its fresh deadline. Bootstrap additionally proves that the exact pending Cargo/npm inventory plus its reserve fits before mutation begins. @@ -740,7 +800,60 @@ possible values but never creates extension support by default. ## Recovery and history repair -On a failed publish, preserve the candidate SHA, run id, lock, complete checkpoint chain, draft releases, and registry responses. Inventory every selected identity as absent, matching, or conflicting; restore and validate the exact-SHA chain, then resume only missing phases. Repository changes require a new version and candidate. +On a failed publish, preserve the candidate SHA, run id, lock, complete checkpoint chain, draft releases, and registry responses. Inventory every selected identity as absent, matching, or conflicting; restore and validate the exact-SHA chain, then resume only missing phases. Product-semantic repository changes require a new version and candidate. + +If immutable packages are already public but the failure requires only a +zero-owner release-control/test fix, use the explicit same-version control +recovery instead of manufacturing a duplicate release. Keep the original +release-bump commit immutable. The later linear `fix(release):` commit carries +exactly one `Oliphaunt-Release-Recovery-Of: ` trailer. +The publication-candidate verifier requires the authoritative base/head release +plan to select zero products, rejects every changed shared path with a semantic +owner, and proves versions and release metadata are unchanged. + +Treat the two identities as distinct and immutable: + +- the **publication source** is the original release-bump commit/tree. It owns + the product bytes, versions, approved publication lock/capsule, terminal + bootstrap ledger, product tags/releases/assets, Swift source tag, registry + receipts, and consumer-facing provenance; +- the **controller** is the later current-main recovery commit. It owns only + the executing workflow, fresh complete control CI, immutable release + transport, OIDC claims, request journals, pacing, and the recovery run + identity. + +The controller must pass a fresh full `Qualified` CI run and a separate +successful recovery dry-run whose control-equivalence evidence is approved +before mutation. The recovery record selects the original source commit/tree, +the complete original 73-artifact CI payload, original approved lock/capsule, +and terminal bootstrap ledger by exact workflow run and artifact +ID/digest/size. Selection by “latest,” name alone, or merely matching SHA is +forbidden. Replaying the lock from those frozen inputs must produce a file +byte-identical to the approved lock, including `source` and `lockDigest`; a +rebound controller-source lock is not equivalent. + +Recovery never invokes `publish-bootstrap`, never creates a controller-bound +replacement ledger, and never uses an automatic continuation. It verifies the +terminal source-bound ledger, inventories the exhaustive original lock +(including generated payload parts), and runs only the root `publish` +operation. Existing exact registry identities are skipped only after their +bytes match the lock. An absent identity may be published once from the frozen +source payload; any conflict fails closed and requires a new product version. +Product tags, releases, assets, Swift source publication, and every product +subject/source field target the original source, never the controller. The +controller-issued recovery attestation uses the dedicated dual-identity +predicate to bind the frozen source evidence and fresh control evidence +together. An interrupted recovery is resumed by rerunning root `publish`, +which reconciles already-completed exact state before any write. + +Ordinary clean-state control-plane workflow, policy, validator, +registry-transport, test, or documentation changes do not use recovery and do +not create a release. With no other unreleased product change, +`prepare-release-pr` reports no releasable products and creates no PR; a direct +publish selects no release changes and performs no registry work. A change to a +compiler, SDK, linker, build command, source selection, target, or packaging +semantic is product-owned even if it lives in CI and therefore requires the +affected versions to advance. A deferred extension is never a recoverable missing publication. If it appears in a release PR, dry-run artifact set, or lock, reject that candidate, remove @@ -780,4 +893,4 @@ If that exact-main run exposes another defect, do not layer a fix commit onto th ## Handoff evidence -Record the candidate SHA/tree, exact CI run, selected product versions, catalog/lock digests, artifact attestations, registry bootstrap/trust status, publication ledger, promoted release URLs, and clean-install results. “The workflow passed” is not sufficient release evidence without those identities. +Record the candidate SHA/tree, exact CI run, selected product versions, catalog/lock digests, artifact attestations, registry bootstrap/trust status, publication ledger, promoted release URLs, and clean-install results. For same-version control recovery, record both the immutable publication source SHA/tree and current controller SHA/tree, plus every pinned original and approved control run/artifact identity. “The workflow passed” is not sufficient release evidence without those identities. diff --git a/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c index 64c0ecbc..33b8002a 100644 --- a/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c +++ b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c @@ -49,6 +49,11 @@ static void liboliphaunt_smoke_static_init(void) { static void push_query(unsigned char **buf, size_t *len, const char *sql) { size_t sql_len = strlen(sql) + 1; + if (sql_len == 0 || sql_len > SIZE_MAX - 5) { + *buf = NULL; + *len = 0; + return; + } size_t frame_len = sql_len + 4; *len = frame_len + 1; *buf = (unsigned char *)calloc(*len, 1); diff --git a/tools/policy/assertions/workflow-contract-core.mjs b/tools/policy/assertions/workflow-contract-core.mjs index f848a687..f171a64c 100644 --- a/tools/policy/assertions/workflow-contract-core.mjs +++ b/tools/policy/assertions/workflow-contract-core.mjs @@ -461,7 +461,10 @@ function detectedMutations(step) { } } const uses = String(step.uses ?? ""); - if (uses.startsWith("actions/attest-build-provenance@")) mutations.push("attestation"); + if ( + uses.startsWith("actions/attest-build-provenance@") + || uses.startsWith("actions/attest@") + ) mutations.push("attestation"); if (uses.startsWith("googleapis/release-please-action@")) mutations.push("release_please"); return mutations; } diff --git a/tools/policy/assertions/workflow-semantics.mjs b/tools/policy/assertions/workflow-semantics.mjs index 23a5d4c6..bbd3c059 100644 --- a/tools/policy/assertions/workflow-semantics.mjs +++ b/tools/policy/assertions/workflow-semantics.mjs @@ -128,9 +128,11 @@ const HAS_RELEASE_CHANGES = "steps.release_plan.outputs.has_release_changes == ' const PUBLISH_OPERATION = "inputs.operation == 'publish'"; const BOOTSTRAP_REQUIRED = "steps.bootstrap_scope.outputs.required == 'true'"; const COMMAND_BOUNDARY = String.raw`(?:^|[\n;|&()])\s*`; +const COMMAND_ENV_PREFIX = + String.raw`(?:(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+))\s+)*`; function commandPattern(command) { - return new RegExp(`${COMMAND_BOUNDARY}${command}`, "mu"); + return new RegExp(`${COMMAND_BOUNDARY}${COMMAND_ENV_PREFIX}${command}`, "mu"); } function workflowSecretReferences(value, context) { @@ -1926,9 +1928,12 @@ export function assertReleaseEntryWorkflow(workflow) { for (const [jobId, { operation, parent }] of Object.entries(dispatchers)) { const job = workflow.jobs[jobId]; assertExactNeeds(workflow, jobId, [parent]); + const expectedCondition = jobId === "dispatch-publish-continuation" + ? `\${{ needs.${parent}.outputs.continuation_required == 'true' && needs.${parent}.outputs.release_control_sha == needs.${parent}.outputs.release_source_sha }}` + : `\${{ needs.${parent}.outputs.continuation_required == 'true' }}`; invariant( - normalized(job.if) === `\${{ needs.${parent}.outputs.continuation_required == 'true' }}`, - `${jobId} must run only for a verified deferred parent result`, + normalized(job.if) === expectedCondition, + `${jobId} must run only for a verified deferred parent result, and normal publication continuation must retain one source/control identity`, ); invariant( job.environment === undefined @@ -2035,13 +2040,26 @@ const GITHUB_STAGE_PHASES = [ "release_plan", "registry_needs", "verify_oidc_identity", + "verify_publication_candidate", + "release_identity", + "recovery_source", + "verify_release_recovery_github_absence", "assert_release_please_markable", "verify_maven_signing", "ci_qualification", + "recovery_payload_ci_qualification", + "release_artifact_source", "verify_qualification", + "recovery_original_publication_lock", + "download_recovery_original_publication_lock", "approved_publication_lock", + "approved_recovery_control", + "download_approved_recovery_control", "setup_github_stage_npm", "freeze_publication_lock", + "verify_release_recovery_lock", + "inventory_release_recovery_registries", + "verify_release_recovery_publication", "preflight_maven_bundle", "preflight_swift_source_tag", "github_request_budget", @@ -2051,12 +2069,15 @@ const GITHUB_STAGE_PHASES = [ "freeze_bootstrap_capsule", "preserve_publication_lock", "preserve_bootstrap_capsule", + "preserve_release_recovery_equivalence", "final_github_request_budget", "ensure_release_transport_ref", "stage_github_releases", "verify_product_tags", "verify_github_staging", "publish_github_assets", + "recovery_promotion_attestation", + "attest_recovery_promotion", "extension_attestation_subjects", "attest_extensions_1", "attest_extensions_2", @@ -2104,6 +2125,7 @@ const FINALIZE_PHASES = [ "setup_finalize_node", "finalize_phase_budget", "download_registry_handoff", + "finalize_publication_candidate", "install_registry_handoff", "setup_finalize_npm", "verify_final_github_staging", @@ -2124,6 +2146,7 @@ const BOOTSTRAP_PHASES = [ "registry_needs", "bootstrap_scope", "verify_bootstrap_oidc_identity", + "verify_bootstrap_publication_candidate", "assert_bootstrap_release_please_markable", "ci_qualification", "verify_bootstrap_qualification", @@ -2132,8 +2155,10 @@ const BOOTSTRAP_PHASES = [ "verify_bootstrap_capsule", "verify_bootstrap_lock", "restore_bootstrap_checkpoint", + "require_bootstrap_credentials", "ensure_bootstrap_transport_ref", "bootstrap_mutation_deadline", + "configure_bootstrap_npm_auth", "bootstrap_registry_identities", "require_bootstrap_execution_decision", "prepare_bootstrap_continuation", @@ -2145,6 +2170,9 @@ const BOOTSTRAP_PHASES = [ function assertNormalStageConditions(workflow) { for (const jobId of ["publish-dry-run", "publish"]) { assertStepCondition(workflow, jobId, "github_stage_job_deadline", [PUBLISH_OPERATION]); + assertStepCondition(workflow, jobId, "verify_publication_candidate", [ + HAS_RELEASE_CHANGES, + ]); for (const id of [ "verify_oidc_identity", "assert_release_please_markable", @@ -2170,8 +2198,17 @@ function assertNormalStageConditions(workflow) { "attest_broker", "attest_node_direct", "attest_wasix", - "publish_swift_source_tag", - ]) assertStepCondition(workflow, jobId, id, [HAS_RELEASE_CHANGES, PUBLISH_OPERATION]); + ]) { + assertStepCondition(workflow, jobId, id, [ + HAS_RELEASE_CHANGES, + PUBLISH_OPERATION, + "steps.verify_publication_candidate.outputs.mode != 'release-recovery'", + ]); + } + assertStepCondition(workflow, jobId, "publish_swift_source_tag", [ + HAS_RELEASE_CHANGES, + PUBLISH_OPERATION, + ]); } const dryRunRequired = [HAS_RELEASE_CHANGES, "inputs.operation == 'publish-dry-run'"]; @@ -2180,7 +2217,12 @@ function assertNormalStageConditions(workflow) { "freeze_bootstrap_capsule", "preserve_publication_lock", "preserve_bootstrap_capsule", - ]) assertStepCondition(workflow, jobId, id, dryRunRequired); + ]) { + assertStepCondition(workflow, jobId, id, [ + ...dryRunRequired, + "steps.verify_publication_candidate.outputs.mode != 'release-recovery'", + ]); + } } } @@ -2195,11 +2237,18 @@ function assertCriticalReleaseCommands(workflow) { const commands = [ ["publish", "release_head", "[.]github/scripts/resolve-release-head[.]sh\\b", "the exact release-head resolver"], ["publish", "verify_oidc_identity", "bun\\s+[.]github/scripts/verify-github-oidc-identity[.]mjs\\b", "the direct-workflow OIDC verifier"], + ["publish", "verify_publication_candidate", "tools/dev/bun[.]sh\\s+tools/release/verify-publication-candidate[.]mjs\\b", "the release or same-version recovery candidate verifier"], + ["publish-dry-run", "verify_release_recovery_github_absence", "bun\\s+[.]github/scripts/manage-release-drafts[.]mjs\\s+recovery-preflight\\b", "the same-version recovery GitHub absence verifier"], ["publish", "assert_release_please_markable", "tools/dev/bun[.]sh\\s+tools/release/release-please-pr-lifecycle[.]mjs\\s+assert-markable\\b", "the pre-publication Release Please lifecycle assertion"], ["publish", "verify_maven_signing", "tools/dev/bun[.]sh\\s+tools/release/verify-maven-signing-readiness[.]mjs\\b", "the pre-mutation Maven signing verifier"], ["publish", "ci_qualification", "bash\\s+[.]github/scripts/require-workflow-success[.]sh\\b", "the exact-SHA CI selector"], ["publish", "verify_qualification", "node\\s+[.]github/scripts/verify-release-candidate[.]mjs\\b", "the candidate verifier"], + ["publish-dry-run", "recovery_original_publication_lock", "bash\\s+[.]github/scripts/require-workflow-success[.]sh\\b", "the original same-version recovery lock selector"], + ["publish-dry-run", "download_recovery_original_publication_lock", "node\\s+[.]github/scripts/download-build-artifacts[.]mjs\\b", "the exact original same-version recovery lock downloader"], ["publish", "approved_publication_lock", "bash\\s+[.]github/scripts/require-workflow-success[.]sh\\b", "the approved-lock selector"], + ["publish-dry-run", "verify_release_recovery_lock", "tools/dev/bun[.]sh\\s+tools/release/verify-release-recovery-lock[.]mjs\\b", "the same-version immutable-envelope verifier"], + ["publish-dry-run", "inventory_release_recovery_registries", "tools/dev/bun[.]sh\\s+tools/release/release-check-registries[.]mjs\\b", "the exact locked same-version registry inventory"], + ["publish-dry-run", "verify_release_recovery_publication", "tools/dev/bun[.]sh\\s+tools/release/verify-release-recovery-publication[.]mjs\\b", "the same-version partial-publication verifier"], ["publish", "preflight_maven_bundle", "tools/dev/bun[.]sh\\s+tools/release/preflight-maven-central-bundle[.]mjs\\b", "the exact pre-mutation Maven Central bundle preflight"], ["publish", "preflight_swift_source_tag", "tools/dev/bun[.]sh\\s+tools/release/preflight-swiftpm-source-tag[.]mjs\\b", "the exact pre-mutation SwiftPM source-tag preflight"], ["publish-dry-run", "freeze_bootstrap_capsule", "tools/dev/bun[.]sh\\s+tools/release/bootstrap-publication-capsule[.]mjs\\s+pack\\b", "the dry-run bootstrap capsule freezer"], @@ -2229,6 +2278,7 @@ function assertCriticalReleaseCommands(workflow) { ["publish-registry", "prepare_registry_continuation", "bun\\s+[.]github/scripts/prepare-release-continuation[.]mjs\\b", "the exact normal-publication continuation sealer"], ["publish-registry", "seal_registry_handoff", "tools/dev/bun[.]sh\\s+tools/release/release-phase-handoff[.]mjs\\s+seal\\b", "the immutable registry handoff sealer"], ["publish-finalize", "finalize_release_head", "[.]github/scripts/resolve-release-head[.]sh\\b", "the finalization release-head resolver"], + ["publish-finalize", "finalize_publication_candidate", "tools/dev/bun[.]sh\\s+tools/release/verify-publication-candidate[.]mjs\\b", "the final release lifecycle identity verifier"], ["publish-finalize", "finalize_phase_budget", "node\\s+tools/release/release-phase-budget[.]mjs\\b", "the finalization phase budget proof"], ["publish-finalize", "install_registry_handoff", "tools/dev/bun[.]sh\\s+tools/release/release-phase-handoff[.]mjs\\s+install\\b", "the validated registry handoff installer"], ["publish-finalize", "verify_final_github_staging", "tools/dev/bun[.]sh\\s+tools/release/verify_product_tags[.]mjs\\b", "final staging verification"], @@ -2239,14 +2289,25 @@ function assertCriticalReleaseCommands(workflow) { ["publish-finalize", "promote_github_releases", "bun\\s+[.]github/scripts/manage-release-drafts[.]mjs\\s+promote\\b", "draft promotion"], ["publish-finalize", "promote_github_releases", "tools/dev/bun[.]sh\\s+tools/release/release-please-pr-lifecycle[.]mjs\\s+mark-tagged\\b", "Release Please lifecycle closure"], ["publish-bootstrap", "assert_bootstrap_release_please_markable", "tools/dev/bun[.]sh\\s+tools/release/release-please-pr-lifecycle[.]mjs\\s+assert-markable\\b", "the pre-bootstrap Release Please lifecycle assertion"], + ["publish-bootstrap", "verify_bootstrap_publication_candidate", "tools/dev/bun[.]sh\\s+tools/release/verify-publication-candidate[.]mjs\\b", "the bootstrap release or same-version recovery candidate verifier"], ]; for (const [jobId, id, command, description] of commands) { assertRunInvocation(workflow, jobId, id, commandPattern(command), description); } - const exactMarkableCommand = - 'tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs assert-markable --release-sha "$RELEASE_HEAD_SHA" --base main'; - const exactMarkTaggedCommand = - 'tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs mark-tagged --release-sha "$RELEASE_HEAD_SHA" --base main'; + const lifecycleShaByStep = { + assert_release_please_markable: + "${{ steps.verify_publication_candidate.outputs.release_sha }}", + assert_bootstrap_release_please_markable: + "${{ steps.verify_bootstrap_publication_candidate.outputs.release_sha }}", + promote_github_releases: + "${{ steps.finalize_publication_candidate.outputs.release_sha }}", + }; + const markableCommand = (stepId) => + "tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs " + + `assert-markable --release-sha "${lifecycleShaByStep[stepId]}" --base main`; + const markTaggedCommand = + "tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs " + + `mark-tagged --release-sha "${lifecycleShaByStep.promote_github_releases}" --base main`; for (const [jobId, stepId, condition] of [ [ "publish", @@ -2261,7 +2322,8 @@ function assertCriticalReleaseCommands(workflow) { ]) { const lifecycle = stepById(workflow, jobId, stepId).step; invariant( - JSON.stringify(canonicalShellLines(lifecycle.run)) === JSON.stringify([exactMarkableCommand]) + JSON.stringify(canonicalShellLines(lifecycle.run)) + === JSON.stringify([markableCommand(stepId)]) && normalized(lifecycle.if) === condition && lifecycle["continue-on-error"] === undefined && lifecycle["timeout-minutes"] === 1 @@ -2276,9 +2338,9 @@ function assertCriticalReleaseCommands(workflow) { ).step; invariant( JSON.stringify(canonicalShellLines(finalLifecycle.run)) === JSON.stringify([ - exactMarkableCommand, - 'bun .github/scripts/manage-release-drafts.mjs promote --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_HEAD_SHA"', - exactMarkTaggedCommand, + markableCommand("promote_github_releases"), + 'bun .github/scripts/manage-release-drafts.mjs promote --products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_SOURCE_SHA"', + markTaggedCommand, ]) && finalLifecycle.if === undefined && finalLifecycle["continue-on-error"] === undefined @@ -2290,6 +2352,42 @@ function assertCriticalReleaseCommands(workflow) { === "${{ needs.publish-registry.outputs.products_json }}", "the literal final step must assert markability, promote exact-SHA drafts, and close the Release Please lifecycle without bypasses", ); + const exactPublicationCandidateCommand = + "tools/dev/bun.sh tools/release/verify-publication-candidate.mjs " + + '--products-json "$PRODUCTS_JSON" --head-ref "$RELEASE_HEAD_SHA" ' + + '--github-output "$GITHUB_OUTPUT"'; + for (const [jobId, stepId, condition, productsJson] of [ + [ + "publish", + "verify_publication_candidate", + "${{ steps.release_plan.outputs.has_release_changes == 'true' }}", + "${{ steps.release_plan.outputs.products_json }}", + ], + [ + "publish-bootstrap", + "verify_bootstrap_publication_candidate", + "${{ steps.bootstrap_scope.outputs.required == 'true' }}", + "${{ steps.release_plan.outputs.products_json }}", + ], + [ + "publish-finalize", + "finalize_publication_candidate", + "", + "${{ needs.publish-registry.outputs.products_json }}", + ], + ]) { + const candidate = stepById(workflow, jobId, stepId).step; + invariant( + JSON.stringify(canonicalShellLines(candidate.run)) + === JSON.stringify([exactPublicationCandidateCommand]) + && normalized(candidate.if) === condition + && candidate["continue-on-error"] === undefined + && candidate["timeout-minutes"] === (jobId === "publish-finalize" ? 1 : 2) + && sameSet(Object.keys(candidate.env ?? {}), ["PRODUCTS_JSON"]) + && candidate.env?.PRODUCTS_JSON === productsJson, + `${jobId}.${stepId} must prove one exact publication/lifecycle identity without bypasses`, + ); + } const releaseTransport = stepById(workflow, "publish", "ensure_release_transport_ref"); const releaseReservation = workflowSteps(workflow, "publish")[releaseTransport.index - 1]; invariant( @@ -2354,7 +2452,7 @@ function assertCriticalReleaseCommands(workflow) { assertActiveTokens(mavenBundle, [ '"$PUBLICATION_LOCK_PATH"', '"$PRODUCTS_JSON"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', ], "pre-mutation Maven Central bundle preflight"); const swiftPreflight = stepById(workflow, "publish", "preflight_swift_source_tag"); invariant( @@ -2365,7 +2463,7 @@ function assertCriticalReleaseCommands(workflow) { ); assertActiveTokens(swiftPreflight, [ '"$PUBLICATION_LOCK_PATH"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', ], "pre-mutation SwiftPM source-tag preflight"); for (const stepId of ["github_request_budget", "final_github_request_budget"]) { const admission = stepById(workflow, "publish", stepId).step; @@ -2391,7 +2489,7 @@ function assertCriticalReleaseCommands(workflow) { "--registry-admission", '"$ADMISSION_FILE"', '"$PUBLICATION_LOCK_PATH"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', ], "registry publication"); const assets = stepById(workflow, "publish", "publish_github_assets"); assertActiveTokens(assets, ["--step github-release-assets", '"$PUBLICATION_LOCK_PATH"'], "GitHub asset publication"); @@ -2646,7 +2744,7 @@ function assertReleaseTiming(workflow) { const registrySteps = workflowSteps(workflow, "publish-registry"); const registryBounded = registrySteps.slice( stepById(workflow, "publish-registry", "registry_job_deadline").index + 1, - ).filter((step) => !new Set([ + ).filter((step) => step.name !== "Reject same-version recovery continuation" && !new Set([ "continued_release_plan", "inspect_registry_continuation", "download_continued_github_stage_handoff", @@ -2784,6 +2882,15 @@ function assertAttestations(workflow) { entry.step["timeout-minutes"] * 60_000 === GITHUB_RELEASE_ATTESTATION_STEP_TIMEOUT_MS, `publish.${id} must use the bounded attestation timeout`, ); + assertConditionRequires( + entry.step.if, + [ + HAS_RELEASE_CHANGES, + PUBLISH_OPERATION, + "steps.verify_publication_candidate.outputs.mode != 'release-recovery'", + ], + `publish.${id}`, + ); const reservation = workflowSteps(workflow, "publish")[entry.index - 1]; invariant(reservation !== undefined, `publish.${id} must have a preceding pacer reservation`); invariant( @@ -2796,6 +2903,111 @@ function assertAttestations(workflow) { `publish.${id} reservation and attestation guards must be identical`, ); } + + const recoveryPrepare = assertRunInvocation( + workflow, + "publish", + "recovery_promotion_attestation", + commandPattern( + "tools/dev/bun[.]sh\\s+tools/release/recovery-promotion-attestation[.]mjs\\s+prepare\\b", + ), + "the dual-identity recovery promotion predicate builder", + ); + const recoveryCondition = + "${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + invariant( + normalized(recoveryPrepare.step.if) === recoveryCondition + && recoveryPrepare.step["continue-on-error"] === undefined + && recoveryPrepare.step["timeout-minutes"] === 2 + && sameSet(Object.keys(recoveryPrepare.step.env ?? {}), [ + "CONTROLLER_APPROVAL_ARTIFACTS_JSON", + "CONTROLLER_APPROVAL_RUN_ATTEMPT", + "CONTROLLER_APPROVAL_RUN_ID", + "CONTROLLER_CI_ARTIFACTS_JSON", + "CONTROLLER_CI_RUN_ATTEMPT", + "CONTROLLER_CI_RUN_ID", + "CONTROLLER_TREE", + ]) + && recoveryPrepare.step.env?.CONTROLLER_TREE + === "${{ steps.release_identity.outputs.controller_tree }}" + && recoveryPrepare.step.env?.CONTROLLER_CI_RUN_ID + === "${{ steps.ci_qualification.outputs.run_id }}" + && recoveryPrepare.step.env?.CONTROLLER_CI_RUN_ATTEMPT + === "${{ steps.ci_qualification.outputs.run_attempt }}" + && recoveryPrepare.step.env?.CONTROLLER_CI_ARTIFACTS_JSON + === "${{ steps.ci_qualification.outputs.artifact_metadata_json }}" + && recoveryPrepare.step.env?.CONTROLLER_APPROVAL_RUN_ID + === "${{ steps.approved_recovery_control.outputs.run_id }}" + && recoveryPrepare.step.env?.CONTROLLER_APPROVAL_RUN_ATTEMPT + === "${{ steps.approved_recovery_control.outputs.run_attempt }}" + && recoveryPrepare.step.env?.CONTROLLER_APPROVAL_ARTIFACTS_JSON + === "${{ steps.approved_recovery_control.outputs.artifact_metadata_json }}", + "recovery promotion predicate must bind the current controller tree, CI, approval, and exact frozen source evidence", + ); + assertActiveTokens(recoveryPrepare, [ + '--lock "$PUBLICATION_LOCK_PATH"', + "--provenance tools/release/same-version-recovery-sources.json", + "--approval target/release/recovery-evidence/lock-equivalence.json", + '--approval-run-id "$CONTROLLER_APPROVAL_RUN_ID"', + '--approval-run-attempt "$CONTROLLER_APPROVAL_RUN_ATTEMPT"', + '--approval-artifacts-json "$CONTROLLER_APPROVAL_ARTIFACTS_JSON"', + '--controller-sha "$RELEASE_CONTROL_SHA"', + '--controller-tree "$CONTROLLER_TREE"', + '--qualification-run-id "$CONTROLLER_CI_RUN_ID"', + '--qualification-run-attempt "$CONTROLLER_CI_RUN_ATTEMPT"', + '--qualification-artifacts-json "$CONTROLLER_CI_ARTIFACTS_JSON"', + '--promotion-run-id "$GITHUB_RUN_ID"', + '--promotion-run-attempt "$GITHUB_RUN_ATTEMPT"', + "--controller-output target/release/recovery-evidence/promotion-controller.json", + "--subjects-output target/release/recovery-evidence/promotion-subjects.json", + "--predicate-output target/release/recovery-evidence/promotion-predicate.json", + "--checksums-output target/release/recovery-evidence/promotion-subjects.sha256", + ], "dual-identity recovery promotion predicate"); + const recoveryAttestation = assertActionStep( + workflow, + "publish", + "attest_recovery_promotion", + "actions/attest@", + ); + const recoveryReservation = workflowSteps(workflow, "publish")[ + recoveryAttestation.index - 1 + ]; + invariant( + recoveryAttestation.step.uses + === "actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d" + && normalized(recoveryAttestation.step.if) + === "${{ steps.recovery_promotion_attestation.outcome == 'success' }}" + && recoveryAttestation.step["continue-on-error"] === undefined + && recoveryAttestation.step["timeout-minutes"] === 5 + && sameSet(Object.keys(recoveryAttestation.step.with ?? {}), [ + "predicate-path", + "predicate-type", + "subject-checksums", + ]) + && recoveryAttestation.step.with?.["subject-checksums"] + === "${{ steps.recovery_promotion_attestation.outputs.checksums_path }}" + && recoveryAttestation.step.with?.["predicate-type"] + === "${{ steps.recovery_promotion_attestation.outputs.predicate_type }}" + && recoveryAttestation.step.with?.["predicate-path"] + === "${{ steps.recovery_promotion_attestation.outputs.predicate_path }}", + "recovery promotion must use exactly the pinned custom-predicate attestation action and prepared subjects", + ); + invariant( + recoveryReservation !== undefined + && normalized(recoveryReservation.if) === normalized(recoveryAttestation.step.if) + && commandPattern( + "tools/dev/bun[.]sh\\s+tools/release/github-content-write-pacer[.]mjs\\s+reserve\\b", + ).test(executableShell(recoveryReservation.run)) + && commandPattern( + "tools/dev/bun[.]sh\\s+tools/release/github-core-request-journal[.]mjs\\s+reserve\\b", + ).test(executableShell(recoveryReservation.run)), + "the custom recovery attestation must immediately follow matching durable content-write and API reservations", + ); + invariant( + recoveryPrepare.index < recoveryAttestation.index, + "the exact recovery predicate and subjects must be prepared before attestation", + ); + invariant( stepById(workflow, "publish", "attest_extensions_1").step.with?.["subject-path"] === "${{ steps.extension_attestation_subjects.outputs.paths_1 }}" @@ -2810,6 +3022,7 @@ function assertAttestations(workflow) { LIBOLIPHAUNT_NATIVE_ATTESTATION_BUNDLE: "attest_liboliphaunt_native", BROKER_ATTESTATION_BUNDLE: "attest_broker", NODE_DIRECT_ATTESTATION_BUNDLE: "attest_node_direct", + RECOVERY_PROMOTION_ATTESTATION_BUNDLE: "attest_recovery_promotion", WASIX_ATTESTATION_BUNDLE: "attest_wasix", }; for (const [environment, id] of Object.entries(expectedBundles)) { @@ -2818,6 +3031,13 @@ function assertAttestations(workflow) { `attestation evidence must bind ${id}'s whole bundle`, ); } + assertActiveTokens({ step: evidence }, [ + '"$RECOVERY_PROMOTION_ATTESTATION_BUNDLE"', + "--recovery-controller target/release/recovery-evidence/promotion-controller.json", + "--recovery-provenance tools/release/same-version-recovery-sources.json", + "--recovery-approval target/release/recovery-evidence/lock-equivalence.json", + '--head-ref "$RELEASE_SOURCE_SHA"', + ], "recovery promotion attestation verification"); } function assertNormalRecovery(workflow) { @@ -2888,7 +3108,7 @@ function assertNormalRecovery(workflow) { const deferredFiles = deferredPath.split(/\s+/u).filter(Boolean); invariant( normalized(deferred.if) - === "${{ steps.require_registry_execution_decision.outputs.deferred == 'true' }}" + === "${{ steps.require_registry_execution_decision.outputs.deferred == 'true' && steps.registry_release_identity.outputs.controller_sha == steps.registry_release_identity.outputs.source_sha }}" && deferred.with?.["if-no-files-found"] === "error" && deferred.with?.["compression-level"] === 0 && deferredPath.includes("normal-publication-checkpoint.json") @@ -2919,6 +3139,31 @@ function assertNormalRecovery(workflow) { } function assertRegistryAdmission(workflow) { + const registrySteps = workflowSteps(workflow, "publish-registry"); + const recoveryContinuationRejects = registrySteps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.name === "Reject same-version recovery continuation"); + invariant( + recoveryContinuationRejects.length === 1, + "registry publication must contain one explicit same-version recovery continuation rejection", + ); + const [recoveryContinuationReject] = recoveryContinuationRejects; + invariant( + normalized(recoveryContinuationReject.step.if) + === "${{ inputs.continuation_pointer != '' }}" + && recoveryContinuationReject.step["continue-on-error"] === undefined + && recoveryContinuationReject.step["timeout-minutes"] === 2, + "every registry continuation must fail closed if it resolves to same-version recovery", + ); + assertActiveTokens(recoveryContinuationReject, [ + "tools/dev/bun.sh tools/release/verify-publication-candidate.mjs", + '--head-ref "$RELEASE_HEAD_SHA"', + "--github-output \"$candidate_output\"", + '[[ "$mode" == release-recovery ]]', + "Same-version recovery continuations are disabled", + "exit 1", + ], "same-version recovery continuation rejection"); + const capacityDeadline = stepById( workflow, "publish-registry", @@ -3016,6 +3261,8 @@ function assertRegistryAdmission(workflow) { '[[ "$DEFERRAL_MODE" == pre-mutation-deadline ]]', '[[ "$DEFERRAL_MODE" == rate-limit ]]', '[[ "$DEFERRAL_MODE" == progress ]]', + '[[ "$DEFERRED" == true && "$RELEASE_CONTROL_SHA" != "$RELEASE_SOURCE_SHA" ]]', + "Same-version recovery cannot create a continuation", 'echo "complete=$COMPLETE"', 'echo "deferred=$DEFERRED"', ], "typed registry execution decision"); @@ -3023,7 +3270,7 @@ function assertRegistryAdmission(workflow) { const prepareEnv = prepare.env ?? {}; invariant( normalized(prepare.if) - === "${{ steps.require_registry_execution_decision.outputs.deferred == 'true' }}" + === "${{ steps.require_registry_execution_decision.outputs.deferred == 'true' && steps.registry_release_identity.outputs.controller_sha == steps.registry_release_identity.outputs.source_sha }}" && sameSet(Object.keys(prepareEnv), [ "APPROVED_ARTIFACT_METADATA_JSON", "APPROVED_RUN_ID", @@ -3122,14 +3369,18 @@ function assertReleaseHandoffs(workflow) { "approved_run_id", "has_release_changes", "products_json", + "release_control_sha", "release_head_sha", + "release_source_sha", "stage_handoff_artifact_digest", "stage_handoff_artifact_id", ]) && stageOutputs.has_release_changes === "${{ steps.release_plan.outputs.has_release_changes }}" && stageOutputs.products_json === "${{ steps.release_plan.outputs.products_json }}" - && stageOutputs.release_head_sha === "${{ steps.release_head.outputs.sha }}" + && stageOutputs.release_head_sha === "${{ steps.release_identity.outputs.controller_sha }}" + && stageOutputs.release_control_sha === "${{ steps.release_identity.outputs.controller_sha }}" + && stageOutputs.release_source_sha === "${{ steps.release_identity.outputs.source_sha }}" && stageOutputs.approved_run_id === "${{ steps.approved_publication_lock.outputs.run_id }}" && stageOutputs.approved_artifact_metadata_json @@ -3151,7 +3402,9 @@ function assertReleaseHandoffs(workflow) { "continuation_required", "products_json", "publication_complete", + "release_control_sha", "release_head_sha", + "release_source_sha", "root_run_id", "registry_handoff_artifact_digest", "registry_handoff_artifact_id", @@ -3159,6 +3412,10 @@ function assertReleaseHandoffs(workflow) { && registryOutputs.publication_complete === "${{ steps.require_registry_execution_decision.outputs.complete }}" && registryOutputs.release_head_sha === "${{ steps.registry_release_head.outputs.sha }}" + && registryOutputs.release_control_sha + === "${{ steps.registry_release_identity.outputs.controller_sha }}" + && registryOutputs.release_source_sha + === "${{ steps.registry_release_identity.outputs.source_sha }}" && registryOutputs.products_json === "${{ steps.registry_inputs.outputs.products_json }}" && registryOutputs.approved_run_id === "${{ steps.registry_inputs.outputs.approved_run_id }}" && registryOutputs.approved_artifact_metadata_json @@ -3183,7 +3440,7 @@ function assertReleaseHandoffs(workflow) { assertActiveTokens(stageSeal, [ "--phase github-staged", '"$PRODUCTS_JSON"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', '"$APPROVED_RUN_ID"', '"$APPROVED_ARTIFACT_METADATA_JSON"', '"$RUNNER_TEMP/github-stage-handoff"', @@ -3219,7 +3476,7 @@ function assertReleaseHandoffs(workflow) { "download_approved_publication_inputs", ); assertActiveTokens(approvedInputs, [ - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', '"$APPROVED_RUN_ID"', '"$APPROVED_ARTIFACT_METADATA_JSON"', "--artifact oliphaunt-publication-lock", @@ -3258,7 +3515,7 @@ function assertReleaseHandoffs(workflow) { assertActiveTokens(stageInstall, [ "--phase github-staged", '"$PRODUCTS_JSON"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', '"$APPROVED_RUN_ID"', '"$APPROVED_ARTIFACT_METADATA_JSON"', '"$RUNNER_TEMP/github-stage-handoff"', @@ -3301,7 +3558,7 @@ function assertReleaseHandoffs(workflow) { assertActiveTokens(registrySeal, [ "--phase registry-published", '"$PRODUCTS_JSON"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', '"$APPROVED_RUN_ID"', '"$APPROVED_ARTIFACT_METADATA_JSON"', '"$RUNNER_TEMP/registry-published-handoff"', @@ -3341,7 +3598,7 @@ function assertReleaseHandoffs(workflow) { assertActiveTokens(registryInstall, [ "--phase registry-published", '"$PRODUCTS_JSON"', - '"$RELEASE_HEAD_SHA"', + '"$RELEASE_SOURCE_SHA"', '"$APPROVED_RUN_ID"', '"$APPROVED_ARTIFACT_METADATA_JSON"', '"$RUNNER_TEMP/registry-published-handoff"', @@ -3358,10 +3615,24 @@ function assertReleaseHandoffs(workflow) { } function assertDryRunEvidence(workflow) { + const normalDryRunCondition = + "${{ inputs.operation == 'publish-dry-run' && steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode != 'release-recovery' }}"; + const recoveryCondition = + "${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + const recoveryModeCondition = + "${{ steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + const recoveryAbsenceCondition = + "${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish-dry-run' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + const recoveryDryRunCondition = + "${{ inputs.operation == 'publish-dry-run' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + const recoveryPublishCondition = + "${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.verify_publication_candidate.outputs.mode == 'release-recovery' }}"; + const capsule = stepById(workflow, "publish-dry-run", "freeze_bootstrap_capsule"); assertActiveTokens(capsule, [ "--lock", '"$PUBLICATION_LOCK_PATH"', + '--head-ref "$RELEASE_SOURCE_SHA"', "--output target/release/oliphaunt-bootstrap-capsule.tar", ], "dry-run bootstrap capsule"); const lock = assertUploadById(workflow, "publish-dry-run", "preserve_publication_lock", { @@ -3373,16 +3644,481 @@ function assertDryRunEvidence(workflow) { path: "target/release/oliphaunt-bootstrap-capsule.tar", }); invariant( - lock.index < capsuleUpload.index + normalized(capsule.step.if) === normalDryRunCondition + && normalized(lock.step.if) === normalDryRunCondition + && normalized(capsuleUpload.step.if) === normalDryRunCondition + && capsule.step["continue-on-error"] === undefined + && lock.step["continue-on-error"] === undefined + && capsuleUpload.step["continue-on-error"] === undefined + && lock.step.with?.["if-no-files-found"] === "error" + && capsuleUpload.step.with?.["if-no-files-found"] === "error" + && lock.step.with?.overwrite === true + && capsuleUpload.step.with?.overwrite === true + && lock.step.with?.["retention-days"] === 90 + && capsuleUpload.step.with?.["retention-days"] === 90 + && lock.index < capsuleUpload.index && capsule.index < lock.index, - "dry-run must freeze one capsule before preserving its exact lock and transport", + "normal dry-run alone must freeze and retain one exact source-bound lock and capsule", ); + const approval = stepById(workflow, "publish", "approved_publication_lock"); + invariant( + normalized(approval.step.if) + === "${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }}" + && approval.step["continue-on-error"] === undefined + && sameSet(Object.keys(approval.step.env ?? {}), [ + "CANDIDATE_MODE", + "GH_REPO", + "GH_TOKEN", + "PINNED_ARTIFACT_METADATA_JSON", + "PINNED_DRY_RUN_ID", + ]) + && approval.step.env?.CANDIDATE_MODE + === "${{ steps.verify_publication_candidate.outputs.mode }}" + && approval.step.env?.GH_REPO === "${{ github.repository }}" + && approval.step.env?.GH_TOKEN === "${{ secrets.GITHUB_TOKEN }}" + && approval.step.env?.PINNED_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_source.outputs.approved_dry_run_artifact_metadata_json }}" + && approval.step.env?.PINNED_DRY_RUN_ID + === "${{ steps.recovery_source.outputs.approved_dry_run_id }}", + "approved publication inputs must select either the current source dry-run or the exact committed recovery dry-run", + ); assertActiveTokens(approval, [ + 'approval_sha="$RELEASE_SOURCE_SHA"', + "approval_timeout=300", + '[[ "$CANDIDATE_MODE" == release-recovery ]]', + "approval_timeout=0", + 'pinned_run_args=(--run-id "$PINNED_DRY_RUN_ID")', + '"$approval_sha"', + '"$approval_timeout"', + "--event workflow_dispatch", + "--artifact oliphaunt-publication-lock", + "--artifact oliphaunt-bootstrap-capsule", + "Approved recovery inputs differ from the pinned recovery provenance.", + ], "approved source-bound dry-run selection"); + + const releaseIdentity = stepById(workflow, "publish", "release_identity"); + invariant( + normalized(releaseIdentity.step.if) + === "${{ steps.release_plan.outputs.has_release_changes == 'true' }}" + && releaseIdentity.step["continue-on-error"] === undefined + && sameSet(Object.keys(releaseIdentity.step.env ?? {}), [ + "CANDIDATE_MODE", + "CONTROL_SHA", + "RECOVERY_RELEASE_SHA", + ]) + && releaseIdentity.step.env?.CANDIDATE_MODE + === "${{ steps.verify_publication_candidate.outputs.mode }}" + && releaseIdentity.step.env?.CONTROL_SHA === "${{ steps.release_head.outputs.sha }}" + && releaseIdentity.step.env?.RECOVERY_RELEASE_SHA + === "${{ steps.verify_publication_candidate.outputs.release_sha }}", + "release identity must resolve one explicit controller and immutable payload source", + ); + assertActiveTokens(releaseIdentity, [ + 'source_sha="$CONTROL_SHA"', + '[[ "$CANDIDATE_MODE" == release-recovery ]]', + 'source_sha="$RECOVERY_RELEASE_SHA"', + '"controller:$CONTROL_SHA"', + '"source:$source_sha"', + 'controller_tree="$(git show -s --format=%T "$CONTROL_SHA^{commit}")"', + 'echo "controller_sha=$CONTROL_SHA"', + 'echo "controller_tree=$controller_tree"', + 'echo "source_sha=$source_sha"', + 'echo "RELEASE_CONTROL_SHA=$CONTROL_SHA"', + 'echo "RELEASE_SOURCE_SHA=$source_sha"', + ], "dual release identity"); + + const recoverySource = assertRunInvocation( + workflow, + "publish", + "recovery_source", + commandPattern( + "tools/dev/bun[.]sh\\s+tools/release/same-version-recovery-source[.]mjs\\b", + ), + "the committed same-version recovery provenance selector", + ); + invariant( + normalized(recoverySource.step.if) === recoveryCondition + && recoverySource.step["continue-on-error"] === undefined + && recoverySource.step["timeout-minutes"] === 1, + "same-version recovery must resolve its exact committed provenance before external evidence selection", + ); + assertActiveTokens(recoverySource, [ + '--release-sha "$RELEASE_SOURCE_SHA"', + '--github-output "$GITHUB_OUTPUT"', + ], "committed same-version recovery provenance"); + + const recoveryGithubAbsence = stepById( + workflow, + "publish", + "verify_release_recovery_github_absence", + ); + invariant( + normalized(recoveryGithubAbsence.step.if) === recoveryAbsenceCondition + && recoveryGithubAbsence.step["continue-on-error"] === undefined + && recoveryGithubAbsence.step["timeout-minutes"] === 5 + && sameSet(Object.keys(recoveryGithubAbsence.step.env ?? {}), [ + "GH_TOKEN", + "PRODUCTS_JSON", + ]) + && recoveryGithubAbsence.step.env?.GH_TOKEN === "${{ secrets.GITHUB_TOKEN }}" + && recoveryGithubAbsence.step.env?.PRODUCTS_JSON + === "${{ steps.release_plan.outputs.products_json }}", + "same-version recovery must prove selected tags and releases are wholly absent", + ); + assertActiveTokens(recoveryGithubAbsence, [ + "recovery-preflight", + '--products-json "$PRODUCTS_JSON"', + '--head-ref "$RELEASE_SOURCE_SHA"', + ], "same-version recovery GitHub absence proof"); + + const controllerQualification = stepById(workflow, "publish", "ci_qualification"); + assertActiveTokens(controllerQualification, [ + '"$RELEASE_CONTROL_SHA"', + "--job Builds", + "--job Required", + "--job Qualified", + ], "controller-bound current CI qualification"); + + const payloadQualification = stepById( + workflow, + "publish", + "recovery_payload_ci_qualification", + ); + invariant( + normalized(payloadQualification.step.if) === recoveryCondition + && payloadQualification.step["continue-on-error"] === undefined + && sameSet(Object.keys(payloadQualification.step.env ?? {}), [ + "GH_REPO", + "GH_TOKEN", + "PINNED_ARTIFACT_METADATA_JSON", + "PINNED_PAYLOAD_CI_RUN_ID", + "RECOVERY_RELEASE_SHA", + ]) + && payloadQualification.step.env?.PINNED_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_source.outputs.payload_ci_artifact_metadata_json }}" + && payloadQualification.step.env?.PINNED_PAYLOAD_CI_RUN_ID + === "${{ steps.recovery_source.outputs.payload_ci_run_id }}" + && payloadQualification.step.env?.RECOVERY_RELEASE_SHA + === "${{ steps.verify_publication_candidate.outputs.release_sha }}", + "same-version recovery must select the exact pinned frozen-payload CI run and complete artifact inventory", + ); + assertActiveTokens(payloadQualification, [ + '"$RECOVERY_RELEASE_SHA"', + '--run-id "$PINNED_PAYLOAD_CI_RUN_ID"', + "--job Builds", + "--job Required", + "--job Qualified", + 'qualification_args+=(--artifact "$artifact")', + "PINNED_ARTIFACT_METADATA_JSON", + "Frozen-payload CI artifact inventory differs from the pinned recovery provenance.", + 'cat "$gate_output" >> "$GITHUB_OUTPUT"', + ], "pinned frozen-payload CI qualification"); + + const artifactSource = stepById(workflow, "publish", "release_artifact_source"); + invariant( + normalized(artifactSource.step.if) + === "${{ steps.release_plan.outputs.has_release_changes == 'true' }}" + && artifactSource.step.env?.CONTROL_CI_RUN_ID + === "${{ steps.ci_qualification.outputs.run_id }}" + && artifactSource.step.env?.PUBLICATION_SHA + === "${{ steps.release_identity.outputs.source_sha }}" + && artifactSource.step.env?.RECOVERY_PAYLOAD_CI_RUN_ID + === "${{ steps.recovery_source.outputs.payload_ci_run_id }}", + "artifact download identity must select current controller evidence normally and the pinned source evidence only in recovery", + ); + assertActiveTokens(artifactSource, [ + 'artifact_sha="$PUBLICATION_SHA"', + 'artifact_ci_run_id="$CONTROL_CI_RUN_ID"', + '[[ "$CANDIDATE_MODE" == release-recovery ]]', + 'artifact_sha="$RECOVERY_RELEASE_SHA"', + 'artifact_ci_run_id="$RECOVERY_PAYLOAD_CI_RUN_ID"', + 'echo "sha=$artifact_sha"', + 'echo "ci_run_id=$artifact_ci_run_id"', + ], "exact artifact source selection"); + + const recoverySelector = stepById( + workflow, + "publish", + "recovery_original_publication_lock", + ); + invariant( + normalized(recoverySelector.step.if) === recoveryCondition + && recoverySelector.step["continue-on-error"] === undefined + && recoverySelector.step["timeout-minutes"] === 5 + && sameSet(Object.keys(recoverySelector.step.env ?? {}), [ + "GH_REPO", + "GH_TOKEN", + "PINNED_ARTIFACT_METADATA_JSON", + "PINNED_DRY_RUN_ID", + "RECOVERY_RELEASE_SHA", + ]) + && recoverySelector.step.env?.GH_REPO === "${{ github.repository }}" + && recoverySelector.step.env?.GH_TOKEN === "${{ secrets.GITHUB_TOKEN }}" + && recoverySelector.step.env?.PINNED_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_source.outputs.approved_dry_run_artifact_metadata_json }}" + && recoverySelector.step.env?.PINNED_DRY_RUN_ID + === "${{ steps.recovery_source.outputs.approved_dry_run_id }}" + && recoverySelector.step.env?.RECOVERY_RELEASE_SHA + === "${{ steps.verify_publication_candidate.outputs.release_sha }}", + "same-version recovery must select the exact committed original dry-run lock and capsule", + ); + assertActiveTokens(recoverySelector, [ + "Release", + '"$RECOVERY_RELEASE_SHA"', + '--run-id "$PINNED_DRY_RUN_ID"', "--event workflow_dispatch", "--artifact oliphaunt-publication-lock", "--artifact oliphaunt-bootstrap-capsule", - ], "approved dry-run selection"); + "Original dry-run artifact inventory differs from the pinned recovery provenance.", + 'cat "$gate_output" >> "$GITHUB_OUTPUT"', + ], "original same-version recovery dry-run selection"); + + const recoveryDownload = stepById( + workflow, + "publish", + "download_recovery_original_publication_lock", + ); + invariant( + normalized(recoveryDownload.step.if) + === "${{ steps.recovery_original_publication_lock.outcome == 'success' }}" + && recoveryDownload.step["continue-on-error"] === undefined + && recoveryDownload.step["timeout-minutes"] === 5 + && sameSet(Object.keys(recoveryDownload.step.env ?? {}), [ + "GH_REPO", + "GH_TOKEN", + "RECOVERY_LOCK_ARTIFACT_METADATA_JSON", + "RECOVERY_LOCK_RUN_ID", + "RECOVERY_RELEASE_SHA", + ]) + && recoveryDownload.step.env?.RECOVERY_LOCK_RUN_ID + === "${{ steps.recovery_original_publication_lock.outputs.run_id }}" + && recoveryDownload.step.env?.RECOVERY_LOCK_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_original_publication_lock.outputs.artifact_metadata_json }}" + && recoveryDownload.step.env?.RECOVERY_RELEASE_SHA + === "${{ steps.verify_publication_candidate.outputs.release_sha }}", + "same-version recovery must download only the exact lock selected for the original release SHA", + ); + assertActiveTokens(recoveryDownload, [ + "Release", + '"$RECOVERY_RELEASE_SHA"', + '"$RECOVERY_LOCK_RUN_ID"', + '"$RECOVERY_LOCK_ARTIFACT_METADATA_JSON"', + "--artifact-metadata-json", + "--artifact oliphaunt-publication-lock", + ], "original same-version recovery lock download"); + + const controlApproval = stepById( + workflow, + "publish", + "approved_recovery_control", + ); + invariant( + normalized(controlApproval.step.if) === recoveryPublishCondition + && controlApproval.step["continue-on-error"] === undefined + && controlApproval.step["timeout-minutes"] === 5 + && sameSet(Object.keys(controlApproval.step.env ?? {}), ["GH_REPO", "GH_TOKEN"]) + && controlApproval.step.env?.GH_REPO === "${{ github.repository }}" + && controlApproval.step.env?.GH_TOKEN === "${{ secrets.GITHUB_TOKEN }}", + "recovery publish must require a separate successful controller dry-run equivalence proof", + ); + assertActiveTokens(controlApproval, [ + "Release", + '"$RELEASE_CONTROL_SHA"', + "300", + "--event workflow_dispatch", + "--artifact oliphaunt-release-recovery-equivalence", + ], "controller-bound recovery approval"); + + const controlDownload = stepById( + workflow, + "publish", + "download_approved_recovery_control", + ); + invariant( + normalized(controlDownload.step.if) + === "${{ steps.approved_recovery_control.outcome == 'success' }}" + && controlDownload.step["continue-on-error"] === undefined + && controlDownload.step["timeout-minutes"] === 5 + && controlDownload.step.env?.RECOVERY_APPROVAL_RUN_ID + === "${{ steps.approved_recovery_control.outputs.run_id }}" + && controlDownload.step.env?.RECOVERY_APPROVAL_ARTIFACT_METADATA_JSON + === "${{ steps.approved_recovery_control.outputs.artifact_metadata_json }}", + "recovery publish must download only the exact controller-approved equivalence artifact", + ); + assertActiveTokens(controlDownload, [ + "Release", + '"$RELEASE_CONTROL_SHA"', + '"$RECOVERY_APPROVAL_RUN_ID"', + '"$RECOVERY_APPROVAL_ARTIFACT_METADATA_JSON"', + "--artifact-metadata-json", + "--artifact oliphaunt-release-recovery-equivalence", + ], "controller-approved recovery evidence transfer"); + + const freezeLock = stepById(workflow, "publish", "freeze_publication_lock"); + assertActiveTokens(freezeLock, [ + 'lock_output=target/release/publication-lock.json', + 'lock_source="$RELEASE_SOURCE_SHA"', + '[[ "$CANDIDATE_MODE" == release-recovery ]]', + "lock_output=target/release/replayed-publication-lock.json", + 'lock_source="$RELEASE_ARTIFACT_SHA"', + '--head-ref "$lock_source"', + 'original_lock="$RUNNER_TEMP/recovery-original-publication-lock/publication-lock.json"', + 'cmp -s "$original_lock" "$lock_output"', + 'cp "$original_lock" "$PUBLICATION_LOCK_PATH"', + "--lock target/release/publication-lock.json", + ], "byte-identical source-bound recovery lock replay"); + + const recoveryProof = stepById( + workflow, + "publish", + "verify_release_recovery_lock", + ); + invariant( + normalized(recoveryProof.step.if) === recoveryModeCondition + && recoveryProof.step["continue-on-error"] === undefined + && recoveryProof.step["timeout-minutes"] === 2 + && sameSet(Object.keys(recoveryProof.step.env ?? {}), [ + "RECOVERY_LOCK_ARTIFACT_METADATA_JSON", + "RECOVERY_LOCK_RUN_ID", + "RECOVERY_RELEASE_SHA", + ]) + && recoveryProof.step.env?.RECOVERY_LOCK_RUN_ID + === "${{ steps.recovery_original_publication_lock.outputs.run_id }}" + && recoveryProof.step.env?.RECOVERY_LOCK_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_source.outputs.approved_lock_artifact_metadata_json }}" + && recoveryProof.step.env?.RECOVERY_RELEASE_SHA + === "${{ steps.verify_publication_candidate.outputs.release_sha }}", + "same-version recovery must prove the original and byte-identical replay lock before approval", + ); + assertActiveTokens(recoveryProof, [ + '--original-lock "$RUNNER_TEMP/recovery-original-publication-lock/publication-lock.json"', + "--replay-lock target/release/replayed-publication-lock.json", + '--release-sha "$RECOVERY_RELEASE_SHA"', + '--controller-sha "$RELEASE_CONTROL_SHA"', + '--original-run-id "$RECOVERY_LOCK_RUN_ID"', + '--original-artifact-metadata-json "$RECOVERY_LOCK_ARTIFACT_METADATA_JSON"', + "--output target/release/recovery-evidence/lock-equivalence.json", + 'cmp -s "$approved" target/release/recovery-evidence/lock-equivalence.json', + ], "same-version recovery v2 byte-envelope proof"); + + const recoveryInventory = stepById( + workflow, + "publish", + "inventory_release_recovery_registries", + ); + invariant( + normalized(recoveryInventory.step.if) === recoveryModeCondition + && recoveryInventory.step["continue-on-error"] === undefined + && recoveryInventory.step["timeout-minutes"] === 20 + && sameSet(Object.keys(recoveryInventory.step.env ?? {}), [ + "GH_TOKEN", + "GITHUB_TOKEN", + "OLIPHAUNT_PUBLICATION_LOCK", + "PRODUCTS_JSON", + ]) + && recoveryInventory.step.env?.GH_TOKEN === "${{ secrets.GITHUB_TOKEN }}" + && recoveryInventory.step.env?.GITHUB_TOKEN === "${{ secrets.GITHUB_TOKEN }}" + && recoveryInventory.step.env?.OLIPHAUNT_PUBLICATION_LOCK + === "${{ env.PUBLICATION_LOCK_PATH }}" + && recoveryInventory.step.env?.PRODUCTS_JSON + === "${{ steps.release_plan.outputs.products_json }}", + "same-version recovery registry inventory must be derived from the exact frozen publication lock", + ); + assertActiveTokens(recoveryInventory, [ + "tools/dev/bun.sh tools/release/release-check-registries.mjs", + '--products-json "$PRODUCTS_JSON"', + '--head-ref "$RELEASE_SOURCE_SHA"', + "--registry-inventory-output target/release/recovery-registry-inventory.json", + ], "same-version recovery frozen registry inventory"); + + const recoveryPublication = stepById( + workflow, + "publish", + "verify_release_recovery_publication", + ); + invariant( + normalized(recoveryPublication.step.if) === recoveryModeCondition + && recoveryPublication.step["continue-on-error"] === undefined + && recoveryPublication.step["timeout-minutes"] === 20 + && sameSet(Object.keys(recoveryPublication.step.env ?? {}), ["PRODUCTS_JSON"]) + && recoveryPublication.step.env?.PRODUCTS_JSON + === "${{ steps.release_plan.outputs.products_json }}", + "same-version recovery must prove a nonempty exact public registry prefix", + ); + assertActiveTokens(recoveryPublication, [ + '--lock "$PUBLICATION_LOCK_PATH"', + "--inventory target/release/recovery-registry-inventory.json", + '--products-json "$PRODUCTS_JSON"', + "--output target/release/recovery-evidence/publication-state.json", + '--github-output "$GITHUB_OUTPUT"', + ], "same-version recovery partial-publication proof"); + + const recoveryEvidence = assertUploadById( + workflow, + "publish", + "preserve_release_recovery_equivalence", + { + name: "oliphaunt-release-recovery-equivalence", + path: "target/release/recovery-evidence", + }, + ); + invariant( + normalized(recoveryEvidence.step.if) === recoveryDryRunCondition + && recoveryEvidence.step["continue-on-error"] === undefined + && recoveryEvidence.step["timeout-minutes"] === 5 + && recoveryEvidence.step.with?.["if-no-files-found"] === "error" + && recoveryEvidence.step.with?.overwrite === true + && recoveryEvidence.step.with?.["retention-days"] === 90 + && recoveryProof.index < recoveryEvidence.index + && recoveryProof.index < recoveryInventory.index + && recoveryInventory.index < recoveryPublication.index + && recoveryPublication.index < recoveryEvidence.index, + "recovery dry-run must retain only mandatory source/controller equivalence evidence, not relabeled release inputs", + ); + + const publishSteps = workflowSteps(workflow, "publish"); + const ledgerDownload = publishSteps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.name === "Download immutable registry bootstrap ledger"); + invariant( + ledgerDownload.length === 1, + "same-version recovery must have one immutable bootstrap-ledger selector", + ); + const [ledger] = ledgerDownload; + invariant( + normalized(ledger.step.if) + === "${{ steps.bootstrap_ledger_state.outputs.needs_ledger == 'true' }}" + && ledger.step.env?.CANDIDATE_MODE + === "${{ steps.verify_publication_candidate.outputs.mode }}" + && ledger.step.env?.PINNED_LEDGER_ARTIFACT_METADATA_JSON + === "${{ steps.recovery_source.outputs.bootstrap_ledger_artifact_metadata_json }}" + && ledger.step.env?.PINNED_LEDGER_RUN_ID + === "${{ steps.recovery_source.outputs.bootstrap_ledger_run_id }}", + "bootstrap ledger restoration must switch to the exact committed terminal ledger in recovery", + ); + assertActiveTokens(ledger, [ + '"$RELEASE_SOURCE_SHA"', + "--artifact oliphaunt-bootstrap-ledger", + '[[ "$CANDIDATE_MODE" == release-recovery ]]', + '--run-id "$PINNED_LEDGER_RUN_ID"', + '--artifact-metadata-json "$PINNED_LEDGER_ARTIFACT_METADATA_JSON"', + ], "pinned terminal bootstrap ledger transfer"); + const ledgerVerify = publishSteps + .map((step, index) => ({ index, step })) + .filter(({ step }) => + step.name === "Verify immutable bootstrap ledger and registry existence"); + invariant( + ledgerVerify.length === 1 + && ledger.index < ledgerVerify[0].index + && normalized(ledgerVerify[0].step.if) === normalized(ledger.step.if), + "the exact terminal bootstrap ledger must be verified against the frozen lock and live registries", + ); + assertActiveTokens(ledgerVerify[0], [ + '--lock "$PUBLICATION_LOCK_PATH"', + '--ledger "$BOOTSTRAP_LEDGER_PATH"', + "--verify-registries", + ], "terminal bootstrap ledger verification"); } function assertOidcBoundaries(workflow) { @@ -3426,6 +4162,7 @@ function assertBootstrapJob(workflow) { assertStepOrder(workflow, "publish-bootstrap", BOOTSTRAP_PHASES); for (const id of [ "verify_bootstrap_oidc_identity", + "verify_bootstrap_publication_candidate", "ci_qualification", "verify_bootstrap_qualification", "verify_bootstrap_capsule", @@ -3487,10 +4224,43 @@ function assertBootstrapJob(workflow) { "bootstrap root branch alone may select an approved dry-run", ); assertActiveTokens(approval, [ + '"$RELEASE_HEAD_SHA"', "--event workflow_dispatch", "--artifact oliphaunt-publication-lock", "--artifact oliphaunt-bootstrap-capsule", ], "approved bootstrap dry-run selection"); + + const bootstrapSteps = workflowSteps(workflow, "publish-bootstrap"); + const recoveryRejects = bootstrapSteps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.name === "Reject same-version recovery bootstrap mutation"); + invariant( + recoveryRejects.length === 1, + "publish-bootstrap must contain one explicit same-version recovery mutation rejection", + ); + const [recoveryReject] = recoveryRejects; + invariant( + normalized(recoveryReject.step.if) + === "${{ steps.bootstrap_scope.outputs.required == 'true' && steps.verify_bootstrap_publication_candidate.outputs.mode == 'release-recovery' }}" + && recoveryReject.step["continue-on-error"] === undefined + && recoveryReject.index + > stepById(workflow, "publish-bootstrap", "verify_bootstrap_publication_candidate").index + && recoveryReject.index + < stepById(workflow, "publish-bootstrap", "bootstrap_registry_identities").index, + "same-version recovery must fail closed before any publish-bootstrap approval or mutation", + ); + assertActiveTokens(recoveryReject, [ + "Same-version recovery cannot run publish-bootstrap.", + "pinned terminal bootstrap ledger", + "exit 1", + ], "same-version recovery bootstrap rejection"); + invariant( + bootstrapSteps.every((step) => + !executableShell(step.run).includes("oliphaunt-release-recovery-equivalence") + && !executableShell(step.run).includes("verify-release-recovery-publication.mjs")), + "publish-bootstrap must not accept recovery equivalence as authority; recovery reuses the pinned terminal ledger through root publish", + ); + const approvedDownloads = workflowSteps(workflow, "publish-bootstrap") .map((step, index) => ({ index, step })) .filter((entry) => activeRun(entry).includes(".github/scripts/download-build-artifacts.mjs") @@ -3520,6 +4290,64 @@ function assertBootstrapJob(workflow) { "$RUNNER_TEMP/approved-bootstrap/publication-lock.json", ], "bootstrap capsule"); assertActiveTokens(lock, ["cmp -s", "publication-lock.mjs verify", '"$RELEASE_HEAD_SHA"'], "bootstrap lock"); + + const credentials = stepById( + workflow, + "publish-bootstrap", + "require_bootstrap_credentials", + ); + invariant( + normalized(credentials.step.if) + === "${{ steps.bootstrap_scope.outputs.required == 'true' }}" + && sameSet(Object.keys(credentials.step.env ?? {}), [ + "CRATES_IO_BOOTSTRAP_TOKEN", + "NPM_BOOTSTRAP_TOKEN", + "SELECTED_NEEDS_CARGO", + "SELECTED_NEEDS_NPM", + ]) + && credentials.step.env?.SELECTED_NEEDS_CARGO + === "${{ steps.registry_needs.outputs.needs_cargo }}" + && credentials.step.env?.SELECTED_NEEDS_NPM + === "${{ steps.registry_needs.outputs.needs_npm }}" + && credentials.step.env?.CRATES_IO_BOOTSTRAP_TOKEN + === "${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }}" + && credentials.step.env?.NPM_BOOTSTRAP_TOKEN + === "${{ secrets.NPM_BOOTSTRAP_TOKEN }}" + && lock.index < credentials.index + && recoveryReject.index < credentials.index, + "normal bootstrap credentials must be requested only after candidate, recovery rejection, and exact lock verification", + ); + assertActiveTokens(credentials, [ + 'needs_cargo="$SELECTED_NEEDS_CARGO"', + 'needs_npm="$SELECTED_NEEDS_NPM"', + '[[ "$needs_cargo" != true && "$needs_cargo" != false ]]', + '[[ "$needs_npm" != true && "$needs_npm" != false ]]', + 'echo "needs_cargo_token=$needs_cargo"', + 'echo "needs_npm_token=$needs_npm"', + ], "bootstrap conditional credential decision"); + const bootstrapPublisher = stepById( + workflow, + "publish-bootstrap", + "bootstrap_registry_identities", + ).step; + invariant( + bootstrapPublisher.env?.CARGO_REGISTRY_TOKEN + === "${{ steps.require_bootstrap_credentials.outputs.needs_cargo_token == 'true' && secrets.CRATES_IO_BOOTSTRAP_TOKEN || '' }}", + "bootstrap publisher must receive a Cargo credential only when the verified missing set needs one", + ); + const npmAuthentication = stepById( + workflow, + "publish-bootstrap", + "configure_bootstrap_npm_auth", + ).step; + invariant( + normalized(npmAuthentication.if) + === "${{ steps.bootstrap_scope.outputs.required == 'true' && steps.require_bootstrap_credentials.outputs.needs_npm_token == 'true' }}" + && sameSet(Object.keys(npmAuthentication.env ?? {}), ["NPM_BOOTSTRAP_TOKEN"]) + && npmAuthentication.env?.NPM_BOOTSTRAP_TOKEN + === "${{ secrets.NPM_BOOTSTRAP_TOKEN }}", + "bootstrap npm credentials must be materialized only for a verified missing npm identity", + ); const restore = stepById(workflow, "publish-bootstrap", "restore_bootstrap_checkpoint").step; invariant( restore.env?.RELEASE_CONTINUATION_POINTER === "${{ inputs.continuation_pointer }}" @@ -3699,6 +4527,7 @@ export function assertReleaseOperationWorkflow(workflow) { }, "publish dry run"); assertPermissions(workflow.jobs.publish.permissions, { actions: "read", + "artifact-metadata": "write", attestations: "write", contents: "write", "id-token": "write", @@ -3920,6 +4749,7 @@ export function assertReleaseOperationWorkflow(workflow) { "publish-registry.exact_registry_publish", ], attestation: [ + "publish.attest_recovery_promotion", "publish.attest_extensions_1", "publish.attest_extensions_2", "publish.attest_liboliphaunt_native", diff --git a/tools/policy/assertions/workflow-semantics.test.mjs b/tools/policy/assertions/workflow-semantics.test.mjs index 92539150..e8cea4d2 100644 --- a/tools/policy/assertions/workflow-semantics.test.mjs +++ b/tools/policy/assertions/workflow-semantics.test.mjs @@ -1990,7 +1990,7 @@ test("dry-run evidence cannot be relabeled as publish evidence or omit the capsu .replace("--artifact oliphaunt-bootstrap-capsule", ""); assert.throws( () => assertReleaseOperationWorkflow(missingCapsule), - /approved dry-run selection must actively bind --artifact oliphaunt-bootstrap-capsule/u, + /approved source-bound dry-run selection must actively bind --artifact oliphaunt-bootstrap-capsule/u, ); const mislabeledLock = candidate(); @@ -2236,3 +2236,146 @@ test("Release Please lifecycle checks are bounded, exact-SHA-bound, and non-bypa /performs unapproved release_please_lifecycle mutation/u, ); }); + +test("same-version recovery identity and byte-equivalence gates are non-bypassable", () => { + for (const [name, mutate, pattern] of [ + [ + "publication identity verifier", + (workflow) => { + step(workflow, "publish", "verify_publication_candidate").run = "echo assumed"; + }, + /publication[/]lifecycle identity|same-version recovery candidate verifier/u, + ], + [ + "lifecycle identity output", + (workflow) => { + step(workflow, "publish", "assert_release_please_markable").run = + 'tools/dev/bun.sh tools/release/release-please-pr-lifecycle.mjs assert-markable --release-sha "$RELEASE_HEAD_SHA" --base main'; + }, + /markability assertion/u, + ], + [ + "immutable-envelope proof", + (workflow) => { + step(workflow, "publish", "verify_release_recovery_lock")["continue-on-error"] = true; + }, + /original and byte-identical replay lock/u, + ], + [ + "equivalence evidence", + (workflow) => { + step( + workflow, + "publish", + "preserve_release_recovery_equivalence", + ).with.path = "target/release/unverified.json"; + }, + /artifact path must be target[/]release[/]recovery-evidence|oliphaunt-release-recovery-equivalence/u, + ], + [ + "source-bound original approval", + (workflow) => { + step(workflow, "publish", "approved_publication_lock").run = + step(workflow, "publish", "approved_publication_lock").run + .replace('approval_sha="$RELEASE_SOURCE_SHA"', 'approval_sha="$RELEASE_CONTROL_SHA"'); + }, + /approved source-bound dry-run selection/u, + ], + [ + "bootstrap recovery rejection", + (workflow) => { + namedStep( + workflow, + "publish-bootstrap", + "Reject same-version recovery bootstrap mutation", + ).run = "echo assumed-safe"; + }, + /same-version recovery bootstrap rejection/u, + ], + [ + "pinned frozen-payload CI inventory", + (workflow) => { + step(workflow, "publish", "recovery_payload_ci_qualification").env + .PINNED_ARTIFACT_METADATA_JSON = "[]"; + }, + /exact pinned frozen-payload CI run and complete artifact inventory/u, + ], + [ + "committed recovery provenance", + (workflow) => { + step(workflow, "publish", "recovery_source").run = "echo assumed"; + }, + /committed same-version recovery provenance selector/u, + ], + [ + "byte-identical lock replay", + (workflow) => { + step(workflow, "publish", "freeze_publication_lock").run = + step(workflow, "publish", "freeze_publication_lock").run + .replace('cmp -s "$original_lock" "$lock_output"', "true"); + }, + /byte-identical source-bound recovery lock replay/u, + ], + [ + "controller recovery approval", + (workflow) => { + step(workflow, "publish", "approved_recovery_control").run = + step(workflow, "publish", "approved_recovery_control").run + .replace('"$RELEASE_CONTROL_SHA"', '"$RELEASE_SOURCE_SHA"'); + }, + /controller-bound recovery approval/u, + ], + [ + "pinned terminal bootstrap ledger", + (workflow) => { + namedStep(workflow, "publish", "Download immutable registry bootstrap ledger").env + .PINNED_LEDGER_RUN_ID = "0"; + }, + /exact committed terminal ledger/u, + ], + [ + "custom recovery attestation pin", + (workflow) => { + step(workflow, "publish", "attest_recovery_promotion").uses = + "actions/attest@0000000000000000000000000000000000000000"; + }, + /pinned custom-predicate attestation action/u, + ], + [ + "ordinary provenance excluded from recovery", + (workflow) => { + step(workflow, "publish", "attest_broker").if = + step(workflow, "publish", "attest_broker").if + .replace(" && steps.verify_publication_candidate.outputs.mode != 'release-recovery'", ""); + }, + /condition does not guarantee/u, + ], + [ + "registry recovery continuation rejection", + (workflow) => { + namedStep( + workflow, + "publish-registry", + "Reject same-version recovery continuation", + ).run = "echo assumed-safe"; + }, + /same-version recovery continuation rejection/u, + ], + [ + "final lifecycle identity verifier", + (workflow) => { + step(workflow, "publish-finalize", "finalize_publication_candidate").run = + "echo assumed"; + }, + /final release lifecycle identity verifier|publication[/]lifecycle identity/u, + ], + ]) { + const bypass = candidate(); + mutate(bypass); + assert.throws( + () => assertReleaseWorkflow(bypass), + pattern, + name, + ); + } +}); diff --git a/tools/policy/source-fetch-core.mjs b/tools/policy/source-fetch-core.mjs index e6b9c71a..c009bbda 100644 --- a/tools/policy/source-fetch-core.mjs +++ b/tools/policy/source-fetch-core.mjs @@ -28,6 +28,9 @@ const COMMAND_MAX_BUFFER = 16 * 1024 * 1024; const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; const CHECKOUT_MAX_ENTRIES = 500_000; const CHECKOUT_MAX_BYTES = 8 * 1024 * 1024 * 1024; +const GNU_MIRROR_ORIGIN = 'https://ftpmirror.gnu.org'; +const GNU_CANONICAL_ARCHIVE_ORIGIN = 'https://ftp.gnu.org/gnu'; +const GNU_ARCHIVE_PATH_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/u; const temporaryPaths = new Set(); const activePromotions = []; @@ -112,6 +115,32 @@ export function curlDownloadArgs(url, output, {platform = process.platform} = {} ]; } +export function canonicalGnuArchiveFallbackUrl(pinnedUrl) { + let parsed; + try { + parsed = assertHttpsUrl(pinnedUrl); + } catch { + return undefined; + } + if ( + parsed.origin !== GNU_MIRROR_ORIGIN || + parsed.search !== '' || + parsed.href !== pinnedUrl + ) { + return undefined; + } + const [, project, file, ...extra] = parsed.pathname.split('/'); + if ( + extra.length !== 0 || + !GNU_ARCHIVE_PATH_COMPONENT.test(project ?? '') || + !GNU_ARCHIVE_PATH_COMPONENT.test(file ?? '') || + (!file.endsWith('.tar.gz') && !file.endsWith('.tgz')) + ) { + return undefined; + } + return `${GNU_CANONICAL_ARCHIVE_ORIGIN}/${project}/${file}`; +} + export function defaultRunProcess({command, args, cwd, env = process.env, label, timeoutMs}) { const result = captureCommandOutput(command, args, { cwd, @@ -635,11 +664,36 @@ export function createSourceFetcher({ })); const download = downloadFile ?? - ((source, output) => - run('curl', curlDownloadArgs(source.url, output), { - label: `download ${source.name} from pinned HTTPS URL`, - timeoutMs: ARCHIVE_DOWNLOAD_TIMEOUT_MS, - })); + ((source, output) => { + try { + return run('curl', curlDownloadArgs(source.url, output), { + label: `download ${source.name} from pinned HTTPS URL`, + timeoutMs: ARCHIVE_DOWNLOAD_TIMEOUT_MS, + }); + } catch (primaryError) { + const fallbackUrl = canonicalGnuArchiveFallbackUrl(source.url); + if (fallbackUrl === undefined) { + throw primaryError; + } + removePath(output); + try { + return run('curl', curlDownloadArgs(fallbackUrl, output), { + label: `download ${source.name} from canonical GNU HTTPS archive`, + timeoutMs: ARCHIVE_DOWNLOAD_TIMEOUT_MS, + }); + } catch (fallbackError) { + const primaryDiagnostic = + primaryError instanceof Error ? primaryError.message : String(primaryError); + const fallbackDiagnostic = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + throw new AggregateError( + [primaryError, fallbackError], + `download ${source.name} failed from pinned GNU mirror: ${primaryDiagnostic}; ` + + `canonical GNU fallback also failed: ${fallbackDiagnostic}`, + ); + } + } + }); function git(source, args, cwd, env, options = {}) { // Pinned source bytes are part of release fingerprints and legal-data diff --git a/tools/policy/source-fetch-core.test.mjs b/tools/policy/source-fetch-core.test.mjs index 32762964..b7023924 100644 --- a/tools/policy/source-fetch-core.test.mjs +++ b/tools/policy/source-fetch-core.test.mjs @@ -17,6 +17,7 @@ import {test} from 'node:test'; import { assertHttpsUrl, + canonicalGnuArchiveFallbackUrl, createSourceFetcher, curlDownloadArgs, curlPlatformTlsArgs, @@ -193,6 +194,30 @@ function archiveSource(fixture, name = 'fixture') { }; } +function gnuArchiveSource(fixture) { + return { + ...archiveSource(fixture, 'libiconv'), + url: 'https://ftpmirror.gnu.org/libiconv/libiconv-1.19.tar.gz', + }; +} + +function archiveTransport(fixture, requests, responseForUrl) { + return (specification) => { + if (specification.command !== 'curl') { + return defaultRunProcess(specification); + } + const url = specification.args.at(specification.args.indexOf('--url') + 1); + const output = specification.args.at(specification.args.indexOf('--output') + 1); + requests.push(url); + const response = responseForUrl(url); + if (response instanceof Error) { + throw response; + } + copyFileSync(fixture, output); + return ''; + }; +} + function writeArchiveManifest(manifestPath, source) { writeFileSync( manifestPath, @@ -307,6 +332,152 @@ test('archive transport is HTTPS-only and bounded', () => { } }); +test('GNU archive transport keeps a healthy pinned mirror as the sole request', async () => { + const root = makeRoot('source-gnu-primary'); + try { + createTarFixtures(root); + const fixture = path.join(root, 'valid.tar.gz'); + const source = gnuArchiveSource(fixture); + const requests = []; + await sourceFetcher(root, { + runProcess: archiveTransport(fixture, requests, () => undefined), + }).materialize(source); + + assert.deepEqual(requests, [source.url]); + const marker = readFileSync( + path.join(root, 'checkouts', source.name, '.oliphaunt-source-pin'), + 'utf8', + ); + assert.equal(marker.split('\n').includes(`url=${source.url}`), true); + assert.doesNotMatch(marker, /ftp\.gnu\.org/u); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('GNU archive transport retries the canonical host after a bounded pinned-mirror failure', async () => { + const root = makeRoot('source-gnu-fallback'); + try { + createTarFixtures(root); + const fixture = path.join(root, 'valid.tar.gz'); + const source = gnuArchiveSource(fixture); + const fallback = 'https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz'; + const requests = []; + await sourceFetcher(root, { + runProcess: archiveTransport(fixture, requests, (url) => + url === source.url ? new Error('injected bounded primary transport failure') : undefined), + }).materialize(source); + + assert.deepEqual(requests, [source.url, fallback]); + const marker = readFileSync( + path.join(root, 'checkouts', source.name, '.oliphaunt-source-pin'), + 'utf8', + ); + assert.equal(marker.split('\n').includes(`url=${source.url}`), true); + assert.doesNotMatch(marker, /ftp\.gnu\.org/u); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('GNU archive fallback bytes must still satisfy the pinned checksum before promotion', async () => { + const root = makeRoot('source-gnu-fallback-checksum'); + try { + createTarFixtures(root); + const fixture = path.join(root, 'valid.tar.gz'); + const wrongFixture = path.join(root, 'updated.tar.gz'); + const source = gnuArchiveSource(fixture); + const fallback = 'https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz'; + const requests = []; + await assert.rejects( + sourceFetcher(root, { + runProcess: archiveTransport(wrongFixture, requests, (url) => + url === source.url ? new Error('injected bounded primary transport failure') : undefined), + }).materialize(source), + new RegExp(`libiconv archive sha256: expected ${source.sha256}, got [0-9a-f]{64}`, 'u'), + ); + + assert.deepEqual(requests, [source.url, fallback]); + assert.deepEqual(readdirSync(path.join(root, 'archives')), []); + assert.equal(existsSync(path.join(root, 'checkouts', source.name)), false); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('GNU archive transport preserves both diagnostics when both bounded endpoints fail', async () => { + const root = makeRoot('source-gnu-both-fail'); + try { + createTarFixtures(root); + const fixture = path.join(root, 'valid.tar.gz'); + const source = gnuArchiveSource(fixture); + const fallback = 'https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz'; + const archiveRoot = path.join(root, 'archives'); + const cached = path.join(archiveRoot, `${source.name}-${source.sha256}.tar.gz`); + mkdirSync(archiveRoot); + writeFileSync(cached, 'prior corrupt archive bytes'); + const requests = []; + let failure; + try { + await sourceFetcher(root, { + runProcess: archiveTransport(fixture, requests, (url) => + new Error(url === source.url ? 'primary diagnostic' : 'fallback diagnostic')), + }).materialize(source); + } catch (error) { + failure = error; + } + + assert.ok(failure instanceof AggregateError); + assert.match(failure.message, /primary diagnostic/u); + assert.match(failure.message, /fallback diagnostic/u); + assert.deepEqual(failure.errors.map((error) => error.message), [ + 'primary diagnostic', + 'fallback diagnostic', + ]); + assert.deepEqual(requests, [source.url, fallback]); + assert.equal(readFileSync(cached, 'utf8'), 'prior corrupt archive bytes'); + assert.deepEqual(readdirSync(archiveRoot), [`${source.name}-${source.sha256}.tar.gz`]); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test('GNU archive fallback is unavailable to other hosts and noncanonical or unsafe paths', async () => { + const root = makeRoot('source-gnu-fallback-scope'); + try { + createTarFixtures(root); + const fixture = path.join(root, 'valid.tar.gz'); + const urls = [ + 'https://example.invalid/libiconv/libiconv-1.19.tar.gz', + 'https://ftpmirror.gnu.org/libiconv/nested/libiconv-1.19.tar.gz', + 'https://ftpmirror.gnu.org/libiconv/libiconv-1.19.tar.gz?mutable=1', + 'https://ftpmirror.gnu.org/libiconv/%6cibiconv-1.19.tar.gz', + 'https://ftpmirror.gnu.org/libiconv/../libiconv-1.19.tar.gz', + ]; + assert.equal( + canonicalGnuArchiveFallbackUrl( + 'https://ftpmirror.gnu.org/libiconv/libiconv-1.19.zip', + ), + undefined, + ); + for (const [index, url] of urls.entries()) { + assert.equal(canonicalGnuArchiveFallbackUrl(url), undefined); + const source = {...archiveSource(fixture, `fixture-${index}`), url}; + const requests = []; + await assert.rejects( + sourceFetcher(path.join(root, String(index)), { + runProcess: archiveTransport(fixture, requests, () => + new Error(`injected primary-only failure ${index}`)), + }).materialize(source), + new RegExp(`injected primary-only failure ${index}`, 'u'), + ); + assert.deepEqual(requests, [url]); + } + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + test('Windows transport tolerates only an unavailable Schannel revocation service', () => { assert.deepEqual(curlPlatformTlsArgs('win32'), ['--ssl-revoke-best-effort']); assert.deepEqual(curlPlatformTlsArgs('linux'), []); diff --git a/tools/release/check-registry-publication-products.test.mjs b/tools/release/check-registry-publication-products.test.mjs index cf1d2a2c..2787cbfe 100644 --- a/tools/release/check-registry-publication-products.test.mjs +++ b/tools/release/check-registry-publication-products.test.mjs @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; -import { productRegistryPackages } from "./check_registry_publication.mjs"; +import { + productRegistryPackages, + productRegistryPackagesFromLock, +} from "./check_registry_publication.mjs"; import { exactExtensionProducts } from "./release-artifact-targets.mjs"; test("no-lock exact-extension registry inventory is explicit, complete, and unique", async () => { @@ -12,3 +15,58 @@ test("no-lock exact-extension registry inventory is explicit, complete, and uniq expect(identities.filter((identity) => identity === `crates:${product}`)).toHaveLength(1); } }); + +test("publication-lock inventory includes dynamic Cargo payload-part carriers", () => { + const product = "fixture-native"; + const version = "1.2.3"; + const publicationLock = { + products: [{ id: product, version }], + carriers: [ + { + ecosystem: "cargo", + name: "fixture-native-linux-x64-gnu-part-001", + product, + role: "payload-part", + version, + }, + { + ecosystem: "cargo", + name: "fixture-native-linux-x64-gnu", + product, + role: "platform-leaf", + version, + }, + { + ecosystem: "npm", + name: "@fixture/native-linux-x64-gnu", + product, + role: "platform-leaf", + version, + }, + ], + }; + + expect(productRegistryPackagesFromLock(publicationLock, product)).toEqual([ + { + kind: "crates", + name: "fixture-native-linux-x64-gnu-part-001", + version, + }, + { + kind: "crates", + name: "fixture-native-linux-x64-gnu", + version, + }, + { + kind: "npm", + name: "@fixture/native-linux-x64-gnu", + version, + }, + ]); + expect(productRegistryPackagesFromLock(publicationLock, product, { + registryKind: "crates", + }).map(({ name }) => name)).toEqual([ + "fixture-native-linux-x64-gnu-part-001", + "fixture-native-linux-x64-gnu", + ]); +}); diff --git a/tools/release/check-release-metadata.mjs b/tools/release/check-release-metadata.mjs index 3e9bdd47..33a300a7 100755 --- a/tools/release/check-release-metadata.mjs +++ b/tools/release/check-release-metadata.mjs @@ -54,6 +54,7 @@ import { publicToolsFeatureDependencies, } from "./wasix-cargo-artifact-contract.mjs"; import { assertReleaseSemanticInputsCurrent } from "./release-semantic-inputs.mjs"; +import { verifyPublicationRecoveryCandidate } from "./verify-publication-candidate.mjs"; const TOOL = "check-release-metadata.mjs"; const STABLE_VERSION = /^[0-9]+[.][0-9]+[.][0-9]+$/u; @@ -349,7 +350,12 @@ function compatibilityValue(entry, ref = null) { function validateCompatibility(graph) { const entries = compatibilityVersionEntries(graph.products, { requireSourceProduct: true, prefix: TOOL }); assert(new Set(entries.map((entry) => entry.id)).size === entries.length, "compatibility field ids must be globally unique"); - const transitions = releasePleaseWorktreeTransitions(ROOT, { prefix: TOOL }); + const recovery = verifyPublicationRecoveryCandidate({ repo: ROOT, headRef: "HEAD" }); + const transitionHeadRef = recovery?.releaseSha ?? "HEAD"; + const transitions = releasePleaseWorktreeTransitions(ROOT, { + headRef: transitionHeadRef, + prefix: TOOL, + }); const transitionedProducts = new Set(transitions.map(({ product }) => product)); const versionSources = new Map(); for (const entry of entries) { @@ -357,6 +363,7 @@ function validateCompatibility(graph) { let source = versionSources.get(entry.product); if (source === undefined) { source = compatibilityVersionSource(entry, graph.products, transitionedProducts, { + headRef: transitionHeadRef, prefix: TOOL, root: ROOT, }); diff --git a/tools/release/check_registry_publication.mjs b/tools/release/check_registry_publication.mjs index a3da1f0a..ca314ced 100644 --- a/tools/release/check_registry_publication.mjs +++ b/tools/release/check_registry_publication.mjs @@ -390,34 +390,45 @@ async function derivedExactExtensionRegistryPackages(product, version) { })); } +export function productRegistryPackagesFromLock( + publicationLock, + product, + { versionOverride = undefined, registryKind = undefined } = {}, +) { + const productRow = publicationLock.products.find((row) => row.id === product); + if (productRow === undefined) { + fail(`publication lock does not contain release product ${JSON.stringify(product)}`); + } + if (versionOverride !== undefined && versionOverride !== productRow.version) { + fail(`${product} requested version ${versionOverride} does not match frozen publication-lock version ${productRow.version}`); + } + const ecosystemByKind = new Map([["crates", "cargo"], ["npm", "npm"], ["maven", "maven"], ["jsr", "jsr"]]); + if (registryKind !== undefined && !ecosystemByKind.has(registryKind)) { + fail(`unsupported registry kind ${JSON.stringify(registryKind)}`); + } + const ecosystem = registryKind === undefined ? undefined : ecosystemByKind.get(registryKind); + const kindByEcosystem = new Map([["cargo", "crates"], ["npm", "npm"], ["maven", "maven"], ["jsr", "jsr"]]); + const packages = lockedCarriers(publicationLock, { product, ecosystem }).map((carrier) => ({ + kind: kindByEcosystem.get(carrier.ecosystem), + name: carrier.name, + version: carrier.version, + })); + if (registryKind !== undefined && packages.length === 0) { + fail(`${product} has no ${registryKind} registry packages in the publication lock`); + } + return packages; +} + export async function productRegistryPackages(product, { versionOverride = undefined, registryKind = undefined } = {}) { const publicationLockPath = process.env.OLIPHAUNT_PUBLICATION_LOCK; if (publicationLockPath) { if (caches.publicationLock === undefined) { caches.publicationLock = loadPublicationLock(path.resolve(ROOT, publicationLockPath)); } - const productRow = caches.publicationLock.products.find((row) => row.id === product); - if (productRow === undefined) { - fail(`publication lock does not contain release product ${JSON.stringify(product)}`); - } - if (versionOverride !== undefined && versionOverride !== productRow.version) { - fail(`${product} requested version ${versionOverride} does not match frozen publication-lock version ${productRow.version}`); - } - const ecosystemByKind = new Map([["crates", "cargo"], ["npm", "npm"], ["maven", "maven"], ["jsr", "jsr"]]); - if (registryKind !== undefined && !ecosystemByKind.has(registryKind)) { - fail(`unsupported registry kind ${JSON.stringify(registryKind)}`); - } - const ecosystem = registryKind === undefined ? undefined : ecosystemByKind.get(registryKind); - const kindByEcosystem = new Map([["cargo", "crates"], ["npm", "npm"], ["maven", "maven"], ["jsr", "jsr"]]); - const packages = lockedCarriers(caches.publicationLock, { product, ecosystem }).map((carrier) => ({ - kind: kindByEcosystem.get(carrier.ecosystem), - name: carrier.name, - version: carrier.version, - })); - if (registryKind !== undefined && packages.length === 0) { - fail(`${product} has no ${registryKind} registry packages in the publication lock`); - } - return packages; + return productRegistryPackagesFromLock(caches.publicationLock, product, { + versionOverride, + registryKind, + }); } const config = await productConfig(product); const version = versionOverride || (await currentVersion(product)); diff --git a/tools/release/check_release_versions.mjs b/tools/release/check_release_versions.mjs index 83ed04a4..0c1d2e76 100644 --- a/tools/release/check_release_versions.mjs +++ b/tools/release/check_release_versions.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { captureCommandOutput } from "../dev/capture-command-output.mjs"; import { currentVersion } from "./product-version.mjs"; import { @@ -19,6 +20,7 @@ import { const TOOL = "check_release_versions.mjs"; const REGISTRY_TARGETS = new Set(["crates-io", "npm", "jsr", "maven-central"]); +const REGISTRY_INVENTORY_SCHEMA = "oliphaunt-release-registry-inventory-v1"; function fail(message) { console.error(`${TOOL}: ${message}`); @@ -106,6 +108,22 @@ function registryQueryProductPublication(product) { return data; } +function registryInventoryPackages(packages, context) { + return packages.map((pkg, index) => { + if ( + pkg === null + || Array.isArray(pkg) + || typeof pkg !== "object" + || typeof pkg.kind !== "string" + || typeof pkg.name !== "string" + || typeof pkg.version !== "string" + ) { + fail(`${context}[${index}] is not a registry package identity`); + } + return { kind: pkg.kind, name: pkg.name, version: pkg.version }; + }); +} + function verifyGithubReleaseAssets(product, version) { run([ process.execPath, @@ -288,30 +306,36 @@ async function validateProduct(product, config, headRef) { async function validateRegistryPublication(products, graph, currentTagAtHead, headRef) { const graphProducts = graph.products; const headCommit = commitForRef(headRef); + const inventory = []; for (const product of products) { const config = graphProducts[product]; const targets = assertStringList(config.publish_targets ?? [], `${product}.publish_targets`); const registryTargets = targets.filter((target) => REGISTRY_TARGETS.has(target)); if (registryTargets.length === 0) { + inventory.push({ product, packages: [], missing: [], published: [] }); continue; } if (currentTagAtHead[product] === true) { const { packages, missing, published } = registryQueryProductPublication(product); if (packages.length === 0) { console.log(`${product} has no external registry packages to check`); - continue; + } else { + console.log( + `${product} registry completion check: ${published.length} published, ${missing.length} missing`, + ); } - console.log( - `${product} registry completion check: ${published.length} published, ${missing.length} missing`, - ); + inventory.push({ + product, + packages: registryInventoryPackages(packages, `${product}.packages`), + missing: registryInventoryPackages(missing, `${product}.missing`), + published: registryInventoryPackages(published, `${product}.published`), + }); continue; } - const { packages, published } = registryQueryProductPublication(product); + const { packages, missing, published } = registryQueryProductPublication(product); if (packages.length === 0) { console.log(`${product} has no external registry packages to check`); - continue; - } - if (published.length > 0) { + } else if (published.length > 0) { if (typeof config.tag_prefix !== "string" || config.tag_prefix.length === 0) { fail(`${product} must declare tag_prefix`); } @@ -322,12 +346,24 @@ async function validateRegistryPublication(products, graph, currentTagAtHead, he .map((item) => String(item.label)) .join(", ")}; ${currentTag} is not yet exact at ${headCommit}. The protected publish workflow must prove these versions with the immutable bootstrap ledger before it stages exact-SHA tags; never create product tags manually.`, ); - continue; + } else { + console.log( + `${product} registry unpublished check passed: ${packages.map((item) => String(item.label)).join(", ")}`, + ); } - console.log( - `${product} registry unpublished check passed: ${packages.map((item) => String(item.label)).join(", ")}`, - ); + inventory.push({ + product, + packages: registryInventoryPackages(packages, `${product}.packages`), + missing: registryInventoryPackages(missing, `${product}.missing`), + published: registryInventoryPackages(published, `${product}.published`), + }); } + return { + schema: REGISTRY_INVENTORY_SCHEMA, + source: { commit: headCommit }, + products: [...products], + results: inventory, + }; } function releaseProductProjectId(product, products, projects) { @@ -446,6 +482,7 @@ function parseArgs(argv) { productsJson: undefined, headRef: "HEAD", checkRegistries: false, + registryInventoryOutput: "", }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; @@ -467,8 +504,16 @@ function parseArgs(argv) { args.headRef = value.slice("--head-ref=".length); } else if (value === "--check-registries") { args.checkRegistries = true; + } else if (value === "--registry-inventory-output") { + if (index + 1 >= argv.length) { + fail("--registry-inventory-output requires a value"); + } + args.registryInventoryOutput = argv[index + 1]; + index += 1; + } else if (value.startsWith("--registry-inventory-output=")) { + args.registryInventoryOutput = value.slice("--registry-inventory-output=".length); } else if (value === "-h" || value === "--help") { - console.log("usage: tools/release/check_release_versions.mjs [--products-json JSON] [--head-ref REF] [--check-registries]"); + console.log("usage: tools/release/check_release_versions.mjs [--products-json JSON] [--head-ref REF] [--check-registries] [--registry-inventory-output FILE]"); process.exit(0); } else { fail(`unknown argument ${value}`); @@ -481,6 +526,9 @@ async function main(argv) { const args = parseArgs(argv); const graph = loadGraph(); const selected = parseProducts(args.productsJson, graph); + if (args.registryInventoryOutput && !args.checkRegistries) { + fail("--registry-inventory-output requires --check-registries"); + } const currentTagAtHead = {}; for (const product of selected) { currentTagAtHead[product] = await validateProduct(product, graph.products[product], args.headRef); @@ -488,7 +536,21 @@ async function main(argv) { await validateRuntimeTiedContribRelease(selected, graph); await validateReleaseDependencies(selected, graph); if (args.checkRegistries) { - await validateRegistryPublication(selected, graph, currentTagAtHead, args.headRef); + const inventory = await validateRegistryPublication( + selected, + graph, + currentTagAtHead, + args.headRef, + ); + if (args.registryInventoryOutput) { + const output = path.resolve(ROOT, args.registryInventoryOutput); + mkdirSync(path.dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(inventory, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + } } console.log("release version checks passed"); } diff --git a/tools/release/download-build-artifacts.test.mjs b/tools/release/download-build-artifacts.test.mjs index e41e0774..db68c2ce 100644 --- a/tools/release/download-build-artifacts.test.mjs +++ b/tools/release/download-build-artifacts.test.mjs @@ -18,6 +18,8 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; + const SCRIPT = path.resolve(".github/scripts/download-build-artifacts.mjs"); const CHECKSUM_MERGER = path.resolve(".github/scripts/merge-checksum-manifest.mjs"); const SHA = "a".repeat(40); @@ -25,7 +27,10 @@ const ARTIFACT = "exact-artifact"; const DOWNLOAD_PROCESS_TIMEOUT_MS = 10_000; test("the workflow Node runtime reaches argument validation without a Bun global", () => { - const result = spawnSync("node", [SCRIPT], { encoding: "utf8" }); + const result = spawnSync("node", [SCRIPT], { + encoding: "utf8", + env: isolatedGitHubTestEnvironment(), + }); assert.equal(result.status, 2); assert.match(result.stderr, /usage: download-build-artifacts[.]mjs/u); assert.doesNotMatch(result.stderr, /Bun is not defined|ERR_INVALID_ARG_TYPE/u); @@ -171,8 +176,7 @@ function invoke(f, mode, extra = [], environment = {}) { { encoding: "utf8", timeout: DOWNLOAD_PROCESS_TIMEOUT_MS, - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${f.bin}${path.delimiter}${process.env.PATH}`, FAKE_ARTIFACT_ARCHIVE: f.archive, FAKE_GH_LOG: f.log, @@ -184,7 +188,7 @@ function invoke(f, mode, extra = [], environment = {}) { OLIPHAUNT_GITHUB_RUN_SNAPSHOT_DIR: f.snapshots, OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), }, ); } @@ -196,8 +200,7 @@ function invokeFallback(f, candidateMode) { { encoding: "utf8", timeout: DOWNLOAD_PROCESS_TIMEOUT_MS, - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${f.bin}${path.delimiter}${process.env.PATH}`, FAKE_ARTIFACT_ARCHIVE: f.archive, FAKE_CANDIDATE_MODE: candidateMode, @@ -209,7 +212,7 @@ function invokeFallback(f, candidateMode) { OLIPHAUNT_GITHUB_RUN_SNAPSHOT_DIR: f.snapshots, OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), }, ); } @@ -304,8 +307,7 @@ test("exact run, workflow, job, SHA, and artifact name remain mandatory", (t) => { encoding: "utf8", timeout: DOWNLOAD_PROCESS_TIMEOUT_MS, - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${f.bin}${path.delimiter}${process.env.PATH}`, FAKE_ARTIFACT_ARCHIVE: f.archive, FAKE_GH_LOG: f.log, @@ -316,7 +318,7 @@ test("exact run, workflow, job, SHA, and artifact name remain mandatory", (t) => OLIPHAUNT_GITHUB_RUN_SNAPSHOT_DIR: f.snapshots, OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), }, ); assert.equal(result.status, 1); diff --git a/tools/release/download-wasix-runtime-build-artifacts.test.mjs b/tools/release/download-wasix-runtime-build-artifacts.test.mjs new file mode 100644 index 00000000..4d258aba --- /dev/null +++ b/tools/release/download-wasix-runtime-build-artifacts.test.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { spawnSync } from "../test/fd-backed-spawn-sync.mjs"; +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; + +const SCRIPT = path.resolve(".github/scripts/download-wasix-runtime-build-artifacts.mjs"); +const CONTROL_SHA = "a".repeat(40); +const ARTIFACT_SHA = "b".repeat(40); + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "oliphaunt-wasix-artifact-download-")); + const bin = path.join(root, "bin"); + const capture = path.join(root, "capture.json"); + mkdirSync(bin); + const cargo = path.join(bin, "cargo"); + writeFileSync(cargo, `#!/usr/bin/env node +const { writeFileSync } = require("node:fs"); +writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + args: process.argv.slice(2), + releaseArtifactSha: process.env.RELEASE_ARTIFACT_SHA, + releaseHeadSha: process.env.RELEASE_HEAD_SHA, +})); +`); + chmodSync(cargo, 0o755); + t.after(() => rmSync(root, { force: true, recursive: true })); + return { bin, capture }; +} + +function runWrapper(t, overrides = {}) { + const { bin, capture } = fixture(t); + const result = spawnSync("bun", [SCRIPT], { + encoding: "utf8", + env: isolatedGitHubTestEnvironment({ + CAPTURE_PATH: capture, + GITHUB_TOKEN: "fixture-token", + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + RELEASE_ARTIFACT_SHA: ARTIFACT_SHA, + RELEASE_HEAD_SHA: CONTROL_SHA, + ...overrides, + }), + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(readFileSync(capture, "utf8")); +} + +test("exact run download preserves controller lineage while selecting frozen payload artifacts", (t) => { + const capture = runWrapper(t, { CI_RUN_ID: "30358387218" }); + assert.deepEqual(capture, { + args: [ + "run", + "-p", + "xtask", + "--", + "assets", + "download", + "--run-id", + "30358387218", + "--required-job", + "Builds", + "--all-targets", + ], + releaseArtifactSha: ARTIFACT_SHA, + releaseHeadSha: CONTROL_SHA, + }); +}); + +test("artifact SHA wins only as the payload selector when no run ID is supplied", (t) => { + const capture = runWrapper(t); + assert.deepEqual(capture.args.slice(6, 8), ["--sha", ARTIFACT_SHA]); + assert.equal(capture.releaseHeadSha, CONTROL_SHA); +}); diff --git a/tools/release/github-content-write-pacer.mjs b/tools/release/github-content-write-pacer.mjs index 9820081a..9f520310 100644 --- a/tools/release/github-content-write-pacer.mjs +++ b/tools/release/github-content-write-pacer.mjs @@ -237,35 +237,28 @@ export function reserveGitHubContentWriteSync({ const expectedIdentity = githubReleaseLineageIdentity(environment); mkdirSync(path.dirname(file), { recursive: true }); const lock = acquireLock(file, { maxLockWaitMs: resolvedTiming.maxLockWaitMs, now, sleep }); + let reservedAt; + let sequence; + let waitMs; try { const previous = parseState(file, expectedIdentity, resolvedTiming); const observedAt = now(); if (!Number.isSafeInteger(observedAt) || observedAt < 0) fail("clock returned an invalid timestamp"); - if (previous !== null && observedAt < previous.lastReservedAtMs) { - fail("clock moved backwards behind the last content-write reservation"); - } // A fresh runner cannot prove whether an interrupted predecessor consumed // secondary-rate-limit write slots without leaving remote state. Charge a - // complete rolling-hour cooldown before its first write. Persisting the - // reservation before the request makes every later process in this job - // crash-conservative; a new runner starts the cooldown again. + // complete rolling-hour cooldown before its first write. Allocate and + // persist the next globally ordered slot while holding the lock briefly, + // then wait outside it. A crashed waiter burns its slot conservatively. const earliest = previous === null ? coldWindowStartedAtMs(environment, observedAt) + resolvedTiming.coldStartMs : previous.lastReservedAtMs + resolvedTiming.intervalMs; - const waitMs = Math.max(0, earliest - observedAt); + reservedAt = Math.max(observedAt, earliest); + waitMs = reservedAt - observedAt; const deadline = hardDeadlineMs(environment); - if (deadline !== null && earliest >= deadline) { - fail("the next content-write reservation would reach the hard release deadline"); - } - sleep(waitMs); - const reservedAt = now(); - if (!Number.isSafeInteger(reservedAt) || reservedAt < earliest) { - fail("clock did not advance through the required content-write pacing interval"); - } if (deadline !== null && reservedAt >= deadline) { - fail("the content-write reservation reached the hard release deadline while waiting"); + fail("the next content-write reservation would reach the hard release deadline"); } - const sequence = (previous?.sequence ?? 0) + 1; + sequence = (previous?.sequence ?? 0) + 1; const reservation = { label, reservedAtMs: reservedAt, sequence }; const state = { schema: SCHEMA, @@ -278,10 +271,20 @@ export function reserveGitHubContentWriteSync({ reservations: [...(previous?.reservations ?? []), reservation], }; writeState(file, state); - return { enabled: true, sequence: state.sequence, waitedMs: waitMs }; } finally { rmSync(lock, { force: true }); } + + sleep(waitMs); + const observedAfterWait = now(); + if (!Number.isSafeInteger(observedAfterWait) || observedAfterWait < reservedAt) { + fail("clock did not advance through the required content-write pacing interval"); + } + const deadline = hardDeadlineMs(environment); + if (deadline !== null && observedAfterWait >= deadline) { + fail("the content-write reservation reached the hard release deadline while waiting"); + } + return { enabled: true, sequence, waitedMs: waitMs }; } function main(argv) { diff --git a/tools/release/github-content-write-pacer.test.mjs b/tools/release/github-content-write-pacer.test.mjs index 6f1be91e..08e1726c 100644 --- a/tools/release/github-content-write-pacer.test.mjs +++ b/tools/release/github-content-write-pacer.test.mjs @@ -15,7 +15,18 @@ import { GITHUB_CONTENT_WRITES_PER_ROLLING_MINUTE, reserveGitHubContentWriteSync, } from "./github-content-write-pacer.mjs"; -import { runGitHubMutationSync } from "./github-release-mutations.mjs"; +import { + GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, + GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS, + GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS, +} from "../../.github/scripts/manage-release-drafts.mjs"; +import { + GITHUB_RELEASE_SNAPSHOT_MAX_READS, + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS, + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_WINDOW_MS, + runGitHubMutationSync, +} from "./github-release-mutations.mjs"; import { loadGraph } from "./release-graph.mjs"; import { allArtifactTargets, @@ -176,7 +187,7 @@ for (let attempt = 0; attempt < 4; attempt += 1) { reserveGitHubContentWriteSync({ environment: process.env, label, - timing: { coldStartMs: 0, intervalMs: 5, maxLockWaitMs: 1_000 }, + timing: { coldStartMs: 0, intervalMs: 50, maxLockWaitMs: 300 }, }); reserveGitHubCoreRequestSync({ environment: process.env, label }); } @@ -194,6 +205,23 @@ for (let attempt = 0; attempt < 4; attempt += 1) { OLIPHAUNT_GITHUB_CORE_REQUEST_JOURNAL_PATH: core, OLIPHAUNT_REQUIRE_GITHUB_CORE_REQUEST_JOURNAL: "true", }; + const seedReservedAtMs = Date.now() + 500; + writeFileSync(pacer, `${JSON.stringify({ + schema: "oliphaunt-github-content-write-pacer-v2", + headSha: SHA, + repository: "f0rr0/oliphaunt", + rootRunId: "456", + coldStartMs: 0, + intervalMs: 50, + sequence: 1, + lastReservedAtMs: seedReservedAtMs, + lastLabel: "seed future slot", + reservations: [{ + label: "seed future slot", + reservedAtMs: seedReservedAtMs, + sequence: 1, + }], + })}\n`); const run = (index) => new Promise((resolve, reject) => { const child = spawn(process.execPath, [worker, String(index)], { env: environment, @@ -210,10 +238,32 @@ for (let attempt = 0; attempt < 4; attempt += 1) { await Promise.all(Array.from({ length: 5 }, (_, index) => run(index))); const pacerState = JSON.parse(readFileSync(pacer, "utf8")); const coreState = JSON.parse(readFileSync(core, "utf8")); - assert.equal(pacerState.sequence, 20); - assert.equal(pacerState.reservations.length, 20); + assert.equal(pacerState.sequence, 21); + assert.equal(pacerState.reservations.length, 21); assert.deepEqual(pacerState.reservations.map(({ sequence }) => sequence), - Array.from({ length: 20 }, (_, index) => index + 1)); + Array.from({ length: 21 }, (_, index) => index + 1)); + const laneReservations = pacerState.reservations.slice(1); + assert.deepEqual( + new Set(laneReservations.map(({ label }) => label)), + new Set(Array.from( + { length: 5 }, + (_, index) => Array.from({ length: 4 }, (__, attempt) => `asset-${index}-${attempt}`), + ).flat()), + ); + for (let index = 0; index < 5; index += 1) { + assert.deepEqual( + laneReservations + .map(({ label }) => label) + .filter((label) => label.startsWith(`asset-${index}-`)), + Array.from({ length: 4 }, (_, attempt) => `asset-${index}-${attempt}`), + ); + } + for (const [index, reservation] of pacerState.reservations.entries()) { + if (index === 0) continue; + assert.ok( + reservation.reservedAtMs >= pacerState.reservations[index - 1].reservedAtMs + 50, + ); + } assert.equal(coreState.sequence, 20); assert.equal(coreState.attempts.length, 20); assert.deepEqual( @@ -332,36 +382,61 @@ test("the live-derived 18-product/141-asset first release fits both GitHub hourl assert.equal(budget.preRegistryContentWrites, 185); assert.equal(budget.totalContentWrites, 209); assert.equal(FIRST_RELEASE_TRANSFER_REQUEST_TOTAL, 88); - assert.equal(FIRST_RELEASE_NOMINAL_CORE_REQUESTS, 421); - assert.equal(budget.conservativeCoreRequests, 421); + assert.equal(FIRST_RELEASE_NOMINAL_CORE_REQUESTS, 579); + assert.equal(budget.conservativeCoreRequests, 579); assert.equal(conservativeCoreRequestCount({ assetCount: 141, assetCounts, attestationWrites: 6, productCount, - }), 421); + }), 579); assert.equal(GITHUB_CONTENT_WRITES_PER_ROLLING_HOUR, 361); assert.equal(GITHUB_CONTENT_WRITES_PER_ROLLING_MINUTE, 7); + assert.equal( + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + 1, + "every admitted snapshot page has exactly one physical request attempt", + ); // Put every non-content request at the busiest possible instant. Even this // conservative concentration plus a full rolling hour of paced writes is // well inside the 1,000-request primary limit. // The SwiftPM git push is a paced content write but not a REST request, so // subtract only REST-backed writes from the nominal core request count. - assert.equal(budget.futureNonContentRequests, 153); - assert.equal(budget.projectedRollingCoreRequests, 674); + assert.equal(budget.futureNonContentRequests, 311); + assert.equal(budget.projectedRollingCoreRequests, 832); assert.ok(budget.projectedRollingCoreRequests < 900); assert.ok(500 - GITHUB_CONTENT_WRITES_PER_ROLLING_HOUR >= 130); assert.equal(budget.totalPacingMs, GITHUB_CONTENT_WRITE_COLD_START_MS + (209 * 10_000)); assert.ok((2 * productCount * GITHUB_CONTENT_WRITE_INTERVAL_MS) < (30 * 60_000), "draft staging fits its operation budget"); assert.ok((Math.max(...assetCounts.values()) * GITHUB_CONTENT_WRITE_INTERVAL_MS) < (20 * 60_000), "largest product pacing fits its count-derived upload budget"); - assert.ok((productCount * GITHUB_CONTENT_WRITE_INTERVAL_MS) < (12 * 60_000), "draft promotion fits finalization timeout"); + assert.ok( + (productCount * GITHUB_CONTENT_WRITE_INTERVAL_MS) + < GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, + "draft promotion pacing fits its command window", + ); + const worstCasePromotionMs = + ((1 + GITHUB_RELEASE_SNAPSHOT_MAX_READS) * GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS) + + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_WINDOW_MS + + (2 * GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS) + + ( + productCount + * ( + GITHUB_CONTENT_WRITE_INTERVAL_MS + + GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS + ) + ); + assert.ok( + worstCasePromotionMs < GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, + "two tag snapshots, one precondition read, every PATCH/pacing bound, and the complete " + + "post-promotion visibility observer fit the bounded promotion command", + ); assert.ok( RELEASE_PLEASE_ASSERT_MARKABLE_WINDOW_MS - + (productCount * GITHUB_CONTENT_WRITE_INTERVAL_MS) + + GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS + RELEASE_PLEASE_MARK_TAGGED_WINDOW_MS < RELEASE_FINALIZATION_STEP_TIMEOUT_MINUTES.promoteDrafts * 60_000, - "pre-promotion assertion, draft pacing, and lifecycle reconciliation fit the final step", + "the promotion command preserves a strict downstream lifecycle margin", ); assert.ok( budget.assetUploadPlan.productCount === 12 @@ -391,7 +466,7 @@ test("the live-derived 18-product/141-asset first release fits both GitHub hourl assert.equal(admitted.finalizationStepTimeoutSeconds, RELEASE_FINALIZATION_STEP_TIMEOUT_SECONDS); assert.equal(admitted.cleanupMarginSeconds, RELEASE_FINALIZATION_CLEANUP_MARGIN_SECONDS); assert.equal(admitted.minimumFinalizationSeconds, RELEASE_MINIMUM_FINALIZATION_SECONDS); - assert.equal(admitted.simulatedRegistryDeadlineEpochSeconds, 3_750); + assert.equal(admitted.simulatedRegistryDeadlineEpochSeconds, 3_510); assert.ok( admitted.simulatedRegistryDeadlineEpochSeconds < independentLaneUpperBoundSeconds, "the staging simulation honestly exercises dependency-closed subset admission instead of pretending every npm carrier fits", @@ -410,6 +485,31 @@ test("release-list admission scales at exact 100-row page boundaries", () => { ); }); +test("asset upload admission charges complete-list draft discovery for every product", () => { + const assetCounts = new Map([ + ["asset-backed", 1], + ["source-only", 0], + ]); + const requests = (releasePageCount) => conservativeCoreRequestCount({ + assetCount: 1, + assetCounts, + attestationWrites: 0, + productCount: 2, + releasePageCount, + transportTagWrites: 0, + }); + const draftManagementPagePasses = + 3 + ((5 + assetCounts.size) * GITHUB_RELEASE_SNAPSHOT_MAX_READS); + assert.equal( + requests(2) - requests(1), + draftManagementPagePasses + + assetCounts.size + + 2, + "one extra release-list page is charged for draft management, each uploader's initial " + + "tag-to-ID discovery, and both receipt snapshots", + ); +}); + test("admission rejects a job that cannot fit bounded pre-registry operations plus mandatory finalization", () => { const budget = contentWriteBudgetFromCounts({ assetCount: 1, diff --git a/tools/release/github-read.mjs b/tools/release/github-read.mjs index 03ef1e64..952e5cfe 100644 --- a/tools/release/github-read.mjs +++ b/tools/release/github-read.mjs @@ -659,6 +659,13 @@ export function runGitHubPaginatedJsonSync(endpoint, options = {}) { const label = options.label ?? "GitHub paginated JSON read"; const itemsField = options.itemsField ?? null; const maxPages = options.maxPages ?? GITHUB_PAGINATION_MAX_PAGES; + const settings = githubReadOptionsFromEnv(options.environment ?? process.env, options); + const now = settings.now ?? Date.now; + const paginationStartedAtMs = now(); + const paginationDeadlineMs = paginationStartedAtMs + settings.deadlineMs; + if (!Number.isSafeInteger(paginationDeadlineMs)) { + throw new GitHubReadError(`${label} pagination deadline exceeds the safe timestamp range`); + } if (!Number.isSafeInteger(maxPages) || maxPages < 1 || maxPages > GITHUB_PAGINATION_MAX_PAGES) { throw new GitHubReadError(`paginated JSON maxPages must be between 1 and ${GITHUB_PAGINATION_MAX_PAGES}`); } @@ -673,8 +680,32 @@ export function runGitHubPaginatedJsonSync(endpoint, options = {}) { query.set("page", String(page)); const pageEndpoint = `${expected.requestedPath.slice(1)}?${query.toString()}`; const pageLabel = `${label} page ${page}`; + const pageStartedAtMs = now(); + const remainingPaginationMs = paginationDeadlineMs - pageStartedAtMs; + if (remainingPaginationMs <= 0) { + throw new GitHubReadError(`${label}: pagination deadline exhausted before page ${page}`, { + deadlineExhausted: true, + retryable: true, + }); + } + let pageClockAnchored = false; + const pageNow = () => { + if (!pageClockAnchored) { + pageClockAnchored = true; + return pageStartedAtMs; + } + return now(); + }; const { data, link } = parseIncludedGithubJson( - runGitHubReadSync(["api", "--include", pageEndpoint], { ...options, label: pageLabel }), + runGitHubReadSync( + ["api", "--include", pageEndpoint], + { + ...options, + deadlineMs: remainingPaginationMs, + label: pageLabel, + now: pageNow, + }, + ), pageLabel, ); const pageRows = itemsField === null diff --git a/tools/release/github-read.test.mjs b/tools/release/github-read.test.mjs index 15f947c3..f6d42521 100644 --- a/tools/release/github-read.test.mjs +++ b/tools/release/github-read.test.mjs @@ -7,6 +7,8 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; + import { GitHubReadError, RetryableReadError, @@ -25,6 +27,7 @@ function deterministic(overrides = {}) { attemptTimeoutMs: 50, baseDelayMs: 10, deadlineMs: 1_000, + environment: {}, maxAttempts: 4, maxDelayMs: 40, now: () => time, @@ -463,12 +466,11 @@ test("CLI entrypoint runs through Bun with both repository-relative and absolute const script = path.resolve("tools/release/github-read.mjs"); const common = { encoding: "utf8", - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${temporary}${path.delimiter}${process.env.PATH}`, OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), }; for (const entrypoint of [path.relative(process.cwd(), script), script]) { const result = spawnSync( diff --git a/tools/release/github-release-asset-upload-plan.mjs b/tools/release/github-release-asset-upload-plan.mjs index bf9ac7cd..dd5646f8 100644 --- a/tools/release/github-release-asset-upload-plan.mjs +++ b/tools/release/github-release-asset-upload-plan.mjs @@ -6,10 +6,10 @@ import { } from "./upload_github_release_assets.mjs"; import { GITHUB_CONTENT_WRITE_INTERVAL_MS } from "./github-content-write-pacer.mjs"; -// The durable content-write pacer holds its filesystem lock while it waits for -// the next 10-second reservation. Keep a complete upload wave below the -// pacer's 60-second production lock-wait ceiling even when every lane reaches -// the pacer at once. A five-lane wave has at most four predecessors (40s). +// The durable content-write pacer allocates future request slots while holding +// its filesystem lock only for short state transitions. Keep uploader +// concurrency bounded so transport overlap, abort draining, and the complete +// wave deadline remain predictable. export const MAX_CONCURRENT_GITHUB_RELEASE_ASSET_PRODUCTS = 5; export const GITHUB_RELEASE_ASSET_WAVE_OVERHEAD_MS = 60_000; export const GITHUB_RELEASE_ASSET_SELECTION_VERIFY_MS = 60_000; diff --git a/tools/release/github-release-mutations.mjs b/tools/release/github-release-mutations.mjs index af6003c8..9ff87464 100644 --- a/tools/release/github-release-mutations.mjs +++ b/tools/release/github-release-mutations.mjs @@ -20,6 +20,15 @@ const FULL_SHA = /^[0-9a-f]{40}$/u; const MAX_MUTATION_CAPTURE_BYTES = 4 * 1024 * 1024; const RECONCILIATION_KINDS = new Set(["absent", "conflict", "desired", "unchanged"]); +export const GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS = + Object.freeze([2_000, 4_000, 8_000, 16_000, 30_000, 30_000]); +export const GITHUB_RELEASE_SNAPSHOT_MAX_READS = + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.length + 1; +export const GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS = 10_000; +export const GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS = 1; +export const GITHUB_RELEASE_SNAPSHOT_VISIBILITY_WINDOW_MS = + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.reduce((total, delay) => total + delay, 0); + export class GitHubReleaseMutationError extends Error { constructor(message, options = {}) { super(message, options); @@ -27,10 +36,22 @@ export class GitHubReleaseMutationError extends Error { } } +export class GitHubReleaseSnapshotRaceError extends GitHubReleaseMutationError { + constructor(message, { observedRelease = undefined, ...options } = {}) { + super(message, options); + this.name = "GitHubReleaseSnapshotRaceError"; + this.observedRelease = observedRelease; + } +} + function mutationError(message, options = {}) { return new GitHubReleaseMutationError(message, options); } +function releaseSnapshotRaceError(message, options = {}) { + return new GitHubReleaseSnapshotRaceError(message, options); +} + function nonNegativeInteger(environment, name, fallback, { maximum = Number.MAX_SAFE_INTEGER, minimum = 0 } = {}) { const raw = environment[name]; if (raw === undefined || raw === "") return fallback; @@ -265,11 +286,27 @@ export function readReleaseMapSync(repo, options = {}) { const label = "GitHub release list"; const releases = githubPaginatedArrayReadSync(repo, "releases", { ...options, label }); const byTag = new Map(); + const byId = new Map(); for (const release of releases) { assertReleaseShape(release); + const priorId = byId.get(release.id); + if (priorId !== undefined) { + const stableFields = ["body", "name", "prerelease", "tag_name", "target_commitish"]; + const changedField = stableFields.find((field) => priorId[field] !== release[field]); + if (changedField !== undefined) { + throw mutationError( + `GitHub returned release ${release.id} more than once with conflicting ${changedField} metadata`, + ); + } + throw releaseSnapshotRaceError( + `GitHub release list repeated release ${release.id} across one paginated snapshot`, + { observedRelease: release }, + ); + } if (byTag.has(release.tag_name)) { throw mutationError(`GitHub returned duplicate releases for tag ${release.tag_name}`); } + byId.set(release.id, release); byTag.set(release.tag_name, release); } return byTag; diff --git a/tools/release/github-release-mutations.test.mjs b/tools/release/github-release-mutations.test.mjs index 89f74e25..cfa2b455 100644 --- a/tools/release/github-release-mutations.test.mjs +++ b/tools/release/github-release-mutations.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { createGitHubOperationBudget, + GitHubReleaseSnapshotRaceError, githubPaginatedArrayReadSync, githubJsonReadSync, githubOptionalJsonReadSync, @@ -343,6 +344,74 @@ test("each paginated REST page retry is independently journaled and exact 200 ro ); }); +test("a repeated release id across pagination is the only retryable snapshot race shape", () => { + const next = "https://api.github.com/repositories/42/releases?per_page=100&page=2"; + const spawnWithSecondPage = (rows) => (_command, args) => { + const endpoint = args.at(-1); + if (endpoint.endsWith("page=1")) { + return { + status: 0, + stderr: "", + stdout: includedJson(releaseRows(1, 100), `<${next}>; rel="next"`), + }; + } + return { status: 0, stderr: "", stdout: includedJson(rows) }; + }; + assert.throws( + () => readReleaseMapSync("o/r", { + baseDelayMs: 0, + deadlineMs: 1_000, + maxAttempts: 1, + spawn: spawnWithSecondPage(releaseRows(100, 1)), + }), + (cause) => + cause instanceof GitHubReleaseSnapshotRaceError + && /repeated release 100 across one paginated snapshot/u.test(cause.message), + ); + + const conflictingTag = { + ...releaseRows(101, 1)[0], + tag_name: "v100", + }; + assert.throws( + () => readReleaseMapSync("o/r", { + baseDelayMs: 0, + deadlineMs: 1_000, + maxAttempts: 1, + spawn: spawnWithSecondPage([conflictingTag]), + }), + (cause) => + !(cause instanceof GitHubReleaseSnapshotRaceError) + && /duplicate releases for tag v100/u.test(cause.message), + ); +}); + +test("one pagination deadline is shared across every physical page", () => { + let nowMs = 0; + const endpoints = []; + assert.throws( + () => githubPaginatedArrayReadSync("o/r", "releases", { + baseDelayMs: 0, + deadlineMs: 100, + maxAttempts: 1, + now: () => nowMs, + spawn: (_command, args) => { + const endpoint = args.at(-1); + endpoints.push(endpoint); + nowMs = 101; + const next = "https://api.github.com/repositories/42/releases?per_page=100&page=2"; + return { + status: 0, + stderr: "", + stdout: includedJson(releaseRows(1, 100), `<${next}>; rel="next"`), + }; + }, + }), + /pagination deadline exhausted before page 2/u, + ); + assert.deepEqual(endpoints, ["repos/o/r/releases?per_page=100&page=1"]); +}); + test("paginated reads reject cross-endpoint and query-mutating next links", () => { const invoke = (next) => githubPaginatedArrayReadSync("o/r", "releases", { baseDelayMs: 0, diff --git a/tools/release/github-release-request-budget.mjs b/tools/release/github-release-request-budget.mjs index e9edbb2f..40291c3b 100644 --- a/tools/release/github-release-request-budget.mjs +++ b/tools/release/github-release-request-budget.mjs @@ -26,7 +26,11 @@ import { readGitHubCoreRequestJournal, } from "./github-core-request-journal.mjs"; import { isExtensionProduct, loadGraph } from "./release-graph.mjs"; -import { readReleaseMapSync } from "./github-release-mutations.mjs"; +import { + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + GITHUB_RELEASE_SNAPSHOT_MAX_READS, + readReleaseMapSync, +} from "./github-release-mutations.mjs"; import { loadPublicationLock, lockedProductArtifactPaths, @@ -75,7 +79,7 @@ export const FIRST_RELEASE_TRANSFER_REQUESTS = Object.freeze({ export const FIRST_RELEASE_TRANSFER_REQUEST_TOTAL = Object.values( FIRST_RELEASE_TRANSFER_REQUESTS, ).reduce((total, count) => total + count, 0); -export const FIRST_RELEASE_RELEASE_API_REQUESTS = 299; +export const FIRST_RELEASE_RELEASE_API_REQUESTS = 457; export const FIRST_RELEASE_ATTESTATION_API_REQUESTS = 6; export const FIRST_RELEASE_TRANSPORT_REF_WRITE_REQUESTS = 1; export const RELEASE_PLEASE_ASSERT_MARKABLE_API_REQUESTS = 3; @@ -136,16 +140,33 @@ export function conservativeCoreRequestCount({ return total + count; }, 0); if (countedAssets !== assetCount) fail("assetCount disagrees with per-product assetCounts"); - // Preflight, stage (before/after), verify, and promotion (before/after) each - // read the complete release list. Tag/release/promotion mutations are one - // content request per product. - const draftManagementRequests = (3 * productCount) + (6 * releasePageCount); + // Preflight, stage-before, and promotion-before each read one complete + // release snapshot. Post-stage, three workflow verification points, and + // promotion-after each require a semantically complete snapshot and may + // consume the full bounded visibility retry schedule. Every draft POST may + // also lose its response and consume one complete no-replay observation + // schedule. + // Tag/release/promotion mutations are one content request per product. + const draftManagementPagePasses = + 3 + ((5 + productCount) * GITHUB_RELEASE_SNAPSHOT_MAX_READS); + const draftManagementRequests = + (3 * productCount) + + ( + draftManagementPagePasses + * releasePageCount + * GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS + ); // Exact release-asset inventory never trusts an embedded release row. A // first publication snapshots nonempty products before and after upload; - // an empty product needs one proof. Charge every selected product even if a - // current publisher happens not to call the empty proof path. + // an empty product needs one proof. Initial tag-to-ID discovery reads the + // complete release list so drafts are visible; later snapshots use the + // immutable release ID. Charge every selected product even if a current + // publisher happens not to call the empty proof path. const releaseAssetRequests = assetCount + [...assetCounts.values()].reduce( - (total, count) => total + ((count > 0 ? 2 : 1) * (1 + pagesForRows(count))), + (total, count) => total + + releasePageCount + + pagesForRows(count) + + (count > 0 ? 1 + pagesForRows(count) : 0), 0, ); // Both pre-mutation and final receipts read all release pages and one exact diff --git a/tools/release/isolated-github-test-environment.test.mjs b/tools/release/isolated-github-test-environment.test.mjs new file mode 100644 index 00000000..c3ff2838 --- /dev/null +++ b/tools/release/isolated-github-test-environment.test.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; + +test("synthetic GitHub fixtures discard hostile credentials, state, lineage, and tuning", () => { + const inherited = { + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "live-oidc-token", + BOOTSTRAP_LEDGER_PATH: "/live/bootstrap-ledger", + CI_RUN_ID: "30358387218", + GH_TOKEN: "live-gh-token", + GITHUB_OUTPUT: "/live/github-output", + GITHUB_REPOSITORY: "live/repository", + GITHUB_RUN_ATTEMPT: "7", + GITHUB_RUN_ID: "123", + GITHUB_SHA: "a".repeat(40), + GITHUB_TOKEN: "live-github-token", + KEEP_ME: "preserved", + OLIPHAUNT_GITHUB_CONTENT_WRITE_COLD_START_EPOCH: "1", + OLIPHAUNT_GITHUB_CONTENT_WRITE_PACER_PATH: "/live/pacer.json", + OLIPHAUNT_GITHUB_CONTENT_WRITE_PACER_TEST_MODE: "true", + OLIPHAUNT_GITHUB_CORE_REQUEST_JOURNAL_PATH: "/live/journal.json", + OLIPHAUNT_GITHUB_READ_ATTEMPT_TIMEOUT_MS: "1", + OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "2", + OLIPHAUNT_GITHUB_READ_DEADLINE_MS: "3", + OLIPHAUNT_GITHUB_READ_MAX_ATTEMPTS: "4", + OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "5", + OLIPHAUNT_GITHUB_RUN_SNAPSHOT_DIR: "/live/snapshots", + OLIPHAUNT_RELEASE_FUTURE_CONTROL: "live-future-control", + OLIPHAUNT_RELEASE_ROOT_RUN_ID: "123", + OLIPHAUNT_REQUIRE_GITHUB_CORE_REQUEST_JOURNAL: "true", + RELEASE_CONTINUATION_ARCHIVE: "/live/continuation.zip", + RELEASE_CONTINUATION_POINTER: '{"live":true}', + RELEASE_CONTINUATION_STATE_PATH: "/live/continuation-state.json", + RELEASE_FINALIZATION_RESERVE_SECONDS: "3000", + RELEASE_FUTURE_CONTROL: "live-future-control", + RELEASE_HEAD_SHA: "a".repeat(40), + RELEASE_JOB_HARD_WINDOW_SECONDS: "21180", + RELEASE_OPERATION: "publish", + RELEASE_PR_TOKEN: "live-release-token", + RELEASE_ROOT_RUN_ID: "123", + RELEASE_TRANSPORT_CONTENT_WRITE_ADMISSION: "pre-reserved", + }; + const environment = isolatedGitHubTestEnvironment( + { + BOOTSTRAP_LEDGER_PATH: "/fixture/bootstrap-ledger", + GH_TOKEN: "fixture-token", + GITHUB_RUN_ID: "900", + OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", + RELEASE_HEAD_SHA: "b".repeat(40), + }, + inherited, + ); + + assert.deepEqual(environment, { + BOOTSTRAP_LEDGER_PATH: "/fixture/bootstrap-ledger", + GH_TOKEN: "fixture-token", + GITHUB_RUN_ID: "900", + KEEP_ME: "preserved", + OLIPHAUNT_GITHUB_READ_BASE_DELAY_MS: "0", + RELEASE_HEAD_SHA: "b".repeat(40), + }); + assert.deepEqual(inherited.GH_TOKEN, "live-gh-token", "the inherited environment must not be mutated"); +}); + +test("future GitHub and release namespace entries are isolated without an enumerated denylist", () => { + const inherited = { + ACTIONS_FUTURE_CREDENTIAL: "live", + GH_FUTURE_CREDENTIAL: "live", + GITHUB_FUTURE_IDENTITY: "live", + OLIPHAUNT_GITHUB_FUTURE_STATE: "live", + OLIPHAUNT_RELEASE_FUTURE_STATE: "live", + RELEASE_FUTURE_STATE: "live", + RUNNER_TEMP: "/preserved/runner-temp", + }; + + assert.deepEqual( + isolatedGitHubTestEnvironment({}, inherited), + { RUNNER_TEMP: "/preserved/runner-temp" }, + ); +}); + +test("environment isolation rejects non-object inputs instead of silently widening inheritance", () => { + assert.throws(() => isolatedGitHubTestEnvironment([], {}), /environment overrides/u); + assert.throws(() => isolatedGitHubTestEnvironment({}, null), /inherited environment/u); +}); diff --git a/tools/release/manage-release-drafts.test.mjs b/tools/release/manage-release-drafts.test.mjs index efcd4d8b..8009b615 100644 --- a/tools/release/manage-release-drafts.test.mjs +++ b/tools/release/manage-release-drafts.test.mjs @@ -7,8 +7,12 @@ import test from "node:test"; import { assertResumableReleaseMetadata, + createReleaseDraftOperationBudget, exactReleaseMetadata, exactTagRefPayload, + GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, + GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS, + GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS, promoteExactReleaseSync, readSelectedRemoteTagMapSync, reconcileSelectedReleasesSync, @@ -16,6 +20,12 @@ import { stageExactDraftReleaseSync, stageExactTagSync, } from "../../.github/scripts/manage-release-drafts.mjs"; +import { + GitHubReleaseSnapshotRaceError, + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS, + GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS, +} from "./github-release-mutations.mjs"; function budget() { return { deadlineMs: 180_000, environment: {}, now: () => 0, startedAtMs: 0 }; @@ -279,6 +289,7 @@ test("batch promotion resumes an exact partially public release set without repl readRelease: (tag) => releases.get(tag) ?? null, readReleaseMap: () => new Map(releases), readTagMap: () => new Map(tags), + releaseSnapshotSleep: () => {}, }; const reconcile = () => reconcileSelectedReleasesSync({ budget: budget(), @@ -290,16 +301,23 @@ test("batch promotion resumes an exact partially public release set without repl selected, }, dependencies); - assert.throws(reconcile, /exhausted 3 mutation attempt.*runner interruption/u); + assert.throws( + reconcile, + (cause) => + cause.cause instanceof Error + && cause.cause.message === "simulated runner interruption before PATCH" + && /did not converge to public state/u.test(cause.message) + && /first failure for/u.test(cause.message), + ); assert.equal(releases.get(selected[0].tag).draft, false); assert.equal(releases.get(interruptedTag).draft, true); - assert.equal(releases.get(selected[2].tag).draft, true); + assert.equal(releases.get(selected[2].tag).draft, false); interrupted = false; assert.doesNotThrow(reconcile); assert.ok([...releases.values()].every(({ draft }) => draft === false)); assert.equal(mutations.filter((tag) => tag === selected[0].tag).length, 1); - assert.equal(mutations.filter((tag) => tag === interruptedTag).length, 5); + assert.equal(mutations.filter((tag) => tag === interruptedTag).length, 2); assert.equal(mutations.filter((tag) => tag === selected[2].tag).length, 1); }); @@ -513,6 +531,558 @@ test("the 49-product first release stays inside a bounded GitHub REST request bu assert.ok(restRequests < 200); }); +test("batch staging waits for the complete draft list without replaying successful POSTs", () => { + const selected = selection(2); + const headRef = "d".repeat(40); + const tags = new Map(selected.map(({ tag }) => [tag, null])); + const releases = new Map(); + const mutations = []; + const sleeps = []; + let releaseListReads = 0; + const dependencies = { + mutationOptions, + mutateRelease: ({ metadata }) => { + mutations.push(metadata.tag_name); + const release = { ...metadata, draft: true, id: releases.size + 1 }; + releases.set(metadata.tag_name, release); + return JSON.stringify(release); + }, + mutateTag: ({ headRef: target, tag }) => { + const ref = { ref: `refs/tags/${tag}`, sha: target, type: "commit" }; + tags.set(tag, ref); + return JSON.stringify({ ref: ref.ref, object: { sha: ref.sha, type: ref.type } }); + }, + readReleaseMap: () => { + releaseListReads += 1; + if (releaseListReads === 1) return new Map(); + if (releaseListReads <= 4) { + return new Map([...releases].filter(([tag]) => tag !== selected.at(-1).tag)); + } + return new Map(releases); + }, + readTagMap: () => new Map(tags), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "stage", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, dependencies); + + assert.deepEqual(mutations, selected.map(({ tag }) => tag)); + assert.equal(releaseListReads, 5); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.slice(0, 3)); +}); + +test("an applied draft POST with a lost response is observed before any replay", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const tag = selected[0].tag; + const metadata = selected[0].metadata; + const tags = new Map([[tag, null]]); + const releases = new Map(); + const sleeps = []; + let releaseListReads = 0; + let releaseMutations = 0; + const dependencies = { + mutationOptions, + mutateRelease: () => { + releaseMutations += 1; + releases.set(tag, { ...metadata, draft: true, id: 1 }); + throw new Error("response lost after draft creation"); + }, + mutateTag: ({ headRef: target }) => { + const ref = { ref: `refs/tags/${tag}`, sha: target, type: "commit" }; + tags.set(tag, ref); + return JSON.stringify({ ref: ref.ref, object: { sha: ref.sha, type: ref.type } }); + }, + readRelease: () => { + throw new Error("draft recovery must not use the by-tag REST endpoint"); + }, + readReleaseMap: () => { + releaseListReads += 1; + if (releaseListReads <= 3) return new Map(); + return new Map(releases); + }, + readTagMap: () => new Map(tags), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "stage", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, dependencies); + + assert.equal(releaseMutations, 1); + assert.equal(releaseListReads, 5); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.slice(0, 2)); +}); + +test("failed draft visibility preserves the original mutation cause without replay", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const tag = selected[0].tag; + const tags = new Map([[tag, null]]); + const mutationFailure = new Error("HTTP 503 after draft POST"); + let releaseMutations = 0; + const dependencies = { + mutationOptions, + mutateRelease: () => { + releaseMutations += 1; + throw mutationFailure; + }, + mutateTag: ({ headRef: target }) => { + const ref = { ref: `refs/tags/${tag}`, sha: target, type: "commit" }; + tags.set(tag, ref); + return JSON.stringify({ ref: ref.ref, object: { sha: ref.sha, type: ref.type } }); + }, + readReleaseMap: () => new Map(), + readTagMap: () => new Map(tags), + releaseSnapshotSleep: () => {}, + }; + + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "stage", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, dependencies), + (cause) => + cause.cause === mutationFailure + && /original draft mutation failure: HTTP 503 after draft POST/u.test(cause.message) + && /did not converge to staged state/u.test(cause.message), + ); + assert.equal(releaseMutations, 1); +}); + +test("required snapshots retry only a recognized duplicate-across-pagination race", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const releases = releaseState(selected); + const tags = tagState(selected, headRef); + const sleeps = []; + let reads = 0; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, { + readReleaseMap: () => { + reads += 1; + if (reads === 1) { + throw new GitHubReleaseSnapshotRaceError( + "GitHub release list repeated release 1 across one paginated snapshot", + ); + } + return new Map(releases); + }, + readTagMap: () => new Map(tags), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }); + + assert.equal(reads, 2); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.slice(0, 1)); +}); + +test("release-list snapshots receive a strict complete-read budget", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const releases = releaseState(selected); + const tags = tagState(selected, headRef); + const readOptions = []; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, { + readReleaseMap: (_repo, options) => { + readOptions.push(options); + return new Map(releases); + }, + readTagMap: () => new Map(tags), + }); + + assert.equal(GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, 645_000); + assert.equal( + createReleaseDraftOperationBudget("promote", { + environment: { OLIPHAUNT_GITHUB_MUTATION_WINDOW_MS: "3600000" }, + now: () => 0, + }).deadlineMs, + GITHUB_RELEASE_PROMOTION_COMMAND_WINDOW_MS, + ); + assert.equal( + createReleaseDraftOperationBudget("promote", { + environment: { OLIPHAUNT_GITHUB_MUTATION_WINDOW_MS: "123000" }, + now: () => 0, + }).deadlineMs, + 123_000, + ); + assert.equal(readOptions.length, 1); + assert.equal(readOptions[0].deadlineMs, GITHUB_RELEASE_SNAPSHOT_READ_WINDOW_MS); + assert.equal(readOptions[0].attemptTimeoutMs, 4_000); + assert.equal( + readOptions[0].maxAttempts, + GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, + ); + assert.equal(GITHUB_RELEASE_SNAPSHOT_MAX_READ_ATTEMPTS, 1); +}); + +test("promotion uses one bounded precondition snapshot and does not wait before mutation", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + let releaseListReads = 0; + let promotionMutations = 0; + let sleeps = 0; + + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "promote", + environment: {}, + expectedState: "public", + headRef, + repo: "o/r", + selected, + }, { + mutatePromotion: () => { + promotionMutations += 1; + }, + readReleaseMap: () => { + releaseListReads += 1; + return new Map(); + }, + readTagMap: () => tagState(selected, headRef), + releaseSnapshotSleep: () => { + sleeps += 1; + }, + }), + /does not exist/u, + ); + + assert.equal(releaseListReads, 1); + assert.equal(promotionMutations, 0); + assert.equal(sleeps, 0); +}); + +test("promotion mutation and tag snapshot transports use their conservative sub-bounds", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const releases = releaseState(selected); + const observedMutationTimeouts = []; + const observedTagTimeouts = []; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "promote", + environment: {}, + expectedState: "public", + headRef, + repo: "o/r", + selected, + }, { + mutatePromotion: ({ metadata, timeoutMs }) => { + observedMutationTimeouts.push(timeoutMs); + const promoted = { ...releases.get(metadata.tag_name), draft: false }; + releases.set(metadata.tag_name, promoted); + return JSON.stringify(promoted); + }, + readReleaseMap: () => new Map(releases), + readTagMap: (_repo, _selected, options) => { + observedTagTimeouts.push(options?.timeoutMs); + return tagState(selected, headRef); + }, + }); + + assert.deepEqual(observedMutationTimeouts, [ + GITHUB_RELEASE_PROMOTION_MUTATION_TIMEOUT_MS, + ]); + assert.deepEqual(observedTagTimeouts, [ + GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS, + GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS, + ]); + assert.equal(GITHUB_RELEASE_PROMOTION_TAG_SNAPSHOT_TIMEOUT_MS, 30_000); +}); + +test("verification waits for a semantically complete release snapshot without mutation", () => { + const selected = selection(2); + const headRef = "d".repeat(40); + const releases = releaseState(selected); + const tags = tagState(selected, headRef); + const sleeps = []; + let releaseListReads = 0; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }, { + readReleaseMap: () => { + releaseListReads += 1; + if (releaseListReads <= 2) { + return new Map([...releases].filter(([tag]) => tag !== selected.at(-1).tag)); + } + return new Map(releases); + }, + readTagMap: () => new Map(tags), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }); + + assert.equal(releaseListReads, 3); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.slice(0, 2)); +}); + +test("promotion waits for stale draft rows to become public without replaying PATCH", () => { + const selected = selection(2); + const headRef = "d".repeat(40); + const tags = tagState(selected, headRef); + const releases = releaseState(selected); + const mutations = []; + const sleeps = []; + let releaseListReads = 0; + const dependencies = { + mutationOptions, + mutatePromotion: ({ expectedId, metadata }) => { + const release = releases.get(metadata.tag_name); + assert.equal(release.id, expectedId); + mutations.push(metadata.tag_name); + const promoted = { ...release, draft: false }; + releases.set(metadata.tag_name, promoted); + return JSON.stringify(promoted); + }, + readReleaseMap: () => { + releaseListReads += 1; + if (releaseListReads === 1 || releaseListReads >= 4) return new Map(releases); + const stale = new Map(releases); + const finalTag = selected.at(-1).tag; + stale.set(finalTag, { ...stale.get(finalTag), draft: true }); + return stale; + }, + readTagMap: () => new Map(tags), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }; + + reconcileSelectedReleasesSync({ + budget: budget(), + command: "promote", + environment: {}, + expectedState: "public", + headRef, + repo: "o/r", + selected, + }, dependencies); + + assert.deepEqual(mutations, selected.map(({ tag }) => tag)); + assert.equal(releaseListReads, 4); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.slice(0, 2)); +}); + +test("promotion rejects a same-metadata release id replacement before lifecycle closure", () => { + const selected = selection(1); + const headRef = "d".repeat(40); + const original = releaseState(selected); + const replacement = new Map([[ + selected[0].tag, + { ...original.get(selected[0].tag), draft: false, id: 99 }, + ]]); + let releaseListReads = 0; + let promotionMutations = 0; + let sleeps = 0; + + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "promote", + environment: {}, + expectedState: "public", + headRef, + repo: "o/r", + selected, + }, { + mutatePromotion: ({ expectedId, metadata }) => { + promotionMutations += 1; + return JSON.stringify({ + ...original.get(metadata.tag_name), + draft: false, + id: expectedId, + }); + }, + readReleaseMap: () => { + releaseListReads += 1; + return releaseListReads === 1 ? new Map(original) : new Map(replacement); + }, + readTagMap: () => tagState(selected, headRef), + releaseSnapshotSleep: () => { + sleeps += 1; + }, + }), + /release id changed from 1 to 99/u, + ); + + assert.equal(releaseListReads, 2); + assert.equal(promotionMutations, 1); + assert.equal(sleeps, 0); +}); + +test("required release snapshots fail boundedly when state never becomes visible", () => { + const selected = selection(1); + const sleeps = []; + let releaseListReads = 0; + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef: "d".repeat(40), + repo: "o/r", + selected, + }, { + readReleaseMap: () => { + releaseListReads += 1; + return new Map(); + }, + readTagMap: () => tagState(selected, "d".repeat(40)), + releaseSnapshotSleep: (milliseconds) => sleeps.push(milliseconds), + }), + new RegExp( + `did not converge to staged state.*${selected[0].tag} \\(missing\\)`, + "u", + ), + ); + assert.equal(releaseListReads, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS.length + 1); + assert.deepEqual(sleeps, GITHUB_RELEASE_SNAPSHOT_VISIBILITY_DELAYS_MS); +}); + +test("required release snapshots reject conflicting metadata without waiting", () => { + const selected = selection(1); + let sleeps = 0; + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef: "d".repeat(40), + repo: "o/r", + selected, + }, { + readReleaseMap: () => new Map([[ + selected[0].tag, + { ...selected[0].metadata, body: "conflict", draft: true, id: 1 }, + ]]), + readTagMap: () => tagState(selected, "d".repeat(40)), + releaseSnapshotSleep: () => { + sleeps += 1; + }, + }), + /conflicts with frozen release metadata/u, + ); + assert.equal(sleeps, 0); + + assert.throws( + () => reconcileSelectedReleasesSync({ + budget: budget(), + command: "verify", + environment: {}, + expectedState: "staged", + headRef: "d".repeat(40), + repo: "o/r", + selected, + }, { + readReleaseMap: () => { + const observedRelease = { + ...selected[0].metadata, + body: "conflict", + draft: true, + id: 1, + }; + throw new GitHubReleaseSnapshotRaceError( + "duplicate across pages", + { observedRelease }, + ); + }, + readTagMap: () => tagState(selected, "d".repeat(40)), + releaseSnapshotSleep: () => { + sleeps += 1; + }, + }), + /conflicts with frozen release metadata/u, + ); + assert.equal(sleeps, 0); +}); + +test("same-version recovery preflight requires every selected tag and release to be absent", () => { + const selected = selection(2); + const headRef = "d".repeat(40); + const absentTags = new Map(selected.map(({ tag }) => [tag, null])); + const context = { + budget: budget(), + command: "recovery-preflight", + environment: {}, + expectedState: "staged", + headRef, + repo: "o/r", + selected, + }; + assert.doesNotThrow(() => reconcileSelectedReleasesSync(context, { + readReleaseMap: () => new Map(), + readTagMap: () => new Map(absentTags), + })); + + const existingTag = new Map(absentTags); + existingTag.set(selected[0].tag, { + ref: `refs/tags/${selected[0].tag}`, + sha: headRef, + type: "commit", + }); + assert.throws( + () => reconcileSelectedReleasesSync(context, { + readReleaseMap: () => new Map(), + readTagMap: () => existingTag, + }), + /requires every selected product tag and GitHub release to be absent/u, + ); + + assert.throws( + () => reconcileSelectedReleasesSync(context, { + readReleaseMap: () => new Map([[ + selected[0].tag, + { ...selected[0].metadata, draft: true, id: 1 }, + ]]), + readTagMap: () => new Map(absentTags), + }), + /requires every selected product tag and GitHub release to be absent/u, + ); +}); + test("batch staging reconciles ambiguous responses once and exact reruns issue no mutations", () => { const selected = selection(1); const headRef = "d".repeat(40); diff --git a/tools/release/record-normal-publication-capacity-deferral.mjs b/tools/release/record-normal-publication-capacity-deferral.mjs index e05ca992..54ab5ea1 100644 --- a/tools/release/record-normal-publication-capacity-deferral.mjs +++ b/tools/release/record-normal-publication-capacity-deferral.mjs @@ -28,6 +28,7 @@ import { stableJson, validateReleaseExecutionResult, } from "./release-continuation-contract.mjs"; +import { resolveReleaseSourceCommit } from "./release-source-identity.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const DEFAULT_RESULT = "target/release/normal-publication-execution-result.json"; @@ -188,7 +189,11 @@ function atomicJson(file, value) { export async function main(environment = process.env) { const selectedProducts = products(required("PRODUCTS_JSON", environment)); - const releaseCommit = required("RELEASE_HEAD_SHA", environment); + const releaseControlCommit = required("RELEASE_HEAD_SHA", environment); + const releaseSourceCommit = resolveReleaseSourceCommit({ + controlCommit: releaseControlCommit, + sourceCommit: environment.RELEASE_SOURCE_SHA, + }, { prefix: "record-normal-publication-capacity-deferral" }); const admission = validateCapacityDeferralAdmission({ authoritativeWindowSeconds: positiveInteger( required("NORMAL_REGISTRY_MUTATION_WINDOW_SECONDS", environment), @@ -208,7 +213,7 @@ export async function main(environment = process.env) { }); const lockFile = path.resolve(ROOT, required("PUBLICATION_LOCK_PATH", environment)); const lock = loadPublicationLock(lockFile); - assertPublicationLockSource(lock, releaseCommit); + assertPublicationLockSource(lock, releaseSourceCommit); const plan = normalPublicationPlan(lock, selectedProducts); const frozenPlanFile = path.resolve( ROOT, diff --git a/tools/release/recovery-promotion-attestation.mjs b/tools/release/recovery-promotion-attestation.mjs new file mode 100644 index 00000000..9018f023 --- /dev/null +++ b/tools/release/recovery-promotion-attestation.mjs @@ -0,0 +1,1371 @@ +#!/usr/bin/env bun + +import { createHash, randomUUID } from "node:crypto"; +import { + appendFileSync, + linkSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + SAME_VERSION_RECOVERY_SOURCES_SCHEMA, + canonicalRecoverySourceJson, + selectSameVersionRecoverySource, + validateSameVersionRecoverySource, +} from "./same-version-recovery-source.mjs"; +import { + RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA, +} from "./verify-release-recovery-lock.mjs"; + +const TOOL = "recovery-promotion-attestation.mjs"; +const PUBLICATION_LOCK_SCHEMA = "oliphaunt-publication-lock-v1"; +const IN_TOTO_STATEMENT_V1 = "https://in-toto.io/Statement/v1"; +const CI_WORKFLOW = ".github/workflows/ci.yml"; +const RELEASE_WORKFLOW = ".github/workflows/release.yml"; +const MAX_JSON_BYTES = 16 * 1024 * 1024; +const MAX_ATTESTATION_SUBJECTS = 1_024; +const MAX_QUALIFICATION_ARTIFACTS = 1_024; +const SHA = /^[0-9a-f]{40}$/u; +const HASH = /^[0-9a-f]{64}$/u; +const ACTIONS_ARTIFACT_DIGEST = /^sha256:[0-9a-f]{64}$/u; +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u; +const GITHUB_RELEASE_ARTIFACT_ROLES = new Set([ + "github-release-asset", + "github-release-metadata", +]); + +export const RECOVERY_PROMOTION_PREDICATE_SCHEMA = + "oliphaunt-same-version-recovery-promotion-v1"; +export const RECOVERY_PROMOTION_PREDICATE_TYPE = + "https://github.com/f0rr0/oliphaunt/attestations/same-version-recovery-promotion/v1"; + +function error(message) { + return new Error(`${TOOL}: ${message}`); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, canonical(value[key])]), + ); + } + return value; +} + +export function canonicalRecoveryPromotionJson(value) { + return JSON.stringify(canonical(value)); +} + +export function prettyCanonicalRecoveryPromotionJson(value) { + return `${JSON.stringify(canonical(value), null, 2)}\n`; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function digestValue(value) { + return sha256(canonicalRecoveryPromotionJson(value)); +} + +function isPlainObject(value) { + return ( + value !== null + && !Array.isArray(value) + && typeof value === "object" + && [Object.prototype, null].includes(Object.getPrototypeOf(value)) + ); +} + +function strictObject(value, expectedKeys, context, { canonicalOrder = false } = {}) { + if (!isPlainObject(value)) { + throw error(`${context} must be a plain object`); + } + const expected = [...expectedKeys].sort(compareText); + const actual = Object.keys(value); + if ( + canonicalRecoveryPromotionJson([...actual].sort(compareText)) + !== canonicalRecoveryPromotionJson(expected) + ) { + throw error( + `${context} must contain exactly ${expected.join(", ")}; ` + + `got ${actual.join(", ") || ""}`, + ); + } + if ( + canonicalOrder + && canonicalRecoveryPromotionJson(actual) + !== canonicalRecoveryPromotionJson(expected) + ) { + throw error(`${context} keys must be in canonical order`); + } + return value; +} + +function assertCanonicalObjectOrder(value, context) { + if (Array.isArray(value)) { + value.forEach((entry, index) => + assertCanonicalObjectOrder(entry, `${context}[${index}]`)); + return; + } + if (!isPlainObject(value)) return; + const actual = Object.keys(value); + const expected = [...actual].sort(compareText); + if ( + canonicalRecoveryPromotionJson(actual) + !== canonicalRecoveryPromotionJson(expected) + ) { + throw error(`${context} keys must be in canonical order`); + } + for (const key of actual) { + assertCanonicalObjectOrder(value[key], `${context}.${key}`); + } +} + +function requireSha(value, context) { + if (typeof value !== "string" || !SHA.test(value)) { + throw error(`${context} must be a lowercase full Git SHA`); + } + return value; +} + +function requireHash(value, context) { + if (typeof value !== "string" || !HASH.test(value)) { + throw error(`${context} must be a lowercase SHA-256 digest`); + } + return value; +} + +function positiveInteger(value, context) { + if (!Number.isSafeInteger(value) || value < 1) { + throw error(`${context} must be a positive safe integer`); + } + return value; +} + +function normalizeSource(value, context) { + strictObject(value, ["commit", "tree"], context); + return canonical({ + commit: requireSha(value.commit, `${context}.commit`), + tree: requireSha(value.tree, `${context}.tree`), + }); +} + +function normalizeQualificationArtifact(value, context) { + strictObject(value, ["digest", "id", "name", "size"], context); + if (!ACTIONS_ARTIFACT_DIGEST.test(value.digest ?? "")) { + throw error(`${context}.digest must be a lowercase sha256: Actions artifact digest`); + } + if (typeof value.name !== "string" || !SAFE_ARTIFACT_NAME.test(value.name)) { + throw error(`${context}.name must be a safe Actions artifact name`); + } + return canonical({ + digest: value.digest, + id: positiveInteger(value.id, `${context}.id`), + name: value.name, + size: positiveInteger(value.size, `${context}.size`), + }); +} + +function compareArtifacts(left, right) { + return compareText(left.name, right.name) || left.id - right.id; +} + +function normalizeQualificationArtifacts(value, context, { requireCanonical = false } = {}) { + if ( + !Array.isArray(value) + || value.length < 1 + || value.length > MAX_QUALIFICATION_ARTIFACTS + ) { + throw error( + `${context} must contain 1-${MAX_QUALIFICATION_ARTIFACTS} artifacts`, + ); + } + const normalized = value + .map((artifact, index) => + normalizeQualificationArtifact(artifact, `${context}[${index}]`)) + .sort(compareArtifacts); + const ids = new Set(); + const names = new Set(); + for (const artifact of normalized) { + if (ids.has(artifact.id)) { + throw error(`${context} repeats Actions artifact id ${artifact.id}`); + } + if (names.has(artifact.name)) { + throw error(`${context} repeats Actions artifact name ${artifact.name}`); + } + ids.add(artifact.id); + names.add(artifact.name); + } + for (const required of ["artifact-build-plan", "oliphaunt-release-candidate"]) { + if (!names.has(required)) { + throw error(`${context} must bind required candidate artifact ${required}`); + } + } + if ( + requireCanonical + && canonicalRecoveryPromotionJson(value) + !== canonicalRecoveryPromotionJson(normalized) + ) { + throw error(`${context} must be in canonical name/id order`); + } + return normalized; +} + +function normalizeApprovalArtifacts(value, context, { requireCanonical = false } = {}) { + if (!Array.isArray(value) || value.length !== 1) { + throw error(`${context} must contain exactly one recovery-equivalence artifact`); + } + const normalized = [ + normalizeQualificationArtifact(value[0], `${context}[0]`), + ]; + if (normalized[0].name !== "oliphaunt-release-recovery-equivalence") { + throw error( + `${context} must identify oliphaunt-release-recovery-equivalence`, + ); + } + if ( + requireCanonical + && canonicalRecoveryPromotionJson(value) + !== canonicalRecoveryPromotionJson(normalized) + ) { + throw error(`${context} must be in canonical name/id order`); + } + return normalized; +} + +function normalizeRun(value, context, { + artifacts = false, + expectedWorkflow, + requireCanonical = false, +} = {}) { + const keys = artifacts + ? ["artifacts", "attempt", "id", "workflow"] + : ["attempt", "id", "workflow"]; + strictObject(value, keys, context, { canonicalOrder: requireCanonical }); + if (value.workflow !== expectedWorkflow) { + throw error(`${context}.workflow must be ${expectedWorkflow}`); + } + const result = { + ...(artifacts + ? { + artifacts: normalizeQualificationArtifacts( + value.artifacts, + `${context}.artifacts`, + { requireCanonical }, + ), + } + : {}), + attempt: positiveInteger(value.attempt, `${context}.attempt`), + id: positiveInteger(value.id, `${context}.id`), + workflow: value.workflow, + }; + return canonical(result); +} + +function normalizeApprovalRun(value, context, { requireCanonical = false } = {}) { + strictObject( + value, + ["artifacts", "attempt", "id", "workflow"], + context, + { canonicalOrder: requireCanonical }, + ); + if (value.workflow !== RELEASE_WORKFLOW) { + throw error(`${context}.workflow must be ${RELEASE_WORKFLOW}`); + } + return canonical({ + artifacts: normalizeApprovalArtifacts( + value.artifacts, + `${context}.artifacts`, + { requireCanonical }, + ), + attempt: positiveInteger(value.attempt, `${context}.attempt`), + id: positiveInteger(value.id, `${context}.id`), + workflow: value.workflow, + }); +} + +export function normalizeRecoveryPromotionController( + value, + { requireCanonical = false } = {}, +) { + strictObject( + value, + ["approvalRun", "promotionRun", "qualificationRun", "source"], + "recovery controller", + { canonicalOrder: requireCanonical }, + ); + const controller = canonical({ + approvalRun: normalizeApprovalRun( + value.approvalRun, + "recovery controller.approvalRun", + { requireCanonical }, + ), + promotionRun: normalizeRun( + value.promotionRun, + "recovery controller.promotionRun", + { + expectedWorkflow: RELEASE_WORKFLOW, + requireCanonical, + }, + ), + qualificationRun: normalizeRun( + value.qualificationRun, + "recovery controller.qualificationRun", + { + artifacts: true, + expectedWorkflow: CI_WORKFLOW, + requireCanonical, + }, + ), + source: normalizeSource(value.source, "recovery controller.source"), + }); + const runIds = [ + controller.approvalRun.id, + controller.promotionRun.id, + controller.qualificationRun.id, + ]; + if (new Set(runIds).size !== runIds.length) { + throw error( + "controller approval, promotion, and qualification must identify " + + "distinct workflow runs", + ); + } + return controller; +} + +export function assertRecoveryPromotionGitHubContext( + value, + env = process.env, +) { + const controller = normalizeRecoveryPromotionController(value); + if (env.GITHUB_ACTIONS !== "true") return controller; + const required = [ + "GITHUB_EVENT_NAME", + "GITHUB_REF", + "GITHUB_REPOSITORY", + "GITHUB_RUN_ATTEMPT", + "GITHUB_RUN_ID", + "GITHUB_SHA", + "GITHUB_WORKFLOW", + "GITHUB_WORKFLOW_REF", + "GITHUB_WORKFLOW_SHA", + ]; + const missing = required.filter((name) => + typeof env[name] !== "string" || env[name].length === 0); + if (missing.length > 0) { + throw error( + `GitHub promotion context lacks ${missing.join(", ")}`, + ); + } + if ( + env.GITHUB_EVENT_NAME !== "workflow_dispatch" + || env.GITHUB_REF !== "refs/heads/main" + || env.GITHUB_REPOSITORY !== "f0rr0/oliphaunt" + || env.GITHUB_WORKFLOW !== "Release" + || env.GITHUB_WORKFLOW_REF + !== "f0rr0/oliphaunt/.github/workflows/release.yml@refs/heads/main" + || env.GITHUB_SHA !== controller.source.commit + || env.GITHUB_WORKFLOW_SHA !== controller.source.commit + || env.GITHUB_RUN_ID !== String(controller.promotionRun.id) + || env.GITHUB_RUN_ATTEMPT !== String(controller.promotionRun.attempt) + ) { + throw error( + "controller promotionRun/source does not match the current " + + "GitHub Release workflow identity", + ); + } + return controller; +} + +function normalizeSubject(value, context) { + strictObject(value, ["name", "sha256"], context); + const name = value.name; + if ( + typeof name !== "string" + || name.length === 0 + || name === "." + || name === ".." + || name.includes("/") + || name.includes("\\") + || /[\u0000-\u001f\u007f]/u.test(name) + || Buffer.byteLength(name) > 255 + ) { + throw error(`${context}.name must be a bounded direct artifact basename`); + } + return canonical({ + name, + sha256: requireHash(value.sha256, `${context}.sha256`), + }); +} + +function compareSubjects(left, right) { + return compareText(left.name, right.name) + || compareText(left.sha256, right.sha256); +} + +export function normalizeRecoveryPromotionSubjects( + value, + { requireCanonical = false } = {}, +) { + if ( + !Array.isArray(value) + || value.length < 1 + || value.length > MAX_ATTESTATION_SUBJECTS + ) { + throw error( + `recovery promotion subjects must contain 1-${MAX_ATTESTATION_SUBJECTS} rows`, + ); + } + const normalized = value + .map((subject, index) => + normalizeSubject(subject, `recovery promotion subjects[${index}]`)) + .sort(compareSubjects); + const names = new Set(); + for (const subject of normalized) { + if (names.has(subject.name)) { + throw error( + `recovery promotion subjects repeat or ambiguously reuse ${subject.name}`, + ); + } + names.add(subject.name); + } + if ( + requireCanonical + && canonicalRecoveryPromotionJson(value) + !== canonicalRecoveryPromotionJson(normalized) + ) { + throw error("recovery promotion subjects must be in canonical name/digest order"); + } + return normalized; +} + +export function recoveryPromotionSubjectChecksums(subjects) { + return normalizeRecoveryPromotionSubjects(subjects) + .map(({ name, sha256: digest }) => `${digest} ${name}\n`) + .join(""); +} + +export function recoveryPromotionSubjectsFromLock(lock) { + validatePublicationLock(lock); + const selectedProducts = new Set(lock.products.map(({ id }) => id)); + if ( + selectedProducts.size !== lock.products.length + || [...selectedProducts].some((product) => + typeof product !== "string" || product.length === 0) + ) { + throw error("original publication lock contains invalid or duplicate products"); + } + const subjects = lock.productArtifacts + .filter(({ role }) => GITHUB_RELEASE_ARTIFACT_ROLES.has(role)) + .map((artifact, index) => { + if (!selectedProducts.has(artifact?.product)) { + throw error( + `original publication lock GitHub artifact[${index}] belongs to an unselected product`, + ); + } + return { + name: artifact.name, + sha256: artifact.sha256, + }; + }); + return normalizeRecoveryPromotionSubjects(subjects); +} + +function validatePublicationLock(lock) { + if (!isPlainObject(lock)) { + throw error("original publication lock must be a plain object"); + } + if (lock.schema !== PUBLICATION_LOCK_SCHEMA) { + throw error(`original publication lock schema must be ${PUBLICATION_LOCK_SCHEMA}`); + } + const source = normalizeSource(lock.source, "original publication lock.source"); + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + requireHash(lock[field], `original publication lock.${field}`); + } + for (const field of ["products", "carriers", "productArtifacts"]) { + if (!Array.isArray(lock[field])) { + throw error(`original publication lock.${field} must be an array`); + } + } + const withoutDigest = structuredClone(lock); + delete withoutDigest.lockDigest; + const expectedDigest = digestValue(withoutDigest); + if (lock.lockDigest !== expectedDigest) { + throw error( + `original publication lock lockDigest mismatch: ` + + `expected ${expectedDigest}, got ${lock.lockDigest}`, + ); + } + return { + catalogDigest: lock.catalogDigest, + lockDigest: lock.lockDigest, + packageEnvelopeDigest: lock.packageEnvelopeDigest, + schema: lock.schema, + source, + }; +} + +function validateSourceProvenanceRecord(record, lockBinding) { + validateSameVersionRecoverySource(record); + if ( + canonicalRecoveryPromotionJson(record.releaseSource) + !== canonicalRecoveryPromotionJson(lockBinding.source) + ) { + throw error("source provenance releaseSource does not match the original lock"); + } + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + if (record.releaseEnvelope[field] !== lockBinding[field]) { + throw error(`source provenance releaseEnvelope.${field} does not match the original lock`); + } + } + return canonical({ + recordDigest: sha256(canonicalRecoverySourceJson(record)), + schema: SAME_VERSION_RECOVERY_SOURCES_SCHEMA, + }); +} + +function validateOriginalLockArtifact(value, context) { + strictObject(value, ["artifact", "runId", "workflow"], context); + if (value.workflow !== "Release") { + throw error(`${context}.workflow must be Release`); + } + const runId = positiveInteger(value.runId, `${context}.runId`); + const artifact = normalizeQualificationArtifact(value.artifact, `${context}.artifact`); + if (artifact.name !== "oliphaunt-publication-lock") { + throw error(`${context}.artifact must identify oliphaunt-publication-lock`); + } + return canonical({ + artifact, + runId, + workflow: value.workflow, + }); +} + +function validateRecoveryApproval( + approval, + lock, + lockBinding, + controller, + provenanceRecord, +) { + strictObject( + approval, + [ + "carrierCount", + "catalogDigest", + "comparedFields", + "controllerSource", + "evidenceDigest", + "originalLockArtifact", + "originalLockDigest", + "packageEnvelopeDigest", + "productArtifactCount", + "productCount", + "releaseSource", + "replayLockDigest", + "schema", + ], + "recovery approval evidence", + ); + if (approval.schema !== RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA) { + throw error( + `recovery approval evidence schema must be ` + + RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA, + ); + } + const releaseSource = normalizeSource( + approval.releaseSource, + "recovery approval evidence.releaseSource", + ); + const controllerSource = normalizeSource( + approval.controllerSource, + "recovery approval evidence.controllerSource", + ); + if ( + canonicalRecoveryPromotionJson(releaseSource) + !== canonicalRecoveryPromotionJson(lockBinding.source) + || canonicalRecoveryPromotionJson(controllerSource) + !== canonicalRecoveryPromotionJson(controller.source) + ) { + throw error("recovery approval source/controller identity does not match promotion inputs"); + } + if ( + approval.originalLockDigest !== lockBinding.lockDigest + || approval.replayLockDigest !== lockBinding.lockDigest + || approval.catalogDigest !== lockBinding.catalogDigest + || approval.packageEnvelopeDigest !== lockBinding.packageEnvelopeDigest + ) { + throw error("recovery approval lock/catalog/package envelope does not match the original lock"); + } + for (const [field, expected] of [ + ["productCount", lock.products.length], + ["carrierCount", lock.carriers.length], + ["productArtifactCount", lock.productArtifacts.length], + ]) { + if (!Number.isSafeInteger(approval[field]) || approval[field] !== expected) { + throw error(`recovery approval ${field} does not match the original lock`); + } + } + const originalLockArtifact = validateOriginalLockArtifact( + approval.originalLockArtifact, + "recovery approval evidence.originalLockArtifact", + ); + const provenanceLockArtifacts = + provenanceRecord.approvedDryRun.artifactInventory.artifacts + .filter(({ name }) => name === "oliphaunt-publication-lock"); + if (provenanceLockArtifacts.length !== 1) { + throw error( + "source provenance must contain exactly one approved publication-lock artifact", + ); + } + const provenanceLockArtifact = normalizeQualificationArtifact( + provenanceLockArtifacts[0], + "source provenance approved publication-lock artifact", + ); + if ( + originalLockArtifact.workflow !== provenanceRecord.approvedDryRun.workflow.name + || originalLockArtifact.runId !== provenanceRecord.approvedDryRun.run.id + || canonicalRecoveryPromotionJson(originalLockArtifact.artifact) + !== canonicalRecoveryPromotionJson(provenanceLockArtifact) + ) { + throw error( + "recovery approval original lock artifact does not match the pinned " + + "source-provenance dry run", + ); + } + if ( + !Array.isArray(approval.comparedFields) + || approval.comparedFields.some((field) => typeof field !== "string") + || new Set(approval.comparedFields).size !== approval.comparedFields.length + ) { + throw error("recovery approval comparedFields must be a unique string list"); + } + const expectedFields = Object.keys(lock).sort(compareText); + if ( + canonicalRecoveryPromotionJson(approval.comparedFields) + !== canonicalRecoveryPromotionJson(expectedFields) + ) { + throw error("recovery approval comparedFields do not cover the complete original lock"); + } + requireHash(approval.evidenceDigest, "recovery approval evidence.evidenceDigest"); + const withoutDigest = structuredClone(approval); + delete withoutDigest.evidenceDigest; + const expectedDigest = digestValue(withoutDigest); + if (approval.evidenceDigest !== expectedDigest) { + throw error( + `recovery approval evidenceDigest mismatch: ` + + `expected ${expectedDigest}, got ${approval.evidenceDigest}`, + ); + } + return canonical({ + evidenceDigest: approval.evidenceDigest, + schema: approval.schema, + }); +} + +function predicateDigest(predicate) { + const withoutDigest = structuredClone(predicate); + delete withoutDigest.evidenceDigest; + return digestValue(withoutDigest); +} + +export function createRecoveryPromotionPredicate({ + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, +} = {}) { + const lockBinding = validatePublicationLock(lock); + const normalizedController = normalizeRecoveryPromotionController(controller); + if ( + lockBinding.source.commit === normalizedController.source.commit + || lockBinding.source.tree === normalizedController.source.tree + ) { + throw error("recovery controller must have a distinct commit and tree"); + } + const normalizedSubjects = normalizeRecoveryPromotionSubjects(subjects); + const expectedSubjects = recoveryPromotionSubjectsFromLock(lock); + if ( + canonicalRecoveryPromotionJson(normalizedSubjects) + !== canonicalRecoveryPromotionJson(expectedSubjects) + ) { + throw error( + "recovery promotion subjects must exactly match every frozen GitHub " + + "release artifact in the original publication lock", + ); + } + const body = canonical({ + controller: normalizedController, + originalLock: lockBinding, + recoveryApproval: validateRecoveryApproval( + recoveryApproval, + lock, + lockBinding, + normalizedController, + provenanceRecord, + ), + schema: RECOVERY_PROMOTION_PREDICATE_SCHEMA, + sourceProvenance: validateSourceProvenanceRecord( + provenanceRecord, + lockBinding, + ), + subjects: normalizedSubjects, + }); + return canonical({ + ...body, + evidenceDigest: digestValue(body), + }); +} + +function validatePredicateShape(predicate) { + strictObject( + predicate, + [ + "controller", + "evidenceDigest", + "originalLock", + "recoveryApproval", + "schema", + "sourceProvenance", + "subjects", + ], + "recovery promotion predicate", + { canonicalOrder: true }, + ); + assertCanonicalObjectOrder(predicate, "recovery promotion predicate"); + if (predicate.schema !== RECOVERY_PROMOTION_PREDICATE_SCHEMA) { + throw error( + `recovery promotion predicate schema must be ` + + RECOVERY_PROMOTION_PREDICATE_SCHEMA, + ); + } + normalizeRecoveryPromotionController(predicate.controller, { + requireCanonical: true, + }); + strictObject( + predicate.originalLock, + ["catalogDigest", "lockDigest", "packageEnvelopeDigest", "schema", "source"], + "recovery promotion predicate.originalLock", + { canonicalOrder: true }, + ); + if (predicate.originalLock.schema !== PUBLICATION_LOCK_SCHEMA) { + throw error("recovery promotion predicate original lock schema is invalid"); + } + normalizeSource( + predicate.originalLock.source, + "recovery promotion predicate.originalLock.source", + ); + for (const field of ["catalogDigest", "lockDigest", "packageEnvelopeDigest"]) { + requireHash( + predicate.originalLock[field], + `recovery promotion predicate.originalLock.${field}`, + ); + } + strictObject( + predicate.recoveryApproval, + ["evidenceDigest", "schema"], + "recovery promotion predicate.recoveryApproval", + { canonicalOrder: true }, + ); + if ( + predicate.recoveryApproval.schema + !== RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA + ) { + throw error("recovery promotion predicate recovery approval schema is invalid"); + } + requireHash( + predicate.recoveryApproval.evidenceDigest, + "recovery promotion predicate.recoveryApproval.evidenceDigest", + ); + strictObject( + predicate.sourceProvenance, + ["recordDigest", "schema"], + "recovery promotion predicate.sourceProvenance", + { canonicalOrder: true }, + ); + if (predicate.sourceProvenance.schema !== SAME_VERSION_RECOVERY_SOURCES_SCHEMA) { + throw error("recovery promotion predicate source provenance schema is invalid"); + } + requireHash( + predicate.sourceProvenance.recordDigest, + "recovery promotion predicate.sourceProvenance.recordDigest", + ); + normalizeRecoveryPromotionSubjects(predicate.subjects, { + requireCanonical: true, + }); + requireHash( + predicate.evidenceDigest, + "recovery promotion predicate.evidenceDigest", + ); + const expectedDigest = predicateDigest(predicate); + if (predicate.evidenceDigest !== expectedDigest) { + throw error( + `recovery promotion predicate evidenceDigest mismatch: ` + + `expected ${expectedDigest}, got ${predicate.evidenceDigest}`, + ); + } +} + +export function validateRecoveryPromotionPredicateEnvelope(predicate) { + validatePredicateShape(predicate); + return predicate; +} + +export function validateRecoveryPromotionPredicate( + predicate, + { + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects = predicate?.subjects, + } = {}, +) { + validatePredicateShape(predicate); + const expected = createRecoveryPromotionPredicate({ + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, + }); + if ( + canonicalRecoveryPromotionJson(predicate) + !== canonicalRecoveryPromotionJson(expected) + ) { + throw error( + "recovery promotion predicate does not match the original lock, " + + "controller runs, source provenance, approval, or subjects", + ); + } + return predicate; +} + +function normalizeStatementSubjects(subjects) { + if (!Array.isArray(subjects)) { + throw error("recovery promotion statement.subject must be an array"); + } + const converted = subjects.map((subject, index) => { + const context = `recovery promotion statement.subject[${index}]`; + strictObject(subject, ["digest", "name"], context); + strictObject(subject.digest, ["sha256"], `${context}.digest`); + return { + name: subject.name, + sha256: subject.digest.sha256, + }; + }); + return normalizeRecoveryPromotionSubjects(converted, { + requireCanonical: true, + }); +} + +export function validateRecoveryPromotionStatement(statement, expectations = {}) { + strictObject( + statement, + ["_type", "predicate", "predicateType", "subject"], + "recovery promotion statement", + ); + if ( + statement._type !== IN_TOTO_STATEMENT_V1 + || statement.predicateType !== RECOVERY_PROMOTION_PREDICATE_TYPE + ) { + throw error( + "recovery promotion statement must be an in-toto v1 statement " + + `with predicate type ${RECOVERY_PROMOTION_PREDICATE_TYPE}`, + ); + } + const subjects = normalizeStatementSubjects(statement.subject); + if ( + canonicalRecoveryPromotionJson(subjects) + !== canonicalRecoveryPromotionJson(statement.predicate?.subjects) + ) { + throw error( + "recovery promotion statement subjects differ from the predicate subject binding", + ); + } + const predicate = validateRecoveryPromotionPredicate( + statement.predicate, + { + ...expectations, + subjects, + }, + ); + return { predicate, subjects }; +} + +function readBoundedJson(file, context) { + const absolute = path.resolve(file); + const metadata = lstatSync(absolute, { throwIfNoEntry: false }); + if ( + !metadata?.isFile() + || metadata.isSymbolicLink() + || metadata.size < 1 + || metadata.size > MAX_JSON_BYTES + ) { + throw error(`${context} must be a bounded regular non-symlink file`); + } + const bytes = readFileSync(absolute); + let value; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch (cause) { + throw error(`${context} is invalid JSON: ${cause.message}`); + } + return { bytes, value }; +} + +function selectedProvenanceRecord(value, releaseSha) { + if ( + isPlainObject(value) + && value.schema === SAME_VERSION_RECOVERY_SOURCES_SCHEMA + && Array.isArray(value.records) + ) { + return selectSameVersionRecoverySource( + value, + releaseSha, + ); + } + validateSameVersionRecoverySource(value); + if (value.releaseSource.commit !== releaseSha) { + throw error("selected provenance record does not match the original lock source"); + } + return value; +} + +function writeImmutableCanonicalJson(file, value) { + const absolute = path.resolve(file); + const body = prettyCanonicalRecoveryPromotionJson(value); + if (Buffer.byteLength(body) > MAX_JSON_BYTES) { + throw error(`recovery promotion predicate exceeds ${MAX_JSON_BYTES} bytes`); + } + mkdirSync(path.dirname(absolute), { recursive: true }); + const temporary = `${absolute}.tmp-${process.pid}-${randomUUID()}`; + try { + writeFileSync(temporary, body, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + try { + linkSync(temporary, absolute); + } catch (cause) { + if (cause?.code !== "EEXIST") throw cause; + const existing = lstatSync(absolute, { throwIfNoEntry: false }); + if ( + !existing?.isFile() + || existing.isSymbolicLink() + || existing.size !== Buffer.byteLength(body) + || readFileSync(absolute, "utf8") !== body + ) { + throw error(`refusing to replace non-identical predicate ${absolute}`); + } + } + } finally { + rmSync(temporary, { force: true }); + } + return absolute; +} + +function writeImmutableText(file, body, context) { + const absolute = path.resolve(file); + if ( + typeof body !== "string" + || body.length < 1 + || Buffer.byteLength(body) > MAX_JSON_BYTES + ) { + throw error(`${context} must be non-empty and at most ${MAX_JSON_BYTES} bytes`); + } + mkdirSync(path.dirname(absolute), { recursive: true }); + const temporary = `${absolute}.tmp-${process.pid}-${randomUUID()}`; + try { + writeFileSync(temporary, body, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + try { + linkSync(temporary, absolute); + } catch (cause) { + if (cause?.code !== "EEXIST") throw cause; + const existing = lstatSync(absolute, { throwIfNoEntry: false }); + if ( + !existing?.isFile() + || existing.isSymbolicLink() + || existing.size !== Buffer.byteLength(body) + || readFileSync(absolute, "utf8") !== body + ) { + throw error(`refusing to replace non-identical ${context} ${absolute}`); + } + } + } finally { + rmSync(temporary, { force: true }); + } + return absolute; +} + +function parsePrepareArgs(argv) { + const allowed = new Set([ + "approval", + "approval-artifacts-json", + "approval-run-attempt", + "approval-run-id", + "checksums-output", + "controller-output", + "controller-sha", + "controller-tree", + "github-output", + "lock", + "predicate-output", + "promotion-run-attempt", + "promotion-run-id", + "provenance", + "qualification-artifacts-json", + "qualification-run-attempt", + "qualification-run-id", + "subjects-output", + ]); + const values = new Map(); + for (let index = 1; index < argv.length; index += 1) { + const flag = argv[index]; + const name = flag?.startsWith("--") ? flag.slice(2) : ""; + const value = argv[index + 1]; + if ( + !allowed.has(name) + || value === undefined + || value.startsWith("--") + || values.has(name) + ) { + throw error(`invalid or repeated prepare argument ${flag ?? ""}`); + } + values.set(name, value); + index += 1; + } + const required = [...allowed].filter((name) => name !== "github-output"); + for (const name of required) { + if (!values.has(name)) throw error(`prepare requires --${name}`); + } + return { + approval: values.get("approval"), + approvalArtifactsJson: values.get("approval-artifacts-json"), + approvalRunAttempt: values.get("approval-run-attempt"), + approvalRunId: values.get("approval-run-id"), + checksumsOutput: values.get("checksums-output"), + controllerOutput: values.get("controller-output"), + controllerSha: values.get("controller-sha"), + controllerTree: values.get("controller-tree"), + githubOutput: values.get("github-output") ?? process.env.GITHUB_OUTPUT?.trim() ?? "", + lock: values.get("lock"), + operation: "prepare", + predicateOutput: values.get("predicate-output"), + promotionRunAttempt: values.get("promotion-run-attempt"), + promotionRunId: values.get("promotion-run-id"), + provenance: values.get("provenance"), + qualificationArtifactsJson: values.get("qualification-artifacts-json"), + qualificationRunAttempt: values.get("qualification-run-attempt"), + qualificationRunId: values.get("qualification-run-id"), + subjectsOutput: values.get("subjects-output"), + }; +} + +function parseArgs(argv) { + const operation = argv[0]; + if (operation === "prepare") return parsePrepareArgs(argv); + if (!["create", "verify"].includes(operation)) { + throw error( + "usage: recovery-promotion-attestation.mjs prepare|create|verify " + + "--lock FILE --controller FILE --provenance FILE " + + "--approval FILE --subjects FILE " + + "(--output FILE | --predicate FILE)", + ); + } + const allowed = new Set([ + "approval", + "controller", + "lock", + "output", + "predicate", + "provenance", + "subjects", + ]); + const values = new Map(); + for (let index = 1; index < argv.length; index += 1) { + const flag = argv[index]; + const name = flag?.startsWith("--") ? flag.slice(2) : ""; + const value = argv[index + 1]; + if ( + !allowed.has(name) + || value === undefined + || value.startsWith("--") + || values.has(name) + ) { + throw error(`invalid or repeated argument ${flag ?? ""}`); + } + values.set(name, value); + index += 1; + } + for (const required of [ + "approval", + "controller", + "lock", + "provenance", + "subjects", + ]) { + if (!values.has(required)) { + throw error(`--${required} is required`); + } + } + if ( + operation === "create" + ? !values.has("output") || values.has("predicate") + : !values.has("predicate") || values.has("output") + ) { + throw error( + operation === "create" + ? "create requires --output and forbids --predicate" + : "verify requires --predicate and forbids --output", + ); + } + return { + approval: values.get("approval"), + controller: values.get("controller"), + lock: values.get("lock"), + operation, + output: values.get("output"), + predicate: values.get("predicate"), + provenance: values.get("provenance"), + subjects: values.get("subjects"), + }; +} + +function preparePositiveInteger(value, context) { + if (!/^[1-9][0-9]*$/u.test(value ?? "")) { + throw error(`${context} must be a positive safe integer`); + } + return positiveInteger(Number(value), context); +} + +function prepareQualificationArtifacts(value) { + let artifacts; + try { + artifacts = JSON.parse(value); + } catch (cause) { + throw error(`qualification artifact metadata is invalid JSON: ${cause.message}`); + } + return normalizeQualificationArtifacts( + artifacts, + "recovery controller.qualificationRun.artifacts", + ); +} + +function prepareApprovalArtifacts(value) { + let artifacts; + try { + artifacts = JSON.parse(value); + } catch (cause) { + throw error(`approval artifact metadata is invalid JSON: ${cause.message}`); + } + return normalizeApprovalArtifacts( + artifacts, + "recovery controller.approvalRun.artifacts", + ); +} + +function appendPrepareGitHubOutput(file, values) { + if (!file) return; + const absolute = path.resolve(file); + const metadata = lstatSync(absolute, { throwIfNoEntry: false }); + if (metadata !== undefined && (!metadata.isFile() || metadata.isSymbolicLink())) { + throw error(`GITHUB_OUTPUT must be an absent or regular non-symlink file: ${absolute}`); + } + const lines = Object.entries(values).map(([name, value]) => { + const rendered = String(value); + if (/[\r\n]/u.test(rendered)) { + throw error(`refusing multiline GITHUB_OUTPUT value for ${name}`); + } + return `${name}=${rendered}`; + }); + appendFileSync(absolute, `${lines.join("\n")}\n`, "utf8"); +} + +function prepareRecoveryPromotionFiles(options) { + const lock = readBoundedJson(options.lock, "original publication lock").value; + const provenanceValue = readBoundedJson( + options.provenance, + "source provenance record", + ).value; + const provenanceRecord = selectedProvenanceRecord( + provenanceValue, + lock?.source?.commit, + ); + const recoveryApproval = readBoundedJson( + options.approval, + "recovery approval evidence", + ).value; + const controller = normalizeRecoveryPromotionController({ + approvalRun: { + artifacts: prepareApprovalArtifacts(options.approvalArtifactsJson), + attempt: preparePositiveInteger( + options.approvalRunAttempt, + "approval run attempt", + ), + id: preparePositiveInteger(options.approvalRunId, "approval run id"), + workflow: RELEASE_WORKFLOW, + }, + promotionRun: { + attempt: preparePositiveInteger( + options.promotionRunAttempt, + "promotion run attempt", + ), + id: preparePositiveInteger(options.promotionRunId, "promotion run id"), + workflow: RELEASE_WORKFLOW, + }, + qualificationRun: { + artifacts: prepareQualificationArtifacts( + options.qualificationArtifactsJson, + ), + attempt: preparePositiveInteger( + options.qualificationRunAttempt, + "qualification run attempt", + ), + id: preparePositiveInteger( + options.qualificationRunId, + "qualification run id", + ), + workflow: CI_WORKFLOW, + }, + source: { + commit: options.controllerSha, + tree: options.controllerTree, + }, + }); + assertRecoveryPromotionGitHubContext(controller); + const subjects = recoveryPromotionSubjectsFromLock(lock); + const predicate = createRecoveryPromotionPredicate({ + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, + }); + const controllerPath = writeImmutableCanonicalJson( + options.controllerOutput, + controller, + ); + const subjectsPath = writeImmutableCanonicalJson( + options.subjectsOutput, + subjects, + ); + const predicatePath = writeImmutableCanonicalJson( + options.predicateOutput, + predicate, + ); + const checksumsPath = writeImmutableText( + options.checksumsOutput, + recoveryPromotionSubjectChecksums(subjects), + "recovery promotion subject checksums", + ); + appendPrepareGitHubOutput(options.githubOutput, { + checksums_path: checksumsPath, + controller_path: controllerPath, + evidence_digest: predicate.evidenceDigest, + predicate_path: predicatePath, + predicate_type: RECOVERY_PROMOTION_PREDICATE_TYPE, + subjects_path: subjectsPath, + }); + process.stdout.write( + `prepared ${RECOVERY_PROMOTION_PREDICATE_TYPE} ` + + `${predicate.evidenceDigest} for ${subjects.length} subject(s)\n`, + ); + return { controller, predicate, subjects }; +} + +export function main(argv = Bun.argv.slice(2)) { + const options = parseArgs(argv); + if (options.operation === "prepare") { + return prepareRecoveryPromotionFiles(options); + } + const lock = readBoundedJson(options.lock, "original publication lock").value; + const controller = readBoundedJson(options.controller, "recovery controller").value; + assertRecoveryPromotionGitHubContext(controller); + const provenanceValue = readBoundedJson( + options.provenance, + "source provenance record", + ).value; + const provenanceRecord = selectedProvenanceRecord( + provenanceValue, + lock?.source?.commit, + ); + const recoveryApproval = readBoundedJson( + options.approval, + "recovery approval evidence", + ).value; + const subjects = readBoundedJson( + options.subjects, + "recovery promotion subjects", + ).value; + if (options.operation === "create") { + const predicate = createRecoveryPromotionPredicate({ + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, + }); + writeImmutableCanonicalJson(options.output, predicate); + process.stdout.write( + `created ${RECOVERY_PROMOTION_PREDICATE_TYPE} ${predicate.evidenceDigest}\n`, + ); + return predicate; + } + const predicateFile = readBoundedJson( + options.predicate, + "recovery promotion predicate", + ); + if ( + predicateFile.bytes.toString("utf8") + !== prettyCanonicalRecoveryPromotionJson(predicateFile.value) + ) { + throw error( + "recovery promotion predicate file must be canonical sorted JSON " + + "with one trailing newline", + ); + } + const predicate = validateRecoveryPromotionPredicate( + predicateFile.value, + { + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, + }, + ); + process.stdout.write( + `verified ${RECOVERY_PROMOTION_PREDICATE_TYPE} ${predicate.evidenceDigest}\n`, + ); + return predicate; +} + +if (import.meta.main) { + try { + main(); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); + } +} diff --git a/tools/release/recovery-promotion-attestation.test.mjs b/tools/release/recovery-promotion-attestation.test.mjs new file mode 100644 index 00000000..7b73c179 --- /dev/null +++ b/tools/release/recovery-promotion-attestation.test.mjs @@ -0,0 +1,718 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { execFileSync } from "../test/fd-backed-spawn-sync.mjs"; +import { + DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + loadSameVersionRecoverySources, +} from "./same-version-recovery-source.mjs"; +import { + verifyReleaseRecoveryLockEquivalence, +} from "./verify-release-recovery-lock.mjs"; +import { + RECOVERY_PROMOTION_PREDICATE_SCHEMA, + RECOVERY_PROMOTION_PREDICATE_TYPE, + assertRecoveryPromotionGitHubContext, + canonicalRecoveryPromotionJson, + createRecoveryPromotionPredicate, + prettyCanonicalRecoveryPromotionJson, + recoveryPromotionSubjectChecksums, + recoveryPromotionSubjectsFromLock, + validateRecoveryPromotionPredicate, + validateRecoveryPromotionStatement, +} from "./recovery-promotion-attestation.mjs"; +import { + buildGithubAttestationReceipt, + validateGithubAttestationReceipt, +} from "./verify_github_release_attestations.mjs"; + +const RELEASE_COMMIT = "9c398f4e5c05f494f9b752a8634e74e0bc11dd19"; +const RELEASE_TREE = "396cf3b10adb1a5b625e66c5ebacf8c3d364b543"; +const CONTROLLER_COMMIT = "3".repeat(40); +const CONTROLLER_TREE = "4".repeat(40); +const TOOL = path.join(import.meta.dir, "recovery-promotion-attestation.mjs"); + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function digestValue(value) { + return sha256(canonicalRecoveryPromotionJson(value)); +} + +function updateSource(record, source) { + record.releaseSource = structuredClone(source); + record.payloadQualification.run.headSha = source.commit; + record.approvedDryRun.run.headSha = source.commit; + record.bootstrapLedger.run.headSha = source.commit; + record.approvedDryRun.capsuleManifest.source = structuredClone(source); + record.bootstrapLedger.terminalCheckpoint.source = structuredClone(source); +} + +function fixture() { + const provenanceRecord = structuredClone( + loadSameVersionRecoverySources( + DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + { verifyGit: false }, + ).records[0], + ); + const source = { commit: RELEASE_COMMIT, tree: RELEASE_TREE }; + updateSource(provenanceRecord, source); + + const lock = { + schema: "oliphaunt-publication-lock-v1", + catalogSchema: "oliphaunt-publication-catalog-v1", + catalogDigest: "5".repeat(64), + source, + products: Array.from( + { length: provenanceRecord.releaseEnvelope.productCount }, + (_, index) => ({ id: `product-${index}`, version: "1.0.0" }), + ), + carriers: Array.from( + { length: provenanceRecord.releaseEnvelope.carrierCount }, + (_, index) => ({ id: `carrier-${index}` }), + ), + productArtifacts: Array.from( + { length: provenanceRecord.releaseEnvelope.productArtifactCount }, + (_, index) => { + if (index === 0) { + return { + id: "artifact-0", + name: "alpha.zip", + path: "target/release/alpha.zip", + product: "product-0", + role: "github-release-asset", + sha256: "a".repeat(64), + size: 100, + }; + } + if (index === 1) { + return { + id: "artifact-1", + name: "zeta.tar.gz", + path: "target/release/zeta.tar.gz", + product: "product-0", + role: "github-release-metadata", + sha256: "b".repeat(64), + size: 200, + }; + } + return { id: `artifact-${index}`, role: "registry-input" }; + }, + ), + packageEnvelopeDigest: "6".repeat(64), + }; + lock.lockDigest = digestValue(lock); + for (const field of ["catalogDigest", "lockDigest", "packageEnvelopeDigest"]) { + provenanceRecord.releaseEnvelope[field] = lock[field]; + provenanceRecord.approvedDryRun.capsuleManifest[field] = lock[field]; + provenanceRecord.bootstrapLedger.terminalCheckpoint[field] = lock[field]; + } + + const controller = { + approvalRun: { + workflow: ".github/workflows/release.yml", + id: 9000, + attempt: 1, + artifacts: [ + { + size: 400, + name: "oliphaunt-release-recovery-equivalence", + id: 7000, + digest: `sha256:${"6".repeat(64)}`, + }, + ], + }, + source: { + tree: CONTROLLER_TREE, + commit: CONTROLLER_COMMIT, + }, + qualificationRun: { + workflow: ".github/workflows/ci.yml", + id: 9001, + attempt: 2, + artifacts: [ + { + size: 300, + name: "oliphaunt-release-candidate", + id: 7002, + digest: `sha256:${"8".repeat(64)}`, + }, + { + size: 200, + name: "artifact-build-plan", + id: 7001, + digest: `sha256:${"7".repeat(64)}`, + }, + ], + }, + promotionRun: { + workflow: ".github/workflows/release.yml", + id: 9002, + attempt: 1, + }, + }; + + const recoveryApproval = verifyReleaseRecoveryLockEquivalence({ + original: lock, + replay: structuredClone(lock), + releaseSource: source, + controllerSource: { + commit: CONTROLLER_COMMIT, + tree: CONTROLLER_TREE, + }, + originalEvidence: { + runId: provenanceRecord.approvedDryRun.run.id, + artifacts: provenanceRecord.approvedDryRun.artifactInventory.artifacts + .filter(({ name }) => name === "oliphaunt-publication-lock"), + }, + }); + + const subjects = [ + { sha256: "b".repeat(64), name: "zeta.tar.gz" }, + { sha256: "a".repeat(64), name: "alpha.zip" }, + ]; + return { + controller, + lock, + provenanceRecord, + recoveryApproval, + subjects, + }; +} + +function create(values = fixture()) { + return createRecoveryPromotionPredicate(values); +} + +function refreshPredicateDigest(predicate) { + const body = structuredClone(predicate); + delete body.evidenceDigest; + predicate.evidenceDigest = digestValue(body); + return predicate; +} + +test("creates one deterministic canonical predicate binding both runs and every evidence identity", () => { + const values = fixture(); + const predicate = create(values); + assert.equal(predicate.schema, RECOVERY_PROMOTION_PREDICATE_SCHEMA); + assert.match( + RECOVERY_PROMOTION_PREDICATE_TYPE, + /^https:\/\/github[.]com\/f0rr0\/oliphaunt\/attestations\//u, + ); + assert.deepEqual(Object.keys(predicate), [ + "controller", + "evidenceDigest", + "originalLock", + "recoveryApproval", + "schema", + "sourceProvenance", + "subjects", + ]); + assert.deepEqual(predicate.controller.source, { + commit: CONTROLLER_COMMIT, + tree: CONTROLLER_TREE, + }); + assert.deepEqual(predicate.controller.approvalRun, { + artifacts: [{ + digest: `sha256:${"6".repeat(64)}`, + id: 7000, + name: "oliphaunt-release-recovery-equivalence", + size: 400, + }], + attempt: 1, + id: 9000, + workflow: ".github/workflows/release.yml", + }); + assert.deepEqual( + predicate.controller.qualificationRun.artifacts.map(({ id, name }) => ({ + id, + name, + })), + [ + { id: 7001, name: "artifact-build-plan" }, + { id: 7002, name: "oliphaunt-release-candidate" }, + ], + ); + assert.deepEqual(predicate.subjects, [ + { name: "alpha.zip", sha256: "a".repeat(64) }, + { name: "zeta.tar.gz", sha256: "b".repeat(64) }, + ]); + assert.equal( + predicate.sourceProvenance.recordDigest, + sha256(canonicalRecoveryPromotionJson(values.provenanceRecord)), + ); + const body = structuredClone(predicate); + delete body.evidenceDigest; + assert.equal(predicate.evidenceDigest, digestValue(body)); + assert.equal( + recoveryPromotionSubjectChecksums(values.subjects), + `${"a".repeat(64)} alpha.zip\n${"b".repeat(64)} zeta.tar.gz\n`, + ); + assert.deepEqual( + recoveryPromotionSubjectsFromLock(values.lock), + predicate.subjects, + ); + + const shuffled = structuredClone(values); + shuffled.subjects.reverse(); + shuffled.controller.qualificationRun.artifacts.reverse(); + assert.deepEqual(create(shuffled), predicate); + assert.equal( + prettyCanonicalRecoveryPromotionJson(predicate), + `${JSON.stringify(predicate, null, 2)}\n`, + ); +}); + +test("validates the decoded custom in-toto statement against lock, controller, provenance, and approval", () => { + const values = fixture(); + const predicate = create(values); + assert.equal( + validateRecoveryPromotionPredicate(predicate, values), + predicate, + ); + const statement = { + _type: "https://in-toto.io/Statement/v1", + subject: predicate.subjects.map(({ name, sha256: digest }) => ({ + digest: { sha256: digest }, + name, + })), + predicateType: RECOVERY_PROMOTION_PREDICATE_TYPE, + predicate, + }; + assert.deepEqual(validateRecoveryPromotionStatement(statement, values), { + predicate, + subjects: predicate.subjects, + }); +}); + +test("persists the exact approval run and custom bundle in the downstream recovery receipt", () => { + const values = fixture(); + const predicate = create(values); + const attestations = [{ + bundleSha256: "c".repeat(64), + subjects: predicate.subjects, + }]; + let nextReleaseId = 100; + let nextAssetId = 1_000; + const releases = values.lock.products.map((product) => ({ + assets: values.lock.productArtifacts + .filter((artifact) => + artifact.product === product.id + && ["github-release-asset", "github-release-metadata"].includes( + artifact.role, + )) + .map((artifact) => ({ + assetId: String(nextAssetId++), + name: artifact.name, + sha256: artifact.sha256, + size: artifact.size, + })), + draft: true, + prerelease: false, + product: product.id, + releaseId: String(nextReleaseId++), + releaseName: `${product.id} v${product.version}`, + tag: `${product.id}-v${product.version}`, + targetCommitish: values.lock.source.commit, + version: product.version, + })); + const receipt = buildGithubAttestationReceipt({ + attestations, + lock: values.lock, + recoveryPromotionPredicate: predicate, + releases, + repo: "f0rr0/oliphaunt", + }); + assert.equal( + receipt.schema, + "oliphaunt-github-release-attestation-receipt-v2", + ); + assert.equal(receipt.recoveryPromotion.bundleSha256, "c".repeat(64)); + assert.equal( + receipt.recoveryPromotion.predicateEvidenceDigest, + predicate.evidenceDigest, + ); + assert.deepEqual( + receipt.recoveryPromotion.controller.approvalRun, + predicate.controller.approvalRun, + ); + assert.equal( + validateGithubAttestationReceipt( + receipt, + values.lock, + { repo: "f0rr0/oliphaunt" }, + ), + receipt, + ); + + const substituted = structuredClone(receipt); + substituted.recoveryPromotion.predicate.controller.approvalRun.artifacts[0].id += 1; + assert.throws( + () => + validateGithubAttestationReceipt( + substituted, + values.lock, + { repo: "f0rr0/oliphaunt" }, + ), + /receipt digest mismatch|evidenceDigest mismatch/u, + ); +}); + +test("binds the claimed promotion run to the live GitHub Release workflow context", () => { + const { controller } = fixture(); + const env = { + GITHUB_ACTIONS: "true", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "f0rr0/oliphaunt", + GITHUB_RUN_ATTEMPT: String(controller.promotionRun.attempt), + GITHUB_RUN_ID: String(controller.promotionRun.id), + GITHUB_SHA: controller.source.commit, + GITHUB_WORKFLOW: "Release", + GITHUB_WORKFLOW_REF: + "f0rr0/oliphaunt/.github/workflows/release.yml@refs/heads/main", + GITHUB_WORKFLOW_SHA: controller.source.commit, + }; + assert.deepEqual( + assertRecoveryPromotionGitHubContext(controller, env), + create(fixture()).controller, + ); + assert.deepEqual( + assertRecoveryPromotionGitHubContext(controller, {}), + create(fixture()).controller, + ); + for (const [field, value] of [ + ["GITHUB_RUN_ID", "9003"], + ["GITHUB_RUN_ATTEMPT", "3"], + ["GITHUB_SHA", RELEASE_COMMIT], + ["GITHUB_REF", "refs/tags/not-main"], + ["GITHUB_WORKFLOW_REF", "f0rr0/oliphaunt/.github/workflows/ci.yml@main"], + ]) { + assert.throws( + () => + assertRecoveryPromotionGitHubContext( + controller, + { ...env, [field]: value }, + ), + /does not match/u, + ); + } +}); + +test("rejects non-canonical, duplicate, malformed, or self-inconsistent predicate bytes", () => { + const values = fixture(); + const predicate = create(values); + + const extra = structuredClone(predicate); + extra.untrusted = true; + assert.throws( + () => validateRecoveryPromotionPredicate(extra, values), + /must contain exactly/u, + ); + + const unsorted = structuredClone(predicate); + unsorted.subjects.reverse(); + refreshPredicateDigest(unsorted); + assert.throws( + () => validateRecoveryPromotionPredicate(unsorted, values), + /canonical name\/digest order/u, + ); + + const duplicate = structuredClone(predicate); + duplicate.subjects[1] = structuredClone(duplicate.subjects[0]); + refreshPredicateDigest(duplicate); + assert.throws( + () => validateRecoveryPromotionPredicate(duplicate, values), + /repeat or ambiguously reuse/u, + ); + + const badName = structuredClone(values); + badName.subjects[0].name = "../escape"; + assert.throws( + () => create(badName), + /direct artifact basename/u, + ); + + const badHash = structuredClone(values); + badHash.subjects[0].sha256 = "A".repeat(64); + assert.throws( + () => create(badHash), + /lowercase SHA-256/u, + ); + + const badDigest = structuredClone(predicate); + badDigest.evidenceDigest = "f".repeat(64); + assert.throws( + () => validateRecoveryPromotionPredicate(badDigest, values), + /evidenceDigest mismatch/u, + ); +}); + +test("rejects substitution in every independently pinned evidence plane", () => { + const values = fixture(); + const predicate = create(values); + + const lockDrift = structuredClone(values); + lockDrift.lock.catalogDigest = "f".repeat(64); + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, lockDrift), + /lockDigest mismatch/u, + ); + + const controllerDrift = structuredClone(values); + controllerDrift.controller.qualificationRun.id += 10; + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, controllerDrift), + /does not match/u, + ); + + const artifactDrift = structuredClone(values); + artifactDrift.controller.qualificationRun.artifacts[0].digest = + `sha256:${"f".repeat(64)}`; + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, artifactDrift), + /does not match/u, + ); + + const approvalRunDrift = structuredClone(values); + approvalRunDrift.controller.approvalRun.id += 10; + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, approvalRunDrift), + /does not match/u, + ); + + const approvalArtifactDrift = structuredClone(values); + approvalArtifactDrift.controller.approvalRun.artifacts[0].digest = + `sha256:${"d".repeat(64)}`; + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, approvalArtifactDrift), + /does not match/u, + ); + + const originalSourceRunDrift = structuredClone(values); + originalSourceRunDrift.recoveryApproval.originalLockArtifact.runId += 1; + const approvalWithoutDigest = structuredClone( + originalSourceRunDrift.recoveryApproval, + ); + delete approvalWithoutDigest.evidenceDigest; + originalSourceRunDrift.recoveryApproval.evidenceDigest = + digestValue(approvalWithoutDigest); + assert.throws( + () => create(originalSourceRunDrift), + /does not match the pinned source-provenance dry run/u, + ); + + const missingSubject = structuredClone(values); + missingSubject.subjects.pop(); + assert.throws( + () => create(missingSubject), + /subjects must exactly match/u, + ); + + const provenanceDrift = structuredClone(values); + provenanceDrift.provenanceRecord.payloadQualification.run.id += 1; + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, provenanceDrift), + /does not match/u, + ); + + const approvalDrift = structuredClone(values); + approvalDrift.recoveryApproval.evidenceDigest = "e".repeat(64); + assert.throws( + () => validateRecoveryPromotionPredicate(predicate, approvalDrift), + /evidenceDigest mismatch/u, + ); +}); + +test("rejects a wrong predicate type or any statement/predicate subject skew", () => { + const values = fixture(); + const predicate = create(values); + const statement = { + _type: "https://in-toto.io/Statement/v1", + predicate, + predicateType: RECOVERY_PROMOTION_PREDICATE_TYPE, + subject: predicate.subjects.map(({ name, sha256: digest }) => ({ + digest: { sha256: digest }, + name, + })), + }; + + const wrongType = structuredClone(statement); + wrongType.predicateType = "https://slsa.dev/provenance/v1"; + assert.throws( + () => validateRecoveryPromotionStatement(wrongType, values), + /predicate type/u, + ); + + const wrongSubject = structuredClone(statement); + wrongSubject.subject[0].digest.sha256 = "f".repeat(64); + assert.throws( + () => validateRecoveryPromotionStatement(wrongSubject, values), + /subjects differ/u, + ); + + const unsorted = structuredClone(statement); + unsorted.subject.reverse(); + assert.throws( + () => validateRecoveryPromotionStatement(unsorted, values), + /canonical name\/digest order/u, + ); +}); + +test("CLI creates immutable canonical predicate bytes and revalidates them", () => { + const root = mkdtempSync( + path.join(tmpdir(), "oliphaunt-recovery-promotion-"), + ); + try { + const values = fixture(); + const env = { + ...process.env, + GITHUB_ACTIONS: "true", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_REF: "refs/heads/main", + GITHUB_REPOSITORY: "f0rr0/oliphaunt", + GITHUB_RUN_ATTEMPT: String(values.controller.promotionRun.attempt), + GITHUB_RUN_ID: String(values.controller.promotionRun.id), + GITHUB_SHA: values.controller.source.commit, + GITHUB_WORKFLOW: "Release", + GITHUB_WORKFLOW_REF: + "f0rr0/oliphaunt/.github/workflows/release.yml@refs/heads/main", + GITHUB_WORKFLOW_SHA: values.controller.source.commit, + }; + const files = Object.fromEntries( + ["lock", "controller", "provenanceRecord", "recoveryApproval", "subjects"] + .map((name) => [name, path.join(root, `${name}.json`)]), + ); + for (const [name, file] of Object.entries(files)) { + writeFileSync(file, `${JSON.stringify(values[name], null, 2)}\n`); + } + const output = path.join(root, "predicate.json"); + const common = [ + "--lock", + files.lock, + "--controller", + files.controller, + "--provenance", + files.provenanceRecord, + "--approval", + files.recoveryApproval, + "--subjects", + files.subjects, + ]; + const created = execFileSync( + process.execPath, + [TOOL, "create", ...common, "--output", output], + { encoding: "utf8", env }, + ); + assert.match(created, new RegExp(RECOVERY_PROMOTION_PREDICATE_TYPE, "u")); + const predicate = JSON.parse(readFileSync(output, "utf8")); + assert.equal( + readFileSync(output, "utf8"), + prettyCanonicalRecoveryPromotionJson(predicate), + ); + const verified = execFileSync( + process.execPath, + [TOOL, "verify", ...common, "--predicate", output], + { encoding: "utf8", env }, + ); + assert.match(verified, /verified https:/u); + + const prepared = { + checksums: path.join(root, "prepared.checksums"), + controller: path.join(root, "prepared-controller.json"), + githubOutput: path.join(root, "github-output"), + predicate: path.join(root, "prepared-predicate.json"), + subjects: path.join(root, "prepared-subjects.json"), + }; + const preparedResult = execFileSync( + process.execPath, + [ + TOOL, + "prepare", + "--lock", + files.lock, + "--provenance", + files.provenanceRecord, + "--approval", + files.recoveryApproval, + "--approval-run-id", + String(values.controller.approvalRun.id), + "--approval-run-attempt", + String(values.controller.approvalRun.attempt), + "--approval-artifacts-json", + JSON.stringify(values.controller.approvalRun.artifacts), + "--controller-sha", + values.controller.source.commit, + "--controller-tree", + values.controller.source.tree, + "--qualification-run-id", + String(values.controller.qualificationRun.id), + "--qualification-run-attempt", + String(values.controller.qualificationRun.attempt), + "--qualification-artifacts-json", + JSON.stringify(values.controller.qualificationRun.artifacts), + "--promotion-run-id", + String(values.controller.promotionRun.id), + "--promotion-run-attempt", + String(values.controller.promotionRun.attempt), + "--controller-output", + prepared.controller, + "--subjects-output", + prepared.subjects, + "--predicate-output", + prepared.predicate, + "--checksums-output", + prepared.checksums, + "--github-output", + prepared.githubOutput, + ], + { encoding: "utf8", env }, + ); + assert.match(preparedResult, /prepared https:/u); + assert.deepEqual( + JSON.parse(readFileSync(prepared.controller, "utf8")), + create(values).controller, + ); + assert.deepEqual( + JSON.parse(readFileSync(prepared.subjects, "utf8")), + create(values).subjects, + ); + assert.deepEqual( + JSON.parse(readFileSync(prepared.predicate, "utf8")), + create(values), + ); + assert.equal( + readFileSync(prepared.checksums, "utf8"), + recoveryPromotionSubjectChecksums(values.subjects), + ); + assert.match( + readFileSync(prepared.githubOutput, "utf8"), + new RegExp(`predicate_type=${RECOVERY_PROMOTION_PREDICATE_TYPE}`, "u"), + ); + + writeFileSync(output, `${JSON.stringify(predicate)}\n`); + assert.throws( + () => + execFileSync( + process.execPath, + [TOOL, "verify", ...common, "--predicate", output], + { encoding: "utf8", env, stdio: "pipe" }, + ), + /canonical sorted JSON/u, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/tools/release/release-check-registries.mjs b/tools/release/release-check-registries.mjs index edb07393..c0017902 100644 --- a/tools/release/release-check-registries.mjs +++ b/tools/release/release-check-registries.mjs @@ -21,7 +21,11 @@ function productsJsonArg(args) { function main(argv) { if (argv.includes("-h") || argv.includes("--help")) { - console.log("usage: tools/release/release-check-registries.mjs [--products-json JSON] [--head-ref REF] [--require-identities]"); + console.log( + "usage: tools/release/release-check-registries.mjs " + + "[--products-json JSON] [--head-ref REF] " + + "[--registry-inventory-output FILE] [--require-identities]", + ); process.exit(0); } diff --git a/tools/release/release-check.mjs b/tools/release/release-check.mjs index 651f5545..e5d78005 100644 --- a/tools/release/release-check.mjs +++ b/tools/release/release-check.mjs @@ -3,6 +3,7 @@ import { lstatSync } from "node:fs"; import path from "node:path"; import { captureCommandOutput } from "../dev/capture-command-output.mjs"; +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; import { run } from "./release-cli-utils.mjs"; const TOOL = "release-check.mjs"; @@ -14,6 +15,10 @@ export const DEDICATED_GATE_TESTS = new Set([ ]); export const MUTATION_TEST_TIMEOUT_MS = 30_000; +export function mutationTestEnvironment(inheritedEnvironment = process.env) { + return isolatedGitHubTestEnvironment({}, inheritedEnvironment); +} + export function mutationTests( root, { gitCommand = "git", gitCommandArgs = [], repositoryRoot = ROOT } = {}, @@ -96,7 +101,9 @@ function main(argv) { `--timeout=${MUTATION_TEST_TIMEOUT_MS}`, ...mutationTests("tools/policy"), ...mutationTests("tools/release"), - ]); + ], { + environment: mutationTestEnvironment(), + }); } if (import.meta.main) { diff --git a/tools/release/release-cli-utils.mjs b/tools/release/release-cli-utils.mjs index 908dadb7..5db59433 100644 --- a/tools/release/release-cli-utils.mjs +++ b/tools/release/release-cli-utils.mjs @@ -8,10 +8,20 @@ export function fail(tool, message, exitCode = 1) { process.exit(exitCode); } -export function run(tool, args, { failExitCode = 1, cwd = ROOT, timeout = undefined } = {}) { +export function run( + tool, + args, + { + failExitCode = 1, + cwd = ROOT, + environment = undefined, + timeout = undefined, + } = {}, +) { console.log(`\n==> ${args.join(" ")}`); const result = spawnSync(args[0], args.slice(1), { cwd, + env: environment, stdio: "inherit", timeout, }); diff --git a/tools/release/release-finalization-budget.mjs b/tools/release/release-finalization-budget.mjs index 391ba0d1..30e5974b 100644 --- a/tools/release/release-finalization-budget.mjs +++ b/tools/release/release-finalization-budget.mjs @@ -11,7 +11,7 @@ export const RELEASE_FINALIZATION_STEP_TIMEOUT_MINUTES = Object.freeze({ preserveConsumerEvidence: 2, reverifyPublicationLock: 2, preservePacingEvidence: 2, - promoteDrafts: 12, + promoteDrafts: 16, }); export const RELEASE_FINALIZATION_STEP_TIMEOUT_SECONDS = Object.values( @@ -28,4 +28,4 @@ export const RELEASE_MINIMUM_FINALIZATION_SECONDS = // Registry mutation stops two minutes before the finalization entry gate's // minimum, so the registry executor can seal local receipts and return without // consuming the protected finalization/cleanup envelope. -export const RELEASE_FINALIZATION_RESERVE_SECONDS = 50 * 60; +export const RELEASE_FINALIZATION_RESERVE_SECONDS = 54 * 60; diff --git a/tools/release/release-gate-topology.test.mjs b/tools/release/release-gate-topology.test.mjs index 5c4e985a..2b1cf85d 100644 --- a/tools/release/release-gate-topology.test.mjs +++ b/tools/release/release-gate-topology.test.mjs @@ -16,6 +16,7 @@ import { test } from "node:test"; import { DEDICATED_GATE_TESTS, MUTATION_TEST_TIMEOUT_MS, + mutationTestEnvironment, mutationTests, } from "./release-check.mjs"; @@ -184,6 +185,7 @@ test("qualified replay proves hosted evidence and clean source before omitting m ); assert.equal(MUTATION_TEST_TIMEOUT_MS, 30_000); assert.match(releaseCheck, /`--timeout=\$\{MUTATION_TEST_TIMEOUT_MS\}`/u); + assert.match(releaseCheck, /environment: mutationTestEnvironment[(][)]/u); assert.doesNotMatch(releaseCheck, /metadata-only/u); const publisher = read("tools/release/release-publish.mjs"); assert.match(publisher, /qualifiedCi && allowDirty/u); @@ -194,6 +196,21 @@ test("qualified replay proves hosted evidence and clean source before omitting m assert.match(publisher, /release-metadata-check[.]mjs/u); }); +test("release mutation tests cannot consume a live publish request journal", () => { + assert.deepEqual( + mutationTestEnvironment({ + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "f0rr0/oliphaunt", + GITHUB_RUN_ID: "30593859032", + KEEP_ME: "preserved", + OLIPHAUNT_GITHUB_CORE_REQUEST_JOURNAL_PATH: "/live/journal.json", + OLIPHAUNT_REQUIRE_GITHUB_CORE_REQUEST_JOURNAL: "true", + RELEASE_HEAD_SHA: "a".repeat(40), + }), + { KEEP_ME: "preserved" }, + ); +}); + test("the canonical release gate is the single hosted repository-graph validator", () => { const graphProject = Bun.YAML.parse(read("tools/graph/moon.yml")); const graphCheck = graphProject.tasks?.check; diff --git a/tools/release/release-phase-budget.mjs b/tools/release/release-phase-budget.mjs index e6994046..f8ecf630 100644 --- a/tools/release/release-phase-budget.mjs +++ b/tools/release/release-phase-budget.mjs @@ -26,13 +26,14 @@ export const REGISTRY_INPUT_VALIDATION_ALLOWANCE_SECONDS = 25 * 60; export const REGISTRY_MUTATION_ALLOWANCE_SECONDS = 190 * 60; export const REGISTRY_EVIDENCE_HANDOFF_ALLOWANCE_SECONDS = 15 * 60; -export const FINALIZE_JOB_TIMEOUT_SECONDS = 120 * 60; -export const FINALIZE_JOB_HARD_WINDOW_SECONDS = 114 * 60; +export const FINALIZE_JOB_TIMEOUT_SECONDS = 124 * 60; +export const FINALIZE_JOB_HARD_WINDOW_SECONDS = 118 * 60; export const FINALIZE_JOB_CLEANUP_SECONDS = 6 * 60; // Includes checkout, exact handoff installation, digest-verified Node/npm and -// the remaining finalization toolchains. The resulting phase still preserves -// a six-minute hard-window margin before the independent cleanup reserve. -export const FINALIZE_SETUP_HANDOFF_ALLOWANCE_SECONDS = 60 * 60; +// the remaining finalization toolchains plus the explicit source/controller +// identity proof. The resulting phase still preserves a five-minute +// hard-window margin before the independent cleanup reserve. +export const FINALIZE_SETUP_HANDOFF_ALLOWANCE_SECONDS = 61 * 60; export const RELEASE_PHASE_BUDGETS = Object.freeze({ "github-staged": Object.freeze({ diff --git a/tools/release/release-phase-budget.test.mjs b/tools/release/release-phase-budget.test.mjs index 5d34440f..3362789e 100644 --- a/tools/release/release-phase-budget.test.mjs +++ b/tools/release/release-phase-budget.test.mjs @@ -5,6 +5,7 @@ import { assertReleasePhaseBudget, FINALIZE_JOB_HARD_WINDOW_SECONDS, FINALIZE_JOB_TIMEOUT_SECONDS, + FINALIZE_SETUP_HANDOFF_ALLOWANCE_SECONDS, GITHUB_STAGE_HANDOFF_ALLOWANCE_SECONDS, GITHUB_STAGE_JOB_HARD_WINDOW_SECONDS, GITHUB_STAGE_JOB_TIMEOUT_SECONDS, @@ -34,6 +35,12 @@ test("all three normal release phases retain explicit positive margins", () => { assert.equal(registry.components.registryMutation, 190 * 60); assert.equal(REGISTRY_MUTATION_ALLOWANCE_SECONDS, 190 * 60); assert.equal(finalize.jobTimeoutSeconds, FINALIZE_JOB_TIMEOUT_SECONDS); + assert.equal(FINALIZE_SETUP_HANDOFF_ALLOWANCE_SECONDS, 61 * 60); + assert.equal( + finalize.components.checkoutToolchainsAndReceiptHandoff, + FINALIZE_SETUP_HANDOFF_ALLOWANCE_SECONDS, + ); + assert.equal(finalize.hardWindowMarginSeconds, 5 * 60); }); test("stage equality and overflow fail closed rather than consuming cleanup", () => { diff --git a/tools/release/release-publish-frozen-artifacts.test.mjs b/tools/release/release-publish-frozen-artifacts.test.mjs index 406a4933..63441bb5 100644 --- a/tools/release/release-publish-frozen-artifacts.test.mjs +++ b/tools/release/release-publish-frozen-artifacts.test.mjs @@ -86,6 +86,9 @@ describe("real publication consumes frozen artifacts", () => { expect(publisher).toContain("if (provenReceipts.has(operation.carrierId)) return true"); expect(publisher).toContain("covered by the complete immutable bootstrap ledger; skipping redundant registry reconciliation"); expect(publisher).toContain("const execution = await executeNormalPublicationPlan"); + expect(publisher).toContain("activePublicationSourceRef()"); + expect(publisher).toContain("environment.RELEASE_SOURCE_SHA?.trim()"); + expect(publisher).toContain("RELEASE_SOURCE_SHA must be a full lowercase commit SHA"); expect(publisher).toContain("operationResults: execution.operationResults"); expect(publisher).toContain("collectNormalPublicationReceipts({"); expect(publisher).toContain("verifyLockedRegistryIntegrity(ACTIVE_PUBLICATION_LOCK"); diff --git a/tools/release/release-publish.mjs b/tools/release/release-publish.mjs index b4c64137..9e516bb7 100755 --- a/tools/release/release-publish.mjs +++ b/tools/release/release-publish.mjs @@ -226,6 +226,15 @@ const GITHUB_RELEASE_ASSET_PRODUCTS = new Set([ "oliphaunt-node-direct", ]); +function activePublicationSourceRef(environment = process.env) { + const configured = environment.RELEASE_SOURCE_SHA?.trim(); + if (configured === undefined || configured === "") return "HEAD"; + if (!/^[0-9a-f]{40}$/u.test(configured)) { + fail("RELEASE_SOURCE_SHA must be a full lowercase commit SHA when provided"); + } + return configured; +} + if (command === "-h" || command === "--help") { usage(); process.exit(0); @@ -242,7 +251,10 @@ if (BOOTSTRAP_IDENTITIES && command !== "publish") { if (command === "publish") { try { ACTIVE_PUBLICATION_LOCK = loadPublicationLock(PUBLICATION_LOCK_PATH); - assertPublicationLockSource(ACTIVE_PUBLICATION_LOCK, "HEAD"); + assertPublicationLockSource( + ACTIVE_PUBLICATION_LOCK, + activePublicationSourceRef(), + ); } catch (error) { fail(error instanceof Error ? error.message : String(error)); } diff --git a/tools/release/release-recovery-workflow.test.mjs b/tools/release/release-recovery-workflow.test.mjs new file mode 100644 index 00000000..dc5a5f32 --- /dev/null +++ b/tools/release/release-recovery-workflow.test.mjs @@ -0,0 +1,552 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const ROOT = path.resolve(import.meta.dir, "../.."); + +function workflow() { + return Bun.YAML.parse( + readFileSync(path.join(ROOT, ".github/workflows/release.yml"), "utf8"), + ); +} + +function namedStep(job, name) { + const matches = job.steps.filter((step) => step?.name === name); + assert.equal(matches.length, 1, `${name} must occur exactly once`); + return matches[0]; +} + +function stepIndex(job, name) { + const index = job.steps.findIndex((step) => step?.name === name); + assert.notEqual(index, -1, `${name} must exist`); + return index; +} + +test("dry-run separately qualifies control HEAD and reuses the frozen release payload", () => { + const job = workflow().jobs["publish-dry-run"]; + const candidate = namedStep( + job, + "Prove workflow HEAD is a release or same-version recovery commit", + ); + assert.equal(candidate.id, "verify_publication_candidate"); + assert.match(candidate.run, /verify-publication-candidate[.]mjs/u); + assert.match(candidate.run, /--github-output "\$GITHUB_OUTPUT"/u); + + const controlCi = namedStep(job, "Require qualified release-commit CI run"); + const payloadCi = namedStep( + job, + "Require qualified frozen-payload CI run for same-version recovery", + ); + const artifactSource = namedStep(job, "Resolve exact release artifact source"); + assert.match(controlCi.run, /"\$RELEASE_CONTROL_SHA"/u); + assert.match(payloadCi.run, /"\$RECOVERY_RELEASE_SHA"/u); + assert.match( + payloadCi.env.RECOVERY_RELEASE_SHA, + /verify_publication_candidate[.]outputs[.]release_sha/u, + ); + assert.match(payloadCi.run, /--job Required/u); + assert.match(payloadCi.run, /--job Qualified/u); + assert.match(payloadCi.run, /PINNED_ARTIFACT_METADATA_JSON/u); + assert.match( + payloadCi.run, + /qualification_args\+=\(--artifact "\$artifact"\)/u, + ); + assert.match( + payloadCi.run, + /Frozen-payload CI artifact inventory differs from the pinned recovery provenance/u, + ); + assert.match(artifactSource.run, /CANDIDATE_MODE.*release-recovery/su); + assert.match(artifactSource.run, /artifact_sha="\$RECOVERY_RELEASE_SHA"/u); + assert.match( + artifactSource.run, + /artifact_ci_run_id="\$RECOVERY_PAYLOAD_CI_RUN_ID"/u, + ); + const identity = namedStep( + job, + "Resolve exact release source and controller identities", + ); + assert.match(identity.run, /source_sha="\$RECOVERY_RELEASE_SHA"/u); + assert.match(identity.run, /RELEASE_CONTROL_SHA=\$CONTROL_SHA/u); + assert.match(identity.run, /RELEASE_SOURCE_SHA=\$source_sha/u); + assert( + stepIndex(job, "Require qualified release-commit CI run") + < stepIndex( + job, + "Require qualified frozen-payload CI run for same-version recovery", + ), + ); + assert( + stepIndex( + job, + "Require qualified frozen-payload CI run for same-version recovery", + ) < stepIndex(job, "Resolve exact release artifact source"), + ); + + const payloadCandidate = namedStep( + job, + "Verify frozen-payload qualification record", + ); + assert.match( + payloadCandidate.env.CI_RUN_ID, + /release_artifact_source[.]outputs[.]ci_run_id/u, + ); + assert.match( + payloadCandidate.env.RELEASE_HEAD_SHA, + /release_artifact_source[.]outputs[.]sha/u, + ); + assert.match( + payloadCandidate.run, + /target\/recovery-payload-candidate\/oliphaunt-release-candidate[.]json/u, + ); + + for (const name of [ + "Download WASIX release assets", + "Download exact-extension package artifacts", + "Download SDK package artifacts", + "Download liboliphaunt release assets", + "Download native helper release assets", + "Download Node direct optional npm packages", + ]) { + const step = namedStep(job, name); + assert.match( + step.env.CI_RUN_ID, + /release_artifact_source[.]outputs[.]ci_run_id/u, + `${name} must use the frozen payload CI run`, + ); + assert.match( + step.run, + /\$RELEASE_ARTIFACT_SHA/u, + `${name} must use the frozen payload SHA`, + ); + assert.doesNotMatch( + step.run, + /"\$RELEASE_HEAD_SHA"/u, + `${name} must not download rebuilt recovery-HEAD artifacts`, + ); + } + const wasixPayload = namedStep(job, "Download WASIX runtime build artifacts"); + assert.match( + wasixPayload.env.CI_RUN_ID, + /release_artifact_source[.]outputs[.]ci_run_id/u, + ); + assert.match( + wasixPayload.env.RELEASE_ARTIFACT_SHA, + /release_artifact_source[.]outputs[.]sha/u, + ); + assert.equal( + wasixPayload.env.RELEASE_HEAD_SHA, + undefined, + "the payload selector must not replace the controller-bound release lineage", + ); + + const productDryRun = namedStep( + job, + "Validate selected release product dry-runs", + ); + assert.match( + productDryRun.env.CI_RUN_ID, + /steps[.]ci_qualification[.]outputs[.]run_id/u, + ); + assert.match(productDryRun.run, /--head-ref "\$RELEASE_HEAD_SHA"/u); + + const markable = namedStep( + job, + "Prove Release Please PR can complete after publication", + ); + assert.match( + markable.run, + /steps[.]verify_publication_candidate[.]outputs[.]release_sha/u, + ); + assert.doesNotMatch(markable.run, /--release-sha "\$RELEASE_HEAD_SHA"/u); + + assert( + stepIndex(job, "Select original approved lock for same-version recovery") + < stepIndex(job, "Freeze exhaustive publication lock"), + ); + const prelockRegistryValidation = namedStep( + job, + "Validate product versions and registry state", + ); + assert.doesNotMatch( + prelockRegistryValidation.run, + /--registry-inventory-output/u, + ); + assert.match( + prelockRegistryValidation.run, + /check_release_versions[.]mjs/u, + ); + assert( + stepIndex(job, "Freeze exhaustive publication lock") + < stepIndex(job, "Prove same-version recovery byte envelope is unchanged"), + ); + const freeze = namedStep(job, "Freeze exhaustive publication lock"); + assert.match(freeze.run, /lock_output=target\/release\/replayed-publication-lock[.]json/u); + assert.match(freeze.run, /lock_source="\$RELEASE_ARTIFACT_SHA"/u); + assert.match(freeze.run, /cmp -s "\$original_lock" "\$lock_output"/u); + assert.match(freeze.run, /cp "\$original_lock" "\$PUBLICATION_LOCK_PATH"/u); + assert( + stepIndex(job, "Prove same-version recovery byte envelope is unchanged") + < stepIndex( + job, + "Inventory exact frozen registry state for same-version recovery", + ), + ); + assert( + stepIndex( + job, + "Inventory exact frozen registry state for same-version recovery", + ) + < stepIndex( + job, + "Prove same-version recovery follows a partial immutable publication", + ), + ); + assert( + stepIndex( + job, + "Prove same-version recovery follows a partial immutable publication", + ) + < stepIndex(job, "Upload same-version recovery equivalence evidence"), + ); + const equivalence = namedStep( + job, + "Prove same-version recovery byte envelope is unchanged", + ); + assert.match(equivalence.run, /verify-release-recovery-lock[.]mjs/u); + assert.match( + equivalence.run, + /--replay-lock target\/release\/replayed-publication-lock[.]json/u, + ); + assert.match(equivalence.run, /--controller-sha "\$RELEASE_CONTROL_SHA"/u); + assert.match( + equivalence.if, + /verify_publication_candidate[.]outputs[.]mode == 'release-recovery'/u, + ); + const inventory = namedStep( + job, + "Inventory exact frozen registry state for same-version recovery", + ); + assert.equal( + inventory.env.OLIPHAUNT_PUBLICATION_LOCK, + "${{ env.PUBLICATION_LOCK_PATH }}", + ); + assert.match(inventory.run, /release-check-registries[.]mjs/u); + assert.match( + inventory.run, + /--registry-inventory-output target\/release\/recovery-registry-inventory[.]json/u, + ); + const publication = namedStep( + job, + "Prove same-version recovery follows a partial immutable publication", + ); + assert.match(publication.run, /verify-release-recovery-publication[.]mjs/u); + assert.match(publication.run, /recovery-registry-inventory[.]json/u); + const evidence = namedStep( + job, + "Upload same-version recovery equivalence evidence", + ); + assert.equal(evidence.with.path, "target/release/recovery-evidence"); + const ledger = namedStep(job, "Download immutable registry bootstrap ledger"); + assert.match( + ledger.env.PINNED_LEDGER_RUN_ID, + /steps[.]recovery_source[.]outputs[.]bootstrap_ledger_run_id/u, + ); + assert.match( + ledger.env.PINNED_LEDGER_ARTIFACT_METADATA_JSON, + /steps[.]recovery_source[.]outputs[.]bootstrap_ledger_artifact_metadata_json/u, + ); + assert.match(ledger.run, /--run-id "\$PINNED_LEDGER_RUN_ID"/u); + assert.match( + ledger.run, + /--artifact-metadata-json "\$PINNED_LEDGER_ARTIFACT_METADATA_JSON"/u, + ); + + const promotion = namedStep( + job, + "Prepare same-version recovery promotion attestation", + ); + assert.match( + promotion.env.CONTROLLER_CI_RUN_ID, + /steps[.]ci_qualification[.]outputs[.]run_id/u, + ); + assert.match( + promotion.env.CONTROLLER_CI_RUN_ATTEMPT, + /steps[.]ci_qualification[.]outputs[.]run_attempt/u, + ); + assert.match( + promotion.env.CONTROLLER_CI_ARTIFACTS_JSON, + /steps[.]ci_qualification[.]outputs[.]artifact_metadata_json/u, + ); + assert.match( + promotion.env.CONTROLLER_APPROVAL_RUN_ID, + /steps[.]approved_recovery_control[.]outputs[.]run_id/u, + ); + assert.match( + promotion.env.CONTROLLER_APPROVAL_RUN_ATTEMPT, + /steps[.]approved_recovery_control[.]outputs[.]run_attempt/u, + ); + assert.match( + promotion.env.CONTROLLER_APPROVAL_ARTIFACTS_JSON, + /steps[.]approved_recovery_control[.]outputs[.]artifact_metadata_json/u, + ); + assert.match(promotion.run, /recovery-promotion-attestation[.]mjs prepare/u); + assert.match(promotion.run, /--controller-sha "\$RELEASE_CONTROL_SHA"/u); + assert.match(promotion.run, /--approval target\/release\/recovery-evidence\/lock-equivalence[.]json/u); + assert.match( + promotion.run, + /--approval-run-id "\$CONTROLLER_APPROVAL_RUN_ID"/u, + ); + assert.match( + promotion.run, + /--approval-run-attempt "\$CONTROLLER_APPROVAL_RUN_ATTEMPT"/u, + ); + assert.match( + promotion.run, + /--approval-artifacts-json "\$CONTROLLER_APPROVAL_ARTIFACTS_JSON"/u, + ); + + const attestPromotion = namedStep( + job, + "Attest same-version recovery promotion", + ); + assert.equal( + attestPromotion.uses, + "actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d", + ); + assert.match( + attestPromotion.with["subject-checksums"], + /recovery_promotion_attestation[.]outputs[.]checksums_path/u, + ); + assert.match( + attestPromotion.with["predicate-type"], + /recovery_promotion_attestation[.]outputs[.]predicate_type/u, + ); + assert.match( + attestPromotion.with["predicate-path"], + /recovery_promotion_attestation[.]outputs[.]predicate_path/u, + ); + + for (const name of [ + "Attest selected extension release assets (shard 1)", + "Attest selected extension release assets (shard 2)", + "Attest liboliphaunt release assets", + "Attest broker release assets", + "Attest Node direct release assets", + "Attest WASIX release assets", + ]) { + assert.match( + namedStep(job, name).if, + /verify_publication_candidate[.]outputs[.]mode != 'release-recovery'/u, + `${name} must not claim ordinary build provenance for frozen recovery bytes`, + ); + } + const freezeEvidence = namedStep( + job, + "Freeze exact GitHub release asset and attestation evidence", + ); + assert.match( + freezeEvidence.env.RECOVERY_PROMOTION_ATTESTATION_BUNDLE, + /attest_recovery_promotion[.]outputs[.]bundle-path/u, + ); + assert.match( + freezeEvidence.run, + /--recovery-controller target\/release\/recovery-evidence\/promotion-controller[.]json/u, + ); + assert.match( + freezeEvidence.run, + /--recovery-provenance tools\/release\/same-version-recovery-sources[.]json/u, + ); + assert.match( + freezeEvidence.run, + /--recovery-approval target\/release\/recovery-evidence\/lock-equivalence[.]json/u, + ); + + for (const name of [ + "Freeze bootstrap publication capsule", + "Upload frozen publication lock", + "Upload frozen bootstrap publication capsule", + ]) { + assert.match( + namedStep(job, name).if, + /verify_publication_candidate[.]outputs[.]mode != 'release-recovery'/u, + `${name} must not relabel original release inputs under the controller run`, + ); + } +}); + +test("publication separately approves immutable source inputs and controller recovery evidence", () => { + const parsed = workflow(); + const publish = parsed.jobs.publish; + assert.equal(publish.permissions["artifact-metadata"], "write"); + const approved = namedStep(publish, "Require one approved dry-run lock and capsule"); + assert.match(approved.run, /--run-id "\$PINNED_DRY_RUN_ID"/u); + assert.match( + approved.run, + /approval_sha="\$RELEASE_SOURCE_SHA"/u, + ); + assert.match( + approved.env.CANDIDATE_MODE, + /steps[.]verify_publication_candidate[.]outputs[.]mode/u, + ); + const controlEvidence = namedStep( + publish, + "Require approved same-version recovery control evidence", + ); + assert.match(controlEvidence.run, /"\$RELEASE_CONTROL_SHA"/u); + assert.match( + controlEvidence.run, + /--artifact oliphaunt-release-recovery-equivalence/u, + ); + + const bootstrap = parsed.jobs["publish-bootstrap"]; + const candidate = namedStep( + bootstrap, + "Prove workflow HEAD is a release or same-version recovery commit", + ); + assert.equal(candidate.id, "verify_bootstrap_publication_candidate"); + assert.match(candidate.run, /verify-publication-candidate[.]mjs/u); + const rejectRecovery = namedStep( + bootstrap, + "Reject same-version recovery bootstrap mutation", + ); + assert.match( + rejectRecovery.if, + /verify_bootstrap_publication_candidate[.]outputs[.]mode == 'release-recovery'/u, + ); + assert.match( + rejectRecovery.run, + /Reuse and verify the pinned terminal bootstrap ledger/u, + ); + const markable = namedStep( + bootstrap, + "Prove Release Please PR can complete after bootstrap publication", + ); + assert.match( + markable.run, + /steps[.]verify_bootstrap_publication_candidate[.]outputs[.]release_sha/u, + ); + const capsule = namedStep(bootstrap, "Select one approved dry-run capsule"); + assert.doesNotMatch( + capsule.run, + /oliphaunt-release-recovery-equivalence/u, + ); + assert.equal( + bootstrap.steps.some( + ({ name }) => name === "Download approved same-version recovery evidence" + || name === "Verify approved same-version recovery publication state", + ), + false, + ); + assert( + stepIndex(bootstrap, "Reject same-version recovery bootstrap mutation") + < stepIndex( + bootstrap, + "Prove Release Please PR can complete after bootstrap publication", + ), + ); + const credentials = namedStep( + bootstrap, + "Require bootstrap credentials before mutation", + ); + assert.match( + credentials.env.CRATES_IO_BOOTSTRAP_TOKEN, + /secrets[.]CRATES_IO_BOOTSTRAP_TOKEN/u, + ); + assert.match(credentials.env.NPM_BOOTSTRAP_TOKEN, /secrets[.]NPM_BOOTSTRAP_TOKEN/u); +}); + +test("finalization tags the original Release Please lifecycle and source commit", () => { + const finalize = workflow().jobs["publish-finalize"]; + const candidate = namedStep(finalize, "Re-prove release lifecycle identity"); + assert.equal(candidate.id, "finalize_publication_candidate"); + assert.match(candidate.run, /verify-publication-candidate[.]mjs/u); + + const promote = namedStep(finalize, "Promote verified GitHub release drafts"); + assert.equal( + promote.run.match( + /steps[.]finalize_publication_candidate[.]outputs[.]release_sha/gu, + )?.length, + 2, + ); + assert.match(promote.run, /--head-ref "\$RELEASE_SOURCE_SHA"/u); + assert.doesNotMatch(promote.run, /--release-sha "\$RELEASE_HEAD_SHA"/u); +}); + +test("same-version recovery cannot escape into a source-relabeling continuation", () => { + const parsed = workflow(); + const registry = parsed.jobs["publish-registry"]; + const restore = namedStep( + registry, + "Restore exact-SHA normal-publication checkpoint", + ); + assert.match( + restore.env.RELEASE_SOURCE_SHA, + /registry_release_identity[.]outputs[.]source_sha/u, + ); + assert.equal(restore.env.RELEASE_HEAD_SHA, undefined); + const deferral = namedStep( + registry, + "Seal typed zero-mutation capacity or deadline deferral", + ); + assert.match( + deferral.env.RELEASE_SOURCE_SHA, + /registry_release_identity[.]outputs[.]source_sha/u, + ); + assert.equal(deferral.env.RELEASE_HEAD_SHA, undefined); + + const decision = namedStep( + registry, + "Require a typed registry execution decision", + ); + assert.match( + decision.env.RELEASE_CONTROL_SHA, + /registry_release_identity[.]outputs[.]controller_sha/u, + ); + assert.match( + decision.env.RELEASE_SOURCE_SHA, + /registry_release_identity[.]outputs[.]source_sha/u, + ); + assert.match( + decision.run, + /DEFERRED.*RELEASE_CONTROL_SHA.*RELEASE_SOURCE_SHA/su, + ); + assert.match( + decision.run, + /Same-version recovery cannot create a continuation/u, + ); + + for (const name of [ + "Seal exact normal-publication continuation contract", + "Preserve immutable deferred normal-publication continuation", + ]) { + assert.match( + namedStep(registry, name).if, + /controller_sha == steps[.]registry_release_identity[.]outputs[.]source_sha/u, + ); + } + assert.match( + parsed.jobs["dispatch-publish-continuation"].if, + /release_control_sha == needs[.]publish-registry[.]outputs[.]release_source_sha/u, + ); +}); + +test("CI rejects a malformed recovery lineage before expensive planning", () => { + const intent = readFileSync( + path.join(ROOT, ".github/scripts/check-release-intent.sh"), + "utf8", + ); + const verifier = intent.indexOf( + "tools/release/verify-publication-candidate.mjs", + ); + const planner = intent.indexOf( + 'release_plan="$(tools/dev/bun.sh tools/release/release_plan.mjs', + ); + assert.notEqual(verifier, -1); + assert.notEqual(planner, -1); + assert(verifier < planner); + assert.match(intent, /--derive-products/u); + assert.match(intent, /Oliphaunt-Release-Recovery-Of/u); +}); diff --git a/tools/release/release-source-identity.mjs b/tools/release/release-source-identity.mjs new file mode 100644 index 00000000..829507ef --- /dev/null +++ b/tools/release/release-source-identity.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +const FULL_SHA = /^[0-9a-f]{40}$/u; + +export function resolveReleaseSourceCommit({ + controlCommit, + sourceCommit, +}, { + prefix = "release-source-identity", +} = {}) { + const control = String(controlCommit ?? "").trim(); + if (!FULL_SHA.test(control)) { + throw new Error(`${prefix}: release control commit must be a full lowercase commit SHA`); + } + const configuredSource = String(sourceCommit ?? "").trim(); + const source = configuredSource || control; + if (!FULL_SHA.test(source)) { + throw new Error( + `${prefix}: release source commit must be a full lowercase commit SHA when provided`, + ); + } + return source; +} diff --git a/tools/release/release-source-identity.test.mjs b/tools/release/release-source-identity.test.mjs new file mode 100644 index 00000000..831bd690 --- /dev/null +++ b/tools/release/release-source-identity.test.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveReleaseSourceCommit } from "./release-source-identity.mjs"; + +const CONTROL = "a".repeat(40); +const SOURCE = "b".repeat(40); + +test("normal publication defaults the immutable source to the control commit", () => { + assert.equal(resolveReleaseSourceCommit({ controlCommit: CONTROL }), CONTROL); + assert.equal( + resolveReleaseSourceCommit({ controlCommit: CONTROL, sourceCommit: " " }), + CONTROL, + ); +}); + +test("same-version recovery retains a distinct immutable release source", () => { + assert.equal( + resolveReleaseSourceCommit({ + controlCommit: CONTROL, + sourceCommit: ` ${SOURCE} `, + }), + SOURCE, + ); +}); + +test("release identities reject moving refs and malformed SHAs", () => { + assert.throws( + () => resolveReleaseSourceCommit({ controlCommit: "main" }), + /control commit must be a full lowercase commit SHA/u, + ); + assert.throws( + () => resolveReleaseSourceCommit({ + controlCommit: CONTROL, + sourceCommit: "release/v0.1.0", + }), + /source commit must be a full lowercase commit SHA/u, + ); +}); diff --git a/tools/release/require-workflow-success.test.mjs b/tools/release/require-workflow-success.test.mjs index b0dea35a..f21c03c9 100644 --- a/tools/release/require-workflow-success.test.mjs +++ b/tools/release/require-workflow-success.test.mjs @@ -7,6 +7,8 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { isolatedGitHubTestEnvironment } from "../test/isolated-github-test-environment.mjs"; + const SCRIPT = path.resolve(".github/scripts/require-workflow-success.sh"); const SHA = "a".repeat(40); const WORKFLOW_HELPER_PROCESS_TIMEOUT_MS = 15_000; @@ -51,6 +53,7 @@ if (args[0] === "api") { head_sha: "${SHA}", status: "completed", conclusion: "success", + run_attempt: 3, html_url: "https://example.invalid/run/77", event: "push", }; @@ -78,13 +81,22 @@ if (args[0] === "api") { digest: "sha256:" + "1".repeat(64), expired: false, }; + const gateArtifact = { + id: 903, + name: "gate-artifact", + size_in_bytes: 456, + digest: "sha256:" + "2".repeat(64), + expired: false, + }; const artifacts = process.env.FAKE_MODE === "duplicate-artifact" ? [artifact, { ...artifact, id: 902 }] : process.env.FAKE_MODE === "expired-artifact" ? [{ ...artifact, expired: true }] + : process.env.FAKE_MODE === "missing-gate-artifact" + ? [artifact] : process.env.FAKE_MODE === "malformed-artifact-metadata" ? [{ ...artifact, digest: undefined }] - : [artifact]; + : [artifact, gateArtifact]; process.stdout.write("HTTP/2.0 200 OK\\n\\n" + JSON.stringify({ artifacts })); process.exit(0); } @@ -100,7 +112,7 @@ if (args[0] === "api") { : process.env.FAKE_MODE === "failed-run" ? "failure" : "success"; - process.stdout.write(sha + "\\t9\\tpush\\t" + status + "\\t" + conclusion + "\\n"); + process.stdout.write(sha + "\\t9\\tpush\\t" + status + "\\t" + conclusion + "\\t3\\n"); process.exit(0); } if (/actions\\/workflows\\/9$/.test(endpoint)) { @@ -123,8 +135,7 @@ function invoke(f, mode, args = ["CI", SHA, "10", "--job", "Qualified", "--artif return spawnSync("bash", [SCRIPT, ...args], { cwd: process.cwd(), encoding: "utf8", - env: { - ...process.env, + env: isolatedGitHubTestEnvironment({ PATH: `${f.bin}${path.delimiter}${process.env.PATH}`, FAKE_LOG: f.log, FAKE_MODE: mode, @@ -136,7 +147,7 @@ function invoke(f, mode, args = ["CI", SHA, "10", "--job", "Qualified", "--artif OLIPHAUNT_GITHUB_READ_DEADLINE_MS: "1000", OLIPHAUNT_GITHUB_READ_MAX_ATTEMPTS: "1", OLIPHAUNT_GITHUB_READ_MAX_DELAY_MS: "0", - }, + }), timeout: WORKFLOW_HELPER_PROCESS_TIMEOUT_MS, }); } @@ -149,12 +160,12 @@ test("a transient exact-SHA run-inventory failure does not abort the long-lived assert.match(result.stdout, /selected CI run 77/u); assert.equal( readFileSync(f.output, "utf8"), - `run_id=77\nartifact_metadata_json=${JSON.stringify([{ + `run_id=77\nrun_attempt=3\nartifact_metadata_json=${JSON.stringify([{ digest: `sha256:${"1".repeat(64)}`, id: 901, name: "required-artifact", size: 123, - }])}\n`, + }])}\ngate_artifact_metadata_json=[]\n`, ); assert.equal(readFileSync(f.state, "utf8"), "2"); }); @@ -229,6 +240,61 @@ test("artifact gates require exactly one non-expired artifact identity", (t) => } }); +test("gate-only artifacts authorize a run without contaminating transfer metadata", (t) => { + const f = fixture(t); + const args = [ + "CI", + SHA, + "0", + "--artifact", + "required-artifact", + "--gate-artifact", + "gate-artifact", + ]; + const result = invoke(f, "", args); + assert.equal(result.status, 0, result.stderr); + assert.equal( + readFileSync(f.output, "utf8"), + `run_id=77\nrun_attempt=3\nartifact_metadata_json=${JSON.stringify([{ + digest: `sha256:${"1".repeat(64)}`, + id: 901, + name: "required-artifact", + size: 123, + }])}\ngate_artifact_metadata_json=${JSON.stringify([{ + digest: `sha256:${"2".repeat(64)}`, + id: 903, + name: "gate-artifact", + size: 456, + }])}\n`, + ); + + const missing = fixture(t); + const missingResult = invoke(missing, "missing-gate-artifact", args); + assert.equal(missingResult.status, 1, missingResult.stderr); + assert.match(missingResult.stderr, /gate-artifact.*found 0/u); + assert.equal(readFileSync(missing.output, "utf8"), ""); +}); + +test("transfer and gate artifact identities must be globally unique", (t) => { + const f = fixture(t); + const result = invoke( + f, + "", + [ + "CI", + SHA, + "0", + "--artifact", + "required-artifact", + "--gate-artifact", + "required-artifact", + ], + ); + assert.equal(result.status, 64, result.stderr); + assert.match(result.stderr, /artifact identity list is malformed/u); + assert.equal(readFileSync(f.output, "utf8"), ""); +}); + test("malformed artifact metadata is a permanent protocol failure, not a retryable absence", (t) => { const f = fixture(t); const result = invoke(f, "malformed-artifact-metadata"); diff --git a/tools/release/same-version-recovery-source.mjs b/tools/release/same-version-recovery-source.mjs new file mode 100644 index 00000000..622b7e81 --- /dev/null +++ b/tools/release/same-version-recovery-source.mjs @@ -0,0 +1,1037 @@ +#!/usr/bin/env bun + +import { createHash } from "node:crypto"; +import { + appendFileSync, + lstatSync, + readFileSync, +} from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { captureCommandOutput } from "../dev/capture-command-output.mjs"; + +const TOOL = "same-version-recovery-source.mjs"; +export const SAME_VERSION_RECOVERY_SOURCES_SCHEMA = + "oliphaunt-same-version-recovery-sources-v1"; +const PUBLICATION_LOCK_SCHEMA = "oliphaunt-publication-lock-v1"; +const CAPSULE_SCHEMA = "oliphaunt-bootstrap-publication-capsule-v1"; +const LEDGER_SCHEMA = "oliphaunt-bootstrap-ledger-checkpoint-v1"; +const SHA = /^[0-9a-f]{40}$/u; +const HASH = /^[0-9a-f]{64}$/u; +const ARTIFACT_DIGEST = /^sha256:[0-9a-f]{64}$/u; +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const DEFAULT_SAME_VERSION_RECOVERY_SOURCES = path.join( + ROOT, + "tools/release/same-version-recovery-sources.json", +); + +function error(message) { + return new Error(`${TOOL}: ${message}`); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map((key) => [key, canonical(value[key])]), + ); + } + return value; +} + +export function canonicalRecoverySourceJson(value) { + return JSON.stringify(canonical(value)); +} + +function prettyCanonicalJson(value) { + return `${JSON.stringify(canonical(value), null, 2)}\n`; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function digestValue(value) { + return sha256(canonicalRecoverySourceJson(value)); +} + +function strictObject(value, expectedKeys, context) { + if ( + value === null + || Array.isArray(value) + || typeof value !== "object" + || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) + ) { + throw error(`${context} must be a plain object`); + } + const actual = Object.keys(value).sort(compareText); + const expected = [...expectedKeys].sort(compareText); + if (canonicalRecoverySourceJson(actual) !== canonicalRecoverySourceJson(expected)) { + throw error( + `${context} must contain exactly ${expected.join(", ")}; got ${actual.join(", ") || ""}`, + ); + } + return value; +} + +function positiveInteger(value, context) { + if (!Number.isSafeInteger(value) || value < 1) { + throw error(`${context} must be a positive safe integer`); + } + return value; +} + +function nonNegativeInteger(value, context) { + if (!Number.isSafeInteger(value) || value < 0) { + throw error(`${context} must be a non-negative safe integer`); + } + return value; +} + +function hash(value, context) { + if (typeof value !== "string" || !HASH.test(value)) { + throw error(`${context} must be a lowercase SHA-256 digest`); + } + return value; +} + +function sourceIdentity(value, context) { + strictObject(value, ["commit", "tree"], context); + if (!SHA.test(value.commit) || !SHA.test(value.tree)) { + throw error(`${context} must contain lowercase full commit/tree SHAs`); + } + return value; +} + +function sameSource(left, right, context) { + if (left.commit !== right.commit || left.tree !== right.tree) { + throw error( + `${context} source ${left.commit}/${left.tree} does not match ` + + `${right.commit}/${right.tree}`, + ); + } +} + +function fileIdentity(value, context, expectedPath = undefined) { + strictObject(value, ["path", "sha256", "size"], context); + if ( + typeof value.path !== "string" + || value.path.length === 0 + || value.path.startsWith("/") + || value.path.includes("\\") + || value.path.split("/").some((part) => part === "" || part === "." || part === "..") + || /[\u0000-\u001f\u007f]/u.test(value.path) + ) { + throw error(`${context}.path must be a safe relative POSIX path`); + } + if (expectedPath !== undefined && value.path !== expectedPath) { + throw error(`${context}.path must be ${expectedPath}`); + } + hash(value.sha256, `${context}.sha256`); + positiveInteger(value.size, `${context}.size`); + return value; +} + +function artifactIdentity(value, context) { + strictObject(value, ["digest", "id", "name", "size"], context); + if (!ARTIFACT_DIGEST.test(value.digest ?? "")) { + throw error(`${context}.digest must be a lowercase sha256: Actions artifact digest`); + } + positiveInteger(value.id, `${context}.id`); + positiveInteger(value.size, `${context}.size`); + if (typeof value.name !== "string" || !SAFE_ARTIFACT_NAME.test(value.name)) { + throw error(`${context}.name is not a safe Actions artifact name`); + } + return value; +} + +function compareArtifacts(left, right) { + return compareText(left.name, right.name) || left.id - right.id; +} + +function artifactInventory(value, context) { + strictObject(value, ["artifacts", "count", "inventoryDigest", "totalSize"], context); + if (!Array.isArray(value.artifacts) || value.artifacts.length === 0) { + throw error(`${context}.artifacts must be a non-empty list`); + } + value.artifacts.forEach((entry, index) => + artifactIdentity(entry, `${context}.artifacts[${index}]`)); + const sorted = [...value.artifacts].sort(compareArtifacts); + if ( + canonicalRecoverySourceJson(sorted) + !== canonicalRecoverySourceJson(value.artifacts) + ) { + throw error(`${context}.artifacts must be sorted by name and id`); + } + const ids = new Set(); + const names = new Set(); + for (const artifact of value.artifacts) { + if (ids.has(artifact.id)) throw error(`${context} repeats artifact id ${artifact.id}`); + if (names.has(artifact.name)) throw error(`${context} repeats artifact name ${artifact.name}`); + ids.add(artifact.id); + names.add(artifact.name); + } + if (value.count !== value.artifacts.length) { + throw error(`${context}.count does not match the complete artifact inventory`); + } + const totalSize = value.artifacts.reduce((total, artifact) => total + artifact.size, 0); + if (!Number.isSafeInteger(totalSize) || value.totalSize !== totalSize) { + throw error(`${context}.totalSize does not match the complete artifact inventory`); + } + hash(value.inventoryDigest, `${context}.inventoryDigest`); + const expectedDigest = digestValue(value.artifacts); + if (value.inventoryDigest !== expectedDigest) { + throw error( + `${context}.inventoryDigest mismatch: expected ${expectedDigest}, got ${value.inventoryDigest}`, + ); + } + return value; +} + +function exactArtifactNames(inventory, expectedNames, context) { + const actual = inventory.artifacts.map((artifact) => artifact.name); + if (canonicalRecoverySourceJson(actual) !== canonicalRecoverySourceJson(expectedNames)) { + throw error(`${context} must contain exactly ${expectedNames.join(", ")}`); + } +} + +function workflow(value, context, expected) { + strictObject(value, ["id", "name", "path"], context); + positiveInteger(value.id, `${context}.id`); + if ( + value.name !== expected.name + || value.path !== expected.path + ) { + throw error(`${context} must identify ${expected.name} at ${expected.path}`); + } + return value; +} + +function successfulRun(value, context, source) { + strictObject( + value, + ["attempt", "conclusion", "event", "headSha", "id", "status"], + context, + ); + positiveInteger(value.id, `${context}.id`); + positiveInteger(value.attempt, `${context}.attempt`); + if ( + value.event !== "workflow_dispatch" + || value.status !== "completed" + || value.conclusion !== "success" + ) { + throw error(`${context} must be a completed/success workflow_dispatch`); + } + if (value.headSha !== source.commit) { + throw error(`${context}.headSha does not match the frozen release commit`); + } + return value; +} + +function releaseEnvelope(value, context) { + strictObject( + value, + [ + "carrierCount", + "catalogDigest", + "lockDigest", + "packageEnvelopeDigest", + "productArtifactCount", + "productCount", + "publicationLock", + "schema", + ], + context, + ); + if (value.schema !== PUBLICATION_LOCK_SCHEMA) { + throw error(`${context}.schema must be ${PUBLICATION_LOCK_SCHEMA}`); + } + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + hash(value[field], `${context}.${field}`); + } + for (const field of ["carrierCount", "productArtifactCount", "productCount"]) { + positiveInteger(value[field], `${context}.${field}`); + } + fileIdentity( + value.publicationLock, + `${context}.publicationLock`, + "target/release/publication-lock.json", + ); + return value; +} + +function capsuleManifest(value, context, source, envelope) { + strictObject( + value, + [ + "carrierCount", + "catalogDigest", + "file", + "lockDigest", + "packageEnvelopeDigest", + "productCount", + "publicationLock", + "schema", + "source", + ], + context, + ); + if (value.schema !== CAPSULE_SCHEMA) { + throw error(`${context}.schema must be ${CAPSULE_SCHEMA}`); + } + sourceIdentity(value.source, `${context}.source`); + sameSource(value.source, source, context); + fileIdentity( + value.file, + `${context}.file`, + "target/release/bootstrap-capsule-manifest.json", + ); + fileIdentity( + value.publicationLock, + `${context}.publicationLock`, + "target/release/publication-lock.json", + ); + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + hash(value[field], `${context}.${field}`); + if (value[field] !== envelope[field]) { + throw error(`${context}.${field} does not match the approved publication lock`); + } + } + for (const field of ["productCount", "carrierCount"]) { + positiveInteger(value[field], `${context}.${field}`); + } + if (value.productCount !== envelope.productCount) { + throw error(`${context}.productCount does not match the approved publication lock`); + } + if ( + canonicalRecoverySourceJson(value.publicationLock) + !== canonicalRecoverySourceJson(envelope.publicationLock) + ) { + throw error(`${context}.publicationLock does not match the approved publication lock bytes`); + } + return value; +} + +function terminalCheckpoint(value, context, source, envelope, capsule) { + strictObject( + value, + [ + "catalogDigest", + "checkpointDigest", + "complete", + "file", + "lockDigest", + "packageEnvelopeDigest", + "previousCheckpointDigest", + "productCount", + "publicationCount", + "receiptCount", + "schema", + "sequence", + "source", + ], + context, + ); + if (value.schema !== LEDGER_SCHEMA) { + throw error(`${context}.schema must be ${LEDGER_SCHEMA}`); + } + sourceIdentity(value.source, `${context}.source`); + sameSource(value.source, source, context); + for (const field of [ + "lockDigest", + "catalogDigest", + "packageEnvelopeDigest", + "checkpointDigest", + "previousCheckpointDigest", + ]) { + hash(value[field], `${context}.${field}`); + } + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + if (value[field] !== envelope[field]) { + throw error(`${context}.${field} does not match the approved publication lock`); + } + } + nonNegativeInteger(value.sequence, `${context}.sequence`); + for (const field of ["productCount", "publicationCount", "receiptCount"]) { + positiveInteger(value[field], `${context}.${field}`); + } + if (value.complete !== true || value.publicationCount !== value.receiptCount) { + throw error(`${context} must identify a terminal complete bootstrap ledger`); + } + if ( + value.productCount !== envelope.productCount + || value.publicationCount !== capsule.carrierCount + ) { + throw error(`${context} counts do not match the approved lock/capsule envelope`); + } + const expectedName = + `checkpoint-${String(value.sequence).padStart(6, "0")}-${value.checkpointDigest}.json`; + fileIdentity(value.file, `${context}.file`, expectedName); + return value; +} + +function requiredArtifactNames(value, inventory, context) { + if ( + !Array.isArray(value) + || value.length === 0 + || value.some((name) => typeof name !== "string" || !SAFE_ARTIFACT_NAME.test(name)) + || new Set(value).size !== value.length + ) { + throw error(`${context} must be a non-empty unique artifact-name list`); + } + const sorted = [...value].sort(compareText); + if (canonicalRecoverySourceJson(sorted) !== canonicalRecoverySourceJson(value)) { + throw error(`${context} must be sorted`); + } + const available = new Set(inventory.artifacts.map((artifact) => artifact.name)); + const missing = value.filter((name) => !available.has(name)); + if (missing.length > 0) { + throw error(`${context} names missing from the complete inventory: ${missing.join(", ")}`); + } + return value; +} + +function validateRecord(value, index) { + const context = `records[${index}]`; + strictObject( + value, + [ + "approvedDryRun", + "bootstrapLedger", + "payloadQualification", + "releaseEnvelope", + "releaseSource", + ], + context, + ); + const source = sourceIdentity(value.releaseSource, `${context}.releaseSource`); + const envelope = releaseEnvelope(value.releaseEnvelope, `${context}.releaseEnvelope`); + + strictObject( + value.payloadQualification, + ["artifactInventory", "requiredArtifactNames", "run", "workflow"], + `${context}.payloadQualification`, + ); + workflow( + value.payloadQualification.workflow, + `${context}.payloadQualification.workflow`, + { name: "CI", path: ".github/workflows/ci.yml" }, + ); + successfulRun( + value.payloadQualification.run, + `${context}.payloadQualification.run`, + source, + ); + const payloadInventory = artifactInventory( + value.payloadQualification.artifactInventory, + `${context}.payloadQualification.artifactInventory`, + ); + requiredArtifactNames( + value.payloadQualification.requiredArtifactNames, + payloadInventory, + `${context}.payloadQualification.requiredArtifactNames`, + ); + + strictObject( + value.approvedDryRun, + ["artifactInventory", "capsuleManifest", "run", "workflow"], + `${context}.approvedDryRun`, + ); + workflow( + value.approvedDryRun.workflow, + `${context}.approvedDryRun.workflow`, + { name: "Release", path: ".github/workflows/release.yml" }, + ); + successfulRun(value.approvedDryRun.run, `${context}.approvedDryRun.run`, source); + const dryRunInventory = artifactInventory( + value.approvedDryRun.artifactInventory, + `${context}.approvedDryRun.artifactInventory`, + ); + exactArtifactNames( + dryRunInventory, + ["oliphaunt-bootstrap-capsule", "oliphaunt-publication-lock"], + `${context}.approvedDryRun.artifactInventory`, + ); + const capsule = capsuleManifest( + value.approvedDryRun.capsuleManifest, + `${context}.approvedDryRun.capsuleManifest`, + source, + envelope, + ); + + strictObject( + value.bootstrapLedger, + ["artifactInventory", "run", "terminalCheckpoint", "workflow"], + `${context}.bootstrapLedger`, + ); + workflow( + value.bootstrapLedger.workflow, + `${context}.bootstrapLedger.workflow`, + { name: "Release", path: ".github/workflows/release.yml" }, + ); + successfulRun(value.bootstrapLedger.run, `${context}.bootstrapLedger.run`, source); + const ledgerInventory = artifactInventory( + value.bootstrapLedger.artifactInventory, + `${context}.bootstrapLedger.artifactInventory`, + ); + exactArtifactNames( + ledgerInventory, + ["oliphaunt-bootstrap-ledger"], + `${context}.bootstrapLedger.artifactInventory`, + ); + terminalCheckpoint( + value.bootstrapLedger.terminalCheckpoint, + `${context}.bootstrapLedger.terminalCheckpoint`, + source, + envelope, + capsule, + ); + + const runIds = [ + value.payloadQualification.run.id, + value.approvedDryRun.run.id, + value.bootstrapLedger.run.id, + ]; + if (new Set(runIds).size !== runIds.length) { + throw error(`${context} must bind three distinct workflow runs`); + } + if ( + value.approvedDryRun.workflow.id !== value.bootstrapLedger.workflow.id + || value.payloadQualification.workflow.id === value.approvedDryRun.workflow.id + ) { + throw error(`${context} workflow identities are inconsistent`); + } + return value; +} + +function gitCommitAndTree(repo, commit) { + const result = captureCommandOutput( + "git", + ["show", "-s", "--format=%H%n%T", `${commit}^{commit}`], + { + cwd: repo, + label: `git show -s --format=%H%n%T ${commit}^{commit}`, + }, + ); + if (result.error !== undefined || result.status !== 0) { + const detail = (result.stderr || result.stdout || result.error?.message || "").trim(); + throw error(`cannot resolve frozen release commit ${commit}${detail ? `: ${detail}` : ""}`); + } + const [resolvedCommit, tree, ...extra] = result.stdout.trimEnd().split("\n"); + if (!SHA.test(resolvedCommit) || !SHA.test(tree) || extra.length > 0) { + throw error(`git returned malformed source identity for ${commit}`); + } + return { commit: resolvedCommit, tree }; +} + +export function validateSameVersionRecoverySource( + record, + { repo = ROOT, verifyGit = true } = {}, +) { + validateRecord(record, 0); + if (verifyGit) { + const resolved = gitCommitAndTree(repo, record.releaseSource.commit); + sameSource(resolved, record.releaseSource, "committed recovery record"); + } + return record; +} + +export function validateSameVersionRecoverySources( + document, + { repo = ROOT, verifyGit = true } = {}, +) { + strictObject(document, ["records", "schema"], "recovery source document"); + if (document.schema !== SAME_VERSION_RECOVERY_SOURCES_SCHEMA) { + throw error( + `recovery source document schema must be ${SAME_VERSION_RECOVERY_SOURCES_SCHEMA}`, + ); + } + if (!Array.isArray(document.records) || document.records.length === 0) { + throw error("recovery source document records must be a non-empty list"); + } + document.records.forEach((record) => + validateSameVersionRecoverySource(record, { repo, verifyGit: false })); + const sorted = [...document.records].sort((left, right) => + compareText(left.releaseSource.commit, right.releaseSource.commit)); + if (canonicalRecoverySourceJson(sorted) !== canonicalRecoverySourceJson(document.records)) { + throw error("recovery source records must be sorted by release commit"); + } + const commits = new Set(); + const trees = new Set(); + for (const record of document.records) { + if (commits.has(record.releaseSource.commit)) { + throw error(`duplicate recovery source commit ${record.releaseSource.commit}`); + } + if (trees.has(record.releaseSource.tree)) { + throw error(`duplicate recovery source tree ${record.releaseSource.tree}`); + } + commits.add(record.releaseSource.commit); + trees.add(record.releaseSource.tree); + if (verifyGit) { + const resolved = gitCommitAndTree(repo, record.releaseSource.commit); + sameSource(resolved, record.releaseSource, "committed recovery record"); + } + } + return document; +} + +export function loadSameVersionRecoverySources( + file = DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + options = {}, +) { + const absolute = path.resolve(file); + const metadata = lstatSync(absolute, { throwIfNoEntry: false }); + if (!metadata?.isFile() || metadata.isSymbolicLink() || metadata.size > 16 * 1024 * 1024) { + throw error(`recovery source record must be a regular non-symlink file: ${absolute}`); + } + const bytes = readFileSync(absolute, "utf8"); + let document; + try { + document = JSON.parse(bytes); + } catch (cause) { + throw error(`recovery source record is invalid JSON: ${cause.message}`); + } + if (bytes !== prettyCanonicalJson(document)) { + throw error("recovery source record must be canonical sorted JSON with one trailing newline"); + } + return validateSameVersionRecoverySources(document, options); +} + +export function selectSameVersionRecoverySource( + document, + releaseSha, + options = {}, +) { + if (!SHA.test(releaseSha ?? "")) { + throw error("release SHA must be a lowercase full commit SHA"); + } + validateSameVersionRecoverySources(document, options); + const matches = document.records.filter((record) => + record.releaseSource.commit === releaseSha); + if (matches.length !== 1) { + throw error( + `expected exactly one same-version recovery source for ${releaseSha}; found ${matches.length}`, + ); + } + return matches[0]; +} + +function readEvidenceFile(file, context, maximum = 64 * 1024 * 1024) { + const absolute = path.resolve(file); + const metadata = lstatSync(absolute, { throwIfNoEntry: false }); + if ( + !metadata?.isFile() + || metadata.isSymbolicLink() + || metadata.size < 1 + || metadata.size > maximum + ) { + throw error(`${context} must be a bounded regular non-symlink file`); + } + const bytes = readFileSync(absolute); + let value; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch (cause) { + throw error(`${context} is invalid JSON: ${cause.message}`); + } + return { bytes, value }; +} + +function verifyFileBytes(bytes, expected, context) { + if (bytes.length !== expected.size || sha256(bytes) !== expected.sha256) { + throw error( + `${context} bytes do not match the recorded ${expected.size}/${expected.sha256} identity`, + ); + } +} + +function verifyCoreEnvelope(value, record, context) { + const source = sourceIdentity(value.source, `${context}.source`); + sameSource(source, record.releaseSource, context); + for (const field of ["lockDigest", "catalogDigest", "packageEnvelopeDigest"]) { + hash(value[field], `${context}.${field}`); + if (value[field] !== record.releaseEnvelope[field]) { + throw error(`${context}.${field} does not match the recovery record`); + } + } +} + +function catalogCarrierEnvelope(carrier) { + return { + declared: carrier.declared, + ecosystem: carrier.ecosystem, + id: carrier.id, + name: carrier.name, + product: carrier.product, + role: carrier.role, + target: carrier.target, + version: carrier.version, + }; +} + +function carrierPackageEnvelope(carrier) { + return { + artifacts: Array.isArray(carrier.artifacts) + ? carrier.artifacts.map(({ path: artifactPath, sha256: artifactSha256, size }) => ({ + path: artifactPath, + sha256: artifactSha256, + size, + })) + : carrier.artifacts, + declared: carrier.declared, + dependencies: carrier.dependencies, + ecosystem: carrier.ecosystem, + id: carrier.id, + name: carrier.name, + packageDependencies: carrier.packageDependencies, + parentCarrier: carrier.parentCarrier ?? null, + part: carrier.part ?? null, + product: carrier.product, + publishOrder: carrier.publishOrder, + role: carrier.role, + target: carrier.target, + version: carrier.version, + }; +} + +function productArtifactPackageEnvelope(artifact) { + return { + id: artifact.id, + identity: artifact.identity, + kind: artifact.kind, + name: artifact.name, + path: artifact.path, + product: artifact.product, + role: artifact.role, + sha256: artifact.sha256, + size: artifact.size, + target: artifact.target, + }; +} + +function validatePublicationLockEvidence(value, record) { + strictObject( + value, + [ + "carriers", + "catalogDigest", + "catalogSchema", + "lockDigest", + "packageEnvelopeDigest", + "productArtifacts", + "products", + "schema", + "source", + ], + "publication lock evidence", + ); + if (value.schema !== PUBLICATION_LOCK_SCHEMA) { + throw error(`publication lock evidence schema must be ${PUBLICATION_LOCK_SCHEMA}`); + } + verifyCoreEnvelope(value, record, "publication lock evidence"); + for (const [field, expected] of [ + ["products", record.releaseEnvelope.productCount], + ["carriers", record.releaseEnvelope.carrierCount], + ["productArtifacts", record.releaseEnvelope.productArtifactCount], + ]) { + if (!Array.isArray(value[field]) || value[field].length !== expected) { + throw error(`publication lock evidence ${field} count does not match the recovery record`); + } + } + const expectedCatalogDigest = digestValue({ + carriers: value.carriers + .filter((carrier) => carrier?.declared === true) + .map(catalogCarrierEnvelope) + .sort((left, right) => compareText(left.id, right.id)), + products: value.products, + schema: value.catalogSchema, + }); + if (value.catalogDigest !== expectedCatalogDigest) { + throw error( + "publication lock evidence has an invalid internal catalogDigest: " + + `expected ${expectedCatalogDigest}`, + ); + } + const expectedPackageEnvelopeDigest = digestValue({ + carriers: value.carriers.map(carrierPackageEnvelope), + productArtifacts: value.productArtifacts.map(productArtifactPackageEnvelope), + }); + if (value.packageEnvelopeDigest !== expectedPackageEnvelopeDigest) { + throw error( + "publication lock evidence has an invalid internal packageEnvelopeDigest: " + + `expected ${expectedPackageEnvelopeDigest}`, + ); + } + const withoutDigest = structuredClone(value); + delete withoutDigest.lockDigest; + const expectedDigest = digestValue(withoutDigest); + if (value.lockDigest !== expectedDigest) { + throw error( + `publication lock evidence has an invalid internal lockDigest: expected ${expectedDigest}`, + ); + } + return value; +} + +function validateCapsuleManifestEvidence(value, record) { + strictObject( + value, + [ + "carriers", + "catalogDigest", + "lockDigest", + "packageEnvelopeDigest", + "products", + "publicationLock", + "schema", + "source", + ], + "capsule manifest evidence", + ); + if (value.schema !== CAPSULE_SCHEMA) { + throw error(`capsule manifest evidence schema must be ${CAPSULE_SCHEMA}`); + } + verifyCoreEnvelope(value, record, "capsule manifest evidence"); + const expected = record.approvedDryRun.capsuleManifest; + if ( + !Array.isArray(value.products) + || value.products.length !== expected.productCount + || new Set(value.products).size !== value.products.length + || !Array.isArray(value.carriers) + || value.carriers.length !== expected.carrierCount + ) { + throw error("capsule manifest evidence counts do not match the recovery record"); + } + strictObject( + value.publicationLock, + ["path", "sha256", "size"], + "capsule manifest evidence.publicationLock", + ); + if ( + canonicalRecoverySourceJson(value.publicationLock) + !== canonicalRecoverySourceJson(expected.publicationLock) + ) { + throw error("capsule manifest embedded publication-lock identity does not match the recovery record"); + } + return value; +} + +function validateTerminalLedgerEvidence(value, record) { + strictObject( + value, + [ + "catalogDigest", + "checkpointDigest", + "complete", + "lockDigest", + "packageEnvelopeDigest", + "previousCheckpointDigest", + "products", + "publications", + "receipts", + "schema", + "sequence", + "source", + ], + "terminal bootstrap ledger evidence", + ); + if (value.schema !== LEDGER_SCHEMA) { + throw error(`terminal bootstrap ledger evidence schema must be ${LEDGER_SCHEMA}`); + } + verifyCoreEnvelope(value, record, "terminal bootstrap ledger evidence"); + const expected = record.bootstrapLedger.terminalCheckpoint; + for (const field of [ + "checkpointDigest", + "previousCheckpointDigest", + "sequence", + "complete", + ]) { + if (value[field] !== expected[field]) { + throw error(`terminal bootstrap ledger evidence.${field} does not match the recovery record`); + } + } + if ( + !Array.isArray(value.products) + || value.products.length !== expected.productCount + || new Set(value.products).size !== value.products.length + || !Array.isArray(value.publications) + || value.publications.length !== expected.publicationCount + || !Array.isArray(value.receipts) + || value.receipts.length !== expected.receiptCount + ) { + throw error("terminal bootstrap ledger evidence counts do not match the recovery record"); + } + const withoutDigest = structuredClone(value); + delete withoutDigest.checkpointDigest; + const expectedDigest = digestValue(withoutDigest); + if (value.checkpointDigest !== expectedDigest) { + throw error( + `terminal bootstrap ledger evidence has an invalid internal checkpointDigest: expected ${expectedDigest}`, + ); + } + return value; +} + +export function validateSameVersionRecoveryEvidence( + record, + { publicationLock, capsuleManifest, terminalLedger }, +) { + validateRecord(record, 0); + const lock = readEvidenceFile(publicationLock, "publication lock evidence"); + const capsule = readEvidenceFile(capsuleManifest, "capsule manifest evidence"); + const ledger = readEvidenceFile(terminalLedger, "terminal bootstrap ledger evidence"); + verifyFileBytes( + lock.bytes, + record.releaseEnvelope.publicationLock, + "publication lock evidence", + ); + verifyFileBytes( + capsule.bytes, + record.approvedDryRun.capsuleManifest.file, + "capsule manifest evidence", + ); + verifyFileBytes( + ledger.bytes, + record.bootstrapLedger.terminalCheckpoint.file, + "terminal bootstrap ledger evidence", + ); + validatePublicationLockEvidence(lock.value, record); + validateCapsuleManifestEvidence(capsule.value, record); + validateTerminalLedgerEvidence(ledger.value, record); + return { + capsuleManifestSha256: sha256(capsule.bytes), + publicationLockSha256: sha256(lock.bytes), + terminalLedgerSha256: sha256(ledger.bytes), + }; +} + +function outputLines(record) { + const artifactJson = (artifacts) => canonicalRecoverySourceJson(artifacts); + const lock = record.approvedDryRun.artifactInventory.artifacts + .filter((artifact) => artifact.name === "oliphaunt-publication-lock"); + const capsule = record.approvedDryRun.artifactInventory.artifacts + .filter((artifact) => artifact.name === "oliphaunt-bootstrap-capsule"); + return { + approved_capsule_artifact_metadata_json: artifactJson(capsule), + approved_dry_run_artifact_metadata_json: artifactJson( + record.approvedDryRun.artifactInventory.artifacts, + ), + approved_dry_run_id: String(record.approvedDryRun.run.id), + approved_lock_artifact_metadata_json: artifactJson(lock), + bootstrap_ledger_artifact_metadata_json: artifactJson( + record.bootstrapLedger.artifactInventory.artifacts, + ), + bootstrap_ledger_run_id: String(record.bootstrapLedger.run.id), + catalog_digest: record.releaseEnvelope.catalogDigest, + lock_digest: record.releaseEnvelope.lockDigest, + package_envelope_digest: record.releaseEnvelope.packageEnvelopeDigest, + payload_ci_artifact_metadata_json: artifactJson( + record.payloadQualification.artifactInventory.artifacts, + ), + payload_ci_run_id: String(record.payloadQualification.run.id), + record_digest: digestValue(record), + record_json: canonicalRecoverySourceJson(record), + release_sha: record.releaseSource.commit, + release_tree: record.releaseSource.tree, + }; +} + +export function appendSameVersionRecoverySourceGitHubOutput(file, record) { + const absolute = path.resolve(file); + const metadata = lstatSync(absolute, { throwIfNoEntry: false }); + if (metadata !== undefined && (!metadata.isFile() || metadata.isSymbolicLink())) { + throw error(`GITHUB_OUTPUT must be an absent or regular non-symlink file: ${absolute}`); + } + const lines = outputLines(record); + for (const [name, value] of Object.entries(lines)) { + if (value.includes("\n") || value.includes("\r")) { + throw error(`refusing multiline GITHUB_OUTPUT value for ${name}`); + } + } + appendFileSync( + absolute, + `${Object.entries(lines).map(([name, value]) => `${name}=${value}`).join("\n")}\n`, + "utf8", + ); + return lines; +} + +function parseArgs(argv) { + const values = new Map(); + const allowed = new Set([ + "capsule-manifest", + "github-output", + "publication-lock", + "record-file", + "release-sha", + "terminal-ledger", + ]); + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (!flag?.startsWith("--") || !allowed.has(flag.slice(2))) { + throw error(`unknown argument ${flag ?? ""}`); + } + const name = flag.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--") || values.has(name)) { + throw error(`--${name} requires one value and may be supplied only once`); + } + values.set(name, value); + index += 1; + } + const releaseSha = values.get("release-sha") ?? ""; + if (!SHA.test(releaseSha)) { + throw error("usage: same-version-recovery-source.mjs --release-sha SHA [--record-file FILE] " + + "[--github-output FILE] [--publication-lock FILE --capsule-manifest FILE " + + "--terminal-ledger FILE]"); + } + const evidenceNames = ["publication-lock", "capsule-manifest", "terminal-ledger"]; + const evidenceCount = evidenceNames.filter((name) => values.has(name)).length; + if (![0, evidenceNames.length].includes(evidenceCount)) { + throw error("publication-lock, capsule-manifest, and terminal-ledger must be supplied together"); + } + return { + capsuleManifest: values.get("capsule-manifest"), + githubOutput: values.get("github-output") ?? process.env.GITHUB_OUTPUT?.trim() ?? "", + publicationLock: values.get("publication-lock"), + recordFile: values.get("record-file") ?? DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + releaseSha, + terminalLedger: values.get("terminal-ledger"), + }; +} + +export function main(argv = Bun.argv.slice(2)) { + const options = parseArgs(argv); + const document = loadSameVersionRecoverySources(options.recordFile); + const record = selectSameVersionRecoverySource(document, options.releaseSha); + if (options.publicationLock !== undefined) { + validateSameVersionRecoveryEvidence(record, { + capsuleManifest: options.capsuleManifest, + publicationLock: options.publicationLock, + terminalLedger: options.terminalLedger, + }); + } + if (options.githubOutput !== "") { + appendSameVersionRecoverySourceGitHubOutput(options.githubOutput, record); + } + process.stdout.write(`${canonicalRecoverySourceJson(record)}\n`); + return record; +} + +if (import.meta.main) { + try { + main(); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); + } +} diff --git a/tools/release/same-version-recovery-source.test.mjs b/tools/release/same-version-recovery-source.test.mjs new file mode 100644 index 00000000..8e116d54 --- /dev/null +++ b/tools/release/same-version-recovery-source.test.mjs @@ -0,0 +1,560 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { execFileSync } from "../test/fd-backed-spawn-sync.mjs"; +import { + DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + SAME_VERSION_RECOVERY_SOURCES_SCHEMA, + appendSameVersionRecoverySourceGitHubOutput, + canonicalRecoverySourceJson, + loadSameVersionRecoverySources, + selectSameVersionRecoverySource, + validateSameVersionRecoveryEvidence, + validateSameVersionRecoverySource, + validateSameVersionRecoverySources, +} from "./same-version-recovery-source.mjs"; + +const RELEASE_SHA = "9c398f4e5c05f494f9b752a8634e74e0bc11dd19"; +const RELEASE_TREE = "396cf3b10adb1a5b625e66c5ebacf8c3d364b543"; +const TOOL = path.join(import.meta.dir, "same-version-recovery-source.mjs"); + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function digestValue(value) { + return sha256(canonicalRecoverySourceJson(value)); +} + +function document({ verifyGit = false } = {}) { + return structuredClone( + loadSameVersionRecoverySources( + DEFAULT_SAME_VERSION_RECOVERY_SOURCES, + { verifyGit }, + ), + ); +} + +function record() { + return document().records[0]; +} + +function setSource(value, source) { + value.releaseSource = structuredClone(source); + value.payloadQualification.run.headSha = source.commit; + value.approvedDryRun.run.headSha = source.commit; + value.bootstrapLedger.run.headSha = source.commit; + value.approvedDryRun.capsuleManifest.source = structuredClone(source); + value.bootstrapLedger.terminalCheckpoint.source = structuredClone(source); +} + +function git(repo, ...args) { + return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); +} + +function writeJson(file, value) { + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + writeFileSync(file, bytes); + return bytes; +} + +function catalogCarrier(carrier) { + return { + declared: carrier.declared, + ecosystem: carrier.ecosystem, + id: carrier.id, + name: carrier.name, + product: carrier.product, + role: carrier.role, + target: carrier.target, + version: carrier.version, + }; +} + +function packageCarrier(carrier) { + return { + artifacts: carrier.artifacts.map(({ path: artifactPath, sha256: digest, size }) => ({ + path: artifactPath, + sha256: digest, + size, + })), + declared: carrier.declared, + dependencies: carrier.dependencies, + ecosystem: carrier.ecosystem, + id: carrier.id, + name: carrier.name, + packageDependencies: carrier.packageDependencies, + parentCarrier: carrier.parentCarrier ?? null, + part: carrier.part ?? null, + product: carrier.product, + publishOrder: carrier.publishOrder, + role: carrier.role, + target: carrier.target, + version: carrier.version, + }; +} + +function packageProductArtifact(artifact) { + return { + id: artifact.id, + identity: artifact.identity, + kind: artifact.kind, + name: artifact.name, + path: artifact.path, + product: artifact.product, + role: artifact.role, + sha256: artifact.sha256, + size: artifact.size, + target: artifact.target, + }; +} + +function evidenceFixture({ + corruptCatalogDigest = false, + corruptCheckpointDigest = false, + corruptLockDigest = false, + corruptPackageEnvelopeDigest = false, +} = {}) { + const root = mkdtempSync(path.join(tmpdir(), "oliphaunt-recovery-source-")); + const repo = path.join(root, "repo"); + const evidence = path.join(root, "evidence"); + mkdirSync(repo); + mkdirSync(evidence); + git(repo, "init", "--quiet"); + git(repo, "config", "user.name", "Oliphaunt Test"); + git(repo, "config", "user.email", "test@oliphaunt.dev"); + writeFileSync(path.join(repo, "tracked.txt"), "fixture\n"); + git(repo, "add", "tracked.txt"); + git(repo, "commit", "--quiet", "-m", "test: fixture"); + const source = { + commit: git(repo, "rev-parse", "HEAD"), + tree: git(repo, "show", "-s", "--format=%T", "HEAD"), + }; + const selected = record(); + setSource(selected, source); + selected.releaseEnvelope.productCount = 1; + selected.releaseEnvelope.carrierCount = 1; + selected.releaseEnvelope.productArtifactCount = 1; + selected.approvedDryRun.capsuleManifest.productCount = 1; + selected.approvedDryRun.capsuleManifest.carrierCount = 1; + selected.bootstrapLedger.terminalCheckpoint.productCount = 1; + selected.bootstrapLedger.terminalCheckpoint.publicationCount = 1; + selected.bootstrapLedger.terminalCheckpoint.receiptCount = 1; + selected.bootstrapLedger.terminalCheckpoint.sequence = 7; + selected.bootstrapLedger.terminalCheckpoint.previousCheckpointDigest = + "c".repeat(64); + + const products = [{ + dependencies: [], + id: "alpha", + kind: "source-sdk", + path: "alpha", + publishTargets: [], + version: "1.0.0", + }]; + const carriers = [{ + artifacts: [{ + path: "target/release/alpha.crate", + sha256: "d".repeat(64), + size: 1, + }], + declared: true, + dependencies: [], + ecosystem: "cargo", + id: "cargo:alpha", + name: "alpha", + packageDependencies: [], + product: "alpha", + publishOrder: 0, + role: "root", + target: null, + version: "1.0.0", + }]; + const productArtifacts = [{ + id: "github:alpha", + identity: null, + kind: "archive", + name: "alpha.tar.gz", + path: "target/release/alpha.tar.gz", + product: "alpha", + role: "release-asset", + sha256: "e".repeat(64), + size: 1, + target: "portable", + }]; + const computedCatalogDigest = digestValue({ + carriers: carriers.map(catalogCarrier), + products, + schema: "oliphaunt-publication-catalog-v1", + }); + const computedPackageEnvelopeDigest = digestValue({ + carriers: carriers.map(packageCarrier), + productArtifacts: productArtifacts.map(packageProductArtifact), + }); + selected.releaseEnvelope.catalogDigest = corruptCatalogDigest + ? "7".repeat(64) + : computedCatalogDigest; + selected.releaseEnvelope.packageEnvelopeDigest = corruptPackageEnvelopeDigest + ? "8".repeat(64) + : computedPackageEnvelopeDigest; + selected.approvedDryRun.capsuleManifest.catalogDigest = + selected.releaseEnvelope.catalogDigest; + selected.approvedDryRun.capsuleManifest.packageEnvelopeDigest = + selected.releaseEnvelope.packageEnvelopeDigest; + selected.bootstrapLedger.terminalCheckpoint.catalogDigest = + selected.releaseEnvelope.catalogDigest; + selected.bootstrapLedger.terminalCheckpoint.packageEnvelopeDigest = + selected.releaseEnvelope.packageEnvelopeDigest; + + const publicationLock = { + schema: "oliphaunt-publication-lock-v1", + catalogSchema: "oliphaunt-publication-catalog-v1", + catalogDigest: selected.releaseEnvelope.catalogDigest, + source, + products, + carriers, + productArtifacts, + packageEnvelopeDigest: selected.releaseEnvelope.packageEnvelopeDigest, + }; + const computedLockDigest = digestValue(publicationLock); + publicationLock.lockDigest = corruptLockDigest + ? "f".repeat(64) + : computedLockDigest; + selected.releaseEnvelope.lockDigest = publicationLock.lockDigest; + selected.approvedDryRun.capsuleManifest.lockDigest = publicationLock.lockDigest; + selected.bootstrapLedger.terminalCheckpoint.lockDigest = publicationLock.lockDigest; + + const publicationLockFile = path.join(evidence, "publication-lock.json"); + const lockBytes = writeJson(publicationLockFile, publicationLock); + const lockFileIdentity = { + path: "target/release/publication-lock.json", + sha256: sha256(lockBytes), + size: lockBytes.length, + }; + selected.releaseEnvelope.publicationLock = structuredClone(lockFileIdentity); + selected.approvedDryRun.capsuleManifest.publicationLock = + structuredClone(lockFileIdentity); + + const capsuleManifest = { + schema: "oliphaunt-bootstrap-publication-capsule-v1", + source, + lockDigest: publicationLock.lockDigest, + packageEnvelopeDigest: selected.releaseEnvelope.packageEnvelopeDigest, + catalogDigest: selected.releaseEnvelope.catalogDigest, + products: ["alpha"], + publicationLock: structuredClone(lockFileIdentity), + carriers: [{ id: "cargo:alpha" }], + }; + const capsuleManifestFile = path.join(evidence, "bootstrap-capsule-manifest.json"); + const capsuleBytes = writeJson(capsuleManifestFile, capsuleManifest); + selected.approvedDryRun.capsuleManifest.file = { + path: "target/release/bootstrap-capsule-manifest.json", + sha256: sha256(capsuleBytes), + size: capsuleBytes.length, + }; + + const terminalLedger = { + schema: "oliphaunt-bootstrap-ledger-checkpoint-v1", + lockDigest: publicationLock.lockDigest, + packageEnvelopeDigest: selected.releaseEnvelope.packageEnvelopeDigest, + catalogDigest: selected.releaseEnvelope.catalogDigest, + source, + products: ["alpha"], + publications: [{ id: "cargo:alpha" }], + sequence: 7, + previousCheckpointDigest: "c".repeat(64), + receipts: [{ id: "cargo:alpha" }], + complete: true, + }; + const computedCheckpointDigest = digestValue(terminalLedger); + terminalLedger.checkpointDigest = corruptCheckpointDigest + ? "e".repeat(64) + : computedCheckpointDigest; + selected.bootstrapLedger.terminalCheckpoint.checkpointDigest = + terminalLedger.checkpointDigest; + const terminalName = + `checkpoint-000007-${terminalLedger.checkpointDigest}.json`; + const terminalLedgerFile = path.join(evidence, terminalName); + const terminalBytes = writeJson(terminalLedgerFile, terminalLedger); + selected.bootstrapLedger.terminalCheckpoint.file = { + path: terminalName, + sha256: sha256(terminalBytes), + size: terminalBytes.length, + }; + + validateSameVersionRecoverySource(selected, { repo }); + return { + capsuleManifest: capsuleManifestFile, + cleanup: () => rmSync(root, { force: true, recursive: true }), + publicationLock: publicationLockFile, + record: selected, + repo, + root, + terminalLedger: terminalLedgerFile, + }; +} + +test("committed record selects the exact original release and complete frozen inventories", () => { + const sources = loadSameVersionRecoverySources(); + const selected = selectSameVersionRecoverySource(sources, RELEASE_SHA); + assert.equal(sources.schema, SAME_VERSION_RECOVERY_SOURCES_SCHEMA); + assert.deepEqual(selected.releaseSource, { + commit: RELEASE_SHA, + tree: RELEASE_TREE, + }); + assert.equal(selected.payloadQualification.run.id, 30358387218); + assert.equal(selected.payloadQualification.artifactInventory.count, 73); + assert.equal( + selected.payloadQualification.artifactInventory.totalSize, + 1516373495, + ); + assert.equal(selected.approvedDryRun.run.id, 30366650928); + assert.deepEqual( + selected.approvedDryRun.artifactInventory.artifacts.map( + ({ id, name }) => ({ id, name }), + ), + [ + { id: 8692161467, name: "oliphaunt-bootstrap-capsule" }, + { id: 8692153698, name: "oliphaunt-publication-lock" }, + ], + ); + assert.equal(selected.bootstrapLedger.run.id, 30548314727); + assert.equal( + selected.bootstrapLedger.artifactInventory.artifacts[0].id, + 8761717044, + ); + assert.equal( + selected.releaseEnvelope.lockDigest, + "5ee675ab3066cca7df21dd425a5c80fd6c9b9c4b276757fc1aa84e2020761266", + ); +}); + +test("CLI and GITHUB_OUTPUT emit the same canonical selected record and exact metadata", () => { + const root = mkdtempSync(path.join(tmpdir(), "oliphaunt-recovery-output-")); + try { + const output = path.join(root, "github-output"); + const selected = selectSameVersionRecoverySource(document(), RELEASE_SHA, { + verifyGit: false, + }); + const lines = appendSameVersionRecoverySourceGitHubOutput(output, selected); + assert.equal(lines.release_sha, RELEASE_SHA); + assert.equal(lines.release_tree, RELEASE_TREE); + assert.equal(lines.payload_ci_run_id, "30358387218"); + assert.equal( + JSON.parse(lines.payload_ci_artifact_metadata_json).length, + 73, + ); + assert.deepEqual(JSON.parse(lines.approved_lock_artifact_metadata_json), [{ + digest: "sha256:b5513012c3260112a484ff25a9d62fd0fb93087f2125448bbd20658a17cd81e5", + id: 8692153698, + name: "oliphaunt-publication-lock", + size: 41287, + }]); + assert.equal(lines.record_json, canonicalRecoverySourceJson(selected)); + assert.equal(lines.record_digest, digestValue(selected)); + assert.match(readFileSync(output, "utf8"), /^record_json=\{/mu); + + const stdout = execFileSync( + process.execPath, + [TOOL, "--release-sha", RELEASE_SHA], + { encoding: "utf8" }, + ); + assert.equal(stdout, `${canonicalRecoverySourceJson(selected)}\n`); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("strict document and inventory mutations fail closed", () => { + const cases = [ + { + mutate: (value) => { + value.future = true; + }, + pattern: /must contain exactly records, schema/u, + }, + { + mutate: (value) => { + value.records[0].payloadQualification.artifactInventory.artifacts[1].id = + value.records[0].payloadQualification.artifactInventory.artifacts[0].id; + }, + pattern: /repeats artifact id/u, + }, + { + mutate: (value) => { + value.records[0].payloadQualification.artifactInventory.artifacts[0].digest = + `sha256:${"f".repeat(64)}`; + }, + pattern: /inventoryDigest mismatch/u, + }, + { + mutate: (value) => { + value.records[0].payloadQualification.artifactInventory.totalSize += 1; + }, + pattern: /totalSize does not match/u, + }, + { + mutate: (value) => { + value.records[0].payloadQualification.requiredArtifactNames[0] = + "absent-artifact"; + }, + pattern: /names missing from the complete inventory/u, + }, + { + mutate: (value) => { + value.records[0].approvedDryRun.capsuleManifest.lockDigest = + "f".repeat(64); + }, + pattern: /does not match the approved publication lock/u, + }, + { + mutate: (value) => { + value.records.push(structuredClone(value.records[0])); + }, + pattern: /duplicate recovery source commit/u, + }, + ]; + for (const { mutate, pattern } of cases) { + const value = document(); + mutate(value); + assert.throws( + () => validateSameVersionRecoverySources(value, { verifyGit: false }), + pattern, + ); + } +}); + +test("source identity is resolved as an exact git commit/tree, not trusted from JSON", () => { + const value = document(); + const mutatedSource = { + commit: RELEASE_SHA, + tree: "f".repeat(40), + }; + setSource(value.records[0], mutatedSource); + assert.throws( + () => validateSameVersionRecoverySources(value), + /committed recovery record source .* does not match/u, + ); + assert.throws( + () => selectSameVersionRecoverySource(document(), "F".repeat(40), { + verifyGit: false, + }), + /lowercase full commit SHA/u, + ); + assert.throws( + () => selectSameVersionRecoverySource(document(), "0".repeat(40), { + verifyGit: false, + }), + /found 0/u, + ); +}); + +test("record file must remain canonical JSON", () => { + const root = mkdtempSync(path.join(tmpdir(), "oliphaunt-recovery-json-")); + try { + const file = path.join(root, "sources.json"); + writeFileSync( + file, + `${readFileSync(DEFAULT_SAME_VERSION_RECOVERY_SOURCES, "utf8")}\n`, + ); + assert.throws( + () => loadSameVersionRecoverySources(file, { verifyGit: false }), + /canonical sorted JSON/u, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("downloaded lock, capsule manifest, and terminal ledger verify as one envelope", () => { + const fixture = evidenceFixture(); + try { + assert.deepEqual( + validateSameVersionRecoveryEvidence(fixture.record, fixture), + { + capsuleManifestSha256: + fixture.record.approvedDryRun.capsuleManifest.file.sha256, + publicationLockSha256: + fixture.record.releaseEnvelope.publicationLock.sha256, + terminalLedgerSha256: + fixture.record.bootstrapLedger.terminalCheckpoint.file.sha256, + }, + ); + } finally { + fixture.cleanup(); + } +}); + +test("raw evidence substitution and internally forged digests fail closed", () => { + { + const fixture = evidenceFixture(); + try { + writeFileSync( + fixture.publicationLock, + Buffer.concat([readFileSync(fixture.publicationLock), Buffer.from(" ")]), + ); + assert.throws( + () => validateSameVersionRecoveryEvidence(fixture.record, fixture), + /bytes do not match the recorded/u, + ); + } finally { + fixture.cleanup(); + } + } + { + const fixture = evidenceFixture({ corruptLockDigest: true }); + try { + assert.throws( + () => validateSameVersionRecoveryEvidence(fixture.record, fixture), + /invalid internal lockDigest/u, + ); + } finally { + fixture.cleanup(); + } + } + { + const fixture = evidenceFixture({ corruptCatalogDigest: true }); + try { + assert.throws( + () => validateSameVersionRecoveryEvidence(fixture.record, fixture), + /invalid internal catalogDigest/u, + ); + } finally { + fixture.cleanup(); + } + } + { + const fixture = evidenceFixture({ corruptPackageEnvelopeDigest: true }); + try { + assert.throws( + () => validateSameVersionRecoveryEvidence(fixture.record, fixture), + /invalid internal packageEnvelopeDigest/u, + ); + } finally { + fixture.cleanup(); + } + } + { + const fixture = evidenceFixture({ corruptCheckpointDigest: true }); + try { + assert.throws( + () => validateSameVersionRecoveryEvidence(fixture.record, fixture), + /invalid internal checkpointDigest/u, + ); + } finally { + fixture.cleanup(); + } + } +}); diff --git a/tools/release/same-version-recovery-sources.json b/tools/release/same-version-recovery-sources.json new file mode 100644 index 00000000..d20ecdc5 --- /dev/null +++ b/tools/release/same-version-recovery-sources.json @@ -0,0 +1,622 @@ +{ + "records": [ + { + "approvedDryRun": { + "artifactInventory": { + "artifacts": [ + { + "digest": "sha256:a7cab90276460daca53801d558ba708040253d22a4db4c5fdad7a70547102126", + "id": 8692161467, + "name": "oliphaunt-bootstrap-capsule", + "size": 411152632 + }, + { + "digest": "sha256:b5513012c3260112a484ff25a9d62fd0fb93087f2125448bbd20658a17cd81e5", + "id": 8692153698, + "name": "oliphaunt-publication-lock", + "size": 41287 + } + ], + "count": 2, + "inventoryDigest": "c81e0528f5255e1cb9642ccb53c504e481ecba6e351f2ae282b4760c745cb5a8", + "totalSize": 411193919 + }, + "capsuleManifest": { + "carrierCount": 279, + "catalogDigest": "b8294c0c0030a7b77977635f864854f3503a3ac01c1e9c65da79425b087f9a43", + "file": { + "path": "target/release/bootstrap-capsule-manifest.json", + "sha256": "3dc600ee9fc837505f942c730cf4158045f858adbfbc1a41ea84a1a516b886e8", + "size": 182232 + }, + "lockDigest": "5ee675ab3066cca7df21dd425a5c80fd6c9b9c4b276757fc1aa84e2020761266", + "packageEnvelopeDigest": "229aaba1d027719172b0801dca2ff70dde22967b8de9f57acad8cafe1ee686c2", + "productCount": 18, + "publicationLock": { + "path": "target/release/publication-lock.json", + "sha256": "e46df2d82e83533c724c8e9d08a32ad0ef7fc96df6ce8c2984c522b65ddfd8fd", + "size": 438770 + }, + "schema": "oliphaunt-bootstrap-publication-capsule-v1", + "source": { + "commit": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "tree": "396cf3b10adb1a5b625e66c5ebacf8c3d364b543" + } + }, + "run": { + "attempt": 1, + "conclusion": "success", + "event": "workflow_dispatch", + "headSha": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "id": 30366650928, + "status": "completed" + }, + "workflow": { + "id": 265799825, + "name": "Release", + "path": ".github/workflows/release.yml" + } + }, + "bootstrapLedger": { + "artifactInventory": { + "artifacts": [ + { + "digest": "sha256:2ad99b147c6e9fd5bc2da921c521c24bc050946313bd21479dc4de8e25e00442", + "id": 8761717044, + "name": "oliphaunt-bootstrap-ledger", + "size": 271894 + } + ], + "count": 1, + "inventoryDigest": "93799e236a9f8622202302130a2794741589f215c88ac0930600aa53dc481337", + "totalSize": 271894 + }, + "run": { + "attempt": 1, + "conclusion": "success", + "event": "workflow_dispatch", + "headSha": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "id": 30548314727, + "status": "completed" + }, + "terminalCheckpoint": { + "catalogDigest": "b8294c0c0030a7b77977635f864854f3503a3ac01c1e9c65da79425b087f9a43", + "checkpointDigest": "5b09ea1f1a4ef9fcef7c6e263ae0aeea2d4da10330d2592be0264b32d3e42ffb", + "complete": true, + "file": { + "path": "checkpoint-000007-5b09ea1f1a4ef9fcef7c6e263ae0aeea2d4da10330d2592be0264b32d3e42ffb.json", + "sha256": "bfc04f4f4e7187895c2d7def9a314a05e043782f2c01ca67b11389615e33df1c", + "size": 392933 + }, + "lockDigest": "5ee675ab3066cca7df21dd425a5c80fd6c9b9c4b276757fc1aa84e2020761266", + "packageEnvelopeDigest": "229aaba1d027719172b0801dca2ff70dde22967b8de9f57acad8cafe1ee686c2", + "previousCheckpointDigest": "fd8fba4faae523d4d7fa01a7e51a6f7768108264e269fc0f899fb066818a6fc2", + "productCount": 18, + "publicationCount": 279, + "receiptCount": 279, + "schema": "oliphaunt-bootstrap-ledger-checkpoint-v1", + "sequence": 7, + "source": { + "commit": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "tree": "396cf3b10adb1a5b625e66c5ebacf8c3d364b543" + } + }, + "workflow": { + "id": 265799825, + "name": "Release", + "path": ".github/workflows/release.yml" + } + }, + "payloadQualification": { + "artifactInventory": { + "artifacts": [ + { + "digest": "sha256:548b9e4c5273cadbc32751a4b77fd43afa09d78f6cf06edda642b5ca010db7b7", + "id": 8687752452, + "name": "artifact-build-plan", + "size": 2798 + }, + { + "digest": "sha256:7c1d365cea4f9393cdede218a65be5fd1bce2da17d8a4097fd80621bc758dd3e", + "id": 8688015181, + "name": "f0rr0~oliphaunt~LAT2UF.dockerbuild", + "size": 34336 + }, + { + "digest": "sha256:e3ec5992e4f5b35b6c87daaa9c0183295e8d51d0674e731ee5f52fc49f2b4b0b", + "id": 8688964385, + "name": "liboliphaunt-native-extension-artifacts-android-arm64-v8a", + "size": 68641264 + }, + { + "digest": "sha256:fb3280fbe275adb4e98a36c21e081642633086b056ece62afe7f2174688e50d4", + "id": 8688899524, + "name": "liboliphaunt-native-extension-artifacts-android-x86_64", + "size": 26061093 + }, + { + "digest": "sha256:aa5a2155a0701f4c2869b275d7bad2bf67e98c4ffb33b85b66e41f82f0a9c648", + "id": 8689873496, + "name": "liboliphaunt-native-extension-artifacts-ios-xcframework", + "size": 74733971 + }, + { + "digest": "sha256:ef05edceebc811d38fbe57bb48822bc8682c22ae3754e2f9d1ff69810f9616ff", + "id": 8688429289, + "name": "liboliphaunt-native-extension-artifacts-linux-arm64-gnu", + "size": 18118924 + }, + { + "digest": "sha256:65fcc1c15ecd7e2c8f69556a60623ebd6a65cf03173e137054e5f07d98733f85", + "id": 8688580446, + "name": "liboliphaunt-native-extension-artifacts-linux-x64-gnu", + "size": 18694304 + }, + { + "digest": "sha256:2208a62fb02901e1c3e1ab9a831c0ae18e235251d0c03ee3c2ff0e4a2d256c67", + "id": 8688582028, + "name": "liboliphaunt-native-extension-artifacts-macos-arm64", + "size": 21960905 + }, + { + "digest": "sha256:d8323b0c905899b96db7e1020c174c4b694be3103a298cdb66277670e91a2a97", + "id": 8688589164, + "name": "liboliphaunt-native-extension-artifacts-windows-x64-msvc", + "size": 18743649 + }, + { + "digest": "sha256:2e73fa998a9acc475f40b58911441bc376465e58a6a6d03052fa4f57ba64aca5", + "id": 8688141404, + "name": "liboliphaunt-native-icu-data", + "size": 13014774 + }, + { + "digest": "sha256:9be9303056a3c03458a1482f9aaf5377e9911a9180718255d863513791317236", + "id": 8688947128, + "name": "liboliphaunt-native-release-assets", + "size": 117399144 + }, + { + "digest": "sha256:a0cd5e77a557f77416bc890effe4fec776efa8278127bbc4a10fc9443ae5558f", + "id": 8688350208, + "name": "liboliphaunt-native-release-assets-android-arm64-v8a", + "size": 5258271 + }, + { + "digest": "sha256:f0a130c448bd358425828a86e6d80059db3de0ac50f5552787741a3024d0964f", + "id": 8688442358, + "name": "liboliphaunt-native-release-assets-android-x86_64", + "size": 5470033 + }, + { + "digest": "sha256:55dee4cdca35112345dda5530d6442cbe18702586da3cf664c6802b178e6ff96", + "id": 8688854951, + "name": "liboliphaunt-native-release-assets-ios-xcframework", + "size": 54179165 + }, + { + "digest": "sha256:f11592787cd7bca41df9014c994c5444882bdb5c3982cec303af8ddb040ae2ef", + "id": 8687998048, + "name": "liboliphaunt-native-release-assets-linux-arm64-gnu", + "size": 13861960 + }, + { + "digest": "sha256:4b39997769f44e41ac15f3463a358d7717f1c3499a1a5bfe5fb2701ea53b8b15", + "id": 8688064403, + "name": "liboliphaunt-native-release-assets-linux-x64-gnu", + "size": 14254941 + }, + { + "digest": "sha256:8d36df8343549fbb390b9d35fb2b1bdb0405fabb5d8cc61946c4431c43ef4f02", + "id": 8688140869, + "name": "liboliphaunt-native-release-assets-macos-arm64", + "size": 12516808 + }, + { + "digest": "sha256:329e5142a445939113f36879d28e8a9275661996a8d60437b4c3c3b68e4d4a24", + "id": 8688296547, + "name": "liboliphaunt-native-release-assets-windows-x64-msvc", + "size": 11857179 + }, + { + "digest": "sha256:681b5b3223acb89ff05b910961418aa0c848fd73999a8e99db9ec05640dec626", + "id": 8688352057, + "name": "liboliphaunt-native-target-android-arm64-v8a", + "size": 20211885 + }, + { + "digest": "sha256:214e280fb84c705e3f515748a8d0dd10a0e9b8405ca8fac3505eaf11097bdc13", + "id": 8688444081, + "name": "liboliphaunt-native-target-android-x86_64", + "size": 20331521 + }, + { + "digest": "sha256:9955b58bfa4ef2d503efd2fca72981e615e217f7440c3bd10d21b07ab628fa19", + "id": 8688858402, + "name": "liboliphaunt-native-target-ios-xcframework", + "size": 36656302 + }, + { + "digest": "sha256:b75b23d25a3f2dfab7e9d4f616f4241fc51b618e36e829efde0a102859816df6", + "id": 8688436770, + "name": "liboliphaunt-wasix-extension-aot-linux-arm64-gnu", + "size": 13431480 + }, + { + "digest": "sha256:960becf87a8f2b1fcb1267035e3d4ea20ae8db8b7fdbd3bfbe47ff4873fca41d", + "id": 8688457623, + "name": "liboliphaunt-wasix-extension-aot-linux-x64-gnu", + "size": 13824714 + }, + { + "digest": "sha256:289f5a5288c975b13bff1b425c9a4e8a1d285afad4d5d56337e038ce9209d840", + "id": 8688476156, + "name": "liboliphaunt-wasix-extension-aot-macos-arm64", + "size": 12835709 + }, + { + "digest": "sha256:2ce660a929f71a08f9037ed708319755431f18ee20a32e3638bf1ff4ebc13089", + "id": 8688654420, + "name": "liboliphaunt-wasix-extension-aot-windows-x64-msvc", + "size": 13509056 + }, + { + "digest": "sha256:a4463428afb0f4721b4a073c8ac88fc4cbc5969d9054373e1f7016bd809abcce", + "id": 8688103873, + "name": "liboliphaunt-wasix-extension-artifacts-wasix-portable", + "size": 9195916 + }, + { + "digest": "sha256:4768c100970a38078d0fd5642a7421c412fb2a30f1c0c87a65b80e71b88969f4", + "id": 8688743725, + "name": "liboliphaunt-wasix-release-assets", + "size": 54104192 + }, + { + "digest": "sha256:99d085ba49e94381455c042a1a3b2d582b054ac8aca31d00d9ebb1bd84b4a817", + "id": 8688435966, + "name": "liboliphaunt-wasix-runtime-aot-linux-arm64-gnu", + "size": 9072547 + }, + { + "digest": "sha256:eb6ff16126b9c37a59bca37b30a0b86e68a80d41cf9e491f4fe49a7dd90b826d", + "id": 8688456364, + "name": "liboliphaunt-wasix-runtime-aot-linux-x64-gnu", + "size": 9400749 + }, + { + "digest": "sha256:e57bb30a35703a188701a9b91f25e3f5e7de0f18118629c3654cac06dba7c007", + "id": 8688475560, + "name": "liboliphaunt-wasix-runtime-aot-macos-arm64", + "size": 8613917 + }, + { + "digest": "sha256:4fe1b394579c4624bc07d99e5c80086a537599ef47a3dfd6f42930918b19269f", + "id": 8688653755, + "name": "liboliphaunt-wasix-runtime-aot-windows-x64-msvc", + "size": 9118220 + }, + { + "digest": "sha256:32d5422362e5eb5cc2ae3c59969d4eae8fb326759138224e86bb7c51a2314ca9", + "id": 8688014332, + "name": "liboliphaunt-wasix-runtime-portable", + "size": 58944991 + }, + { + "digest": "sha256:4bdd90df95ba09c1a8d9a08ea80c4c86c3d59786bc8f3f5f4484b0b15eb7724a", + "id": 8689913951, + "name": "native-extension-lifecycle-evidence", + "size": 36693 + }, + { + "digest": "sha256:fc081a134f2a44994e0e1f8c78b6e738bbf3bbabd543902d530e2321824ee819", + "id": 8689906437, + "name": "native-extension-lifecycle-evidence-0", + "size": 10626 + }, + { + "digest": "sha256:af625cd25e1f65a0c764977ca018f7ab904a0592d83f0e5a90db84b7b02f964c", + "id": 8689900875, + "name": "native-extension-lifecycle-evidence-1", + "size": 10599 + }, + { + "digest": "sha256:7010ded5a3776c5bfa3114acd949e9d917b981a75f9c1b9648d032886b0fb99f", + "id": 8689905691, + "name": "native-extension-lifecycle-evidence-2", + "size": 10962 + }, + { + "digest": "sha256:783ec4a6463ae0454be43f06d629bfed9f7bd809d7fa6f47ac5bee8569cb15ca", + "id": 8687832576, + "name": "oliphaunt-broker-release-assets-linux-arm64-gnu", + "size": 484218 + }, + { + "digest": "sha256:d20a09563a70af5a1f2ce2b69eb2a7f0258c2a65a057b7267417f407d608394a", + "id": 8688091392, + "name": "oliphaunt-broker-release-assets-linux-x64-gnu", + "size": 538269 + }, + { + "digest": "sha256:ea5c17ba920ee214d798f2132d5e44f51bc45de7e6a51aade05d01df761468b3", + "id": 8687827073, + "name": "oliphaunt-broker-release-assets-macos-arm64", + "size": 468962 + }, + { + "digest": "sha256:dec6288ac01195ad97a5508d9769b4871d49d3c87862a5db8c6cff3c9f16ad0f", + "id": 8688100146, + "name": "oliphaunt-broker-release-assets-windows-x64-msvc", + "size": 619276 + }, + { + "digest": "sha256:50cd3b365cff5a543058252cd768510847284f2377ca058c28c3c1c6f69b6813", + "id": 8690232316, + "name": "oliphaunt-extension-package-artifacts", + "size": 351075265 + }, + { + "digest": "sha256:6ef24ed1cfbfc7870a7aafc7f14bafb4f6847cd599327b443843413102351057", + "id": 8689992578, + "name": "oliphaunt-js-exact-candidate-consumer-linux-arm64-gnu", + "size": 76207 + }, + { + "digest": "sha256:9448ce95b7c8f25230603104b294bdb6989cf6616d4b3b3368c11b2ff1c60ba3", + "id": 8690008043, + "name": "oliphaunt-js-exact-candidate-consumer-linux-x64-gnu", + "size": 76638 + }, + { + "digest": "sha256:e68864c02d6810d234848c5b91f36ac4d2ce7728aff1847e9c1ba580ede932fa", + "id": 8689979791, + "name": "oliphaunt-js-exact-candidate-consumer-macos-arm64", + "size": 76193 + }, + { + "digest": "sha256:6a96e3ed8bc32c4b06cb911eb344d243e8d3ca453b635fa2b95bf17cde06c94d", + "id": 8690089913, + "name": "oliphaunt-js-exact-candidate-consumer-windows-x64-msvc", + "size": 83663 + }, + { + "digest": "sha256:907c1742318e5dc13a84ac6e308e40746ec37d2de22c4fde78d23ac609d77458", + "id": 8687812139, + "name": "oliphaunt-js-sdk-package-artifacts", + "size": 304393 + }, + { + "digest": "sha256:d432ee86a4ad4275db5e5712aa701ce499b70b0b26ef98e299bf8cfea8648a21", + "id": 8687915429, + "name": "oliphaunt-kotlin-sdk-package-artifacts", + "size": 1884334 + }, + { + "digest": "sha256:3e3a3bdfd9974ee7c502b3359a5eeba8a2f3e071aa86d85322d1e3dbdad1de02", + "id": 8689934034, + "name": "oliphaunt-mobile-extension-package-artifacts", + "size": 198788737 + }, + { + "digest": "sha256:734eb4f4b3a634d0ee262fdd0526fcd3c8dc181addca13f2849f992c26e58457", + "id": 8687887050, + "name": "oliphaunt-native-extension-proof-linux-x64-gnu", + "size": 636300 + }, + { + "digest": "sha256:7da51a0f75f46800ff58fecbd1923d764e8f5c0956e8efd4c133015c354c01a0", + "id": 8687861797, + "name": "oliphaunt-node-direct-npm-package-linux-arm64-gnu", + "size": 24906 + }, + { + "digest": "sha256:cae3fe8b4d57c84f250459c724f54f31c3dd33bb4cc994367ea740ca0b0ea4fe", + "id": 8687856084, + "name": "oliphaunt-node-direct-npm-package-linux-x64-gnu", + "size": 26172 + }, + { + "digest": "sha256:19e55d9e81ce70328422fa5396f374b9a082191b0a5bf4e1772e919ca98defcc", + "id": 8687815574, + "name": "oliphaunt-node-direct-npm-package-macos-arm64", + "size": 19570 + }, + { + "digest": "sha256:6ed48344ccb0eab31e03c952af74aff79d043cb048ee7f8c7fa7582b09f54726", + "id": 8688127768, + "name": "oliphaunt-node-direct-npm-package-windows-x64-msvc", + "size": 84951 + }, + { + "digest": "sha256:38b14fad0e10741be60ce94b0fe4e1f3dcb375455b5a34c53abd6a73dabade29", + "id": 8687861391, + "name": "oliphaunt-node-direct-release-assets-linux-arm64-gnu", + "size": 24720 + }, + { + "digest": "sha256:f554bca9a46a7d507d5ba8aa0d82ff7e4435c95234741dd0418474e8704fa5a4", + "id": 8687855723, + "name": "oliphaunt-node-direct-release-assets-linux-x64-gnu", + "size": 26155 + }, + { + "digest": "sha256:9c5b648f78a4727fc980687d7f76a57aeca98daaa3c483556b0d4e7945dd01e1", + "id": 8687815126, + "name": "oliphaunt-node-direct-release-assets-macos-arm64", + "size": 19495 + }, + { + "digest": "sha256:fef0ce5bb7f68a9a4d5895635a03b6ee58d42abab5a91e33b6f6575aebaf4c8e", + "id": 8688127207, + "name": "oliphaunt-node-direct-release-assets-windows-x64-msvc", + "size": 82146 + }, + { + "digest": "sha256:bdfaac7d4afffa2979aa888442c2b815cdd57a01c49fde421480f6f02a59e957", + "id": 8690691725, + "name": "oliphaunt-react-native-ios-carriers", + "size": 123095 + }, + { + "digest": "sha256:1639bea1ef0f0802aefd817970a614087f015f6a184e1b20792e0cb723296a83", + "id": 8688924637, + "name": "oliphaunt-react-native-sdk-package-artifacts", + "size": 146894 + }, + { + "digest": "sha256:324135375658ab6c76b13d8fb0f2988215ea50abcb9cb8c498cab8374a5602ab", + "id": 8690971529, + "name": "oliphaunt-release-candidate", + "size": 1161 + }, + { + "digest": "sha256:9d52ad0f6994d310527d0cec55c90fea5ec0db70ad15b683b6a76cdedf832274", + "id": 8689922129, + "name": "oliphaunt-rust-exact-candidate-consumer-evidence", + "size": 5030 + }, + { + "digest": "sha256:bba60740be13f22db93d04576d169101faa9fd4c620b34e02eed97390b1b731b", + "id": 8687868633, + "name": "oliphaunt-rust-sdk-package-artifacts", + "size": 258465 + }, + { + "digest": "sha256:e02108a37e441ee4f547d52e552d9a784297516e76c104fb747f1e192239f8f3", + "id": 8689050114, + "name": "oliphaunt-swift-exact-candidate-consumer-evidence", + "size": 136460 + }, + { + "digest": "sha256:53c19efb03851656615dd2f0eb163ef412a7df5b6a8d61b3729d0aebb62fc506", + "id": 8689052563, + "name": "oliphaunt-swift-sdk-package-artifacts", + "size": 15114567 + }, + { + "digest": "sha256:501082acdf57b9f4a7f1b50915bcd10d97660ab35482105e10f5680da69bd1af", + "id": 8688800700, + "name": "oliphaunt-wasix-rust-exact-candidate-consumer-evidence", + "size": 34313 + }, + { + "digest": "sha256:58ea645c3ed1ef8b011da24096c1bc4b39893bb8df9ab1dde82ebc678fae7ae0", + "id": 8687973820, + "name": "oliphaunt-wasix-rust-package-artifacts", + "size": 363144 + }, + { + "digest": "sha256:e5c6cfbdae6fbe075b0f206a6c21f31823aad67a263d70f10d760826ef14d8b5", + "id": 8690432460, + "name": "react-native-mobile-android-app-android-arm64-v8a", + "size": 58753812 + }, + { + "digest": "sha256:e60e4a9727cc78896d2d3ed82bb80a3cad70c25864cb20c366d113372e3e5bd5", + "id": 8690338245, + "name": "react-native-mobile-android-app-android-x86_64", + "size": 59319208 + }, + { + "digest": "sha256:de41a5d7ed59b063ee9eac4558c04eb384a92a2e8393919809e343e8637fdc32", + "id": 8690517414, + "name": "react-native-mobile-android-e2e-reports", + "size": 7189 + }, + { + "digest": "sha256:af459783da56e56c69c57f7ca60705ce5203a81ad2f812c81afa10e478617f01", + "id": 8690690551, + "name": "react-native-mobile-ios-app", + "size": 41706064 + }, + { + "digest": "sha256:aa3463802d8d2ee2dd4ca0c4c9a990fe7e04232f3055a74299993598475a5618", + "id": 8690688584, + "name": "react-native-mobile-ios-build-logs", + "size": 805466 + }, + { + "digest": "sha256:12d461714fdb7bbbbc4e85406536c56b9aa1a67c4652a191244c676ed60410a6", + "id": 8690928757, + "name": "react-native-mobile-ios-e2e-reports", + "size": 40807 + }, + { + "digest": "sha256:027f20b594a53768aada589ad6bde47267c40337cb1a0d74618613b38c999dd8", + "id": 8688901265, + "name": "wasix-release-regression-evidence", + "size": 13652 + } + ], + "count": 73, + "inventoryDigest": "1d60fba40b040b98bcb3ccde9eedae53df440d77f85724123790f2e49fd7454c", + "totalSize": 1516373495 + }, + "requiredArtifactNames": [ + "artifact-build-plan", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-release-assets", + "liboliphaunt-wasix-runtime-aot-linux-arm64-gnu", + "liboliphaunt-wasix-runtime-aot-linux-x64-gnu", + "liboliphaunt-wasix-runtime-aot-macos-arm64", + "liboliphaunt-wasix-runtime-aot-windows-x64-msvc", + "liboliphaunt-wasix-runtime-portable", + "oliphaunt-broker-release-assets-linux-arm64-gnu", + "oliphaunt-broker-release-assets-linux-x64-gnu", + "oliphaunt-broker-release-assets-macos-arm64", + "oliphaunt-broker-release-assets-windows-x64-msvc", + "oliphaunt-extension-package-artifacts", + "oliphaunt-js-sdk-package-artifacts", + "oliphaunt-kotlin-sdk-package-artifacts", + "oliphaunt-node-direct-npm-package-linux-arm64-gnu", + "oliphaunt-node-direct-npm-package-linux-x64-gnu", + "oliphaunt-node-direct-npm-package-macos-arm64", + "oliphaunt-node-direct-npm-package-windows-x64-msvc", + "oliphaunt-node-direct-release-assets-linux-arm64-gnu", + "oliphaunt-node-direct-release-assets-linux-x64-gnu", + "oliphaunt-node-direct-release-assets-macos-arm64", + "oliphaunt-node-direct-release-assets-windows-x64-msvc", + "oliphaunt-react-native-sdk-package-artifacts", + "oliphaunt-release-candidate", + "oliphaunt-rust-sdk-package-artifacts", + "oliphaunt-swift-sdk-package-artifacts", + "oliphaunt-wasix-rust-package-artifacts", + "wasix-release-regression-evidence" + ], + "run": { + "attempt": 1, + "conclusion": "success", + "event": "workflow_dispatch", + "headSha": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "id": 30358387218, + "status": "completed" + }, + "workflow": { + "id": 265767525, + "name": "CI", + "path": ".github/workflows/ci.yml" + } + }, + "releaseEnvelope": { + "carrierCount": 303, + "catalogDigest": "b8294c0c0030a7b77977635f864854f3503a3ac01c1e9c65da79425b087f9a43", + "lockDigest": "5ee675ab3066cca7df21dd425a5c80fd6c9b9c4b276757fc1aa84e2020761266", + "packageEnvelopeDigest": "229aaba1d027719172b0801dca2ff70dde22967b8de9f57acad8cafe1ee686c2", + "productArtifactCount": 152, + "productCount": 18, + "publicationLock": { + "path": "target/release/publication-lock.json", + "sha256": "e46df2d82e83533c724c8e9d08a32ad0ef7fc96df6ce8c2984c522b65ddfd8fd", + "size": 438770 + }, + "schema": "oliphaunt-publication-lock-v1" + }, + "releaseSource": { + "commit": "9c398f4e5c05f494f9b752a8634e74e0bc11dd19", + "tree": "396cf3b10adb1a5b625e66c5ebacf8c3d364b543" + } + } + ], + "schema": "oliphaunt-same-version-recovery-sources-v1" +} diff --git a/tools/release/upload-github-release-assets.test.mjs b/tools/release/upload-github-release-assets.test.mjs index cd3c33c3..762a3a16 100644 --- a/tools/release/upload-github-release-assets.test.mjs +++ b/tools/release/upload-github-release-assets.test.mjs @@ -11,7 +11,10 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { exactReleaseMetadata } from "./github-release-mutations.mjs"; +import { + exactReleaseMetadata, + readReleaseByTagSync, +} from "./github-release-mutations.mjs"; import { allArtifactTargets, exactExtensionProducts, @@ -21,6 +24,7 @@ import { FIRST_RELEASE_NOMINAL_CORE_REQUESTS, FIRST_RELEASE_TRANSFER_REQUEST_TOTAL, } from "./github-release-request-budget.mjs"; +import { readGitHubCoreRequestJournal } from "./github-core-request-journal.mjs"; import { expectedExtensionGithubReleaseAssetCount } from "./publication-lock.mjs"; import { assertExactFrozenUploadSelection, @@ -29,6 +33,7 @@ import { githubReleaseAssetUploadWindowMs, GITHUB_RELEASE_ASSET_UPLOAD_SNAPSHOT_RESERVE_MS, MAX_SAFE_EMBEDDED_RELEASE_ASSETS, + readExactReleaseAssetSnapshotSync, uploadFrozenReleaseAssetsSync, withStagedFrozenAssetSync, } from "./upload_github_release_assets.mjs"; @@ -36,8 +41,8 @@ import { GITHUB_CONTENT_WRITE_INTERVAL_MS } from "./github-content-write-pacer.m const HEAD = "a".repeat(40); -function budget() { - return { deadlineMs: 60_000, environment: {}, now: () => 0, startedAtMs: 0 }; +function budget(environment = {}) { + return { deadlineMs: 60_000, environment, now: () => 0, startedAtMs: 0 }; } function frozenAsset(name, index, size = index + 1) { @@ -99,6 +104,15 @@ function deterministicReads(maxAttempts = 1) { }; } +function includedJson(data) { + return [ + "HTTP/2.0 200 OK", + "Content-Type: application/json; charset=utf-8", + "", + JSON.stringify(data), + ].join("\n"); +} + test("the upload operation window is derived from the exact frozen asset count", () => { const assetCount = 19; const required = (assetCount * ( @@ -121,10 +135,16 @@ function uploadDependencies(uploadPlan, remote, overrides = {}) { return { budget: budget(), environment: {}, - readRelease: (...args) => { + readReleaseById: (...args) => { lastRelease = selectedReadRelease(...args); return lastRelease; }, + readReleaseMap: (...args) => { + lastRelease = selectedReadRelease(...args); + return lastRelease === null + ? new Map() + : new Map([[lastRelease.tag_name, lastRelease]]); + }, readAssets: (...args) => selectedReadAssets(...args), snapshotReadOptions: deterministicReads(), uploadAsset: ({ asset }) => { @@ -191,6 +211,89 @@ test("the upload request is bound to one immutable release id and frozen asset n ); }); +test("draft upload discovery uses the complete release list, then the exact release id", (t) => { + const root = mkdtempSync(path.join(os.tmpdir(), "oliphaunt-draft-upload-discovery-")); + t.after(() => rmSync(root, { force: true, recursive: true })); + const environment = { + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "o/r", + GITHUB_RUN_ATTEMPT: "1", + GITHUB_RUN_ID: "123", + GITHUB_SHA: HEAD, + OLIPHAUNT_GITHUB_CORE_REQUEST_JOURNAL_PATH: path.join(root, "journal.json"), + OLIPHAUNT_REQUIRE_GITHUB_CORE_REQUEST_JOURNAL: "true", + }; + const uploadPlan = plan(); + const draft = releaseFor(uploadPlan, new Map(), { draft: true, id: 73 }); + const endpoints = []; + const spawn = (_command, args) => { + const endpoint = args.at(-1); + endpoints.push(endpoint); + if (endpoint.includes("/releases/tags/")) { + return { status: 1, stderr: "gh: Not Found (HTTP 404)", stdout: "" }; + } + if (endpoint === "repos/o/r/releases?per_page=100&page=1") { + return { status: 0, stderr: "", stdout: includedJson([draft]) }; + } + if (endpoint === "repos/o/r/releases/73") { + return { status: 0, stderr: "", stdout: JSON.stringify(draft) }; + } + if (endpoint === "repos/o/r/releases/73/assets?per_page=100&page=1") { + return { status: 0, stderr: "", stdout: includedJson([]) }; + } + return { status: 1, stderr: `unexpected endpoint ${endpoint}`, stdout: "" }; + }; + const readOptions = { + baseDelayMs: 0, + coreJournalOptions: { now: () => 20_000 }, + deadlineMs: 10_000, + environment, + maxAttempts: 1, + maxDelayMs: 0, + now: () => 20_000, + sleep: () => {}, + spawn, + }; + + assert.equal( + readReleaseByTagSync("o/r", uploadPlan.tag, readOptions), + null, + "GitHub's by-tag endpoint does not expose the draft", + ); + endpoints.length = 0; + + const initial = readExactReleaseAssetSnapshotSync({ + budget: budget(environment), + expectedReleaseId: undefined, + phase: "pre-upload", + plan: uploadPlan, + }, { singleReadOptions: readOptions }); + assert.equal(initial.release.draft, true); + assert.equal(initial.releaseId, 73); + assert.deepEqual(endpoints, [ + "repos/o/r/releases?per_page=100&page=1", + "repos/o/r/releases/73/assets?per_page=100&page=1", + ]); + + endpoints.length = 0; + const later = readExactReleaseAssetSnapshotSync({ + budget: budget(environment), + expectedReleaseId: 73, + phase: "post-upload", + plan: uploadPlan, + }, { singleReadOptions: readOptions }); + assert.equal(later.release.draft, true); + assert.equal(later.releaseId, 73); + assert.deepEqual(endpoints, [ + "repos/o/r/releases/73", + "repos/o/r/releases/73/assets?per_page=100&page=1", + ]); + assert.deepEqual( + readGitHubCoreRequestJournal({ environment, now: () => 20_000 }), + { enabled: true, rollingCount: 5, sequence: 5 }, + ); +}); + test("one product snapshot skips matching assets and uploads missing assets sequentially", () => { const assets = [frozenAsset("one.tgz", 1), frozenAsset("two.tgz", 2)]; const uploadPlan = plan(assets); diff --git a/tools/release/upload_github_release_assets.mjs b/tools/release/upload_github_release_assets.mjs index 39c3b0ed..c738b566 100644 --- a/tools/release/upload_github_release_assets.mjs +++ b/tools/release/upload_github_release_assets.mjs @@ -26,7 +26,8 @@ import { createGitHubOperationBudget, exactReleaseMetadata, readReleaseAssetsSync, - readReleaseByTagSync, + readReleaseByIdSync, + readReleaseMapSync, releaseNotesForVersion, remainingGitHubReadOptions, runGitHubMutationSync, @@ -393,12 +394,25 @@ export function readExactReleaseAssetSnapshotSync( ) { const context = { expectedReleaseId, phase, plan }; const readOptions = () => singleSnapshotReadOptions(budget, dependencies); - const readRelease = dependencies.readRelease ?? (() => - readReleaseByTagSync(plan.repo, plan.tag, readOptions())); + const readReleaseMap = dependencies.readReleaseMap ?? (() => + readReleaseMapSync(plan.repo, readOptions())); + const readReleaseById = dependencies.readReleaseById ?? ((releaseId) => + readReleaseByIdSync(plan.repo, releaseId, readOptions())); const readAssets = dependencies.readAssets ?? ((releaseId) => readReleaseAssetsSync(plan.repo, releaseId, readOptions())); - const release = readRelease(context); + let release; + if (expectedReleaseId === undefined) { + const releasesByTag = readReleaseMap(context); + if (!(releasesByTag instanceof Map)) { + throw permanentError( + `${plan.product} GitHub release ${plan.tag} returned a non-canonical release inventory`, + ); + } + release = releasesByTag.get(plan.tag) ?? null; + } else { + release = readReleaseById(expectedReleaseId, context); + } if (release === null) { throw permanentError( `${plan.product} GitHub release ${plan.tag} does not exist. ` diff --git a/tools/release/verify-github-release-attestation-receipt.test.mjs b/tools/release/verify-github-release-attestation-receipt.test.mjs index 117e9f2f..941b1239 100644 --- a/tools/release/verify-github-release-attestation-receipt.test.mjs +++ b/tools/release/verify-github-release-attestation-receipt.test.mjs @@ -854,6 +854,8 @@ describe("GitHub release attestation receipt", () => { }); expect(calls).toHaveLength(1); expect(calls[0].file).toBe(local); + expect(calls[0].predicateType).toBe("https://slsa.dev/provenance/v1"); + expect(calls[0].recoveryExpectations).toBeUndefined(); expect(records[0].subjects).toEqual(subjects); await expect(verifyAttestationBundles(lock, [bundlePath], { @@ -882,7 +884,34 @@ describe("GitHub release attestation receipt", () => { "--signer-digest", COMMIT, ]); + expect(args.slice(args.indexOf("--predicate-type"), args.indexOf("--predicate-type") + 2)).toEqual([ + "--predicate-type", + "https://slsa.dev/provenance/v1", + ]); expect(args).toContain("--deny-self-hosted-runners"); + + const recoveryType = + "https://github.com/f0rr0/oliphaunt/attestations/same-version-recovery-promotion/v1"; + const controller = "4".repeat(40); + const recoveryArgs = ghBundleVerifyArgs({ + bundlePath: "/tmp/recovery-bundle.json", + file: "/tmp/asset.tar.zst", + head: controller, + predicateType: recoveryType, + repo: REPO, + }); + expect( + recoveryArgs.slice( + recoveryArgs.indexOf("--predicate-type"), + recoveryArgs.indexOf("--predicate-type") + 2, + ), + ).toEqual(["--predicate-type", recoveryType]); + expect( + recoveryArgs.slice( + recoveryArgs.indexOf("--source-digest"), + recoveryArgs.indexOf("--source-digest") + 2, + ), + ).toEqual(["--source-digest", controller]); }); test("publishes receipt files atomically, cleans interrupted temps, and permits only identical reruns", async () => { diff --git a/tools/release/verify-publication-candidate.mjs b/tools/release/verify-publication-candidate.mjs new file mode 100644 index 00000000..5dc8274c --- /dev/null +++ b/tools/release/verify-publication-candidate.mjs @@ -0,0 +1,502 @@ +#!/usr/bin/env bun + +import { appendFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { captureCommandOutput } from "../dev/capture-command-output.mjs"; +import { ROOT } from "./release-graph.mjs"; +import { RELEASE_SEMANTIC_INPUTS_PATH } from "./release-semantic-inputs.mjs"; +import { + deriveReleaseProducts, + verifyReleaseCommit, +} from "./verify-release-commit.mjs"; + +const TOOL = "verify-publication-candidate.mjs"; +export const RELEASE_RECOVERY_TRAILER = "Oliphaunt-Release-Recovery-Of"; +const SHA = /^[0-9a-f]{40}$/u; +const RECOVERY_SUBJECT = /^fix\(release\): .+/u; + +function error(message) { + return new Error(`${TOOL}: ${message}`); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function git( + repo, + args, + { + allowEmptyOutput = false, + input = undefined, + stdoutTerminator = undefined, + } = {}, +) { + const result = captureCommandOutput("git", args, { + allowEmptyOutput, + cwd: repo, + input, + label: `git ${args.join(" ")}`, + stdoutTerminator, + }); + if (result.error !== undefined || result.status !== 0) { + const detail = (result.stderr || result.stdout || result.error?.message || "").trim(); + throw error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`); + } + return result.stdout; +} + +function show(repo, commit, file) { + return git(repo, ["show", `${commit}:${file}`]); +} + +function showJson(repo, commit, file) { + let value; + try { + value = JSON.parse(show(repo, commit, file)); + } catch (cause) { + throw error(`${file} at ${commit} is not valid JSON: ${cause.message}`); + } + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw error(`${file} at ${commit} must contain a JSON object`); + } + return value; +} + +function parsedTrailers(repo, commit) { + const body = git(repo, ["show", "-s", "--format=%B", commit]); + const parsed = git( + repo, + ["interpret-trailers", "--parse"], + { + allowEmptyOutput: true, + input: body, + stdoutTerminator: "\n", + }, + ); + return parsed + .trimEnd() + .split("\n") + .filter(Boolean) + .map((line) => { + const separator = line.indexOf(":"); + if (separator <= 0) throw error(`git interpret-trailers emitted malformed line ${JSON.stringify(line)}`); + return { + key: line.slice(0, separator), + value: line.slice(separator + 1).trimStart(), + }; + }); +} + +function recoveryTrailer(repo, commit) { + const matches = parsedTrailers(repo, commit) + .filter(({ key }) => key.toLowerCase() === RELEASE_RECOVERY_TRAILER.toLowerCase()); + if (matches.length === 0) return null; + if (matches.length !== 1) { + throw error(`${commit} must contain exactly one ${RELEASE_RECOVERY_TRAILER} trailer`); + } + const [{ key, value }] = matches; + if (key !== RELEASE_RECOVERY_TRAILER || !SHA.test(value)) { + throw error( + `${commit} recovery trailer must be exactly ` + + `${RELEASE_RECOVERY_TRAILER}: `, + ); + } + return value; +} + +function changedFiles(repo, base, head) { + return git( + repo, + [ + "diff", + "--no-renames", + "--name-only", + "--diff-filter=ACDMRT", + "-z", + base, + head, + ], + { allowEmptyOutput: true, stdoutTerminator: "\0" }, + ) + .split("\0") + .filter(Boolean) + .sort(compareText); +} + +function canonicalPath(value, context) { + if ( + typeof value !== "string" + || value.length === 0 + || path.posix.isAbsolute(value) + || path.posix.normalize(value) !== value + || value === ".." + || value.startsWith("../") + || /[\\\u0000-\u001f\u007f]/u.test(value) + ) { + throw error(`${context} must be a canonical repository-relative path`); + } + return value; +} + +function semanticPatterns(repo, commit) { + let value; + try { + value = Bun.TOML.parse(show(repo, commit, RELEASE_SEMANTIC_INPUTS_PATH)); + } catch (cause) { + throw error( + `${RELEASE_SEMANTIC_INPUTS_PATH} at ${commit} is not valid TOML: ${cause.message}`, + ); + } + if ( + value === null + || Array.isArray(value) + || typeof value !== "object" + || value.schema !== "oliphaunt-release-semantic-inputs-v1" + || !Array.isArray(value.rules) + || value.rules.length === 0 + ) { + throw error(`${RELEASE_SEMANTIC_INPUTS_PATH} at ${commit} has an invalid ownership shape`); + } + return value.rules.flatMap((rule, ruleIndex) => { + const products = rule?.products ?? []; + const productKinds = rule?.product_kinds ?? []; + if ( + rule === null + || Array.isArray(rule) + || typeof rule !== "object" + || !Array.isArray(rule.paths) + || rule.paths.length === 0 + || !Array.isArray(products) + || products.some((product) => typeof product !== "string" || product.length === 0) + || !Array.isArray(productKinds) + || productKinds.some((kind) => typeof kind !== "string" || kind.length === 0) + || products.length + productKinds.length === 0 + ) { + throw error( + `${RELEASE_SEMANTIC_INPUTS_PATH} rule ${ruleIndex} has an invalid ownership shape`, + ); + } + return rule.paths.map((candidate, pathIndex) => { + const raw = canonicalPath( + candidate, + `${RELEASE_SEMANTIC_INPUTS_PATH} rule ${ruleIndex} path ${pathIndex}`, + ); + const directory = raw.endsWith("/**"); + const root = directory ? raw.slice(0, -3) : raw; + if (!root || root.includes("*")) { + throw error( + `${RELEASE_SEMANTIC_INPUTS_PATH} rule ${ruleIndex} path ${pathIndex} ` + + "must be exact or end in /**", + ); + } + return { directory, raw, root }; + }); + }); +} + +function semanticallyOwnedFiles(repo, commit, files) { + const patterns = semanticPatterns(repo, commit); + return files.filter((file) => + file === RELEASE_SEMANTIC_INPUTS_PATH + || patterns.some((pattern) => + pattern.directory ? file.startsWith(`${pattern.root}/`) : file === pattern.root)); +} + +function releaseProductsForRange(repo, releaseCommit, publicationCommit) { + const script = path.join(repo, "tools/release/release_plan.mjs"); + const result = captureCommandOutput( + process.execPath, + [ + script, + "--base-ref", + releaseCommit, + "--head-ref", + publicationCommit, + "--format", + "json", + ], + { + cwd: repo, + env: process.env, + label: `${process.execPath} ${script} recovery range`, + maxOutputBytes: 64 * 1024 * 1024, + }, + ); + if (result.error !== undefined || result.status !== 0) { + const detail = (result.stderr || result.stdout || result.error?.message || "").trim(); + throw error(`could not derive recovery release impact${detail ? `: ${detail}` : ""}`); + } + let plan; + try { + plan = JSON.parse(result.stdout); + } catch (cause) { + throw error(`recovery release plan returned invalid JSON: ${cause.message}`); + } + if ( + !Array.isArray(plan?.releaseProducts) + || plan.releaseProducts.some((product) => + typeof product !== "string" || product.length === 0) + ) { + throw error("recovery release plan has an invalid releaseProducts list"); + } + return [...plan.releaseProducts].sort(compareText); +} + +function assertLinearRecoveryChain(repo, releaseCommit, publicationCommit) { + const rows = git( + repo, + ["rev-list", "--reverse", "--topo-order", "--parents", `${releaseCommit}..${publicationCommit}`], + ) + .trim() + .split("\n") + .filter(Boolean); + if (rows.length === 0) { + throw error("release recovery commit must be a strict descendant of its release-bump commit"); + } + let expectedParent = releaseCommit; + for (const row of rows) { + const fields = row.split(/\s+/u); + if (fields.length !== 2 || fields[1] !== expectedParent) { + throw error( + "release recovery history must be one linear, one-parent chain directly after " + + `${releaseCommit}`, + ); + } + const [commit] = fields; + const subject = git(repo, ["show", "-s", "--format=%s", commit]).trimEnd(); + if (!RECOVERY_SUBJECT.test(subject)) { + throw error( + `release recovery commit ${commit} subject must start with "fix(release): "; ` + + `got ${JSON.stringify(subject)}`, + ); + } + const trailer = recoveryTrailer(repo, commit); + if (trailer !== releaseCommit) { + throw error( + `release recovery commit ${commit} must carry ` + + `${RELEASE_RECOVERY_TRAILER}: ${releaseCommit}`, + ); + } + expectedParent = commit; + } + if (expectedParent !== publicationCommit) { + throw error("release recovery history did not terminate at the publication commit"); + } +} + +function sameFile(repo, left, right, file) { + return show(repo, left, file) === show(repo, right, file); +} + +export function derivePublicationProducts({ + repo = ROOT, + headRef = "HEAD", +} = {}) { + const publicationCommit = git( + repo, + ["rev-parse", "--verify", `${headRef}^{commit}`], + ).trimEnd(); + const releaseCommit = recoveryTrailer(repo, publicationCommit) ?? publicationCommit; + return deriveReleaseProducts({ repo, headRef: releaseCommit }).products; +} + +/** + * Return a fully verified recovery context when HEAD explicitly carries the + * recovery trailer. Ordinary source and release-bump commits return null so + * general repository checks retain their existing behavior. + */ +export function verifyPublicationRecoveryCandidate({ + repo = ROOT, + headRef = "HEAD", + deriveRecoveryProducts = releaseProductsForRange, +} = {}) { + const publicationCommit = git( + repo, + ["rev-parse", "--verify", `${headRef}^{commit}`], + ).trimEnd(); + if (recoveryTrailer(repo, publicationCommit) === null) return null; + return verifyPublicationCandidate({ + repo, + headRef: publicationCommit, + products: derivePublicationProducts({ repo, headRef: publicationCommit }), + deriveRecoveryProducts, + }); +} + +export function verifyPublicationCandidate({ + repo = ROOT, + headRef = "HEAD", + products, + deriveRecoveryProducts = releaseProductsForRange, +} = {}) { + const publicationCommit = git( + repo, + ["rev-parse", "--verify", `${headRef}^{commit}`], + ).trimEnd(); + const releaseCommit = recoveryTrailer(repo, publicationCommit); + if (releaseCommit === null) { + const verified = verifyReleaseCommit({ repo, headRef: publicationCommit, products }); + return { + mode: "release-bump", + publicationSha: verified.commit, + releaseSha: verified.commit, + products: verified.products, + versions: verified.versions, + recoveryChangedFiles: [], + }; + } + + const ancestor = captureCommandOutput( + "git", + ["merge-base", "--is-ancestor", releaseCommit, publicationCommit], + { cwd: repo, label: `git merge-base --is-ancestor ${releaseCommit} ${publicationCommit}` }, + ); + if (ancestor.error !== undefined || ancestor.status !== 0) { + throw error( + `${releaseCommit} is not an ancestor of recovery publication commit ${publicationCommit}`, + ); + } + assertLinearRecoveryChain(repo, releaseCommit, publicationCommit); + + const verified = verifyReleaseCommit({ repo, headRef: releaseCommit, products }); + for (const file of [ + ".release-please-manifest.json", + "release-please-config.json", + RELEASE_SEMANTIC_INPUTS_PATH, + ]) { + if (!sameFile(repo, releaseCommit, publicationCommit, file)) { + throw error(`release recovery changes immutable release metadata ${file}`); + } + } + + const changed = changedFiles(repo, releaseCommit, publicationCommit); + if (changed.length === 0) { + throw error("release recovery must contain at least one control-plane change"); + } + const recoveryProducts = deriveRecoveryProducts( + repo, + releaseCommit, + publicationCommit, + ); + if ( + !Array.isArray(recoveryProducts) + || recoveryProducts.some((product) => + typeof product !== "string" || product.length === 0) + ) { + throw error("release recovery impact derivation must return a product string list"); + } + if (recoveryProducts.length > 0) { + throw error( + "release recovery selects release-impacting product(s): " + + `${[...new Set(recoveryProducts)].sort(compareText).join(", ")}; ` + + "create a new product version instead", + ); + } + const owned = semanticallyOwnedFiles(repo, publicationCommit, changed); + if (owned.length > 0) { + throw error( + "release recovery changes product-semantic path(s): " + + `${owned.join(", ")}; create a new product version instead`, + ); + } + + const manifest = showJson(repo, publicationCommit, ".release-please-manifest.json"); + const config = showJson(repo, publicationCommit, "release-please-config.json"); + const packages = config.packages; + if (packages === null || Array.isArray(packages) || typeof packages !== "object") { + throw error("release-please-config.json must define a packages object"); + } + const currentVersions = new Map( + Object.entries(packages).map(([packagePath, packageConfig]) => [ + packageConfig?.component, + manifest[packagePath], + ]), + ); + for (const [product, version] of Object.entries(verified.versions)) { + if (currentVersions.get(product) !== version) { + throw error( + `release recovery changed ${product} from ${version} to ` + + `${JSON.stringify(currentVersions.get(product))}`, + ); + } + } + + return { + mode: "release-recovery", + publicationSha: publicationCommit, + releaseSha: releaseCommit, + products: verified.products, + versions: verified.versions, + recoveryChangedFiles: changed, + }; +} + +function parseArgs(argv) { + let productsJson = ""; + let headRef = "HEAD"; + let githubOutput = ""; + let deriveProducts = false; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--products-json") { + productsJson = argv[index + 1] ?? ""; + index += 1; + } else if (arg === "--derive-products") { + deriveProducts = true; + } else if (arg === "--head-ref") { + headRef = argv[index + 1] ?? ""; + index += 1; + } else if (arg === "--github-output") { + githubOutput = argv[index + 1] ?? ""; + index += 1; + } else { + throw error(`unknown argument ${arg}`); + } + } + if (!headRef || deriveProducts === Boolean(productsJson)) { + throw error( + "usage: verify-publication-candidate.mjs " + + "(--products-json JSON | --derive-products) " + + "[--head-ref REF] [--github-output FILE]", + ); + } + let products; + if (deriveProducts) { + products = derivePublicationProducts({ headRef }); + } else { + try { + products = JSON.parse(productsJson); + } catch (cause) { + throw error(`--products-json must be valid JSON: ${cause.message}`); + } + } + return { githubOutput, headRef, products }; +} + +if (import.meta.main) { + try { + const args = parseArgs(Bun.argv.slice(2)); + const verified = verifyPublicationCandidate(args); + if (args.githubOutput) { + appendFileSync( + args.githubOutput, + [ + `mode=${verified.mode}`, + `publication_sha=${verified.publicationSha}`, + `release_sha=${verified.releaseSha}`, + "", + ].join("\n"), + ); + } + console.log( + `verified ${verified.mode} publication commit ${verified.publicationSha} ` + + `for release ${verified.releaseSha} and ${verified.products.length} product(s)`, + ); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); + } +} diff --git a/tools/release/verify-publication-candidate.test.mjs b/tools/release/verify-publication-candidate.test.mjs new file mode 100644 index 00000000..66c31762 --- /dev/null +++ b/tools/release/verify-publication-candidate.test.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { execFileSync } from "../test/fd-backed-spawn-sync.mjs"; +import { + derivePublicationProducts, + RELEASE_RECOVERY_TRAILER, + verifyPublicationCandidate, + verifyPublicationRecoveryCandidate, +} from "./verify-publication-candidate.mjs"; + +const PRODUCT = "alpha"; + +function git(repo, ...args) { + return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); +} + +function write(repo, file, contents) { + const target = path.join(repo, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, contents); +} + +function commit(repo, subject, trailers = []) { + git(repo, "add", "."); + const args = ["commit", "-m", subject]; + if (trailers.length > 0) args.push("-m", trailers.join("\n")); + git(repo, ...args); + return git(repo, "rev-parse", "HEAD"); +} + +function fixture() { + const repo = mkdtempSync(path.join(os.tmpdir(), "oliphaunt-publication-candidate-")); + git(repo, "init", "-q"); + git(repo, "config", "user.name", "Release Test"); + git(repo, "config", "user.email", "release@example.invalid"); + write(repo, "release-please-config.json", `${JSON.stringify({ + packages: { + "packages/alpha": { + "release-type": "simple", + component: PRODUCT, + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md", + }, + }, + }, null, 2)}\n`); + write(repo, ".release-please-manifest.json", '{"packages/alpha":"0.0.0"}\n'); + write(repo, "tools/release/release-semantic-inputs.toml", [ + 'schema = "oliphaunt-release-semantic-inputs-v1"', + "", + "[[rules]]", + 'id = "product-input"', + 'paths = ["src/product.txt", "src/product/**"]', + 'products = ["alpha"]', + "", + ].join("\n")); + write(repo, "packages/alpha/VERSION", "0.0.0\n"); + write(repo, "packages/alpha/CHANGELOG.md", "# Changelog\n"); + write(repo, "src/product.txt", "seed\n"); + commit(repo, "feat: introduce fixture"); + + write(repo, ".release-please-manifest.json", '{"packages/alpha":"0.1.0"}\n'); + write(repo, "packages/alpha/VERSION", "0.1.0\n"); + write( + repo, + "packages/alpha/CHANGELOG.md", + "# Changelog\n\n## 0.1.0 (2026-07-30)\n\n- Initial release.\n", + ); + const release = commit(repo, "chore(release): publish alpha 0.1.0"); + return { repo, release }; +} + +function recovery(repo, release, file = "tools/test/recovery.txt", contents = "repair\n") { + write(repo, file, contents); + return commit( + repo, + "fix(release): repair publication control", + [`${RELEASE_RECOVERY_TRAILER}: ${release}`], + ); +} + +function verify(repo, headRef, products = [PRODUCT], recoveryProducts = []) { + return verifyPublicationCandidate({ + repo, + headRef, + products, + deriveRecoveryProducts: () => recoveryProducts, + }); +} + +test("accepts normal release commits and explicit control-only recovery chains", { timeout: 20_000 }, () => { + const { repo, release } = fixture(); + assert.deepEqual( + verify(repo, release), + { + mode: "release-bump", + publicationSha: release, + releaseSha: release, + products: [PRODUCT], + versions: { [PRODUCT]: "0.1.0" }, + recoveryChangedFiles: [], + }, + ); + + const first = recovery(repo, release); + assert.deepEqual( + derivePublicationProducts({ repo, headRef: first }), + [PRODUCT], + ); + assert.equal( + verifyPublicationRecoveryCandidate({ + repo, + headRef: release, + deriveRecoveryProducts: () => [], + }), + null, + ); + git(repo, "switch", "-q", "-c", "untrailed-control", release); + write(repo, "tools/test/untrailed.txt", "untrailed\n"); + const untrailed = commit(repo, "fix: untrailed publication control"); + assert.equal( + verifyPublicationRecoveryCandidate({ + repo, + headRef: untrailed, + deriveRecoveryProducts: () => [], + }), + null, + ); + git(repo, "switch", "-q", "--detach", first); + assert.equal( + verifyPublicationRecoveryCandidate({ + repo, + headRef: first, + deriveRecoveryProducts: () => [], + })?.releaseSha, + release, + ); + const verifiedFirst = verify(repo, first); + assert.equal(verifiedFirst.mode, "release-recovery"); + assert.equal(verifiedFirst.publicationSha, first); + assert.equal(verifiedFirst.releaseSha, release); + assert.deepEqual(verifiedFirst.recoveryChangedFiles, ["tools/test/recovery.txt"]); + + const second = recovery(repo, release, ".github/workflows/recovery.yml", "name: recovery\n"); + const verifiedSecond = verify(repo, second); + assert.equal(verifiedSecond.publicationSha, second); + assert.equal(verifiedSecond.releaseSha, release); + assert.deepEqual( + verifiedSecond.recoveryChangedFiles, + [".github/workflows/recovery.yml", "tools/test/recovery.txt"], + ); +}); + +test("rejects product-owned, release-metadata, nonlinear, and ambiguously authorized recovery", { timeout: 30_000 }, () => { + const { repo, release } = fixture(); + + git(repo, "switch", "-q", "-c", "owned", release); + const owned = recovery(repo, release, "src/product/nested.txt", "changed\n"); + assert.throws( + () => verify(repo, owned), + /product-semantic path.*src\/product\/nested[.]txt/u, + ); + assert.throws( + () => verifyPublicationRecoveryCandidate({ + repo, + headRef: owned, + deriveRecoveryProducts: () => [], + }), + /product-semantic path.*src\/product\/nested[.]txt/u, + ); + + git(repo, "switch", "-q", "-c", "product-range", release); + const productRange = recovery( + repo, + release, + "packages/alpha/source.txt", + "changed\n", + ); + assert.throws( + () => verify(repo, productRange, [PRODUCT], [PRODUCT]), + /selects release-impacting product.*alpha/u, + ); + + git(repo, "switch", "-q", "-c", "metadata", release); + write(repo, "release-please-config.json", `${JSON.stringify({ + packages: { + "packages/alpha": { + "release-type": "simple", + component: PRODUCT, + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md", + "extra-files": [], + }, + }, + }, null, 2)}\n`); + const metadata = commit( + repo, + "fix(release): mutate release metadata", + [`${RELEASE_RECOVERY_TRAILER}: ${release}`], + ); + assert.throws( + () => verify(repo, metadata), + /immutable release metadata release-please-config[.]json/u, + ); + + git(repo, "switch", "-q", "-c", "missing-intermediate-trailer", release); + write(repo, "tools/test/first.txt", "first\n"); + commit(repo, "fix(release): unbound intermediate"); + const missingIntermediate = recovery(repo, release, "tools/test/second.txt", "second\n"); + assert.throws( + () => verify(repo, missingIntermediate), + /must carry Oliphaunt-Release-Recovery-Of/u, + ); + + git(repo, "switch", "-q", "-c", "duplicate-trailer", release); + write(repo, "tools/test/duplicate.txt", "duplicate\n"); + const duplicate = commit( + repo, + "fix(release): duplicate authorization", + [ + `${RELEASE_RECOVERY_TRAILER}: ${release}`, + `${RELEASE_RECOVERY_TRAILER}: ${release}`, + ], + ); + assert.throws( + () => verify(repo, duplicate), + /exactly one Oliphaunt-Release-Recovery-Of trailer/u, + ); + + git(repo, "switch", "-q", "-c", "wrong-product", release); + const wrongProduct = recovery(repo, release, "tools/test/wrong-product.txt", "wrong\n"); + assert.throws( + () => verify(repo, wrongProduct, ["beta"]), + /selected release product beta is absent/u, + ); + + git(repo, "switch", "-q", "-c", "wrong-subject", release); + write(repo, "tools/test/wrong-subject.txt", "wrong\n"); + const wrongSubject = commit( + repo, + "ci: repair publication", + [`${RELEASE_RECOVERY_TRAILER}: ${release}`], + ); + assert.throws( + () => verify(repo, wrongSubject), + /subject must start with "fix\(release\): "/u, + ); +}); + +test("rejects malformed, non-ancestor, and non-release recovery anchors", { timeout: 20_000 }, () => { + const { repo, release } = fixture(); + + git(repo, "switch", "-q", "-c", "uppercase", release); + write(repo, "tools/test/uppercase.txt", "uppercase\n"); + const uppercase = commit( + repo, + "fix(release): uppercase recovery anchor", + [`${RELEASE_RECOVERY_TRAILER}: ${release.toUpperCase()}`], + ); + assert.throws( + () => verify(repo, uppercase), + /lowercase-full-sha/u, + ); + + git(repo, "switch", "-q", "-c", "unrelated", `${release}^`); + write(repo, "tools/test/unrelated.txt", "unrelated\n"); + const unrelated = commit(repo, "fix(release): unrelated anchor target"); + write(repo, "tools/test/unrelated-recovery.txt", "recovery\n"); + const nonAncestor = commit( + repo, + "fix(release): point outside ancestry", + [`${RELEASE_RECOVERY_TRAILER}: ${release}`], + ); + assert.throws( + () => verify(repo, nonAncestor), + /is not an ancestor/u, + ); + + git(repo, "switch", "-q", "-c", "non-release-anchor", unrelated); + const nonReleaseRecovery = recovery( + repo, + unrelated, + "tools/test/non-release-anchor.txt", + "recovery\n", + ); + assert.throws( + () => verify(repo, nonReleaseRecovery), + /release commit .* subject must start/u, + ); +}); diff --git a/tools/release/verify-release-recovery-lock.mjs b/tools/release/verify-release-recovery-lock.mjs new file mode 100644 index 00000000..53f9839e --- /dev/null +++ b/tools/release/verify-release-recovery-lock.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env bun + +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { captureCommandOutput } from "../dev/capture-command-output.mjs"; +import { ROOT } from "./release-graph.mjs"; +import { loadPublicationLock } from "./publication-lock.mjs"; + +const TOOL = "verify-release-recovery-lock.mjs"; +export const RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA = + "oliphaunt-release-recovery-lock-equivalence-v2"; +const SHA = /^[0-9a-f]{40}$/u; +const HASH = /^[0-9a-f]{64}$/u; +const ARTIFACT_DIGEST = /^sha256:[0-9a-f]{64}$/u; + +function error(message) { + return new Error(`${TOOL}: ${message}`); +} + +function gitCommitAndTree(repo, ref) { + const result = captureCommandOutput( + "git", + ["show", "-s", "--format=%H%n%T", `${ref}^{commit}`], + { cwd: repo, label: `git show -s --format=%H%n%T ${ref}^{commit}` }, + ); + if (result.error !== undefined || result.status !== 0) { + const detail = (result.stderr || result.stdout || result.error?.message || "").trim(); + throw error(`cannot resolve ${ref}${detail ? `: ${detail}` : ""}`); + } + const [commit, tree, ...extra] = result.stdout.trimEnd().split("\n"); + if (!SHA.test(commit) || !SHA.test(tree) || extra.length > 0) { + throw error(`${ref} did not resolve to one commit/tree identity`); + } + return { commit, tree }; +} + +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonical(value[key])]), + ); + } + return value; +} + +function canonicalJson(value) { + return JSON.stringify(canonical(value)); +} + +function requireLockIdentity(lock, expected, context) { + if ( + lock?.source?.commit !== expected.commit + || lock?.source?.tree !== expected.tree + ) { + throw error( + `${context} source ${lock?.source?.commit ?? ""}/` + + `${lock?.source?.tree ?? ""} does not match ` + + `${expected.commit}/${expected.tree}`, + ); + } + if (!HASH.test(lock.lockDigest)) { + throw error(`${context} has an invalid lockDigest`); + } +} + +function originalLockArtifactEvidence(value) { + if ( + value === null + || Array.isArray(value) + || typeof value !== "object" + || !Number.isSafeInteger(value.runId) + || value.runId < 1 + || !Array.isArray(value.artifacts) + || value.artifacts.length !== 1 + ) { + throw error("original lock evidence must identify one positive run and one artifact"); + } + const [artifact] = value.artifacts; + if ( + artifact === null + || Array.isArray(artifact) + || typeof artifact !== "object" + || artifact.name !== "oliphaunt-publication-lock" + || !Number.isSafeInteger(artifact.id) + || artifact.id < 1 + || !Number.isSafeInteger(artifact.size) + || artifact.size < 1 + || !ARTIFACT_DIGEST.test(artifact.digest ?? "") + || Object.keys(artifact).sort().join(",") !== "digest,id,name,size" + ) { + throw error( + "original lock evidence artifact must be the exact id/digest/size-bound " + + "oliphaunt-publication-lock", + ); + } + return { + workflow: "Release", + runId: value.runId, + artifact: { + digest: artifact.digest, + id: artifact.id, + name: artifact.name, + size: artifact.size, + }, + }; +} + +export function verifyReleaseRecoveryLockEquivalence({ + original, + replay, + releaseSource, + controllerSource, + originalEvidence, +} = {}) { + if (!SHA.test(releaseSource?.commit ?? "") || !SHA.test(releaseSource?.tree ?? "")) { + throw error("releaseSource must contain lowercase full commit/tree SHAs"); + } + if ( + !SHA.test(controllerSource?.commit ?? "") + || !SHA.test(controllerSource?.tree ?? "") + ) { + throw error("controllerSource must contain lowercase full commit/tree SHAs"); + } + if ( + releaseSource.commit === controllerSource.commit + || releaseSource.tree === controllerSource.tree + ) { + throw error("recovery controller must have a distinct commit and tree"); + } + requireLockIdentity(original, releaseSource, "original publication lock"); + requireLockIdentity(replay, releaseSource, "replayed publication lock"); + const originalLockArtifact = originalLockArtifactEvidence(originalEvidence); + + const comparedFields = [...new Set([ + ...Object.keys(original), + ...Object.keys(replay), + ])].sort(); + for (const field of comparedFields) { + if (canonicalJson(original[field]) !== canonicalJson(replay[field])) { + throw error( + `replayed publication lock changes ${field}; ` + + "the existing version cannot be recovered and must not be republished", + ); + } + } + + const receipt = { + schema: RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA, + releaseSource, + controllerSource, + originalLockDigest: original.lockDigest, + replayLockDigest: replay.lockDigest, + originalLockArtifact, + catalogDigest: replay.catalogDigest, + packageEnvelopeDigest: replay.packageEnvelopeDigest, + productCount: replay.products.length, + carrierCount: replay.carriers.length, + productArtifactCount: replay.productArtifacts.length, + comparedFields, + }; + return { + ...receipt, + evidenceDigest: createHash("sha256").update(canonicalJson(receipt)).digest("hex"), + }; +} + +function parseArgs(argv) { + const options = { + originalLock: "", + replayLock: "", + releaseSha: "", + controllerSha: "", + originalRunId: "", + originalArtifactMetadataJson: "", + output: "", + }; + const flags = new Map([ + ["--original-lock", "originalLock"], + ["--replay-lock", "replayLock"], + ["--release-sha", "releaseSha"], + ["--controller-sha", "controllerSha"], + ["--original-run-id", "originalRunId"], + ["--original-artifact-metadata-json", "originalArtifactMetadataJson"], + ["--output", "output"], + ]); + for (let index = 0; index < argv.length; index += 1) { + const key = flags.get(argv[index]); + if (key === undefined) throw error(`unknown argument ${argv[index]}`); + options[key] = argv[index + 1] ?? ""; + index += 1; + } + if (Object.values(options).some((value) => !value)) { + throw error( + "usage: verify-release-recovery-lock.mjs " + + "--original-lock FILE --replay-lock FILE " + + "--release-sha SHA --controller-sha SHA " + + "--original-run-id ID --original-artifact-metadata-json JSON --output FILE", + ); + } + if (!SHA.test(options.releaseSha) || !SHA.test(options.controllerSha)) { + throw error("--release-sha and --controller-sha must be lowercase full commit SHAs"); + } + if (!/^[1-9][0-9]*$/u.test(options.originalRunId)) { + throw error("--original-run-id must be a positive integer"); + } + try { + options.originalArtifactMetadata = JSON.parse(options.originalArtifactMetadataJson); + } catch (cause) { + throw error(`--original-artifact-metadata-json is invalid JSON: ${cause.message}`); + } + return options; +} + +if (import.meta.main) { + try { + const options = parseArgs(Bun.argv.slice(2)); + const original = loadPublicationLock(path.resolve(options.originalLock)); + const replay = loadPublicationLock(path.resolve(options.replayLock)); + const receipt = verifyReleaseRecoveryLockEquivalence({ + original, + replay, + releaseSource: gitCommitAndTree(ROOT, options.releaseSha), + controllerSource: gitCommitAndTree(ROOT, options.controllerSha), + originalEvidence: { + runId: Number(options.originalRunId), + artifacts: options.originalArtifactMetadata, + }, + }); + const output = path.resolve(options.output); + mkdirSync(path.dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + console.log( + `verified exact same-version recovery replay for ${receipt.carrierCount} carrier(s) ` + + `and ${receipt.productArtifactCount} product artifact(s)`, + ); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); + } +} diff --git a/tools/release/verify-release-recovery-lock.test.mjs b/tools/release/verify-release-recovery-lock.test.mjs new file mode 100644 index 00000000..69bfbdb2 --- /dev/null +++ b/tools/release/verify-release-recovery-lock.test.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA, + verifyReleaseRecoveryLockEquivalence, +} from "./verify-release-recovery-lock.mjs"; + +const RELEASE_COMMIT = "1".repeat(40); +const RELEASE_TREE = "2".repeat(40); +const PUBLICATION_COMMIT = "3".repeat(40); +const PUBLICATION_TREE = "4".repeat(40); + +function lock(source, lockDigest) { + return { + schema: "oliphaunt-publication-lock-v1", + catalogSchema: "oliphaunt-publication-catalog-v1", + catalogDigest: "5".repeat(64), + source, + products: [{ id: "alpha", version: "0.1.0" }], + carriers: [{ + id: "cargo:alpha", + product: "alpha", + version: "0.1.0", + artifacts: [{ path: "alpha.crate", sha256: "6".repeat(64), size: 42 }], + }], + productArtifacts: [{ + product: "alpha", + id: "github:alpha", + path: "alpha.tar.gz", + sha256: "7".repeat(64), + size: 84, + }], + packageEnvelopeDigest: "8".repeat(64), + lockDigest, + }; +} + +function verify(original, recovery) { + return verifyReleaseRecoveryLockEquivalence({ + original, + replay: recovery, + releaseSource: { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + controllerSource: { commit: PUBLICATION_COMMIT, tree: PUBLICATION_TREE }, + originalEvidence: { + runId: 123, + artifacts: [{ + digest: `sha256:${"b".repeat(64)}`, + id: 456, + name: "oliphaunt-publication-lock", + size: 789, + }], + }, + }); +} + +test("accepts only an exact original-source lock replay under a distinct controller", () => { + const original = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + const recovery = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + const receipt = verify(original, recovery); + assert.equal(receipt.schema, RELEASE_RECOVERY_LOCK_EQUIVALENCE_SCHEMA); + assert.equal(receipt.originalLockDigest, "9".repeat(64)); + assert.equal(receipt.replayLockDigest, "9".repeat(64)); + assert.equal(receipt.productCount, 1); + assert.equal(receipt.carrierCount, 1); + assert.equal(receipt.productArtifactCount, 1); + assert.deepEqual(receipt.originalLockArtifact, { + workflow: "Release", + runId: 123, + artifact: { + digest: `sha256:${"b".repeat(64)}`, + id: 456, + name: "oliphaunt-publication-lock", + size: 789, + }, + }); + assert.deepEqual( + receipt.comparedFields, + [ + "carriers", + "catalogDigest", + "catalogSchema", + "lockDigest", + "packageEnvelopeDigest", + "productArtifacts", + "products", + "schema", + "source", + ], + ); + assert.match(receipt.evidenceDigest, /^[0-9a-f]{64}$/u); +}); + +test("rejects every changed lock field and mismatched source identity", () => { + const original = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + for (const field of [ + "catalogSchema", + "catalogDigest", + "products", + "carriers", + "productArtifacts", + "packageEnvelopeDigest", + ]) { + const recovery = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + recovery[field] = field.endsWith("Digest") + ? "b".repeat(64) + : field === "catalogSchema" + ? "changed-schema" + : []; + assert.throws( + () => verify(original, recovery), + new RegExp(`changes ${field}`, "u"), + ); + } + + const wrongSource = lock( + { commit: "f".repeat(40), tree: PUBLICATION_TREE }, + "9".repeat(64), + ); + assert.throws( + () => verify(original, wrongSource), + /replayed publication lock source/u, + ); + + const futureOriginal = structuredClone(original); + const futureRecovery = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + futureOriginal.futureEnvelope = { digest: "c".repeat(64) }; + futureRecovery.futureEnvelope = { digest: "d".repeat(64) }; + assert.throws( + () => verify(futureOriginal, futureRecovery), + /changes futureEnvelope/u, + ); +}); + +test("rejects relabeling the original lock to the controller source", () => { + const original = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + const recovery = lock( + { commit: PUBLICATION_COMMIT, tree: PUBLICATION_TREE }, + "9".repeat(64), + ); + assert.throws( + () => verify(original, recovery), + /replayed publication lock source/u, + ); + assert.throws( + () => verifyReleaseRecoveryLockEquivalence({ + original, + replay: original, + releaseSource: { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + controllerSource: { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + originalEvidence: { + runId: 123, + artifacts: [{ + digest: `sha256:${"b".repeat(64)}`, + id: 456, + name: "oliphaunt-publication-lock", + size: 789, + }], + }, + }), + /distinct commit and tree/u, + ); +}); + +test("binds one exact original lock run/artifact identity", () => { + const original = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + const recovery = lock( + { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + "9".repeat(64), + ); + for (const originalEvidence of [ + undefined, + { runId: 0, artifacts: [] }, + { runId: 123, artifacts: [] }, + { + runId: 123, + artifacts: [{ + digest: `sha256:${"b".repeat(64)}`, + id: 456, + name: "wrong", + size: 789, + }], + }, + ]) { + assert.throws( + () => verifyReleaseRecoveryLockEquivalence({ + original, + replay: recovery, + releaseSource: { commit: RELEASE_COMMIT, tree: RELEASE_TREE }, + controllerSource: { commit: PUBLICATION_COMMIT, tree: PUBLICATION_TREE }, + originalEvidence, + }), + /original lock evidence/u, + ); + } +}); diff --git a/tools/release/verify-release-recovery-publication.mjs b/tools/release/verify-release-recovery-publication.mjs new file mode 100644 index 00000000..ca71d76b --- /dev/null +++ b/tools/release/verify-release-recovery-publication.mjs @@ -0,0 +1,403 @@ +#!/usr/bin/env bun + +import { createHash } from "node:crypto"; +import { + appendFileSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { loadPublicationLock } from "./publication-lock.mjs"; +import { compareText } from "./release-graph.mjs"; +import { + validateLockedRegistryReceipts, + verifyLockedRegistryIntegrity, +} from "./registry-integrity.mjs"; + +const TOOL = "verify-release-recovery-publication.mjs"; +const INVENTORY_SCHEMA = "oliphaunt-release-registry-inventory-v1"; +export const RECOVERY_PUBLICATION_STATE_SCHEMA = + "oliphaunt-release-recovery-publication-state-v1"; +const SHA = /^[0-9a-f]{40}$/u; +const KIND_ECOSYSTEM = new Map([ + ["crates", "cargo"], + ["jsr", "jsr"], + ["maven", "maven"], + ["npm", "npm"], +]); +const REGISTRY_ECOSYSTEMS = new Set(KIND_ECOSYSTEM.values()); + +function error(message) { + return new Error(`${TOOL}: ${message}`); +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.keys(value).sort(compareText) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sameStrings(left, right) { + const a = [...left].sort(compareText); + const b = [...right].sort(compareText); + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function exactKeys(value, expected, context) { + if ( + value === null + || Array.isArray(value) + || typeof value !== "object" + || !sameStrings(Object.keys(value), expected) + ) { + throw error(`${context} must contain exactly ${expected.join(", ")}`); + } + return value; +} + +function uniqueStrings(value, context, { nonempty = true } = {}) { + if ( + !Array.isArray(value) + || (nonempty && value.length === 0) + || value.some((item) => typeof item !== "string" || item.length === 0) + || new Set(value).size !== value.length + ) { + throw error(`${context} must be a ${nonempty ? "nonempty " : ""}unique string list`); + } + return value; +} + +function inventoryPackage(value, context) { + exactKeys(value, ["kind", "name", "version"], context); + if ( + !KIND_ECOSYSTEM.has(value.kind) + || typeof value.name !== "string" + || value.name.length === 0 + || typeof value.version !== "string" + || value.version.length === 0 + ) { + throw error(`${context} has an invalid registry identity`); + } + return { + ecosystem: KIND_ECOSYSTEM.get(value.kind), + id: `${KIND_ECOSYSTEM.get(value.kind)}:${value.name}`, + name: value.name, + version: value.version, + }; +} + +function inventoryPackageMap(packages, context) { + if (!Array.isArray(packages)) throw error(`${context} must be a list`); + const records = packages.map((pkg, index) => inventoryPackage(pkg, `${context}[${index}]`)); + const byId = new Map(records.map((record) => [record.id, record])); + if (byId.size !== records.length) throw error(`${context} contains duplicate registry identities`); + return byId; +} + +export function classifyReleaseRecoveryPublication({ + lock, + inventory, + products, +} = {}) { + const selectedProducts = uniqueStrings(products, "products"); + exactKeys(inventory, ["products", "results", "schema", "source"], "registry inventory"); + if (inventory.schema !== INVENTORY_SCHEMA) { + throw error(`registry inventory schema must be ${INVENTORY_SCHEMA}`); + } + exactKeys(inventory.source, ["commit"], "registry inventory source"); + if (!SHA.test(inventory.source.commit) || inventory.source.commit !== lock?.source?.commit) { + throw error("registry inventory source must match the publication lock commit"); + } + uniqueStrings(inventory.products, "registry inventory products"); + if (!sameStrings(inventory.products, selectedProducts)) { + throw error("registry inventory products do not match the selected recovery products"); + } + const lockedProducts = lock.products.map(({ id }) => id); + if (!sameStrings(lockedProducts, selectedProducts)) { + throw error("publication lock products do not match the selected recovery products"); + } + if (!Array.isArray(inventory.results)) throw error("registry inventory results must be a list"); + const results = new Map(); + for (const [index, result] of inventory.results.entries()) { + exactKeys(result, ["missing", "packages", "product", "published"], `registry result ${index}`); + if ( + typeof result.product !== "string" + || !selectedProducts.includes(result.product) + || results.has(result.product) + ) { + throw error(`registry result ${index} has an unknown or duplicate product`); + } + const packages = inventoryPackageMap(result.packages, `${result.product}.packages`); + const missing = inventoryPackageMap(result.missing, `${result.product}.missing`); + const published = inventoryPackageMap(result.published, `${result.product}.published`); + if ( + missing.size + published.size !== packages.size + || [...missing].some(([id]) => published.has(id) || !packages.has(id)) + || [...published].some(([id]) => !packages.has(id)) + ) { + throw error(`${result.product} registry state does not exactly partition its package inventory`); + } + for (const [id, pkg] of packages) { + const state = missing.get(id) ?? published.get(id); + if (stableJson(pkg) !== stableJson(state)) { + throw error(`${result.product} registry state changes identity ${id}`); + } + } + results.set(result.product, { missing, packages, published }); + } + if (!sameStrings(results.keys(), selectedProducts)) { + throw error("registry inventory must contain exactly one result for every selected product"); + } + + const expectedCarriers = lock.carriers + .filter((carrier) => + selectedProducts.includes(carrier.product) + && REGISTRY_ECOSYSTEMS.has(carrier.ecosystem)); + const expectedById = new Map(expectedCarriers.map((carrier) => [carrier.id, carrier])); + if (expectedById.size !== expectedCarriers.length) { + throw error("publication lock contains duplicate registry carrier identities"); + } + const publicCarrierIds = []; + const missingCarrierIds = []; + const observed = new Set(); + for (const [product, state] of results) { + for (const [id, pkg] of state.packages) { + const carrier = expectedById.get(id); + if ( + carrier === undefined + || carrier.product !== product + || carrier.name !== pkg.name + || carrier.version !== pkg.version + || carrier.ecosystem !== pkg.ecosystem + || observed.has(id) + ) { + throw error(`${product} registry inventory does not match locked carrier ${id}`); + } + observed.add(id); + (state.published.has(id) ? publicCarrierIds : missingCarrierIds).push(id); + } + } + if (!sameStrings(observed, expectedById.keys())) { + throw error("registry inventory does not cover every selected locked carrier exactly once"); + } + publicCarrierIds.sort(compareText); + missingCarrierIds.sort(compareText); + if (publicCarrierIds.length === 0) { + throw error( + "same-version recovery requires at least one already-public immutable registry carrier", + ); + } + return { + source: lock.source, + lockDigest: lock.lockDigest, + products: [...selectedProducts].sort(compareText), + selectedCarrierCount: expectedCarriers.length, + publicCarrierIds, + missingCarrierIds, + needsCargoToken: missingCarrierIds.some((id) => id.startsWith("cargo:")), + needsNpmToken: missingCarrierIds.some((id) => id.startsWith("npm:")), + }; +} + +export function releaseRecoveryPublicationReceipt(classification, receipts) { + if (!Array.isArray(receipts)) throw error("public registry receipts must be a list"); + const receiptIds = receipts.map((receipt) => receipt?.id); + if ( + receiptIds.some((id) => typeof id !== "string" || id.length === 0) + || !sameStrings(receiptIds, classification.publicCarrierIds) + ) { + throw error("public registry receipts must prove every classified public carrier exactly once"); + } + const record = { + schema: RECOVERY_PUBLICATION_STATE_SCHEMA, + source: classification.source, + lockDigest: classification.lockDigest, + products: classification.products, + selectedCarrierCount: classification.selectedCarrierCount, + publicCarrierCount: classification.publicCarrierIds.length, + missingCarrierCount: classification.missingCarrierIds.length, + publicCarrierIds: classification.publicCarrierIds, + missingCarrierIds: classification.missingCarrierIds, + needsCargoToken: classification.needsCargoToken, + needsNpmToken: classification.needsNpmToken, + receipts, + }; + return { + ...record, + evidenceDigest: createHash("sha256").update(stableJson(record)).digest("hex"), + }; +} + +export function validateReleaseRecoveryPublicationReceipt({ + lock, + receipt, + products, + validateReceipts = validateLockedRegistryReceipts, +} = {}) { + const fields = [ + "evidenceDigest", + "lockDigest", + "missingCarrierCount", + "missingCarrierIds", + "needsCargoToken", + "needsNpmToken", + "products", + "publicCarrierCount", + "publicCarrierIds", + "receipts", + "schema", + "selectedCarrierCount", + "source", + ]; + exactKeys(receipt, fields, "recovery publication receipt"); + const selectedProducts = uniqueStrings(products, "products"); + uniqueStrings(receipt.products, "receipt products"); + if ( + receipt.schema !== RECOVERY_PUBLICATION_STATE_SCHEMA + || stableJson(receipt.source) !== stableJson(lock.source) + || receipt.lockDigest !== lock.lockDigest + || !sameStrings(receipt.products, selectedProducts) + ) { + throw error("recovery publication receipt is not bound to the selected publication lock"); + } + uniqueStrings(receipt.publicCarrierIds, "receipt publicCarrierIds"); + uniqueStrings(receipt.missingCarrierIds, "receipt missingCarrierIds", { nonempty: false }); + const selectedCarriers = lock.carriers.filter((carrier) => + selectedProducts.includes(carrier.product) && REGISTRY_ECOSYSTEMS.has(carrier.ecosystem)); + const selectedIds = selectedCarriers.map(({ id }) => id); + if ( + receipt.publicCarrierCount !== receipt.publicCarrierIds.length + || receipt.publicCarrierCount < 1 + || receipt.missingCarrierCount !== receipt.missingCarrierIds.length + || receipt.selectedCarrierCount !== selectedCarriers.length + || !sameStrings( + [...receipt.publicCarrierIds, ...receipt.missingCarrierIds], + selectedIds, + ) + || receipt.needsCargoToken + !== receipt.missingCarrierIds.some((id) => id.startsWith("cargo:")) + || receipt.needsNpmToken + !== receipt.missingCarrierIds.some((id) => id.startsWith("npm:")) + ) { + throw error("recovery publication receipt carrier partition is inconsistent"); + } + validateReceipts(lock, { + carrierIds: receipt.publicCarrierIds, + receipts: receipt.receipts, + }); + const unsigned = { ...receipt }; + delete unsigned.evidenceDigest; + const expectedDigest = createHash("sha256").update(stableJson(unsigned)).digest("hex"); + if (receipt.evidenceDigest !== expectedDigest) { + throw error("recovery publication receipt evidenceDigest mismatch"); + } + return receipt; +} + +function appendGitHubOutputs(file, receipt) { + if (!file) return; + appendFileSync( + file, + [ + `needs_cargo_token=${receipt.needsCargoToken}`, + `needs_npm_token=${receipt.needsNpmToken}`, + `public_carrier_count=${receipt.publicCarrierCount}`, + `missing_carrier_count=${receipt.missingCarrierCount}`, + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const options = { + githubOutput: "", + inventory: "", + lock: "", + output: "", + productsJson: "", + verifyReceipt: "", + }; + const flags = new Map([ + ["--github-output", "githubOutput"], + ["--inventory", "inventory"], + ["--lock", "lock"], + ["--output", "output"], + ["--products-json", "productsJson"], + ["--verify-receipt", "verifyReceipt"], + ]); + for (let index = 0; index < argv.length; index += 1) { + const key = flags.get(argv[index]); + if (key === undefined) throw error(`unknown argument ${argv[index]}`); + options[key] = argv[index + 1] ?? ""; + index += 1; + } + if ( + !options.lock + || !options.productsJson + || Boolean(options.verifyReceipt) === Boolean(options.inventory || options.output) + || (!options.verifyReceipt && (!options.inventory || !options.output)) + ) { + throw error( + "usage: verify-release-recovery-publication.mjs " + + "--lock FILE --products-json JSON " + + "(--inventory FILE --output FILE | --verify-receipt FILE) " + + "[--github-output FILE]", + ); + } + try { + options.products = JSON.parse(options.productsJson); + } catch (cause) { + throw error(`--products-json is invalid JSON: ${cause.message}`); + } + return options; +} + +if (import.meta.main) { + try { + const options = parseArgs(Bun.argv.slice(2)); + const lock = loadPublicationLock(path.resolve(options.lock)); + if (options.verifyReceipt) { + const receipt = validateReleaseRecoveryPublicationReceipt({ + lock, + receipt: JSON.parse(readFileSync(path.resolve(options.verifyReceipt), "utf8")), + products: options.products, + }); + appendGitHubOutputs(options.githubOutput, receipt); + console.log( + `verified retained recovery proof for ${receipt.publicCarrierCount} public carrier(s)`, + ); + process.exit(0); + } + const inventory = JSON.parse(readFileSync(path.resolve(options.inventory), "utf8")); + const classification = classifyReleaseRecoveryPublication({ + lock, + inventory, + products: options.products, + }); + const receipts = await verifyLockedRegistryIntegrity(lock, { + carrierIds: classification.publicCarrierIds, + }); + const receipt = releaseRecoveryPublicationReceipt(classification, receipts); + const output = path.resolve(options.output); + mkdirSync(path.dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + appendGitHubOutputs(options.githubOutput, receipt); + console.log( + `verified ${receipt.publicCarrierCount} already-public immutable carrier(s); ` + + `${receipt.missingCarrierCount} carrier(s) remain absent`, + ); + } catch (cause) { + console.error(cause instanceof Error ? cause.message : String(cause)); + process.exit(1); + } +} diff --git a/tools/release/verify-release-recovery-publication.test.mjs b/tools/release/verify-release-recovery-publication.test.mjs new file mode 100644 index 00000000..8c8cd489 --- /dev/null +++ b/tools/release/verify-release-recovery-publication.test.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env bun + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyReleaseRecoveryPublication, + RECOVERY_PUBLICATION_STATE_SCHEMA, + releaseRecoveryPublicationReceipt, + validateReleaseRecoveryPublicationReceipt, +} from "./verify-release-recovery-publication.mjs"; + +const COMMIT = "1".repeat(40); +const TREE = "2".repeat(40); + +function carrier(ecosystem, name, product = "alpha") { + return { + id: `${ecosystem}:${name}`, + ecosystem, + name, + product, + version: "0.1.0", + }; +} + +function lock() { + return { + source: { commit: COMMIT, tree: TREE }, + lockDigest: "3".repeat(64), + products: [{ id: "alpha" }, { id: "beta" }], + carriers: [ + carrier("cargo", "alpha"), + carrier("npm", "@example/alpha"), + carrier("maven", "dev.example:beta", "beta"), + carrier("jsr", "@example/beta", "beta"), + ], + }; +} + +function pkg(kind, name) { + return { kind, name, version: "0.1.0" }; +} + +function inventory() { + const cargo = pkg("crates", "alpha"); + const npm = pkg("npm", "@example/alpha"); + const maven = pkg("maven", "dev.example:beta"); + const jsr = pkg("jsr", "@example/beta"); + return { + schema: "oliphaunt-release-registry-inventory-v1", + source: { commit: COMMIT }, + products: ["alpha", "beta"], + results: [ + { + product: "alpha", + packages: [cargo, npm], + published: [cargo], + missing: [npm], + }, + { + product: "beta", + packages: [maven, jsr], + published: [], + missing: [maven, jsr], + }, + ], + }; +} + +test("classifies an exact partial publication and derives only actually needed bootstrap tokens", () => { + const classification = classifyReleaseRecoveryPublication({ + lock: lock(), + inventory: inventory(), + products: ["alpha", "beta"], + }); + assert.deepEqual(classification.publicCarrierIds, ["cargo:alpha"]); + assert.deepEqual( + classification.missingCarrierIds, + ["jsr:@example/beta", "maven:dev.example:beta", "npm:@example/alpha"], + ); + assert.equal(classification.needsCargoToken, false); + assert.equal(classification.needsNpmToken, true); + + const receipt = releaseRecoveryPublicationReceipt( + classification, + [{ id: "cargo:alpha", proof: "exact" }], + ); + assert.equal(receipt.schema, RECOVERY_PUBLICATION_STATE_SCHEMA); + assert.equal(receipt.publicCarrierCount, 1); + assert.equal(receipt.missingCarrierCount, 3); + assert.match(receipt.evidenceDigest, /^[0-9a-f]{64}$/u); + assert.equal( + validateReleaseRecoveryPublicationReceipt({ + lock: lock(), + receipt, + products: ["alpha", "beta"], + validateReceipts: (_lock, { carrierIds, receipts }) => { + assert.deepEqual(carrierIds, ["cargo:alpha"]); + assert.deepEqual(receipts, [{ id: "cargo:alpha", proof: "exact" }]); + }, + }), + receipt, + ); + + const tampered = structuredClone(receipt); + tampered.needsCargoToken = true; + assert.throws( + () => validateReleaseRecoveryPublicationReceipt({ + lock: lock(), + receipt: tampered, + products: ["alpha", "beta"], + validateReceipts: () => {}, + }), + /carrier partition is inconsistent/u, + ); +}); + +test("rejects recovery before any immutable carrier is public", () => { + const value = inventory(); + for (const result of value.results) { + result.missing = result.packages; + result.published = []; + } + assert.throws( + () => classifyReleaseRecoveryPublication({ + lock: lock(), + inventory: value, + products: ["alpha", "beta"], + }), + /at least one already-public immutable registry carrier/u, + ); +}); + +test("rejects incomplete, conflicting, or lock-divergent registry inventories", () => { + const cases = [ + (value) => value.results.pop(), + (value) => value.results[0].missing.push(value.results[0].published[0]), + (value) => { + value.results[0].packages[0] = pkg("crates", "renamed"); + value.results[0].published[0] = value.results[0].packages[0]; + }, + (value) => { + value.source.commit = "f".repeat(40); + }, + ]; + for (const mutate of cases) { + const value = inventory(); + mutate(value); + assert.throws( + () => classifyReleaseRecoveryPublication({ + lock: lock(), + inventory: value, + products: ["alpha", "beta"], + }), + /registry|carrier|source/u, + ); + } +}); + +test("requires byte receipts for exactly every public carrier", () => { + const classification = classifyReleaseRecoveryPublication({ + lock: lock(), + inventory: inventory(), + products: ["alpha", "beta"], + }); + for (const receipts of [ + [], + [{ id: "npm:@example/alpha" }], + [{ id: "cargo:alpha" }, { id: "cargo:alpha" }], + ]) { + assert.throws( + () => releaseRecoveryPublicationReceipt(classification, receipts), + /every classified public carrier exactly once/u, + ); + } +}); diff --git a/tools/release/verify_github_release_attestations.mjs b/tools/release/verify_github_release_attestations.mjs index 8fae3bec..99313af4 100755 --- a/tools/release/verify_github_release_attestations.mjs +++ b/tools/release/verify_github_release_attestations.mjs @@ -23,6 +23,19 @@ import { } from "./publication-lock.mjs"; import { reserveGitHubCoreRequestSync } from "./github-core-request-journal.mjs"; import { swiftExtensionCarrierAssetName } from "./ios-carrier-manifest.mjs"; +import { + RECOVERY_PROMOTION_PREDICATE_TYPE, + createRecoveryPromotionPredicate, + normalizeRecoveryPromotionController, + recoveryPromotionSubjectsFromLock, + validateRecoveryPromotionPredicateEnvelope, + validateRecoveryPromotionStatement, +} from "./recovery-promotion-attestation.mjs"; +import { + SAME_VERSION_RECOVERY_SOURCES_SCHEMA, + selectSameVersionRecoverySource, + validateSameVersionRecoverySource, +} from "./same-version-recovery-source.mjs"; const ROOT = path.resolve(import.meta.dir, "../.."); const PREFIX = "verify_github_release_attestations.mjs"; @@ -46,6 +59,8 @@ const GITHUB_RELEASE_FALLBACK_QUERY_CONCURRENCY = 1; const GH_ATTESTATION_VERIFY_TIMEOUT_MS = 5 * 60 * 1000; const GH_ATTESTATION_VERIFY_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; const GITHUB_ATTESTATION_RECEIPT_SCHEMA = "oliphaunt-github-release-attestation-receipt-v1"; +const GITHUB_RECOVERY_ATTESTATION_RECEIPT_SCHEMA = + "oliphaunt-github-release-attestation-receipt-v2"; const SLSA_PROVENANCE_V1 = "https://slsa.dev/provenance/v1"; const IN_TOTO_STATEMENT_V1 = "https://in-toto.io/Statement/v1"; const GITHUB_RELEASE_ARTIFACT_ROLES = new Set([ @@ -1829,20 +1844,81 @@ function githubSignerWorkflow(repo) { return `${repo}/.github/workflows/release.yml`; } -export function buildGithubAttestationReceipt({ attestations, lock, releases, repo = repository() }) { +function normalizeRecoveryPromotionReceipt(lock, attestations, predicate) { + validateRecoveryPromotionPredicateEnvelope(predicate); + if ( + predicate.originalLock.schema !== lock.schema + || predicate.originalLock.lockDigest !== lock.lockDigest + || predicate.originalLock.catalogDigest !== lock.catalogDigest + || predicate.originalLock.packageEnvelopeDigest !== lock.packageEnvelopeDigest + || predicate.originalLock.source.commit !== lock.source.commit + || predicate.originalLock.source.tree !== lock.source.tree + ) { + throw new Error( + "recovery promotion predicate does not match the frozen publication lock", + ); + } + if ( + predicate.controller.source.commit === lock.source.commit + || predicate.controller.source.tree === lock.source.tree + ) { + throw new Error( + "recovery promotion receipt requires distinct controller and publication source identities", + ); + } + if (attestations.length !== 1) { + throw new Error( + "recovery promotion receipt requires exactly one custom attestation bundle", + ); + } + const [attestation] = attestations; + if ( + stableStringify(attestation.subjects) + !== stableStringify(predicate.subjects) + ) { + throw new Error( + "recovery promotion receipt subjects differ from its validated predicate", + ); + } + return { + bundleSha256: attestation.bundleSha256, + controller: predicate.controller, + predicate, + predicateEvidenceDigest: predicate.evidenceDigest, + predicateType: RECOVERY_PROMOTION_PREDICATE_TYPE, + }; +} + +export function buildGithubAttestationReceipt({ + attestations, + lock, + recoveryPromotionPredicate = undefined, + releases, + repo = repository(), +}) { const canonicalRepo = normalizedRepository(repo); const normalizedReleases = normalizeGithubReleaseSnapshot(lock, releases); const normalizedAttestations = assertAttestationSubjectCoverage( frozenGithubReleaseAssets(lock), attestations, ); + const recoveryPromotion = recoveryPromotionPredicate === undefined + ? undefined + : normalizeRecoveryPromotionReceipt( + lock, + normalizedAttestations, + recoveryPromotionPredicate, + ); const receipt = { attestations: normalizedAttestations, head: lock.source.commit, lockDigest: lock.lockDigest, + ...(recoveryPromotion === undefined ? {} : { recoveryPromotion }), releases: normalizedReleases, repository: canonicalRepo, - schema: GITHUB_ATTESTATION_RECEIPT_SCHEMA, + schema: recoveryPromotion === undefined + ? GITHUB_ATTESTATION_RECEIPT_SCHEMA + : GITHUB_RECOVERY_ATTESTATION_RECEIPT_SCHEMA, signerWorkflow: githubSignerWorkflow(canonicalRepo), sourceRef: "refs/heads/main", sourceTree: lock.source.tree, @@ -1853,11 +1929,14 @@ export function buildGithubAttestationReceipt({ attestations, lock, releases, re export function validateGithubAttestationReceipt(receipt, lock, { repo = repository() } = {}) { const canonicalRepo = normalizedRepository(repo); + const recoveryReceipt = + receipt?.schema === GITHUB_RECOVERY_ATTESTATION_RECEIPT_SCHEMA; assertKeySet(receipt, [ "attestations", "head", "lockDigest", "receiptDigest", + ...(recoveryReceipt ? ["recoveryPromotion"] : []), "releases", "repository", "schema", @@ -1866,7 +1945,10 @@ export function validateGithubAttestationReceipt(receipt, lock, { repo = reposit "sourceTree", ], "GitHub attestation receipt"); if ( - receipt.schema !== GITHUB_ATTESTATION_RECEIPT_SCHEMA + !new Set([ + GITHUB_ATTESTATION_RECEIPT_SCHEMA, + GITHUB_RECOVERY_ATTESTATION_RECEIPT_SCHEMA, + ]).has(receipt.schema) || receipt.repository !== canonicalRepo || receipt.head !== lock.source.commit || receipt.sourceTree !== lock.source.tree @@ -1886,6 +1968,22 @@ export function validateGithubAttestationReceipt(receipt, lock, { repo = reposit frozenGithubReleaseAssets(lock), receipt.attestations, ); + const recoveryPromotion = recoveryReceipt + ? normalizeRecoveryPromotionReceipt( + lock, + attestations, + receipt.recoveryPromotion?.predicate, + ) + : undefined; + if ( + recoveryReceipt + && stableStringify(recoveryPromotion) + !== stableStringify(receipt.recoveryPromotion) + ) { + throw new Error( + "GitHub recovery promotion receipt is not in deterministic canonical form", + ); + } if ( stableStringify(releases) !== stableStringify(receipt.releases) || stableStringify(attestations) !== stableStringify(receipt.attestations) @@ -2056,8 +2154,24 @@ function decodeBundleStatement(bundle, context) { return statement; } -function statementSubjects(statement, context) { - if (statement._type !== IN_TOTO_STATEMENT_V1 || statement.predicateType !== SLSA_PROVENANCE_V1) { +function statementSubjects(statement, context, { + recoveryExpectations = undefined, +} = {}) { + if (statement._type !== IN_TOTO_STATEMENT_V1) { + throw new Error(`${context} must be an in-toto v1 statement`); + } + if (recoveryExpectations !== undefined) { + if (statement.predicateType !== RECOVERY_PROMOTION_PREDICATE_TYPE) { + throw new Error( + `${context} must use the approved same-version recovery predicate type`, + ); + } + return validateRecoveryPromotionStatement( + statement, + recoveryExpectations, + ).subjects; + } + if (statement.predicateType !== SLSA_PROVENANCE_V1) { throw new Error(`${context} must be an in-toto v1 SLSA provenance v1 statement`); } return normalizeAttestationSubjects(statement.subject, context); @@ -2084,7 +2198,13 @@ async function lockedLocalSubject(asset) { return file; } -export function ghBundleVerifyArgs({ bundlePath, file, head, repo }) { +export function ghBundleVerifyArgs({ + bundlePath, + file, + head, + predicateType = SLSA_PROVENANCE_V1, + repo, +}) { if (typeof head !== "string" || !/^[0-9a-f]{40}$/u.test(head)) { throw new Error("attestation source head must be a full lowercase commit SHA"); } @@ -2102,6 +2222,8 @@ export function ghBundleVerifyArgs({ bundlePath, file, head, repo }) { bundlePath, "--format", "json", + "--predicate-type", + predicateType, "--signer-workflow", githubSignerWorkflow(canonicalRepo), "--signer-digest", @@ -2114,8 +2236,22 @@ export function ghBundleVerifyArgs({ bundlePath, file, head, repo }) { ]; } -function runGhBundleVerification({ bundle, bundlePath, file, head, repo }) { - const args = ghBundleVerifyArgs({ bundlePath, file, head, repo }); +function runGhBundleVerification({ + bundle, + bundlePath, + file, + head, + predicateType, + recoveryExpectations, + repo, +}) { + const args = ghBundleVerifyArgs({ + bundlePath, + file, + head, + predicateType, + repo, + }); const result = captureCommandOutput("gh", args, { cwd: ROOT, label: `gh attestation verify for ${bundlePath}`, @@ -2140,10 +2276,15 @@ function runGhBundleVerification({ bundle, bundlePath, file, head, repo }) { } const statement = verified.verificationResult?.statement; requireObject(statement, `gh verification statement for ${bundlePath}`); - return statementSubjects(statement, `gh verified statement for ${bundlePath}`); + return statementSubjects( + statement, + `gh verified statement for ${bundlePath}`, + { recoveryExpectations }, + ); } export async function verifyAttestationBundles(lock, bundlePaths, { + recoveryExpectations = undefined, repo = repository(), verifyBundleImpl = runGhBundleVerification, } = {}) { @@ -2156,6 +2297,14 @@ export async function verifyAttestationBundles(lock, bundlePaths, { throw new Error("attestation bundles contaminate a release selection with no frozen GitHub assets"); } const expectedByKey = new Map(assets.map((asset) => [githubAssetSubjectKey(asset), asset])); + const predicateType = recoveryExpectations === undefined + ? SLSA_PROVENANCE_V1 + : RECOVERY_PROMOTION_PREDICATE_TYPE; + const signerHead = recoveryExpectations === undefined + ? lock.source.commit + : normalizeRecoveryPromotionController( + recoveryExpectations.controller, + ).source.commit; const records = []; const suppliedPaths = new Set(); const suppliedDigests = new Set(); @@ -2175,6 +2324,7 @@ export async function verifyAttestationBundles(lock, bundlePaths, { const untrustedSubjects = statementSubjects( decodeBundleStatement(bundle, `attestation bundle ${bundlePath}`), `unverified statement for ${bundlePath}`, + { recoveryExpectations }, ); const representatives = untrustedSubjects .map((subject) => expectedByKey.get(githubAssetSubjectKey(subject))) @@ -2188,7 +2338,9 @@ export async function verifyAttestationBundles(lock, bundlePaths, { bundle, bundlePath: absolute, file, - head: lock.source.commit, + head: signerHead, + predicateType, + recoveryExpectations, repo: canonicalRepo, }); if (stableStringify(verifiedSubjects) !== stableStringify(untrustedSubjects)) { @@ -2262,6 +2414,9 @@ function parseReceiptArgs(command, argv) { productsJson: undefined, publicationLock: undefined, receipt: undefined, + recoveryApproval: undefined, + recoveryController: undefined, + recoveryProvenance: undefined, repo: repository(), }; const assign = (key, value, flag) => { @@ -2288,6 +2443,12 @@ function parseReceiptArgs(command, argv) { assign("publicationLock", value, flag); } else if (flag === "--receipt") { assign("receipt", value, flag); + } else if (flag === "--recovery-approval") { + assign("recoveryApproval", value, flag); + } else if (flag === "--recovery-controller") { + assign("recoveryController", value, flag); + } else if (flag === "--recovery-provenance") { + assign("recoveryProvenance", value, flag); } else if (flag === "--repo") { assign("repo", value, flag); } else if (flag === "--help" || flag === "-h") { @@ -2311,6 +2472,30 @@ function parseReceiptArgs(command, argv) { if (command === "finalize" && (args.output !== undefined || args.attestationBundles.length > 0)) { throw new Error("finalize does not accept --output or --attestation-bundle"); } + const recoveryInputs = [ + args.recoveryApproval, + args.recoveryController, + args.recoveryProvenance, + ]; + const recoveryInputCount = recoveryInputs.filter((value) => value !== undefined).length; + if (![0, recoveryInputs.length].includes(recoveryInputCount)) { + throw new Error( + "same-version recovery requires --recovery-approval, " + + "--recovery-controller, and --recovery-provenance together", + ); + } + if (command === "finalize" && recoveryInputCount > 0) { + throw new Error("finalize does not accept same-version recovery input files"); + } + if ( + command === "pre-mutation" + && recoveryInputCount > 0 + && args.attestationBundles.length !== 1 + ) { + throw new Error( + "same-version recovery requires exactly one custom promotion attestation bundle", + ); + } return args; } @@ -2334,10 +2519,57 @@ function assertRequestedProducts(lock, productsJson) { function receiptUsage() { console.log("usage:"); - console.log(" tools/release/verify_github_release_attestations.mjs pre-mutation --publication-lock FILE --head-ref REF --output FILE [--products-json JSON] [--attestation-bundle FILE ...]"); + console.log(" tools/release/verify_github_release_attestations.mjs pre-mutation --publication-lock FILE --head-ref REF --output FILE [--products-json JSON] [--attestation-bundle FILE ...] [--recovery-controller FILE --recovery-provenance FILE --recovery-approval FILE]"); console.log(" tools/release/verify_github_release_attestations.mjs finalize --publication-lock FILE --head-ref REF --receipt FILE [--products-json JSON]"); } +async function loadRecoveryAttestationExpectations(args, lock) { + if (args.recoveryController === undefined) return undefined; + const read = async (file, context) => + parseJsonBytes( + await readBoundedRegularFile( + path.resolve(file), + MAX_ATTESTATION_RECEIPT_BYTES, + context, + ), + context, + ); + const controller = normalizeRecoveryPromotionController( + await read(args.recoveryController, "same-version recovery controller"), + ); + const recoveryApproval = await read( + args.recoveryApproval, + "same-version recovery approval", + ); + const provenance = await read( + args.recoveryProvenance, + "same-version recovery provenance", + ); + let provenanceRecord; + if ( + provenance?.schema === SAME_VERSION_RECOVERY_SOURCES_SCHEMA + && Array.isArray(provenance.records) + ) { + provenanceRecord = selectSameVersionRecoverySource( + provenance, + lock.source.commit, + ); + } else { + provenanceRecord = validateSameVersionRecoverySource(provenance); + if (provenanceRecord.releaseSource.commit !== lock.source.commit) { + throw new Error( + "same-version recovery provenance does not match the publication lock source", + ); + } + } + return { + controller, + lock, + provenanceRecord, + recoveryApproval, + }; +} + async function receiptMain(command, argv) { const args = parseReceiptArgs(command, argv); if (args.help) { @@ -2349,9 +2581,29 @@ async function receiptMain(command, argv) { assertRequestedProducts(lock, args.productsJson); const repo = normalizedRepository(args.repo); if (command === "pre-mutation") { + const recoveryExpectations = await loadRecoveryAttestationExpectations( + args, + lock, + ); const releases = await queryLockedGithubReleases(lock, { repo }); - const attestations = await verifyAttestationBundles(lock, args.attestationBundles, { repo }); - const receipt = buildGithubAttestationReceipt({ attestations, lock, releases, repo }); + const attestations = await verifyAttestationBundles( + lock, + args.attestationBundles, + { recoveryExpectations, repo }, + ); + const recoveryPromotionPredicate = recoveryExpectations === undefined + ? undefined + : createRecoveryPromotionPredicate({ + ...recoveryExpectations, + subjects: recoveryPromotionSubjectsFromLock(lock), + }); + const receipt = buildGithubAttestationReceipt({ + attestations, + lock, + recoveryPromotionPredicate, + releases, + repo, + }); const output = await writeImmutableReceipt(args.output, receipt); console.log( `GitHub release attestation receipt created at ${rel(output)} ` diff --git a/tools/test/isolated-github-test-environment.mjs b/tools/test/isolated-github-test-environment.mjs new file mode 100644 index 00000000..39cf5678 --- /dev/null +++ b/tools/test/isolated-github-test-environment.mjs @@ -0,0 +1,47 @@ +import process from "node:process"; + +const ISOLATED_ENVIRONMENT_PREFIXES = Object.freeze([ + "ACTIONS_", + "GH_", + "GITHUB_", + "OLIPHAUNT_GITHUB_", + "OLIPHAUNT_RELEASE_", + "RELEASE_", +]); + +const ISOLATED_ENVIRONMENT_NAMES = new Set([ + "BOOTSTRAP_LEDGER_PATH", + "CI_RUN_ID", + "OLIPHAUNT_REQUIRE_GITHUB_CORE_REQUEST_JOURNAL", +]); + +function environmentObject(value, label) { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new TypeError(`${label} must be an environment object`); + } + return value; +} + +/** + * Synthetic GitHub fixtures must not inherit a live Actions job's GitHub + * credentials, journal, pacer, snapshots, retry tuning, or release state. + * Tests add every GitHub identity and release knob they intend to exercise + * back through overrides. + */ +export function isolatedGitHubTestEnvironment( + overrides = {}, + inheritedEnvironment = process.env, +) { + const inherited = environmentObject(inheritedEnvironment, "inherited environment"); + const additions = environmentObject(overrides, "environment overrides"); + const environment = { ...inherited }; + for (const name of Object.keys(environment)) { + if ( + ISOLATED_ENVIRONMENT_PREFIXES.some((prefix) => name.startsWith(prefix)) + || ISOLATED_ENVIRONMENT_NAMES.has(name) + ) { + delete environment[name]; + } + } + return { ...environment, ...additions }; +}