diff --git a/docs/beta-release.md b/docs/beta-release.md index 03e3ee9..7bf7762 100644 --- a/docs/beta-release.md +++ b/docs/beta-release.md @@ -10,7 +10,7 @@ The replacement release path is artifact-only and current-repository-only. After Neither the local tag command nor the workflow reads or mutates Vercel, Convex, DNS, hosted aliases, or any retired HRA v0 resource. The release workflow does not rerun the repository gate. Instead, `scripts/check-commit-ci-run.ts` independently reads the GitHub Actions API with `actions: read` and admits packaging only when the CI workflow has exactly one `push` run on the default branch whose head is that exact commit, that run completed with conclusion `success`, and its `Required` job for the same attempt and commit completed with conclusion `success`. A tag on a commit CI never checked, checked with any other result, or is still checking cannot package or publish. On Linux the verifier still downloads and checksum-verifies the same pinned Zig toolchain as CI, rebuilds and verifies the native authority-supervisor artifacts, enables and proves an isolated user namespace, and runs the focused native custody test; an always-running cleanup restores the runner restriction. It then builds one npm tarball in the runner's temporary directory, outside the checked-out public tree, verifies that same tarball on macOS and Linux, and uses the pinned npm client to prove the package-specific trusted-publisher exchange with a non-publishing dry run. Only after that reversible proof may it create and prove an immutable GitHub Release from the tarball plus `SHA256SUMS`, publish the same tarball through npm trusted publishing, and admit the public bytes and provenance before success. -The dependency prerequisite and existing npm authority are satisfied: HRA pins the immutable public registry release `@hraness/oh@0.2.7`, the `@hraness/hra` coordinate completed its non-executable bootstrap, and npm trusted publishing names repository `hraness/hra` and workflow `release.yml`. Before `v0.6.0`, the one-time migration must additionally bind that trust to GitHub environment `npm-release`; the environment has no human reviewers, disables administrator bypass, and admits only tag pattern `v*`. The publish job names that environment, and `bun run release:tag` refuses to consume a version unless the exact tag-only environment is live. This externally prevents a changed copy of `release.yml` running from `main` from obtaining the package's trusted-publisher identity. Stable `@hraness/hra@0.5.0` is authoritative until the next release is admitted. The immutable `v0.5.0` record below preserves its reviewed source, tag, workflow attempts, npm package, and GitHub artifacts as historical evidence. +The dependency prerequisite and npm authority are satisfied: HRA pins the immutable public registry release `@hraness/oh@0.2.7`, the `@hraness/hra` coordinate completed its non-executable bootstrap, and npm trusted publishing has exactly one binding, ID `28b1ff93-c1b0-42a7-bef6-41bffb992eb2`, for repository `hraness/hra`, workflow `release.yml`, and environment `npm-release`, with registry permissions `publish, stage publish`. The registry mandates stage permission for newly created bindings, but HRA's workflow uses only direct publication. The environment has no human reviewers, disables administrator bypass, and admits only tag pattern `v*`. The publish job names that environment, and `bun run release:tag` refuses to consume a version unless the exact tag-only environment is live. This externally prevents a changed copy of `release.yml` running from `main` from obtaining the package's trusted-publisher identity. Stable `@hraness/hra@0.5.0` is authoritative until the next release is admitted. The immutable `v0.5.0` record below preserves its reviewed source, tag, workflow attempts, npm package, and GitHub artifacts as historical evidence. Every release job starts from a shallow checkout of only the requested tag or its verified commit, then explicitly unshallows only reviewed `main` and that exact annotated tag. It fails unless those are the only two refs in the runner. The unchanged package gate can therefore scan every local ref and every commit in the complete governed release ancestry without importing unrelated remote branches, deleted local-only tags, or automatic tag following. @@ -170,7 +170,7 @@ repository-variable or conversational approval. After the coordinate existed, an operator using npm CLI 11.19.0 verified the sole trusted publisher as GitHub repository `hraness/hra`, workflow `release.yml`, publish-only, -with no npm environment for the releases through `v0.5.0`. Before `v0.6.0`, replace that sole binding in one batched npm-authenticated maintenance session with the same repository and workflow plus environment `npm-release` and direct `--allow-publish`; do not grant staged publishing. Stable `v0.1.5` is the first complete OIDC/provenance publication, +with no npm environment for the releases through `v0.5.0`. On 2026-09-06, the operator revoked binding `41124856-baa6-46ad-b242-6e3278c73ce8` and created replacement binding `28b1ff93-c1b0-42a7-bef6-41bffb992eb2` in one npm-authenticated maintenance session with the same repository and workflow, environment `npm-release`, and direct `--allow-publish`. The request omitted `--allow-stage-publish`, but npm automatically grants stage publishing to trusted-publisher configurations created after 2026-09-03. Authenticated readback showed exactly that one replacement binding with repository `hraness/hra`, workflow `release.yml`, environment `npm-release`, and permissions `publish, stage publish`. The registry-mandated stage permission is not an HRA release path: `.github/workflows/release.yml` invokes direct `npm publish` and never invokes `npm stage publish`. Stable `v0.1.5` is the first complete OIDC/provenance publication, stable `v0.1.6` was the second, stable `v0.2.0` the third, stable `v0.2.1` the fourth, stable `v0.3.0` the fifth, stable `v0.4.0` the sixth, stable `v0.4.1` the seventh, and stable `v0.5.0` is the current complete publication; `v0.6.0` is the next release candidate. The stable workflow never performs the bootstrap or grants a second publisher. Exact `0.1.6` publication moved `latest` from stable `0.1.5` to stable `0.1.6`, while the bootstrap diff --git a/docs/hosted-sync.md b/docs/hosted-sync.md index 9f28b51..dd0d094 100644 --- a/docs/hosted-sync.md +++ b/docs/hosted-sync.md @@ -135,7 +135,7 @@ The bootstrap pre-read must positively return the tracked unbound attestation. H These identity checks detect substitutions visible at their explicit checkpoints. They are not a filesystem sandbox against hostile code already running as the same operating-system user, which could race pathname access after a check or modify an inode in place. Do not run the release operator beside untrusted same-UID code. Authority containment owns provider-process descendants; it does not expand this filesystem boundary. -Deploy the candidate from its exact clean detached commit and bind it to the bootstrap receipt: +Deploy each candidate from its exact clean detached commit and bind it to the receipt for the deployment that is currently live. The first candidate names the bootstrap receipt. Every later candidate names the immediately preceding candidate receipt: ```sh bun run hosted:deploy -- \ @@ -146,11 +146,11 @@ bun run hosted:deploy -- \ --deployment-url https://steady-otter-321.convex.cloud \ --source-commit \ --phase candidate \ - --previous-deploy-evidence /protected/release/bootstrap-deploy.json \ - --evidence-path /protected/release/candidate-deploy.json + --previous-deploy-evidence /protected/release/.json \ + --evidence-path /protected/release/candidate--deploy.json ``` -The candidate intent requires its `before` attestation to equal the bootstrap `after` attestation, names the bootstrap evidence digest as its predecessor, advances deployment time and runtime revision, and binds `runtimeSourceCommit` to `N_COMMIT`. A bootstrap may be an earlier clean commit; the candidate must be the release commit. Losing CLI output never authorizes a speculative redeploy. A retry may finalize only when the durable intent, current runtime attestation, fixed target, and prior evidence still match exactly. Drift or an ambiguous provider read is a refusal. An exact completed evidence file replays through read-only attestation and target checks without deploying. +The candidate intent requires its `before` attestation to equal the predecessor receipt's `after` attestation, requires the predecessor and candidate to name the same fixed target, names the predecessor evidence digest, advances deployment time and runtime revision, and binds `runtimeSourceCommit` to `N_COMMIT`. Choose a new, unused, source-qualified evidence path for every candidate. Never rename or overwrite an earlier receipt. A reviewed exact protected-main commit may be deployed as a candidate before a Git tag, GitHub Release, or npm publication exists. If hosted evidence is cited for a tagged release, the final deployed source commit must equal the tagged commit. When protected `main` advances after a candidate deployment, deploy another candidate from the new exact commit and chain it from the currently live candidate receipt. Do not bootstrap again. Losing CLI output never authorizes a speculative redeploy. A retry may finalize only when the durable intent, current runtime attestation, fixed target, and prior evidence still match exactly. Drift or an ambiguous provider read is a refusal. An exact completed evidence file replays through read-only attestation and target checks without deploying. There is one exceptional supersession path for a bootstrap intent that cannot deploy its source. Use it only with independent evidence that the failed Convex process stopped determinately before the remote `runPush` mutation boundary, local process cleanup is proven, the exact numeric target is reverified, and a fresh authority read exactly equals the failed intent's recorded `before` attestation. Launching Convex or performing read-only target resolution does not disqualify this path. Any possibility that `runPush` began prohibits it. Keep the failed source-qualified evidence path and its `.intent` unchanged as quarantine evidence. From a newer exact clean fixed commit, choose a different source-qualified evidence path in the same protected release directory and run bootstrap there under the single release authority. Never delete, rename, overwrite, or retry the failed path from the newer checkout. Once the new runtime binds, its non-null attestation makes the old null-before intent inert and any replay of the old path fails closed. An ambiguous mutation boundary, changed or unreadable runtime, unproven cleanup, target drift, reused path, or missing old intent prohibits supersession. diff --git a/scripts/deploy-hosted-sync.test.ts b/scripts/deploy-hosted-sync.test.ts index 7626d02..b2c0658 100644 --- a/scripts/deploy-hosted-sync.test.ts +++ b/scripts/deploy-hosted-sync.test.ts @@ -27,8 +27,11 @@ import { resolvedTargetAssertionCommand, } from "./deploy-hosted-sync"; import { + deployEvidenceSchema, parseDeployEvidenceFile, type RuntimeReleaseAttestation, + withSelfDigest, + writeProtectedJsonNoReplace, } from "./release-evidence"; import { HRA_EXPECTED_CONVEX_DEPLOY_URL, @@ -118,6 +121,85 @@ const outputWriter = (chunks: string[]): Pick => ({ }, }); +const makeDeployEvidenceHarness = async () => { + const repositoryRoot = await makeTemporaryDirectory("hra-hosted-chain-source-"); + const temporaryRoot = await makeTemporaryDirectory("hra-hosted-chain-temp-"); + const evidenceDirectory = await realpath( + await makeTemporaryDirectory("hra-hosted-chain-output-"), + ); + await chmod(evidenceDirectory, 0o700); + let activeSourceCommit = sourceCommit; + let runtime: RuntimeReleaseAttestation | null = null; + let deploymentCalls = 0; + let authorityReads = 0; + const runner: CommandRunner = async (request) => { + if (request.executable === "/usr/bin/git") { + return request.arguments[0] === "rev-parse" + ? { exitCode: 0, stderr: "", stdout: `${activeSourceCommit}\n` } + : { exitCode: 0, stderr: "", stdout: "" }; + } + if (request.executable === "/usr/bin/tar") { + await materializeArchivedSource(request); + return { exitCode: 0, stderr: "", stdout: "" }; + } + if (request.phase === "source-dependency-install") { + await materializeArchivedDependencies(request); + return { exitCode: 0, stderr: "", stdout: "installed" }; + } + deploymentCalls += 1; + const overlay = await readFile(join(request.cwd, "convex", "releaseAttestation.ts"), "utf8"); + const match = /Object\.freeze\((\{.*\}) as const\)/u.exec(overlay); + if (match?.[1] === undefined) throw new Error("missing attestation overlay"); + runtime = JSON.parse(match[1]) as RuntimeReleaseAttestation; + return { exitCode: 0, stderr: "", stdout: "deployed" }; + }; + + return { + get authorityReads() { + return authorityReads; + }, + get deploymentCalls() { + return deploymentCalls; + }, + evidenceDirectory, + async deploy(options: Readonly<{ + evidenceName: string; + now: number; + phase: "bootstrap" | "candidate"; + previousEvidenceName?: string; + revision: string; + sourceCommit: string; + target?: ConvexTarget; + }>) { + activeSourceCommit = options.sourceCommit; + return await deployHostedSync({ + evidencePath: join(evidenceDirectory, options.evidenceName), + now: () => options.now, + phase: options.phase, + ...(options.previousEvidenceName === undefined + ? {} + : { + previousDeployEvidencePath: join( + evidenceDirectory, + options.previousEvidenceName, + ), + }), + readAttestation: async () => { + authorityReads += 1; + return runtime; + }, + repositoryRoot, + revision: () => options.revision, + runner, + sourceCommit: options.sourceCommit, + target: options.target ?? target, + temporaryRoot, + verifyTarget: async () => undefined, + }); + }, + }; +}; + describe("verified hosted deployment", () => { test("ships the Convex typecheck project required by the deploy gate", async () => { const configuration = JSON.parse(await readFile( @@ -1376,6 +1458,130 @@ describe("verified hosted deployment", () => { .toBe(true); }); + test("chains every later candidate from the immediately current candidate receipt", async () => { + const harness = await makeDeployEvidenceHarness(); + const bootstrap = await harness.deploy({ + evidenceName: "bootstrap.json", + now: 1_000, + phase: "bootstrap", + revision: "00000000-0000-4000-8000-000000000010", + sourceCommit, + }); + const firstCandidate = await harness.deploy({ + evidenceName: "candidate-one.json", + now: 2_000, + phase: "candidate", + previousEvidenceName: "bootstrap.json", + revision: "00000000-0000-4000-8000-000000000020", + sourceCommit: "b".repeat(40), + }); + const secondCandidate = await harness.deploy({ + evidenceName: "candidate-two.json", + now: 3_000, + phase: "candidate", + previousEvidenceName: "candidate-one.json", + revision: "00000000-0000-4000-8000-000000000030", + sourceCommit: "c".repeat(40), + }); + + expect(bootstrap?.phase).toBe("bootstrap"); + expect(firstCandidate).toMatchObject({ + before: bootstrap?.after, + phase: "candidate", + previousDeployDigest: bootstrap?.selfDigest, + sourceCommit: "b".repeat(40), + }); + expect(secondCandidate).toMatchObject({ + before: firstCandidate?.after, + phase: "candidate", + previousDeployDigest: firstCandidate?.selfDigest, + sourceCommit: "c".repeat(40), + }); + expect(harness.deploymentCalls).toBe(3); + }); + + test("refuses a candidate predecessor receipt from another target before attestation read or mutation", async () => { + const harness = await makeDeployEvidenceHarness(); + await harness.deploy({ + evidenceName: "bootstrap.json", + now: 1_000, + phase: "bootstrap", + revision: "00000000-0000-4000-8000-000000000010", + sourceCommit, + }); + const authorityReads = harness.authorityReads; + const deploymentCalls = harness.deploymentCalls; + const otherTarget: ConvexTarget = { + ...target, + deploymentId: target.deploymentId + 1, + deploymentName: "steady-otter-322", + deploymentUrl: "https://steady-otter-322.convex.cloud", + }; + + await expect(harness.deploy({ + evidenceName: "wrong-target-candidate.json", + now: 2_000, + phase: "candidate", + previousEvidenceName: "bootstrap.json", + revision: "00000000-0000-4000-8000-000000000020", + sourceCommit: "b".repeat(40), + target: otherTarget, + })).rejects.toThrow("source_changed"); + expect(harness.authorityReads).toBe(authorityReads); + expect(harness.deploymentCalls).toBe(deploymentCalls); + expect((await readdir(harness.evidenceDirectory)).sort()).toEqual([ + "bootstrap.json", + "bootstrap.json.intent", + ]); + }); + + test("refuses completed candidate evidence whose before state differs from its predecessor", async () => { + const harness = await makeDeployEvidenceHarness(); + const bootstrap = await harness.deploy({ + evidenceName: "bootstrap.json", + now: 1_000, + phase: "bootstrap", + revision: "00000000-0000-4000-8000-000000000010", + sourceCommit, + }); + const candidate = await harness.deploy({ + evidenceName: "candidate.json", + now: 2_000, + phase: "candidate", + previousEvidenceName: "bootstrap.json", + revision: "00000000-0000-4000-8000-000000000020", + sourceCommit: "b".repeat(40), + }); + if (bootstrap === undefined || candidate === undefined) { + throw new Error("missing deployment evidence"); + } + const { selfDigest: _selfDigest, ...candidateBody } = candidate; + void _selfDigest; + const malformed = deployEvidenceSchema.parse(withSelfDigest({ + ...candidateBody, + before: { + ...bootstrap.after, + deployedAtMs: bootstrap.after.deployedAtMs + 1, + }, + })); + writeProtectedJsonNoReplace( + join(harness.evidenceDirectory, "malformed-candidate.json"), + malformed, + deployEvidenceSchema, + ); + const deploymentCalls = harness.deploymentCalls; + + await expect(harness.deploy({ + evidenceName: "malformed-candidate.json", + now: 3_000, + phase: "candidate", + previousEvidenceName: "bootstrap.json", + revision: "00000000-0000-4000-8000-000000000030", + sourceCommit: "b".repeat(40), + })).rejects.toThrow("source_changed"); + expect(harness.deploymentCalls).toBe(deploymentCalls); + }); + test("reconciles a committed bootstrap intent across invocations without redeploying", async () => { const repositoryRoot = await makeTemporaryDirectory("hra-hosted-crash-source-"); const temporaryRoot = await makeTemporaryDirectory("hra-hosted-crash-temp-"); diff --git a/scripts/deploy-hosted-sync.ts b/scripts/deploy-hosted-sync.ts index 4c5b439..4dc318a 100644 --- a/scripts/deploy-hosted-sync.ts +++ b/scripts/deploy-hosted-sync.ts @@ -884,14 +884,20 @@ export async function deployHostedSync( throw error; } })(); + const previous = options.phase === "candidate" + ? parseDeployEvidenceFile(options.previousDeployEvidencePath ?? "") + : undefined; + if ( + previous !== undefined + && canonicalDigest(previous.target) !== canonicalDigest(target) + ) throw new HostedDeployError("source_changed"); if (existingEvidence !== undefined) { - const expectedPrevious = options.previousDeployEvidencePath === undefined - ? null - : parseDeployEvidenceFile(options.previousDeployEvidencePath).selfDigest; + const expectedPrevious = previous?.selfDigest ?? null; if ( existingEvidence.sourceCommit !== options.sourceCommit || existingEvidence.phase !== options.phase || existingEvidence.previousDeployDigest !== expectedPrevious + || canonicalDigest(existingEvidence.before) !== canonicalDigest(previous?.after ?? null) || canonicalDigest(existingEvidence.target) !== canonicalDigest(target) || !sameAttestation(await readAttestation(target), existingEvidence.after) ) throw new HostedDeployError("source_changed"); @@ -899,9 +905,6 @@ export async function deployHostedSync( await requireExactSource(runner, repositoryRoot, environment, options.sourceCommit); return existingEvidence; } - const previous = options.phase === "candidate" - ? parseDeployEvidenceFile(options.previousDeployEvidencePath ?? "") - : undefined; const before = previous?.after ?? null; const previousDeployDigest = previous?.selfDigest ?? null; intent = readDeployIntent({ diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 31df951..8a766f8 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -567,7 +567,7 @@ describe("release workflow", () => { expect(releaseRecord).toContain("unshallows only that exact commit into `refs/remotes/ci/verified`"); expect(releaseRecord).toContain("The package gate still scans `rev-list --all`"); expect(releaseRecord).toContain("coordinate completed its non-executable bootstrap"); - expect(releaseRecord).toContain("npm trusted publishing names repository `hraness/hra` and workflow `release.yml`"); + expect(releaseRecord).toContain("npm trusted publishing has exactly one binding"); expect(releaseRecord).toContain("Stable `@hraness/hra@0.5.0` is authoritative until the next release is admitted"); expect(releaseRecord).toContain("The canonical README and website use a two-phase local-release surface"); expect(releaseRecord).toContain("The website remains live and the `v0.6.0` local CLI tag stays release-ready until exact release admission"); @@ -601,7 +601,10 @@ describe("release workflow", () => { expect(releaseRecord).toContain("the owner-authorized exact annotated tag is the publication authorization"); expect(releaseRecord).toContain("exact event `push`"); expect(releaseRecord).not.toContain("must remove it before the next release"); - expect(releaseRecord).toContain("Before `v0.6.0`, replace that sole binding"); + expect(releaseRecord).toContain("binding `41124856-baa6-46ad-b242-6e3278c73ce8`"); + expect(releaseRecord).toContain("binding `28b1ff93-c1b0-42a7-bef6-41bffb992eb2`"); + expect(releaseRecord).toContain("environment `npm-release`"); + expect(releaseRecord).toContain("permissions `publish, stage publish`"); expect(releaseRecord).toContain("npm CLI 11.19.0"); expect(releaseRecord).toContain("numeric owner ID\n`307125679`"); expect(releaseRecord).toContain("owner ID `307125679`");