Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/beta-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/hosted-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- \
Expand All @@ -146,11 +146,11 @@ bun run hosted:deploy -- \
--deployment-url https://steady-otter-321.convex.cloud \
--source-commit <N_COMMIT> \
--phase candidate \
--previous-deploy-evidence /protected/release/bootstrap-deploy.json \
--evidence-path /protected/release/candidate-deploy.json
--previous-deploy-evidence /protected/release/<CURRENT_DEPLOY_RECEIPT>.json \
--evidence-path /protected/release/candidate-<N_COMMIT>-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.

Expand Down
206 changes: 206 additions & 0 deletions scripts/deploy-hosted-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -118,6 +121,85 @@ const outputWriter = (chunks: string[]): Pick<NodeJS.WriteStream, "write"> => ({
},
});

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(
Expand Down Expand Up @@ -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-");
Expand Down
Loading