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
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"fixed": [
[
"@cloudhal/core-contracts",
"@cloudhal/release-verify",
"@cloudhal/storage",
"@cloudhal/analytics",
"@cloudhal/core-ts",
Expand Down
21 changes: 21 additions & 0 deletions .changeset/release-verify-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@cloudhal/release-verify": patch
---

New package `@cloudhal/release-verify` (RFC-0046 §11a). It is the one verifier of Cloudhal release bundles, and it is Worker-safe.

`verifyRelease` runs these checks in order, and refuses on the first failure:
- the Ed25519 signature on `release-statement.json`, against the pinned release keys;
- that the statement is for the version and channel you asked for;
- both precompiled validators;
- every digest and §7 limit;
- the tarball, in two passes.

The tarball is never held whole. The first pass checks its digest, and the second yields each entry only once it is verified. Unsafe entry paths (`..`, `.`, empty segments, backslashes, control characters, case-fold duplicates) are refused with `entry-path`. Every refusal carries a stable `code`.

The package also exports:
- `missingScopes`, which applies §3's rule that `workers-scripts.admin` covers `workers-scripts.edit`;
- `releaseAssetUrls`;
- `PINNED_KEYS`, which can only be reduced.

The Node-only subpath `@cloudhal/release-verify/attestation` checks the GitHub build attestation with `gh` 2.68.0 or newer.
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,31 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @cloudhal/core-contracts gen
- run: pnpm --filter @cloudhal/release-verify gen:ts
- name: Generated code must match the committed code
run: |
git diff --exit-code -- packages/core-contracts/src/generated crates/cloudhal-schema/src/generated \
git diff --exit-code -- packages/core-contracts/src/generated packages/release-verify/src/generated crates/cloudhal-schema/src/generated \
|| { echo "::error::Generated types are out of date. Run 'pnpm gen' and commit."; exit 1; }

release-verify-v0-9-0:
# RFC-0046 §11a: @cloudhal/release-verify against the real, immutable v0.9.0 assets, tarball
# included. Ordinary test runs skip this test (no network); only this job downloads them.
name: Release verifier on the real v0.9.0 bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download the v0.9.0 release assets
env:
GH_TOKEN: ${{ github.token }}
run: gh release download v0.9.0 --repo cloudhal-dev/cloudhal --dir "$RUNNER_TEMP/v0.9.0"
- name: Verify them (statement, manifest, every tarball entry)
working-directory: packages/release-verify
env:
CLOUDHAL_RELEASE_ASSETS: ${{ runner.temp }}/v0.9.0
run: pnpm exec vitest run test/real-tarball.test.ts
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ node scripts/release-bundle.ts build|verify … # RFC-0046 release bundle: det
- `packages/core-contracts/schemas/*.schema.json` — **the only hand-edited contract source**. `common.schema.json` holds shared `$defs`.
- `packages/core-contracts/src/generated/`, `crates/cloudhal-schema/src/generated/` — generated; never edit.
- `packages/core-contracts/fixtures/{valid,invalid}/<schema>/` — golden fixtures used by both languages.
- `packages/release-verify` — RFC-0046 §11a's published verifier of release bundles (Worker-safe: WebCrypto Ed25519, `DecompressionStream`, `DigestStream` else `@noble/hashes`): `verifyRelease` (§2's six steps, the two-pass tarball, `entry-path`), stable error codes, `PINNED_KEYS` (kept equal to the key doc), `missingScopes`; `./attestation` is the Node-only `gh attestation verify` check. Its validators for the two release roots are generated by its own `gen:ts` (after core-contracts'); `scripts/release-bundle/verify.ts` calls it for signed bundles.
- `packages/storage` — `SqlDriver`/`BlobStore` + adapters (`sqlite`, `d1`, `do-sqlite`, `fs`, `r2`; `SqlBlobStore`, `LayeredBlobStore`, `FetcherBlobStore` at the root) + migrations (`migrations/*.ts`, plain SQL). Conformance suites in `src/testing.ts`.
- `packages/core-ts` — `SiteGraphStore` (CAS on `rev`, snapshots, diff, publish, rollback). Behavioural suite in `src/testing.ts` runs on sqlite and Miniflare D1. `./local` opens the offline store; `seedStarterSite` seeds a demo. `./surfaces` is RFC-0020's generative UI: the catalog (primitives + block types), eval-free surface checks, bindings, repairs, JSON Patch (`patchPage`), `catalogPrompt`; `./interop` converts A2UI v0.9 and json-render to and from surfaces; `./design` is RFC-0021's DESIGN.md lint and guidance. `./fields` is RFC-0041's typed custom fields (definition and value checks, `resolveFields`, `formatFieldValue`, the agents' JSON Schema, the conversion table); `src/fields-operations/` runs rename/retype/localize/remove. `search.ts` is RFC-0019's FTS5 site search (index written with the publish batch; articles, pages, terms and authors, RFC-0029); `search-provider.ts` is RFC-0029's `SearchProvider` (`fts5`, `memory`, `withFallback`), set with `SiteGraphStoreOptions.search`; `search-ai.ts` is the experimental Cloudflare AI Search provider (`hybridProvider`, `searchProviderFromEnv`, `cloudhal.json` `search`, docs/guides/search.md).
- `packages/schedules` — RFC-0008: time model (only `temporal-polyfill/implementation`, `now` injected, never `Temporal.Now`), RRULE subset, release model (`rl1:`), automation/run state, taint and budgets, `runDue`; `./sql` is `SqlScheduleStore`, `./engine` the Temporal-free part core-ts and the site Worker use. Worker-safe; conformance suites in `src/testing.ts` on sqlite and Miniflare D1.
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"!**/.turbo",
"!**/node_modules",
"!apps/studio/src/assets/geo",
"!packages/cli/test/fixtures/theme-*"
"!packages/cli/test/fixtures/theme-*",
"!packages/release-verify/test/fixtures/v0.9.0"
]
},
"formatter": {
Expand Down
9 changes: 9 additions & 0 deletions docs/security/release-signing-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ Releases are immutable and can never gain an asset, so a release signed by a rev
4. Set the old key's status to `retiring` in this file. Keep it pinned by consumers for as long as releases it signed are installed.
5. A compromised key goes straight to `revoked` here, with an advisory under [SECURITY.md](../../SECURITY.md), and the next release is signed with the new key.

## Verifying a release

[`@cloudhal/release-verify`](../../packages/release-verify/README.md) can do two of the steps below for you:

- **Step 1:** `verifyRelease` checks the statement, its signature and every digest, including each entry of the tarball, on Node, Workers or in a browser. Its `PINNED_KEYS` is kept equal to the table above.
- **Step 3:** the attestation is a separate call. On Node, `verifyAttestation` (in `@cloudhal/release-verify/attestation`) runs `gh attestation verify` for you.

Step 2, `gh release verify-asset`, stays manual. To check by hand instead, follow these steps.

## Verifying a release by hand

Download the release's assets into an empty directory, for example with `gh release download v<version> --repo cloudhal-dev/cloudhal --dir <dir>`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"gen": "turbo run gen:ts gen:rs",
"drift": "pnpm gen && git diff --exit-code -- packages/core-contracts/src/generated crates/cloudhal-schema/src/generated apps/studio/src/i18n/generated",
"drift": "pnpm gen && git diff --exit-code -- packages/core-contracts/src/generated packages/release-verify/src/generated crates/cloudhal-schema/src/generated apps/studio/src/i18n/generated",
"cargo:test": "cargo test --workspace --locked",
"cargo:clippy": "cargo clippy --workspace --all-targets --locked -- -D warnings",
"changeset": "changeset",
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/release-bundle-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
loadBundle,
publicKeysFor,
RELEASE_KEY,
verifyAsConsumer,
waitForDraft,
} from "../../../scripts/publish-ci.ts";
import { buildReleaseBundle, writeReleaseBundle } from "../../../scripts/release-bundle/build.ts";
Expand Down Expand Up @@ -46,6 +47,16 @@ describe("publish-ci loadBundle", () => {
return dir;
}

it("runs the consumer's verifier over the signed bundle before publishing (§11a)", async () => {
const k = throwawayKey();
const dir = await signedDir(k.privateKey);
await expect(verifyAsConsumer(dir, "0.8.0", { [KEY_ID]: k.raw })).resolves.toBeUndefined();
await expect(
verifyAsConsumer(dir, "0.8.0", { [KEY_ID]: throwawayKey().raw }),
).rejects.toMatchObject({ code: "signature-invalid" });
await expect(verifyAsConsumer(dir, "0.8.1", { [KEY_ID]: k.raw })).rejects.toThrow();
});

it("reads the five assets in upload order with their digests, and the statement", async () => {
const k = throwawayKey();
const dir = await signedDir(k.privateKey);
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/test/release-bundle-verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,58 @@ describe("verifyBundle", () => {
});
});

describe("the producer's self-check runs the consumer's rules (RFC-0046 §11a, #232)", () => {
/** An unsigned bundle of the fixture build with `edit` applied to its build output first. */
async function built(edit: (out: ReturnType<typeof readBuildOutput>) => void): Promise<string> {
const fx = fixtureProject();
roots.push(fx.root);
const out = readBuildOutput({ project: fx.project, studio: fx.studio });
edit(out);
const secrets = readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json"));
const b = await buildReleaseBundle(out, facts({ version: "0.8.0" }), secrets);
const dir = mkdtempSync(path.join(os.tmpdir(), "release-bundle-out-"));
roots.push(dir);
writeReleaseBundle(b, dir);
return dir;
}
const addAsset = (p: string) => (out: ReturnType<typeof readBuildOutput>) => {
(out.site.assets as { path: string; bytes: Uint8Array }[]).push({
path: p,
bytes: enc.encode("x"),
});
};

it("refuses assets that collide after case folding, unsigned", async () => {
// Built in memory: a case-insensitive file system could not hold both files.
const dir = await built(addAsset("/FAVICON.svg"));
await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toMatchObject({
code: "entry-path",
message: expect.stringMatching(/collide/),
});
});

it("refuses a path over 512 bytes and a .. segment, unsigned", async () => {
const long = await built(
addAsset(`/${"a".repeat(200)}/${"b".repeat(200)}/${"c".repeat(200)}.svg`),
);
await expect(verifyBundle(long, { version: "0.8.0" })).rejects.toMatchObject({
code: "entry-path",
});
const dots = await built(addAsset("/x/../y.svg"));
await expect(verifyBundle(dots, { version: "0.8.0" })).rejects.toMatchObject({
code: "entry-path",
});
});

it("writes and reads long non-ASCII names with one byte rule (PAX + ustar name)", async () => {
const names = [`/${"界".repeat(30)}.txt`, `/x${"é".repeat(50)}`, `/${"😀".repeat(30)}`];
const dir = await built((out) => {
for (const n of names) addAsset(n)(out);
});
await expect(verifyBundle(dir, { version: "0.8.0" })).resolves.toMatchObject({});
});
});

describe("tar and gzip format checks", () => {
const file = { path: "site/modules/a.mjs", bytes: enc.encode("a") };

Expand Down
73 changes: 72 additions & 1 deletion packages/cli/test/release-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ import {
isSecurityRelease,
type Pkg,
readPackages,
releaseGroup,
releaseNotes,
sharedVersion,
unpublished,
} from "../../../scripts/release-plan.ts";
import { planPublish } from "../../../scripts/release-publish-plan.ts";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const pkg = (name: string, version: string): Pkg => ({ dir: name, name, version });

describe("release plan", () => {
it("the repo's publishable packages share one version (changesets fixed group)", () => {
const pkgs = readPackages(ROOT);
expect(pkgs).toHaveLength(13);
expect(pkgs).toHaveLength(14);
expect(sharedVersion(pkgs)).toMatch(/^\d+\.\d+\.\d+$/);
});

Expand Down Expand Up @@ -104,3 +106,72 @@ describe("release plan", () => {
expect(isSecurityRelease([], "0.8.1")).toBe(false);
});
});

describe("a package that joined the fixed group before its first version PR (#232)", () => {
// main right after @cloudhal/release-verify merged: every package at 0.9.0, all released and
// with a "## 0.9.0" changelog entry, except release-verify (no CHANGELOG.md, nothing on npm).
const NEW = "@cloudhal/release-verify";
const at = (version: string) => readPackages(ROOT).map((p) => ({ ...p, version }));
const changelogs =
(version: string, newEntry: boolean) =>
(p: Pkg): string | undefined =>
p.name !== NEW || newEntry ? `# ${p.name}\n\n## ${version}\n\n- a change\n` : undefined;
const npm =
(forNew: string[], released: string) =>
async (name: string): Promise<string[]> =>
name === NEW ? forNew : ["0.8.0", released];

it("is left out of the release: nothing publishes it at the group's current version", async () => {
for (const onNpmNew of [[], ["0.0.0-placeholder.0"]]) {
const pkgs = at("0.9.0");
expect(pkgs.map((p) => p.name)).toContain(NEW);
const r = await releaseGroup(
pkgs,
"0.9.0",
changelogs("0.9.0", false),
npm(onNpmNew, "0.9.0"),
);
expect(r.notYetReleased.map((p) => p.name)).toEqual([NEW]);
expect(r.group).toHaveLength(pkgs.length - 1);
// `--plan` on main: npm has everything else, so publish=false (main stays green).
const missing = await unpublished(r.group, async () => true);
expect(missing).toEqual([]);
// A workflow_dispatch dry run publishes the group with --dry-run: never the new package.
const dry = planPublish({
version: "0.9.0",
head: "a".repeat(40),
dryRun: true,
runNumber: "7",
bundle: null,
tagCommit: null,
releases: [],
packages: r.group.map((p) => p.name),
npmMissing: missing.map((p) => p.name),
tags: ["v0.9.0"],
});
const published = dry.flatMap((s) => (s.kind === "npm-publish" ? s.packages : []));
expect(published.map((p) => p.name)).not.toContain(NEW);
expect(published).toHaveLength(pkgs.length - 1);
}
});

it("is released by its first version PR, after only the placeholder", async () => {
const pkgs = at("0.9.1");
const r = await releaseGroup(
pkgs,
"0.9.1",
changelogs("0.9.1", true),
npm(["0.0.0-placeholder.0"], "0.9.0"),
);
expect(r.notYetReleased).toEqual([]);
const missing = await unpublished(r.group, async () => false);
expect(missing.map((p) => p.name)).toContain(NEW);
});

it("never leaves out a package npm has released, even without a changelog entry", async () => {
const pkgs = at("0.9.1");
const r = await releaseGroup(pkgs, "0.9.1", () => undefined, npm(["0.9.0"], "0.9.0"));
expect(r.notYetReleased).toEqual([]);
expect(r.group).toHaveLength(pkgs.length);
});
});
59 changes: 59 additions & 0 deletions packages/release-verify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# @cloudhal/release-verify

Verifies a [Cloudhal](https://cloudhal.dev) release bundle before you deploy it. It follows [RFC-0046](https://github.com/cloudhal-dev/cloudhal/blob/main/docs/rfcs/0046-release-bundles.md) §2, §7 and §11a: hosts, tools and the `cloudhal` CLI share this one verifier. It runs on Workers, Node 24 and browsers.

```ts
import { releaseAssetUrls, verifyRelease } from "@cloudhal/release-verify";

const release = await verifyRelease({
request: { version: "0.9.0" }, // what you asked for: a signed older release is refused
statement, signature, manifest, // the exact downloaded bytes (Uint8Array)
tarball: () => fetchTarballStream(), // opened twice: digest first, then entries
planVersion: 1, // the highest plan version your deployer supports
});
for await (const entry of release.entries()) {
// { worker, kind, path, type?, bytes }: verified against the signed manifest
}
// Activate the deployment only after the loop has finished without an error.
```

## What it checks

1. **Sizes.** The statement is at most 4096 bytes, and the signature is exactly 64.
2. **Signature.** Ed25519 over the exact statement bytes, with a trusted key for `signing.keyId`.
3. **Request.** The statement is for the requested version and channel.
4. **Statement.** Its schema, through the precompiled validator, plus RFC-0046 §2 invariants 1 to 3 and 6.
5. **Manifest.**
- Its size and SHA-256 equal the statement's.
- Its schema and invariants 4 and 5.
- The `release-statement.sig` signature form.
- `planVersion`.
- Every listed path, and §7's limits.
6. **Tarball.** Its size and SHA-256 equal the statement's.

Then `entries()` re-reads the tarball. It refuses:
- an entry the manifest does not list, or one whose size or SHA-256 differs from its listing;
- a missing entry;
- anything outside §7's tar format;
- an unsafe path: `..`, `.`, an empty segment, a backslash, a control character, more than 512 bytes, or two paths that collide after case folding.

There is no soft-fail mode. Every refusal is a `ReleaseVerifyError` with a stable `code` (`ERROR_CODES`).

## Keys

`PINNED_KEYS` holds the keys of [docs/security/release-signing-keys.md](https://github.com/cloudhal-dev/cloudhal/blob/main/docs/security/release-signing-keys.md) as they were when this version was published. It cannot learn about later revocations by itself.

You can only reduce trust: pass `revoked: ["<keyId>"]` to withdraw a key. Passing your own `keys` map replaces the table, so treat that as a reviewed change.

## The second factor

The Node-only subpath `@cloudhal/release-verify/attestation` checks the GitHub build-provenance attestation of the tarball, the manifest and the statement. It needs `gh` 2.68.0 or newer, signed in. It returns `verified` or `failed`, or `unavailable` when `gh` is missing, too old, or signed out.

```ts
import { verifyAttestation } from "@cloudhal/release-verify/attestation";
const r = await verifyAttestation({ dir, version: "0.9.0", commit: release.statement.commit });
```

## Scopes

`missingScopes(manifest, granted)` compares Cloudflare scope ids exactly, with one exception: `workers-scripts.admin` covers `workers-scripts.edit`, which manifests up to v0.9.0 name (RFC-0046 §3).
Loading
Loading