From bcc8416ba8e171bbbd7bca900007ca8e86a7c3fa Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:20:59 +0300 Subject: [PATCH 1/6] docs(rfc-0046): design @cloudhal/release-verify and deploy --release (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addendum §11 before building #227: - 11a: the published verifier: runtimes (WebCrypto Ed25519, DecompressionStream, incremental SHA-256: Workers' DigestStream, else @noble/hashes, the one audited runtime dependency), generated standalone validators for the two roots, PINNED_KEYS with reduce-only trust, the two-pass tarball API (digest first, then entries verified one by one), stable error codes, missingScopes, the Node-only attestation subpath, tests on the real v0.9.0 statement/signature/manifest plus an opt-in full-tarball test in its own CI job (the 7.2 MB tarball is never committed), and the owner's first publish and trusted-publisher entry. - 11b: deploy --release: the CLI's own version only, verified bytes deployed through wrangler with no_bundle, refusals for anything the bundle bakes in (theme, settings, design, block types, skills, src/, attached or multi-site installs, Workers Builds), attestation when gh is available, --from offline, --dry-run. - 11c: four open points with defaults. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 71 ++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 5e7ae4f8..f412f20f 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -198,9 +198,76 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no ### 10. Later phases (in scope, not in this change) -- `cloudhal deploy --release `: the CLI installs the standard theme from a verified bundle instead of building it. Until then the CLI's bytes differ from the bundle's; what is shared is the manifest contract and its guards. +- `cloudhal deploy --release `: the CLI installs the standard theme from a verified bundle instead of building it (designed in §11b). Until then the CLI's bytes differ from the bundle's; what is shared is the manifest contract and its guards. - Runtime configuration of the standard theme: values baked into its build today (theme settings, block types, installation names, site ids) move to bindings and `CLOUDHAL_CONFIG` vars, so one bundle serves every installation of the standard theme. Custom themes keep building with `cloudhal deploy`. Until then the bundle is the standard `cloudhal init` starter. -- A zero-dependency WebCrypto reference verifier package, KMS custody, an SBOM, and an independent rebuild check once two builds of one tag are byte-identical. +- A WebCrypto reference verifier package (designed in §11a, with one audited dependency), KMS custody, an SBOM, and an independent rebuild check once two builds of one tag are byte-identical. + +### 11. Addendum (2026-11-14, #227): the shared verifier and `cloudhal deploy --release` + +§2 says what every consumer checks. This section designs the two consumer tools that §10 left for later: one published verifier that hosts, tools and the CLI share, and the CLI's install from a verified bundle. Signed key lists and yanking stay with §9 and #226. This section only leaves room for them. + +#### 11a. `@cloudhal/release-verify` + +A new published package, `packages/release-verify`. It is the reference implementation of §2's six steps and of §7's limits, and there is no second implementation in the repository: `scripts/release-bundle/verify.ts` and the CLI both call it. + +- **Runtimes.** The root entry runs on Workers, Node 24 and browsers. It uses WebCrypto Ed25519 (`crypto.subtle.verify`), `DecompressionStream("gzip")` and an incremental SHA-256, because WebCrypto's `digest` cannot stream. The SHA-256 is Workers' native `crypto.DigestStream` where it exists, and otherwise `sha256` from `@noble/hashes`. That package has been independently audited, has no dependencies, and only its SHA-2 module (a few KB) is imported. It is the package's only runtime dependency. A home-grown SHA-256 would be small, but it would be new cryptographic code in the one package whose job is to be trusted. A pinned, audited, zero-dependency hash does that job better. Tests check both paths against the NIST SHA-256 vectors and against `node:crypto` on random inputs. +- **Validators.** Rule 6 forbids Ajv at runtime. `pnpm gen` writes precompiled standalone validators for exactly `release-manifest` and `release-statement` into `src/generated/`, and the drift check covers them. The package does not depend on `@cloudhal/core-contracts`, so a Worker deployer does not pull in every validator. +- **Keys.** `PINNED_KEYS` is the table of `docs/security/release-signing-keys.md`, as `{ keyId: { publicKey, status } }`, and a repository test keeps the two equal. A caller may only reduce trust: `revoked: string[]` withdraws key ids. A caller that trusts a key outside the table passes a whole `keys` map, which is a reviewed change on its side. Once #226 lands, a verified `keys.json` feeds the same reduce-only path. A key that is `revoked` is refused. A key past its `notAfter` is refused as well, using the expiry rule that #226 sets for releases a key has already signed (§9). +- **API.** Every byte the consumer downloaded goes in. Nothing is re-serialised. + + ```ts + const release = await verifyRelease({ + request: { version: "0.9.1" }, // channel: derived from the version unless given (invariant 7) + statement, signature, manifest, // Uint8Array, exactly as downloaded + tarball: () => stream, // a factory: read twice, see below + planVersion: 1, // the highest planVersion the caller supports + }); + for await (const entry of release.entries()) { /* { path, bytes }: verified, see below */ } + ``` + + `verifyRelease` runs steps 1 to 5. It then reads the tarball once to check its size and SHA-256 against the statement, so a tarball that does not match is refused before anything is extracted. `entries()` then reads the tarball a second time. It yields an entry only after that entry's size and SHA-256 equal its manifest listing. It throws on an entry the manifest does not list, on anything outside §7's tar format, and, at the end, on a listed entry that never came. A consumer may upload verified entries while it iterates. It must not activate a deployment until the iteration has finished without an error. This keeps a Worker deployer within memory: the tarball is at most 100 MiB and is never held whole. +- **Errors.** A `ReleaseVerifyError` carries a stable `code`, which is public API: `statement-too-large`, `signature-size`, `key-unknown`, `key-revoked`, `key-expired`, `signature-invalid`, `wrong-version`, `wrong-channel`, `statement-invalid`, `manifest-mismatch`, `manifest-invalid`, `statement-manifest-mismatch` (invariants 4 and 5), `plan-version-unsupported`, `tarball-mismatch`, `tar-format`, `entry-unlisted`, `entry-mismatch`, `entry-missing` and `limit-exceeded`. There is no option that turns a refusal into a warning. +- **Helpers.** `missingScopes(manifest, granted)` applies §3's scopes rule, so `workers-scripts.admin` covers `workers-scripts.edit`. `releaseAssetUrls(version)` gives the four GitHub download URLs, `https://github.com/cloudhal-dev/cloudhal/releases/download/v/`. Downloading is discovery only, never a trust factor. +- **The second factor.** The subpath `@cloudhal/release-verify/attestation` is Node-only and runs `gh attestation verify` with §2's exact flags for each of the three files: `--signer-workflow`, `--source-ref refs/heads/main`, `--source-digest ` and `--deny-self-hosted-runners`. It reports `verified`, `failed` or `unavailable` (no `gh`, or not signed in). `failed` is always a refusal. +- **Tests.** The real `v0.9.0` statement, signature and manifest are committed as fixtures, about 250 KB of signed, immutable data. The tarball, 7.2 MB, is not: it would be by far the largest file in the repository. Tests on those fixtures check: + - that they verify with the pinned key; + - that a flipped byte in each file is refused; + - that a wrong key, and a revoked or expired one, is refused; + - that a request for another version or channel is refused; + - that a manifest whose `version` differs from the statement's is refused. + + The real tarball is checked by one opt-in test that runs only when `CLOUDHAL_RELEASE_ASSETS` names a directory holding the downloaded assets. Ordinary test runs skip it and never need the network. A separate CI job fills that directory with `gh release download v0.9.0` and runs only that test. The tarball logic itself is covered on a small synthetic bundle signed with a throwaway key, which tests pass as `keys`. Those tests cover: + - a tarball entry that is unlisted, missing or changed; + - a tarball that does not match the statement; + - `planVersion` above what the caller supports; + - every limit in §7. +- **Versioning and publication.** The package joins the fixed version group (ADR-0013). npm trusted publishing can only be configured on a package that already exists. So the **first publish of `@cloudhal/release-verify` and its trusted-publisher entry are the owner's step** (a manual first publish of the first version, then the entry for `release.yml` and the `npm-publish` environment). The release workflow publishes it from then on. + +#### 11b. `cloudhal deploy --release []` + +Today `cloudhal deploy` builds the site with Astro and the admin Worker with wrangler's bundler. With `--release`, it downloads the release's assets, verifies them with `@cloudhal/release-verify`, extracts the verified entries into `.cloudhal/deploy/release//` and deploys those bytes. Everything else stays as it is: provisioning, secrets, the downgrade guard, settings, the scheduler fallback, the first push and the cache purge. + +- **How the bytes are deployed.** The deploy generates the same wrangler configs as today, with `main` set to the extracted module, `no_bundle: true` and module rules for `.mjs`/`.js`, and `assets.directory` set to the extracted assets. `_headers` is written from the manifest's inline text. Wrangler uploads the modules unchanged. A deployer that calls the Workers API directly is not needed for the CLI. Hosts that cannot run wrangler use the verifier and their own uploader. +- **Which version.** `--release` with no value means the CLI's own version. A different version is refused. The CLI's starter, its config generator and its downgrade guard then match the bundle exactly. To install another version, run that version of the CLI (`npx @cloudhal/cli@ deploy --release`). `next` prereleases work the same way. +- **What the bundle can serve.** §10 still applies: theme settings, theme design, block types and skills are baked into the build. The site id is a var (`CLOUDHAL_SITE_ID`), so it is not baked. Before downloading the tarball, the CLI compares the project with the manifest. It refuses, and says to run `cloudhal deploy` without `--release`, when: + - the project's theme is not `manifest.theme.name`/`version`; + - its theme settings or design hash differ from `settingsSha256`/`designSha256`; + - its block types or skills differ; + - its `src/` differs from the starter that this CLI version copies on `cloudhal init`; + - it is attached to another installation (RFC-0034 `installation`) or serves several sites; + - it runs in Workers Builds. + + Everything that a binding or a var carries works as today: analytics, comments, mail, AI, Browser Rendering, domains, the scheduler, the jurisdiction, placement and observability. +- **Verification in the CLI.** The CLI checks the signature and every digest, with no flag to skip them. It verifies the attestation when `gh` is available and refuses when that check fails. When `gh` is unavailable, it prints one line saying that only the release key was checked. `--require-attestation` turns unavailable into a refusal. `--from ` takes the four assets from a directory, for offline machines. The same verification runs on them. +- **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy. `manifest.levels.schedules` feeds the existing downgrade guard. +- **Dry run.** `--release --dry-run` downloads, verifies, extracts and writes the configs, then stops before wrangler. It needs no Cloudflare account. + +#### 11c. Open points for this addendum + +1. Which incremental SHA-256 should run outside Workers: `@noble/hashes`, or our own of about 120 lines? **[default: `@noble/hashes`, audited and zero-dependency, pinned exactly; Workers use the native `crypto.DigestStream`]** +2. Should `--release` accept another version than the CLI's own? **[default: no, until §10's runtime configuration lands]** +3. When `gh` is unavailable, should the CLI refuse by default instead of falling back to the release key alone? **[default: release key alone, with a line saying so; `--require-attestation` refuses]** +4. Where does the full-tarball test run? **[default: opt-in through `CLOUDHAL_RELEASE_ASSETS`, in its own CI job that downloads `v0.9.0`; the 7.2 MB tarball is never committed]** ## Alternatives considered From 89fa0aa1eea1cef5a8aa053b466be692bcf6c30a Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:48:27 +0300 Subject: [PATCH 2/6] =?UTF-8?q?docs(rfc-0046):=20=C2=A711=20review=20round?= =?UTF-8?q?=201=20(PR=20#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the theme/skills hash refusals, which compared placeholders (v0.9.0 has empty lists and sha256("")), with a defined "unmodified starter" check: template files byte for byte, no extra src/, public/ or config files, package.json dependencies, and cloudhal.json build-input keys from one table that a test keeps equal to what buildSite and prepareAdminWorker read. comments.available and media.transform (unless features.mediaTransformVar) count as build inputs. - Entry-path safety: an entry-path code (empty, . and .. segments, backslash, control characters, case-fold duplicates, length), a linear scanner (rule 13), and extraction into a fresh directory with resolve checks, exclusive create, and module types from the manifest. - First publish: a deprecated 0.0.0-placeholder.0, then the trusted publisher, so the first real version has provenance; the fixed group in .changeset/config.json. - Attestation: an availability check first, exact flags, --bundle with the Sigstore asset (now in releaseAssetUrls and --from), any non-zero exit counts as failed, and it runs before extraction. The CLI refuses without the second factor unless --allow-single-factor is given. - Revocation: deploy --release waits for #226's signed keys.json, fetched on every run with a hard refusal; the package can ship first. - Runtime dependencies stated honestly: @noble/hashes, ajv runtime helpers and ajv-formats, guarded by a test. - missingScopes points to §3; key-expired moves to the open points (#226 decision 6); invariant-5 tests use the synthetic bundle; a release-unavailable refusal when there is no public bundle. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 168 ++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 46 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index f412f20f..840df0a7 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -200,74 +200,150 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - `cloudhal deploy --release `: the CLI installs the standard theme from a verified bundle instead of building it (designed in §11b). Until then the CLI's bytes differ from the bundle's; what is shared is the manifest contract and its guards. - Runtime configuration of the standard theme: values baked into its build today (theme settings, block types, installation names, site ids) move to bindings and `CLOUDHAL_CONFIG` vars, so one bundle serves every installation of the standard theme. Custom themes keep building with `cloudhal deploy`. Until then the bundle is the standard `cloudhal init` starter. -- A WebCrypto reference verifier package (designed in §11a, with one audited dependency), KMS custody, an SBOM, and an independent rebuild check once two builds of one tag are byte-identical. +- A WebCrypto reference verifier package (designed in §11a), KMS custody, an SBOM, and an independent rebuild check once two builds of one tag are byte-identical. ### 11. Addendum (2026-11-14, #227): the shared verifier and `cloudhal deploy --release` -§2 says what every consumer checks. This section designs the two consumer tools that §10 left for later: one published verifier that hosts, tools and the CLI share, and the CLI's install from a verified bundle. Signed key lists and yanking stay with §9 and #226. This section only leaves room for them. +§2 says what every consumer checks. This section designs the two consumer tools that §10 left for later: one published verifier that hosts, tools and the CLI share, and the CLI's install from a verified bundle. The verifier can ship on its own. `deploy --release` depends on #226 (§11b, *Revocation*). #### 11a. `@cloudhal/release-verify` A new published package, `packages/release-verify`. It is the reference implementation of §2's six steps and of §7's limits, and there is no second implementation in the repository: `scripts/release-bundle/verify.ts` and the CLI both call it. -- **Runtimes.** The root entry runs on Workers, Node 24 and browsers. It uses WebCrypto Ed25519 (`crypto.subtle.verify`), `DecompressionStream("gzip")` and an incremental SHA-256, because WebCrypto's `digest` cannot stream. The SHA-256 is Workers' native `crypto.DigestStream` where it exists, and otherwise `sha256` from `@noble/hashes`. That package has been independently audited, has no dependencies, and only its SHA-2 module (a few KB) is imported. It is the package's only runtime dependency. A home-grown SHA-256 would be small, but it would be new cryptographic code in the one package whose job is to be trusted. A pinned, audited, zero-dependency hash does that job better. Tests check both paths against the NIST SHA-256 vectors and against `node:crypto` on random inputs. -- **Validators.** Rule 6 forbids Ajv at runtime. `pnpm gen` writes precompiled standalone validators for exactly `release-manifest` and `release-statement` into `src/generated/`, and the drift check covers them. The package does not depend on `@cloudhal/core-contracts`, so a Worker deployer does not pull in every validator. -- **Keys.** `PINNED_KEYS` is the table of `docs/security/release-signing-keys.md`, as `{ keyId: { publicKey, status } }`, and a repository test keeps the two equal. A caller may only reduce trust: `revoked: string[]` withdraws key ids. A caller that trusts a key outside the table passes a whole `keys` map, which is a reviewed change on its side. Once #226 lands, a verified `keys.json` feeds the same reduce-only path. A key that is `revoked` is refused. A key past its `notAfter` is refused as well, using the expiry rule that #226 sets for releases a key has already signed (§9). +- **Runtimes.** The root entry runs on Workers, Node 24 and browsers. + - It uses WebCrypto Ed25519 (`crypto.subtle.verify`) and `DecompressionStream("gzip")`. + - It needs an incremental SHA-256, because WebCrypto's `digest` cannot stream. It uses Workers' native `crypto.DigestStream` where that exists, and otherwise `sha256` from `@noble/hashes`. + - `@noble/hashes` is pinned exactly, and only its SHA-2 module is imported. It is independently audited and has no dependencies. + - A home-grown SHA-256 would be small, but it would be new cryptographic code in the one package whose job is to be trusted. + - Tests check both paths against the NIST SHA-256 vectors and against `node:crypto` on random inputs. +- **Validators and runtime dependencies.** Rule 6 forbids compiling schemas at runtime. + - `pnpm gen` writes precompiled standalone validators for exactly `release-manifest` and `release-statement` into `src/generated/`, and the drift check covers them. + - Ajv's standalone output imports `ajv/dist/runtime/equal`, `ajv/dist/runtime/ucs2length` and the `date-time` format from `ajv-formats`. So the package's runtime dependencies are `@noble/hashes`, `ajv` and `ajv-formats`, pinned through the catalog at core-contracts' versions. Nothing in the package constructs `Ajv`. A test fails when a generated validator imports anything outside those three runtime modules. + - The package does not depend on `@cloudhal/core-contracts`, so a Worker deployer does not pull in every validator. +- **Keys.** `PINNED_KEYS` is the table of `docs/security/release-signing-keys.md` as `{ keyId: { publicKey, status } }`, and a repository test keeps the two equal. + - It is frozen into each published version. It cannot learn about a revocation by itself; §11b says how the CLI does. + - A caller may only reduce trust. `revoked: string[]` withdraws key ids, and once #226 lands, a verified `keys.json` feeds the same reduce-only path. + - A caller that trusts a key outside the table passes a whole `keys` map, which is a reviewed change on its side. + - A `revoked` key is refused. `PINNED_KEYS` carries no `notAfter` yet, so the package refuses no key for its age until #226 decides what an expiry date means (§11c). +- **Entry paths.** Every path in the manifest's module and asset listings, and every tar entry name, is checked before any byte of that entry is used. Paths that break any of these rules are refused with `entry-path`: + - no empty segment; + - no `.` or `..` segment; + - no backslash; + - no control character (U+0000 to U+001F, U+007F); + - no leading `/`, except the asset form `/` that §3 defines; + - at most 512 bytes; + - no two paths in one Worker's modules, or in its assets, may be equal after Unicode case folding. + + The check is a single pass over the characters with `split("/")`, not a regular expression, so it is linear in the path's length (AGENTS.md rule 13). The manifest's listings are checked in step 5, before the tarball is read. - **API.** Every byte the consumer downloaded goes in. Nothing is re-serialised. ```ts const release = await verifyRelease({ - request: { version: "0.9.1" }, // channel: derived from the version unless given (invariant 7) + request: { version: "0.10.0" }, // channel: derived from the version unless given (invariant 7) statement, signature, manifest, // Uint8Array, exactly as downloaded tarball: () => stream, // a factory: read twice, see below planVersion: 1, // the highest planVersion the caller supports }); - for await (const entry of release.entries()) { /* { path, bytes }: verified, see below */ } + for await (const entry of release.entries()) { /* { worker, kind, path, type?, bytes }: verified */ } ``` - `verifyRelease` runs steps 1 to 5. It then reads the tarball once to check its size and SHA-256 against the statement, so a tarball that does not match is refused before anything is extracted. `entries()` then reads the tarball a second time. It yields an entry only after that entry's size and SHA-256 equal its manifest listing. It throws on an entry the manifest does not list, on anything outside §7's tar format, and, at the end, on a listed entry that never came. A consumer may upload verified entries while it iterates. It must not activate a deployment until the iteration has finished without an error. This keeps a Worker deployer within memory: the tarball is at most 100 MiB and is never held whole. -- **Errors.** A `ReleaseVerifyError` carries a stable `code`, which is public API: `statement-too-large`, `signature-size`, `key-unknown`, `key-revoked`, `key-expired`, `signature-invalid`, `wrong-version`, `wrong-channel`, `statement-invalid`, `manifest-mismatch`, `manifest-invalid`, `statement-manifest-mismatch` (invariants 4 and 5), `plan-version-unsupported`, `tarball-mismatch`, `tar-format`, `entry-unlisted`, `entry-mismatch`, `entry-missing` and `limit-exceeded`. There is no option that turns a refusal into a warning. -- **Helpers.** `missingScopes(manifest, granted)` applies §3's scopes rule, so `workers-scripts.admin` covers `workers-scripts.edit`. `releaseAssetUrls(version)` gives the four GitHub download URLs, `https://github.com/cloudhal-dev/cloudhal/releases/download/v/`. Downloading is discovery only, never a trust factor. -- **The second factor.** The subpath `@cloudhal/release-verify/attestation` is Node-only and runs `gh attestation verify` with §2's exact flags for each of the three files: `--signer-workflow`, `--source-ref refs/heads/main`, `--source-digest ` and `--deny-self-hosted-runners`. It reports `verified`, `failed` or `unavailable` (no `gh`, or not signed in). `failed` is always a refusal. -- **Tests.** The real `v0.9.0` statement, signature and manifest are committed as fixtures, about 250 KB of signed, immutable data. The tarball, 7.2 MB, is not: it would be by far the largest file in the repository. Tests on those fixtures check: - - that they verify with the pinned key; - - that a flipped byte in each file is refused; - - that a wrong key, and a revoked or expired one, is refused; - - that a request for another version or channel is refused; - - that a manifest whose `version` differs from the statement's is refused. - - The real tarball is checked by one opt-in test that runs only when `CLOUDHAL_RELEASE_ASSETS` names a directory holding the downloaded assets. Ordinary test runs skip it and never need the network. A separate CI job fills that directory with `gh release download v0.9.0` and runs only that test. The tarball logic itself is covered on a small synthetic bundle signed with a throwaway key, which tests pass as `keys`. Those tests cover: - - a tarball entry that is unlisted, missing or changed; - - a tarball that does not match the statement; - - `planVersion` above what the caller supports; - - every limit in §7. -- **Versioning and publication.** The package joins the fixed version group (ADR-0013). npm trusted publishing can only be configured on a package that already exists. So the **first publish of `@cloudhal/release-verify` and its trusted-publisher entry are the owner's step** (a manual first publish of the first version, then the entry for `release.yml` and the `npm-publish` environment). The release workflow publishes it from then on. - -#### 11b. `cloudhal deploy --release []` - -Today `cloudhal deploy` builds the site with Astro and the admin Worker with wrangler's bundler. With `--release`, it downloads the release's assets, verifies them with `@cloudhal/release-verify`, extracts the verified entries into `.cloudhal/deploy/release//` and deploys those bytes. Everything else stays as it is: provisioning, secrets, the downgrade guard, settings, the scheduler fallback, the first push and the cache purge. - -- **How the bytes are deployed.** The deploy generates the same wrangler configs as today, with `main` set to the extracted module, `no_bundle: true` and module rules for `.mjs`/`.js`, and `assets.directory` set to the extracted assets. `_headers` is written from the manifest's inline text. Wrangler uploads the modules unchanged. A deployer that calls the Workers API directly is not needed for the CLI. Hosts that cannot run wrangler use the verifier and their own uploader. -- **Which version.** `--release` with no value means the CLI's own version. A different version is refused. The CLI's starter, its config generator and its downgrade guard then match the bundle exactly. To install another version, run that version of the CLI (`npx @cloudhal/cli@ deploy --release`). `next` prereleases work the same way. -- **What the bundle can serve.** §10 still applies: theme settings, theme design, block types and skills are baked into the build. The site id is a var (`CLOUDHAL_SITE_ID`), so it is not baked. Before downloading the tarball, the CLI compares the project with the manifest. It refuses, and says to run `cloudhal deploy` without `--release`, when: - - the project's theme is not `manifest.theme.name`/`version`; - - its theme settings or design hash differ from `settingsSha256`/`designSha256`; - - its block types or skills differ; - - its `src/` differs from the starter that this CLI version copies on `cloudhal init`; - - it is attached to another installation (RFC-0034 `installation`) or serves several sites; - - it runs in Workers Builds. - - Everything that a binding or a var carries works as today: analytics, comments, mail, AI, Browser Rendering, domains, the scheduler, the jurisdiction, placement and observability. -- **Verification in the CLI.** The CLI checks the signature and every digest, with no flag to skip them. It verifies the attestation when `gh` is available and refuses when that check fails. When `gh` is unavailable, it prints one line saying that only the release key was checked. `--require-attestation` turns unavailable into a refusal. `--from ` takes the four assets from a directory, for offline machines. The same verification runs on them. -- **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy. `manifest.levels.schedules` feeds the existing downgrade guard. -- **Dry run.** `--release --dry-run` downloads, verifies, extracts and writes the configs, then stops before wrangler. It needs no Cloudflare account. + `verifyRelease` runs steps 1 to 5. It then reads the tarball once to check its size and SHA-256 against the statement, so a tarball that does not match is refused before anything is extracted. + + `entries()` reads the tarball a second time. It yields an entry only after its path passes the checks above and its size and SHA-256 equal its manifest listing. A module entry carries its `type` from the manifest. It throws on an entry the manifest does not list, on anything outside §7's tar format, and, at the end, on a listed entry that never came. + + A consumer may upload verified entries while it iterates. It must not activate a deployment until the iteration has finished without an error. This keeps a Worker deployer within memory: the tarball is at most 100 MiB and is never held whole. +- **Errors.** A `ReleaseVerifyError` carries a stable `code`, which is public API. There is no option that turns a refusal into a warning. The codes: + - `statement-too-large`, `signature-size`, `key-unknown`, `key-revoked`, `signature-invalid`; + - `wrong-version`, `wrong-channel`, `statement-invalid`; + - `manifest-mismatch`, `manifest-invalid`, `statement-manifest-mismatch` (invariants 4 and 5), `plan-version-unsupported`; + - `tarball-mismatch`, `tar-format`, `entry-path`, `entry-unlisted`, `entry-mismatch`, `entry-missing`, `limit-exceeded`. +- **Helpers.** + - `missingScopes(manifest, granted)` compares scope ids exactly, with the one exception §3 defines: `workers-scripts.admin` covers `workers-scripts.edit`. + - `releaseAssetUrls(version)` gives the five download URLs of §1, Sigstore bundle included, under `https://github.com/cloudhal-dev/cloudhal/releases/download/v/`. Downloading is discovery only, never a trust factor. +- **The second factor.** The subpath `@cloudhal/release-verify/attestation` is Node-only. It reports `verified`, `failed` or `unavailable`. + - It first checks that `gh --version` and `gh auth status` both exit 0. If either does not, the result is `unavailable`, and nothing is verified. + - It then runs, for each of `cloudhal-release-.tar.gz`, `manifest.json` and `release-statement.json`: + + ``` + gh attestation verify --repo cloudhal-dev/cloudhal \ + --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml \ + --source-ref refs/heads/main --source-digest \ + --deny-self-hosted-runners [--bundle cloudhal-release-.sigstore.json] + ``` + + `--bundle` is passed when the release's Sigstore bundle is at hand, which it is for every bundled release. + - Any non-zero exit, a signal, or no exit within 60 seconds is `failed`. It is never `unavailable`. +- **Tests.** + - **Real fixtures.** The real `v0.9.0` statement, signature and manifest are committed, about 250 KB of signed, immutable data. Tests check that they verify with the pinned key, and that a flipped byte in each file is refused. They also cover a wrong key, a revoked key, and a request for another version or channel. + - **No real tarball in git.** The tarball (7.2 MB) is not committed: it would be by far the largest file in the repository. + - **Opt-in full test.** One test checks the real tarball. It runs only when `CLOUDHAL_RELEASE_ASSETS` names a directory holding the downloaded assets. Ordinary test runs skip it and never need the network. A separate CI job fills the directory with `gh release download v0.9.0` and runs only that test. + - **Synthetic bundle.** Everything that needs a tampered but validly signed bundle uses a small synthetic bundle, signed with a throwaway key that tests pass as `keys`. Editing the real manifest would only ever reach `manifest-mismatch`. The synthetic bundle covers: + - a manifest whose `version` differs from the statement's (invariant 5); + - an unlisted, missing or changed entry; + - every `entry-path` rule; + - a tarball that does not match the statement; + - `planVersion` above what the caller supports; + - every limit in §7. +- **Versioning and publication.** + - The package joins the fixed version group: `.changeset/config.json` `fixed` and ADR-0013. + - npm trusted publishing can only be configured on a package that already exists, and a version published by hand carries no provenance. `planPublish` would also skip that version from then on. So the **owner's step** is to publish a placeholder: `0.0.0-placeholder.0`, with only a README and `--tag placeholder`, then `npm deprecate` it. Next, add the trusted-publisher entry (`release.yml`, the `npm-publish` environment). + - The first real version then comes from `release.yml` with `--provenance`, like every other package in the group. + +#### 11b. `cloudhal deploy --release` + +Today `cloudhal deploy` builds the site with Astro and the admin Worker with wrangler's bundler. With `--release`, it downloads the release's five assets and verifies them. It then extracts the verified entries and deploys those bytes. Everything else stays as it is: provisioning, secrets, the downgrade guard, settings, the scheduler fallback, the first push and the cache purge. + +- **Which version.** `--release` deploys the CLI's own version and takes no other. The CLI's starter, its config generator and its downgrade guard then match the bundle exactly. To install another version, run that version of the CLI (`npx @cloudhal/cli@ deploy --release`). `next` prereleases work the same way. +- **No bundle, no deploy.** The CLI refuses with `release-unavailable` when `v` has no published release, when the release is still a draft (publish-ci's window between attaching assets and publishing), or when it has no bundle assets (`RELEASE_BUNDLES` off, or `v0.8.0` and older). The message says to run `cloudhal deploy` without `--release`. It never falls back to building. +- **Revocation.** `PINNED_KEYS` is frozen into each published CLI. A stolen key would therefore stay trusted by every CLI already published, and `--release` sends older versions through exactly those CLIs. So **`deploy --release` ships only after #226's signed `keys.json` exists.** + - On every `--release`, the CLI fetches `keys.json` from its stable URL, falling back to the release asset. It verifies it with the root key the CLI pins, checks its `expires`, and applies it reduce-only. + - A `keys.json` that cannot be fetched, does not verify, or has expired is a refusal. There is no override. + - `--from ` needs `keys.json` in that directory too, and the same rules apply. + - The verifier package itself can ship before #226. Its callers pass the keys they trust. +- **Order.** + 1. Steps 1 to 5 of §2, with the keys reduced by `keys.json`. + 2. The tarball digest. + 3. The attestation. + 4. Extraction. + 5. Wrangler. + + Nothing is written outside `.cloudhal/deploy/` and nothing is uploaded before step 3 succeeds. +- **The second factor.** The CLI refuses when the attestation is `failed`. When it is `unavailable`, the CLI also refuses by default and says how to install and sign in to `gh`. `--allow-single-factor` continues on the release key alone, and the CLI prints a line saying so. The flag is never implied, including in CI and non-interactive runs. +- **Extraction.** Into a fresh directory, `.cloudhal/deploy/release//`, which is removed first and created empty. + - Every target is `path.resolve`d and refused unless it lies inside that directory. + - Files are created with exclusive create (`wx`), so a duplicate fails. + - Only verified entries are written, so the directory holds exactly the manifest's listings and no stale files. + - `_headers` is written from the manifest's inline text. +- **How the bytes are deployed.** + - The deploy generates the same wrangler configs as today, with `main` set to the extracted main module and `no_bundle: true`. + - Each module type comes from the manifest. The config lists one `rules` entry per module type, whose globs are that type's exact module paths, so wrangler uploads exactly the listed modules, unchanged. + - `assets.directory` is the extracted assets directory. + - A deployer that calls the Workers API directly is not needed for the CLI. Hosts that cannot run wrangler use the verifier and their own uploader. +- **An unmodified starter.** §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. Instead, before downloading the tarball, the CLI checks that the project is the starter that this CLI version's `cloudhal init` writes, which is the starter the bundle was built from (§4 step 3). It refuses, naming the first difference and saying to run `cloudhal deploy` without `--release`, when any of these hold: + 1. A file the starter template contains is missing or differs, byte for byte. `cloudhal.json` and `package.json`, which `init` fills in, are compared as below instead. + 2. A file exists under `src/` or `public/` that the template does not contain, or a root `astro.config.*`, `tsconfig*.json` or other `*.config.*` file does. + 3. `package.json`'s `dependencies` or `devDependencies` differ from the template's. `name` and `version` may differ. Lockfiles are ignored, because no local build runs. + 4. `cloudhal.json` differs from the template's in a key that is a build input. + - The key list lives in one exported table next to `readProjectConfig`. Each key is marked `deploy` (it becomes a binding, a var or a resource) or `build` (the site or admin build reads it). + - `deploy`: `siteId`, `dataDir`, `domains`, `analytics`, `mail`, `ai`, `render`, `observability`, `placement`, and `schedules` (plan and automations). + - `build`: `comments.available` and the theme settings. `media.transform` is `build` unless the manifest's `features.mediaTransformVar` is true. Every other key is `build`. + - A test fails when `buildSite` or `prepareAdminWorker` reads a key that the table marks `deploy`, or one it does not list. The table therefore cannot drift from the build. + 5. The project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds. + + Paths are compared as normalised relative paths. No pattern runs over file contents. +- **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy, and `manifest.levels.schedules` feeds the existing downgrade guard. +- **Offline and dry runs.** + - `--from ` takes the five assets and `keys.json` from a directory, for offline machines. The same verification runs on them. + - `--release --dry-run` downloads, verifies, extracts and writes the configs, then stops before wrangler. It needs no Cloudflare account. #### 11c. Open points for this addendum -1. Which incremental SHA-256 should run outside Workers: `@noble/hashes`, or our own of about 120 lines? **[default: `@noble/hashes`, audited and zero-dependency, pinned exactly; Workers use the native `crypto.DigestStream`]** +1. Which incremental SHA-256 runs outside Workers: `@noble/hashes`, or our own of about 120 lines? **[default: `@noble/hashes`, audited and zero-dependency, pinned exactly. Workers use the native `crypto.DigestStream`]** 2. Should `--release` accept another version than the CLI's own? **[default: no, until §10's runtime configuration lands]** -3. When `gh` is unavailable, should the CLI refuse by default instead of falling back to the release key alone? **[default: release key alone, with a line saying so; `--require-attestation` refuses]** -4. Where does the full-tarball test run? **[default: opt-in through `CLOUDHAL_RELEASE_ASSETS`, in its own CI job that downloads `v0.9.0`; the 7.2 MB tarball is never committed]** +3. When `gh` is unavailable, does the CLI refuse? **[default: yes. `--allow-single-factor` continues on the release key alone, with a line saying so. A failed attestation is always a refusal]** +4. Where does the full-tarball test run? **[default: opt-in through `CLOUDHAL_RELEASE_ASSETS`, in its own CI job that downloads `v0.9.0`. The 7.2 MB tarball is never committed]** +5. What does a key's `notAfter` mean for releases it has already signed? This is #226's decision 6. Until it is settled, the verifier has no `key-expired` code, and `PINNED_KEYS` carries no dates. **[default: #226's answer]** +6. Should a later release record real `theme` and `skills` values in the manifest, so a host can compare a project without the CLI's starter? **[default: not part of this change. Consumers ignore the placeholders]** ## Alternatives considered From 711b16f069f75ef4faad02f15ea09681c4e815fc Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:21:43 +0300 Subject: [PATCH 3/6] =?UTF-8?q?docs(rfc-0046):=20=C2=A711=20review=20round?= =?UTF-8?q?=202=20(PR=20#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The unmodified-starter check no longer blocks upgrades and rollbacks. - Files are compared with the starter that created the project, through a committed cloudhal.starter.json written by init (a new starter-lock root schema), or, for older projects, through a frozen starter-history.json generated from the tags. - Config build keys are compared with the target, which is the running CLI. - Dependency versions are ignored and only their names are compared, since the bundle is self-contained. - The real 0.9.0 to 0.10.0 case and a rollback are in the test list. - Any file outside the base starter, minus a defined ignore list, is refused. This catches a root skills/ directory. - The config table now has deploy, runtime and build classes, with the runtime overrides stated (CLOUDHAL_SITE_ID, dataDir unused by the cloudflare target, analytics vars). - The drift guards test the builds themselves: a static guard over astro.config.mjs's project keys, override tests, and admin outputs byte-identical across deploy keys. - The gh probe requires gh 2.68.0 or later (source-ref and source-digest) and every flag in --help; an older gh is "unavailable", which --allow-single-factor covers. - Open point 7: --from refuses a keys.json older than 30 days by its signed issuedAt (needs #226's format). Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 83 +++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 840df0a7..c60a6216 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -261,7 +261,13 @@ A new published package, `packages/release-verify`. It is the reference implemen - `missingScopes(manifest, granted)` compares scope ids exactly, with the one exception §3 defines: `workers-scripts.admin` covers `workers-scripts.edit`. - `releaseAssetUrls(version)` gives the five download URLs of §1, Sigstore bundle included, under `https://github.com/cloudhal-dev/cloudhal/releases/download/v/`. Downloading is discovery only, never a trust factor. - **The second factor.** The subpath `@cloudhal/release-verify/attestation` is Node-only. It reports `verified`, `failed` or `unavailable`. - - It first checks that `gh --version` and `gh auth status` both exit 0. If either does not, the result is `unavailable`, and nothing is verified. + - It first probes `gh`. The result is `unavailable`, and nothing is verified, when any of these hold: + - `gh` is missing; + - `gh --version` reports a version below **2.68.0**, the first release with `--source-ref` and `--source-digest` (Ubuntu 24.04 ships 2.45, which has no `gh attestation` at all); + - `gh attestation verify --help` does not list every flag below; + - `gh auth status` does not exit 0. + + The message names the required version. - It then runs, for each of `cloudhal-release-.tar.gz`, `manifest.json` and `release-statement.json`: ``` @@ -298,7 +304,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - **Revocation.** `PINNED_KEYS` is frozen into each published CLI. A stolen key would therefore stay trusted by every CLI already published, and `--release` sends older versions through exactly those CLIs. So **`deploy --release` ships only after #226's signed `keys.json` exists.** - On every `--release`, the CLI fetches `keys.json` from its stable URL, falling back to the release asset. It verifies it with the root key the CLI pins, checks its `expires`, and applies it reduce-only. - A `keys.json` that cannot be fetched, does not verify, or has expired is a refusal. There is no override. - - `--from ` needs `keys.json` in that directory too, and the same rules apply. + - `--from ` needs `keys.json` in that directory too. The same rules apply, plus a freshness limit (§11c, point 7). - The verifier package itself can ship before #226. Its callers pass the keys they trust. - **Order.** 1. Steps 1 to 5 of §2, with the keys reduced by `keys.json`. @@ -319,18 +325,66 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - Each module type comes from the manifest. The config lists one `rules` entry per module type, whose globs are that type's exact module paths, so wrangler uploads exactly the listed modules, unchanged. - `assets.directory` is the extracted assets directory. - A deployer that calls the Workers API directly is not needed for the CLI. Hosts that cannot run wrangler use the verifier and their own uploader. -- **An unmodified starter.** §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. Instead, before downloading the tarball, the CLI checks that the project is the starter that this CLI version's `cloudhal init` writes, which is the starter the bundle was built from (§4 step 3). It refuses, naming the first difference and saying to run `cloudhal deploy` without `--release`, when any of these hold: - 1. A file the starter template contains is missing or differs, byte for byte. `cloudhal.json` and `package.json`, which `init` fills in, are compared as below instead. - 2. A file exists under `src/` or `public/` that the template does not contain, or a root `astro.config.*`, `tsconfig*.json` or other `*.config.*` file does. - 3. `package.json`'s `dependencies` or `devDependencies` differ from the template's. `name` and `version` may differ. Lockfiles are ignored, because no local build runs. - 4. `cloudhal.json` differs from the template's in a key that is a build input. - - The key list lives in one exported table next to `readProjectConfig`. Each key is marked `deploy` (it becomes a binding, a var or a resource) or `build` (the site or admin build reads it). - - `deploy`: `siteId`, `dataDir`, `domains`, `analytics`, `mail`, `ai`, `render`, `observability`, `placement`, and `schedules` (plan and automations). - - `build`: `comments.available` and the theme settings. `media.transform` is `build` unless the manifest's `features.mediaTransformVar` is true. Every other key is `build`. - - A test fails when `buildSite` or `prepareAdminWorker` reads a key that the table marks `deploy`, or one it does not list. The table therefore cannot drift from the build. - 5. The project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds. - - Paths are compared as normalised relative paths. No pattern runs over file contents. +- **An unmodified starter.** §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. + + Instead, the CLI checks two things before it downloads the tarball: + - **Files:** the project has no local changes relative to the starter that *created* it. + - **Config:** the project's build-input config equals the starter of the *target*, which is the running CLI's own version, and which the bundle was built from (§4 step 3). + + The deployed code comes entirely from the bundle, so upgrades and rollbacks only need these two facts, not matching versions. + - **The starter lock.** From the version that ships `--release` on, `cloudhal init` writes `cloudhal.starter.json` at the project root, and the project commits it. It records: + - `starter` (`blog`); + - the `version` that ran `init`; + - the SHA-256 of every file `init` wrote, except `cloudhal.json` and `package.json`; + - the sorted dependency names of `package.json` (`dependencies` and `devDependencies`). + + It is a new public file format: a `starter-lock` root schema in core-contracts (`schemaVersion: 1`), through the contract-change skill. Its format is fixed now, so a CLI older than the one that wrote a lock can read it. A user who edits the lock to get past the check opts out of the check. The lock is a guard against silently dropping customisations, not a security boundary. + - **Projects created before the lock.** The CLI carries a frozen table, `starter-history.json`, with the same per-file hashes for every published version before the lock. It is generated once from the `v*` tags by `node scripts/starter-history.ts` (the starter at each tag, with `init`'s writes applied) and reviewed in the code PR. + - The base version is the exact `@cloudhal/cli` version in the project's `package.json`, which `init` pins. + - A project whose base version is missing, not exact, or not in the table is refused with "cannot tell which starter created this project". + - **The refusals.** The CLI refuses, names the first difference, and says to run `cloudhal deploy` without `--release`, when any of these hold: + 1. A file recorded for the base version is missing or differs from its hash. + 2. A file exists anywhere in the project that the base version did not record and that is not on the ignore list. Ignored are: + - the configured `dataDir` (default `.cloudhal/`); + - `node_modules/`, `.git/`, `dist/`, `.astro/` and `.wrangler/`; + - lockfiles (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock*`); + - `.env*` and `.dev.vars*`; + - `.DS_Store`, `README*`, `LICENSE*`, `.gitignore`, `.github/`, `.vscode/` and `.idea/`; + - `cloudhal.starter.json` itself. + + This catches a root `skills/` directory, which `resolveSkillsDir` prefers and the admin build bakes in, and every other added source. + 3. The dependency names in `package.json` differ from the base version's. Versions are ignored, because `init` pins exact versions per release and nothing from `node_modules` is deployed: the bundle is self-contained. + 4. `cloudhal.json` differs from the target's starter in a key the table marks `build`, or holds a key the target's table does not know. + 5. The project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds. + - **Upgrades and rollbacks.** Both pass the same check: + - the files are compared with the base version, whatever the target is; + - the config is compared with the target, which is the running CLI. + + After an upgrade, the local starter files stay at the base version: `cloudhal dev` serves them, while the deployment runs the target's bundle. Moving local files forward is a separate, later command (`cloudhal starter sync`, not designed here), and it would rewrite the lock. + - **The config table.** The key list lives in one exported table next to `readProjectConfig`. Each key is marked with one of three classes: + - `deploy`: it becomes a binding, a var or a resource, and no build reads it. These are `domains`, `mail`, `ai`, `render`, `observability`, `placement` and `schedules`. + - `runtime`: a build reads it, but the deployed Worker overrides it. + - `siteId`: the Site Worker's `CLOUDHAL_SITE_ID`/`CLOUDHAL_SITE_IDS` vars override the baked id. + - `dataDir`: the `cloudflare` target never reads it. The baked value is the CI build's absolute path. + - `analytics`: the Site Worker reads its vars instead. + - `build`: a build reads it and nothing overrides it. + - `comments` is always `build`. + - `media` is `build` unless the manifest's `features.mediaTransformVar` is true. + - Every other key is `build`. + - **Drift guards.** These are tests of the builds themselves, not of config generation: + - **Site.** The template's `astro.config.mjs` is a lock-covered file, so a project can only carry the template's copy. A repository test scans `starters/blog/astro.config.mjs` for every `project.` it reads, and fails unless that set equals the table's site keys (`siteId`, `dataDir`, `media`, `analytics`, `comments`) with their classes. Two further tests hold the `runtime` overrides: + - a Site Worker test serves a site whose id differs from the baked one, through `CLOUDHAL_SITE_ID`; + - an astro-renderer test shows the `cloudflare` target ignores `dataDir`. If it does not, the integration stops emitting `dataDir` for that target. + - **Admin.** A test runs `prepareAdminWorker` twice, with different `mail`, `ai`, `browser`, `observability`, `placement`, `domains.studio` and `siteId`. The generated entry, the skills module and the theme-design module must be byte-identical; only the wrangler config may differ. The code the admin build bakes in reads only files (block types, theme settings, `src/DESIGN.md`, skills), and the lock covers those files. + - **Tests for the check.** + - **Real 0.9.0 project.** A committed copy of what `cloudhal init` 0.9.0 wrote, generated from the tag, passes against this version's template: the real 0.9.0 → 0.10.0 case. The same project is refused, with the file or key named, after any one of these: + - one `src/` file edited; + - a `skills/x/SKILL.md` added; + - a dependency name added; + - `comments.available: false` set. + - **Rollback.** A project created by this version passes against an older target template. With a `cloudhal.json` key the older target does not know, it is refused. + - **Ignore list.** `.env`, `node_modules/` and the `dataDir` never count. + - **Linear checks.** Paths are compared as normalised relative paths, with no pattern over file contents or paths. - **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy, and `manifest.levels.schedules` feeds the existing downgrade guard. - **Offline and dry runs.** - `--from ` takes the five assets and `keys.json` from a directory, for offline machines. The same verification runs on them. @@ -344,6 +398,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra 4. Where does the full-tarball test run? **[default: opt-in through `CLOUDHAL_RELEASE_ASSETS`, in its own CI job that downloads `v0.9.0`. The 7.2 MB tarball is never committed]** 5. What does a key's `notAfter` mean for releases it has already signed? This is #226's decision 6. Until it is settled, the verifier has no `key-expired` code, and `PINNED_KEYS` carries no dates. **[default: #226's answer]** 6. Should a later release record real `theme` and `skills` values in the manifest, so a host can compare a project without the CLI's starter? **[default: not part of this change. Consumers ignore the placeholders]** +7. With `--from`, an old `keys.json` replays a key list from before a revocation. **[default: `--from` refuses a `keys.json` whose signed `issuedAt` is more than 30 days old, and the remaining window is documented. This needs #226's format to carry `issuedAt`. Online runs always fetch the current list]** ## Alternatives considered From 4c863e5e50d73838d82698cf79eb909a7f209aa5 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:14:36 +0300 Subject: [PATCH 4/6] =?UTF-8?q?docs(rfc-0046):=20=C2=A711b=20theme=20check?= =?UTF-8?q?,=20option=20B=20(PR=20#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner chose the simpler rule: --release installs the official standard theme of this CLI's version, and it does not build the project. - It refuses when src/, public/, astro.config.mjs or a root skills/ differ from this CLI version's own starter. Files are compared byte for byte after CRLF is normalised to LF, and both missing and extra files count. The refusal lists the files. - --discard-local-theme deploys the official theme anyway and lists every file that is not deployed. It is never implied. - A project whose starter files come from an older version needs the flag. `cloudhal starter sync` is named for later and is out of scope. - It still refuses on build-class cloudhal.json keys, which the flag does not cover. The deploy/runtime/build table and its drift guards stay. - It checks for a minimum wrangler version: the one in manifest.toolchain.wrangler, read from the installed wrangler. - Removed: cloudhal.starter.json, starter-history.json, the ignore list and the base-version logic. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 88 +++++++++++++------------------ 1 file changed, 36 insertions(+), 52 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index c60a6216..e9603ed4 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -325,66 +325,50 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - Each module type comes from the manifest. The config lists one `rules` entry per module type, whose globs are that type's exact module paths, so wrangler uploads exactly the listed modules, unchanged. - `assets.directory` is the extracted assets directory. - A deployer that calls the Workers API directly is not needed for the CLI. Hosts that cannot run wrangler use the verifier and their own uploader. -- **An unmodified starter.** §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. - - Instead, the CLI checks two things before it downloads the tarball: - - **Files:** the project has no local changes relative to the starter that *created* it. - - **Config:** the project's build-input config equals the starter of the *target*, which is the running CLI's own version, and which the bundle was built from (§4 step 3). - - The deployed code comes entirely from the bundle, so upgrades and rollbacks only need these two facts, not matching versions. - - **The starter lock.** From the version that ships `--release` on, `cloudhal init` writes `cloudhal.starter.json` at the project root, and the project commits it. It records: - - `starter` (`blog`); - - the `version` that ran `init`; - - the SHA-256 of every file `init` wrote, except `cloudhal.json` and `package.json`; - - the sorted dependency names of `package.json` (`dependencies` and `devDependencies`). - - It is a new public file format: a `starter-lock` root schema in core-contracts (`schemaVersion: 1`), through the contract-change skill. Its format is fixed now, so a CLI older than the one that wrote a lock can read it. A user who edits the lock to get past the check opts out of the check. The lock is a guard against silently dropping customisations, not a security boundary. - - **Projects created before the lock.** The CLI carries a frozen table, `starter-history.json`, with the same per-file hashes for every published version before the lock. It is generated once from the `v*` tags by `node scripts/starter-history.ts` (the starter at each tag, with `init`'s writes applied) and reviewed in the code PR. - - The base version is the exact `@cloudhal/cli` version in the project's `package.json`, which `init` pins. - - A project whose base version is missing, not exact, or not in the table is refused with "cannot tell which starter created this project". - - **The refusals.** The CLI refuses, names the first difference, and says to run `cloudhal deploy` without `--release`, when any of these hold: - 1. A file recorded for the base version is missing or differs from its hash. - 2. A file exists anywhere in the project that the base version did not record and that is not on the ignore list. Ignored are: - - the configured `dataDir` (default `.cloudhal/`); - - `node_modules/`, `.git/`, `dist/`, `.astro/` and `.wrangler/`; - - lockfiles (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock*`); - - `.env*` and `.dev.vars*`; - - `.DS_Store`, `README*`, `LICENSE*`, `.gitignore`, `.github/`, `.vscode/` and `.idea/`; - - `cloudhal.starter.json` itself. - - This catches a root `skills/` directory, which `resolveSkillsDir` prefers and the admin build bakes in, and every other added source. - 3. The dependency names in `package.json` differ from the base version's. Versions are ignored, because `init` pins exact versions per release and nothing from `node_modules` is deployed: the bundle is self-contained. - 4. `cloudhal.json` differs from the target's starter in a key the table marks `build`, or holds a key the target's table does not know. - 5. The project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds. - - **Upgrades and rollbacks.** Both pass the same check: - - the files are compared with the base version, whatever the target is; - - the config is compared with the target, which is the running CLI. - - After an upgrade, the local starter files stay at the base version: `cloudhal dev` serves them, while the deployment runs the target's bundle. Moving local files forward is a separate, later command (`cloudhal starter sync`, not designed here), and it would rewrite the lock. - - **The config table.** The key list lives in one exported table next to `readProjectConfig`. Each key is marked with one of three classes: +- **What `--release` means.** `--release` installs the official standard theme of this CLI's version into the project's Cloudflare account. It does not build the project. The site and admin code come entirely from the bundle, and the project contributes only its content (the first push), its config and its secrets. + + §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. +- **The theme check.** Before downloading the tarball, the CLI compares the project with **this CLI version's own starter**, the template it ships and `cloudhal init` copies, which is the starter the bundle was built from (§4 step 3). The comparison covers: + - every file under `src/` and `public/`; + - `astro.config.mjs`; + - a root `skills/` directory, which the starter does not have. `resolveSkillsDir` prefers it, and the admin build bakes it in. + + Files are compared byte for byte after CRLF is normalised to LF, so a Windows checkout of an unmodified starter passes. A file the starter has that the project lacks is a difference, and so is a file the project has that the starter lacks. Paths are compared as normalised relative paths, with no pattern over paths or contents. Nothing outside these paths is compared, so `README`, `.gitignore`, lockfiles and `wrangler.jsonc` never matter. + + On any difference, the CLI refuses. It lists the differing files (the first 20, then a count) and names the two ways forward: `cloudhal deploy` without `--release`, which builds the project as it is, or `--discard-local-theme`. +- **`--discard-local-theme`.** The flag deploys the official theme anyway. The CLI then lists every file that is not deployed (changed, added or missing relative to the starter) before it uploads. The flag is never implied, including in CI and non-interactive runs. It discards nothing on disk: the project's files stay as they are. +- **Older projects.** A project whose starter files come from an older version differs from this CLI's starter wherever the starter changed between the two versions, even if nobody edited it. Such a project needs `--discard-local-theme` to upgrade or roll back with `--release`. A future `cloudhal starter sync` would bring the local starter files to the CLI's version; it is named here and is out of scope. +- **Config build keys.** Separately from the theme check, the CLI refuses when `cloudhal.json` sets a key the table below marks `build` to a value other than this version's starter's. `--discard-local-theme` does not cover this. The config is the project's own, so the user changes the key or deploys without `--release`. A `cloudhal.json` key this CLI does not know is refused the same way. + - **The table.** It lives in one exported table next to `readProjectConfig`, and each key has one of three classes: - `deploy`: it becomes a binding, a var or a resource, and no build reads it. These are `domains`, `mail`, `ai`, `render`, `observability`, `placement` and `schedules`. - `runtime`: a build reads it, but the deployed Worker overrides it. - `siteId`: the Site Worker's `CLOUDHAL_SITE_ID`/`CLOUDHAL_SITE_IDS` vars override the baked id. - `dataDir`: the `cloudflare` target never reads it. The baked value is the CI build's absolute path. - `analytics`: the Site Worker reads its vars instead. - `build`: a build reads it and nothing overrides it. - - `comments` is always `build`. - - `media` is `build` unless the manifest's `features.mediaTransformVar` is true. + - `comments.available` is always `build`. + - `media.transform` is `build` unless the manifest's `features.mediaTransformVar` is true. - Every other key is `build`. - - **Drift guards.** These are tests of the builds themselves, not of config generation: - - **Site.** The template's `astro.config.mjs` is a lock-covered file, so a project can only carry the template's copy. A repository test scans `starters/blog/astro.config.mjs` for every `project.` it reads, and fails unless that set equals the table's site keys (`siteId`, `dataDir`, `media`, `analytics`, `comments`) with their classes. Two further tests hold the `runtime` overrides: + - **Drift guards.** These test the builds themselves, not config generation. + - **Site.** A repository test scans `starters/blog/astro.config.mjs` for every `project.` it reads. It fails unless that set equals the table's site keys (`siteId`, `dataDir`, `media`, `analytics`, `comments`) with their classes. The theme check guarantees that a project deployed with `--release` carries this exact file. Two further tests hold the `runtime` overrides: - a Site Worker test serves a site whose id differs from the baked one, through `CLOUDHAL_SITE_ID`; - - an astro-renderer test shows the `cloudflare` target ignores `dataDir`. If it does not, the integration stops emitting `dataDir` for that target. - - **Admin.** A test runs `prepareAdminWorker` twice, with different `mail`, `ai`, `browser`, `observability`, `placement`, `domains.studio` and `siteId`. The generated entry, the skills module and the theme-design module must be byte-identical; only the wrangler config may differ. The code the admin build bakes in reads only files (block types, theme settings, `src/DESIGN.md`, skills), and the lock covers those files. - - **Tests for the check.** - - **Real 0.9.0 project.** A committed copy of what `cloudhal init` 0.9.0 wrote, generated from the tag, passes against this version's template: the real 0.9.0 → 0.10.0 case. The same project is refused, with the file or key named, after any one of these: - - one `src/` file edited; - - a `skills/x/SKILL.md` added; - - a dependency name added; - - `comments.available: false` set. - - **Rollback.** A project created by this version passes against an older target template. With a `cloudhal.json` key the older target does not know, it is refused. - - **Ignore list.** `.env`, `node_modules/` and the `dataDir` never count. - - **Linear checks.** Paths are compared as normalised relative paths, with no pattern over file contents or paths. + - an astro-renderer test shows that the `cloudflare` target ignores `dataDir`. If it does not, the integration stops emitting `dataDir` for that target. + - **Admin.** A test runs `prepareAdminWorker` twice, with different `mail`, `ai`, `browser`, `observability`, `placement`, `domains.studio` and `siteId`. The generated entry, the skills module and the theme-design module must be byte-identical; only the wrangler config may differ. The code the admin build bakes in reads only files (block types, theme settings, `src/DESIGN.md`, skills), and the theme check covers those files. +- **Other refusals.** The CLI also refuses when the project is attached to another installation (RFC-0034 `installation`), serves several sites (`sites`), or runs in Workers Builds. +- **Wrangler.** `--release` needs a wrangler at least as new as the one the bundle was built with (`manifest.toolchain.wrangler`), because the generated configs use its asset and module settings. The CLI reads the version of the wrangler it will run, resolved the way `deploy` resolves it today, from that wrangler's own `package.json`. An older wrangler is refused, and the refusal names the required version. The starter only declares a range (`wrangler ^4.158.0`), so the check trusts the installed version, never the range. +- **Tests for the check.** + - An unmodified starter passes, also with CRLF line endings. + - Each of these is refused, naming the file: + - an edited `src/` file; + - an added `public/` file; + - a starter file deleted; + - an edited `astro.config.mjs`; + - an added `skills/x/SKILL.md`. + - `--discard-local-theme` turns each of those refusals into a deploy that lists the files not deployed. + - `comments.available: false` is refused with and without the flag. So is `media.transform` while `features.mediaTransformVar` is false. + - Edits to `README.md`, `.gitignore` or `wrangler.jsonc` pass. + - A starter from the previous version's tag is refused without the flag and passes with it, which covers the upgrade and rollback rule. + - A wrangler older than `manifest.toolchain.wrangler` is refused. - **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy, and `manifest.levels.schedules` feeds the existing downgrade guard. - **Offline and dry runs.** - `--from ` takes the five assets and `keys.json` from a directory, for offline machines. The same verification runs on them. From 3c17ac78702cc429d32681ff370854d5c7957d79 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:25:39 +0300 Subject: [PATCH 5/6] =?UTF-8?q?docs(rfc-0046):=20=C2=A711b=20review=20fixe?= =?UTF-8?q?s=20for=20option=20B=20(PR=20#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip OS metadata files (.DS_Store, Thumbs.db, desktop.ini) by exact basename under the compared paths; test public/fonts/v1/.DS_Store. - The key table has no fallback class. search, comments.rateLimit and the analytics budget, retention and rate-limit keys are deploy. A type mapped over ProjectConfig (+ media) and its mixed sub-keys, plus tests over OWN_KEYS and the integration's option sub-keys, fail on any key left unclassed. - Root build inputs, checked against the installed Astro 7.4.7 and Vite 8.3.5: - tsconfig.json is compared with the starter; - jsconfig.json, every PostCSS config Vite loads, and a package.json postcss key must not exist; - vite.config.* is not loaded (Astro passes configFile: false); - .env* cannot bake anything into the starter's code. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 52 ++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index e9603ed4..45644a32 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -330,27 +330,48 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra §10 still applies: the bundle bakes in its build inputs. In `v0.9.0` the manifest's `theme.blockTypes`, `settingsSha256`, `designSha256` and `skills` are placeholders: an empty list and the SHA-256 of the empty string. Consumers ignore those fields, and no check reads them. - **The theme check.** Before downloading the tarball, the CLI compares the project with **this CLI version's own starter**, the template it ships and `cloudhal init` copies, which is the starter the bundle was built from (§4 step 3). The comparison covers: - every file under `src/` and `public/`; - - `astro.config.mjs`; - - a root `skills/` directory, which the starter does not have. `resolveSkillsDir` prefers it, and the admin build bakes it in. + - the root build inputs the starter has: `astro.config.mjs`, and `tsconfig.json` (Astro reads its `paths`, and the transform reads its `compilerOptions`); + - root build inputs the starter does not have, which must not exist: + - a `skills/` directory. `resolveSkillsDir` prefers it, and the admin build bakes it in. + - `jsconfig.json`. + - any PostCSS config Vite loads: `postcss.config.{js,cjs,mjs,ts,cts,mts}`, `.postcssrc`, `.postcssrc.{json,yaml,yml,js,cjs,mjs,ts,cts,mts}`, and a `postcss` key in `package.json`. - Files are compared byte for byte after CRLF is normalised to LF, so a Windows checkout of an unmodified starter passes. A file the starter has that the project lacks is a difference, and so is a file the project has that the starter lacks. Paths are compared as normalised relative paths, with no pattern over paths or contents. Nothing outside these paths is compared, so `README`, `.gitignore`, lockfiles and `wrangler.jsonc` never matter. + The list was checked against the installed Astro 7.4.7 and Vite 8.3.5. + - Astro passes `configFile: false` to Vite (`create-vite.js`), so a `vite.config.*` is never loaded and is not compared. + - The PostCSS names are the search places of the `postcss-load-config` 6.0.1 that Vite bundles. + - `.env*` files are not compared. Vite loads them, but only code that reads a variable can bake it in, and the code is the starter's, which reads no `import.meta.env`. + - The list is rechecked on every Astro or Vite major upgrade, and the code PR adds that step to the upgrade notes next to the list. + + Files are compared byte for byte after CRLF is normalised to LF, so a Windows checkout of an unmodified starter passes. A file the starter has that the project lacks is a difference, and so is a file the project has that the starter lacks. + + Three operating-system metadata files are skipped wherever they appear under the compared paths: `.DS_Store`, `Thumbs.db` and `desktop.ini`. They are matched by exact basename, with no patterns, and the starter never contains them. A Finder visit to `public/` must not teach anyone to reach for the flag. + + Paths are compared as normalised relative paths, with no pattern over paths or contents. Nothing outside these paths is compared, so `README`, `.gitignore`, lockfiles and `wrangler.jsonc` never matter. On any difference, the CLI refuses. It lists the differing files (the first 20, then a count) and names the two ways forward: `cloudhal deploy` without `--release`, which builds the project as it is, or `--discard-local-theme`. - **`--discard-local-theme`.** The flag deploys the official theme anyway. The CLI then lists every file that is not deployed (changed, added or missing relative to the starter) before it uploads. The flag is never implied, including in CI and non-interactive runs. It discards nothing on disk: the project's files stay as they are. - **Older projects.** A project whose starter files come from an older version differs from this CLI's starter wherever the starter changed between the two versions, even if nobody edited it. Such a project needs `--discard-local-theme` to upgrade or roll back with `--release`. A future `cloudhal starter sync` would bring the local starter files to the CLI's version; it is named here and is out of scope. - **Config build keys.** Separately from the theme check, the CLI refuses when `cloudhal.json` sets a key the table below marks `build` to a value other than this version's starter's. `--discard-local-theme` does not cover this. The config is the project's own, so the user changes the key or deploys without `--release`. A `cloudhal.json` key this CLI does not know is refused the same way. - - **The table.** It lives in one exported table next to `readProjectConfig`, and each key has one of three classes: - - `deploy`: it becomes a binding, a var or a resource, and no build reads it. These are `domains`, `mail`, `ai`, `render`, `observability`, `placement` and `schedules`. - - `runtime`: a build reads it, but the deployed Worker overrides it. + - **The table.** It lives in one exported table next to `readProjectConfig`. Every key and every sub-key that a reader uses has an explicit class. There is no fallback class: a key the table does not class is a drift-test failure, and it is never assumed. The classes: + - `deploy`: the key becomes a binding, a var or a resource, and no build reads it. + - `domains`, `mail`, `ai`, `render`, `observability`, `placement`, `schedules`. + - `search`, which feeds only `searchDeploymentOf`. + - `comments.rateLimit`, which feeds only `commentsRateLimitDeployment`. + - `analytics.queryBudget`, `analytics.rawRetentionDays` and `analytics.rateLimit`. + - `runtime`: a build reads the key, but the deployed Worker overrides it. - `siteId`: the Site Worker's `CLOUDHAL_SITE_ID`/`CLOUDHAL_SITE_IDS` vars override the baked id. - `dataDir`: the `cloudflare` target never reads it. The baked value is the CI build's absolute path. - - `analytics`: the Site Worker reads its vars instead. - - `build`: a build reads it and nothing overrides it. - - `comments.available` is always `build`. - - `media.transform` is `build` unless the manifest's `features.mediaTransformVar` is true. - - Every other key is `build`. + - `analytics.enabled`, `analytics.uniqueVisitors` and `analytics.vitalsOneIn`: the Site Worker reads its vars instead. + - `analytics.dev`: only `cloudhal dev` reads it. + - `build`: a build reads the key and nothing overrides it. + - `comments.available`. + - `media.transform`, unless the manifest's `features.mediaTransformVar` is true; then it is `runtime`. + - `refused`: `sites` and `installation`. The other refusals below handle them. - **Drift guards.** These test the builds themselves, not config generation. - - **Site.** A repository test scans `starters/blog/astro.config.mjs` for every `project.` it reads. It fails unless that set equals the table's site keys (`siteId`, `dataDir`, `media`, `analytics`, `comments`) with their classes. The theme check guarantees that a project deployed with `--release` carries this exact file. Two further tests hold the `runtime` overrides: + - **Every key is classed.** The table's type is mapped over `ProjectConfig`, plus `media`, which only `astro.config.mjs` reads. Objects with mixed classes (`analytics`, `comments`, `media`) map over their sub-keys, so `tsc` fails on a key or sub-key that has no class. A test also checks: + - that every key in `config.ts`'s `OWN_KEYS` is a table key; + - that every sub-key the `cloudhal()` integration's option types declare for `media`, `analytics` and `comments` is classed. + - **Site.** A repository test scans `starters/blog/astro.config.mjs` for every `project.` it reads. It fails unless every such key is in the table. For `media`, `analytics` and `comments`, which the file passes to the integration whole, the sub-key check above applies. The theme check guarantees that a project deployed with `--release` carries this exact file. Two further tests hold the `runtime` overrides: - a Site Worker test serves a site whose id differs from the baked one, through `CLOUDHAL_SITE_ID`; - an astro-renderer test shows that the `cloudflare` target ignores `dataDir`. If it does not, the integration stops emitting `dataDir` for that target. - **Admin.** A test runs `prepareAdminWorker` twice, with different `mail`, `ai`, `browser`, `observability`, `placement`, `domains.studio` and `siteId`. The generated entry, the skills module and the theme-design module must be byte-identical; only the wrangler config may differ. The code the admin build bakes in reads only files (block types, theme settings, `src/DESIGN.md`, skills), and the theme check covers those files. @@ -366,6 +387,13 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - an added `skills/x/SKILL.md`. - `--discard-local-theme` turns each of those refusals into a deploy that lists the files not deployed. - `comments.available: false` is refused with and without the flag. So is `media.transform` while `features.mediaTransformVar` is false. + - Each of these is refused, naming it: + - an edited `tsconfig.json`; + - an added `postcss.config.mjs` or `.postcssrc.json`; + - a `postcss` key in `package.json`; + - an added `jsconfig.json`. + - A `public/fonts/v1/.DS_Store`, a `Thumbs.db` or a `desktop.ini` under `src/` passes, and so do a `vite.config.ts` and a `.env`. + - A project with `search` set, or with `comments.rateLimit` set, passes the config check. - Edits to `README.md`, `.gitignore` or `wrangler.jsonc` pass. - A starter from the previous version's tag is refused without the flag and passes with it, which covers the upgrade and rollback rule. - A wrangler older than `manifest.toolchain.wrangler` is refused. From 7b27bf9bef925a2b61de57f97c12fc1cf59d648b Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:35:05 +0300 Subject: [PATCH 6/6] docs(rfc-0046): search PostCSS configs up to Vite's workspace root (PR #230) Vite 8.3.5's resolvePostcssConfig searches upward from the project root and stops at searchForWorkspaceRoot: the nearest pnpm-workspace.yaml, lerna.json, package.json with workspaces, or deno workspace. Without one it falls back to the nearest package.json. The --release check now searches the same directories, so a monorepo's root PostCSS config cannot be dropped silently. There are test cases for a pnpm workspace root postcss.config.cjs, an intermediate package.json postcss key, and a config above the stop directory. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 45644a32..86bd8ff2 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -334,7 +334,10 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - root build inputs the starter does not have, which must not exist: - a `skills/` directory. `resolveSkillsDir` prefers it, and the admin build bakes it in. - `jsconfig.json`. - - any PostCSS config Vite loads: `postcss.config.{js,cjs,mjs,ts,cts,mts}`, `.postcssrc`, `.postcssrc.{json,yaml,yml,js,cjs,mjs,ts,cts,mts}`, and a `postcss` key in `package.json`. + - any PostCSS config Vite loads: `postcss.config.{js,cjs,mjs,ts,cts,mts}`, `.postcssrc`, `.postcssrc.{json,yaml,yml,js,cjs,mjs,ts,cts,mts}`, and a `postcss` key in `package.json`. Vite searches for these upward, so the CLI does too. + - It checks every directory from the project root up to and including Vite's workspace root. That is the nearest directory with a `pnpm-workspace.yaml` or `lerna.json`, a `package.json` with `workspaces`, or a `deno.json`/`deno.jsonc` with `workspace`. + - Without one, it stops at the project root: Vite falls back to the nearest `package.json`, which is the project's own. + - This is Vite 8.3.5's own rule (`resolvePostcssConfig` with `stopDir` from `searchForWorkspaceRoot`). `cloudhal init` supports projects inside an existing workspace, so a monorepo's root PostCSS config would otherwise apply to a local deploy and be dropped silently by `--release`. The list was checked against the installed Astro 7.4.7 and Vite 8.3.5. - Astro passes `configFile: false` to Vite (`create-vite.js`), so a `vite.config.*` is never loaded and is not compared. @@ -390,9 +393,12 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - Each of these is refused, naming it: - an edited `tsconfig.json`; - an added `postcss.config.mjs` or `.postcssrc.json`; + - a project inside a pnpm workspace whose root has a `postcss.config.cjs`; + - a `postcss` key in the `package.json` of a directory between the project and that workspace root; - a `postcss` key in `package.json`; - an added `jsconfig.json`. - A `public/fonts/v1/.DS_Store`, a `Thumbs.db` or a `desktop.ini` under `src/` passes, and so do a `vite.config.ts` and a `.env`. + - A `postcss.config.cjs` above the workspace root, or above a standalone project, passes, because Vite never reads it. - A project with `search` set, or with `comments.rateLimit` set, passes the config check. - Edits to `README.md`, `.gitignore` or `wrangler.jsonc` pass. - A starter from the previous version's tag is refused without the flag and passes with it, which covers the upgrade and rollback rule.