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
20 changes: 20 additions & 0 deletions .changeset/deploy-release-227.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@cloudhal/cli": minor
"@cloudhal/release-verify": patch
"@cloudhal/astro-renderer": patch
---

`cloudhal deploy --release` installs the signed release of the CLI's own version instead of building the project (RFC-0046 §11b, #227).

- **Trust lists.** On every run it fetches the signed key list and yank list from cloudhal.dev and verifies them against the pinned root. It never falls back to an older copy. It keeps a yank memory in `~/.cloudhal/release-trust.json`, so a rolled-back list, a replayed key list or a revoked yank key is refused.
- **Before the root ceremony** no root is pinned, so the command refuses and names the ceremony.
- **The release.** The release must be published (not a draft) and carry a bundle; `v0.8.0` and older have none. The CLI verifies it with `@cloudhal/release-verify`: the keys reduced by both lists, yanked versions refused, and every file checked against its digest.
- **The attestation.** It checks the build-provenance attestation with `gh`. `--allow-single-factor` continues on the release key alone when `gh` is unavailable; a failed attestation is always refused.
- **The project.** Its theme files must equal the CLI's own starter, unless `--discard-local-theme` is given. Its `cloudhal.json` build keys must match the starter's, a key the CLI does not know is refused, and the installed wrangler must be at least the bundle's.
- **Refused projects.** Projects attached to another installation, multi-site projects and Workers Builds are refused.
- **Deploying.** The verified files are extracted into a fresh `.cloudhal/deploy/release/<version>/`. Both Workers are then deployed with `no_bundle` wrangler configs that upload exactly those bytes.
- **Offline and dry runs.** `--from <dir>` installs offline from the assets and both lists, and `--release --dry-run` stops before wrangler.

The CLI now depends on `@cloudhal/release-verify`.

`@cloudhal/release-verify` now typechecks under every consumer's lib settings: WebCrypto inputs are `ArrayBuffer`-backed views, and the gzip pipe is typed through explicitly. `@cloudhal/astro-renderer` names the Site Worker's single-site choice `singleSiteId`, so a test holds that `CLOUDHAL_SITE_ID` overrides the built id.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ node scripts/release-bundle.ts build|verify … # RFC-0046 release bundle: det
- `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.
- `packages/astro-renderer` — `GraphSource` (live/snapshot), `cloudhal()` integration (virtual config + dev route-cache watcher), `.astro` section components (`Surface.astro` for RFC-0020 surfaces; `./surfaces` renders them, `./kit` has the table of contents and reading time; `./fields` and `EntryFields.astro` render typed field values, RFC-0041). `./status-page` is RFC-0043's status pages (`statusPage(state, input) → Response`, served by the Site Worker entry before Astro and by `cloudhal dev` at `/__cloudhal/status/<state>`; the optional `statusPage` theme hook; the catalog in `src/status-strings/` where only `en.ts` is hand-edited and the other languages come from `pnpm i18n:import`). Routes live in starters, not here.
- `packages/cli` — the `cloudhal` CLI (`init`, `dev`, `studio`, `mcp`, `deploy`, `push`, `pull`, `export`, `import`, …; every command is in `src/cli/help.ts`, which `cloudhal --help` prints and the docs site's CLI reference is generated from); TS bin run by Node 24 type stripping. `src/cli.ts` parses flags and dispatches through `COMMANDS`, built from `src/cli/commands-{local,remote,content,ops,site}.ts` (a new command is one handler plus one line in `src/cli/help.ts`). `src/archive/` is RFC-0039's `.cloudhal` container (tar, gzip, manifest, limits), read and written by `src/commands/export.ts` and `import*.ts` (`archive-bundle.ts`, `archive-reassemble.ts`); the starter demo archive `test/fixtures/site-archives/starter-blog.cloudhal` is written by `pnpm demo:archive`, and `pnpm check` fails when it is stale. E2E test spawns the bin (Astro disables dev middleware under `VITEST`). Cloudflare tests run both Workers on workerd via wrangler's `createTestHarness`; test files run serially (shared starter caches).
- `packages/cli` — the `cloudhal` CLI (`init`, `dev`, `studio`, `mcp`, `deploy`, `push`, `pull`, `export`, `import`, …; every command is in `src/cli/help.ts`, which `cloudhal --help` prints and the docs site's CLI reference is generated from); TS bin run by Node 24 type stripping. `src/cli.ts` parses flags and dispatches through `COMMANDS`, built from `src/cli/commands-{local,remote,content,ops,site}.ts` (a new command is one handler plus one line in `src/cli/help.ts`). `src/archive/` is RFC-0039's `.cloudhal` container (tar, gzip, manifest, limits), read and written by `src/commands/export.ts` and `import*.ts` (`archive-bundle.ts`, `archive-reassemble.ts`); the starter demo archive `test/fixtures/site-archives/starter-blog.cloudhal` is written by `pnpm demo:archive`, and `pnpm check` fails when it is stale. E2E test spawns the bin (Astro disables dev middleware under `VITEST`). Cloudflare tests run both Workers on workerd via wrangler's `createTestHarness`; test files run serially (shared starter caches). `src/commands/deploy/release/` is RFC-0046 §11b's `deploy --release`: the signed key and yank lists (`trust.ts`, the yank memory in `~/.cloudhal/release-trust.json`), the release lookup and download, the theme check against the CLI's starter (`theme-check.ts`; recheck its build-input list on every Astro or Vite major), the build keys of `src/config-build-keys.ts`, extraction and the `no_bundle` wrangler configs; tests use `@cloudhal/release-verify`'s synthetic bundle (`test/fixtures/release-deploy.ts`), never the network.
- `packages/cloudflare` — the admin Worker (`createAdminWorker`); `./playground` is RFC-0036's playground Worker (`createPlaygroundWorker`, the `PlaygroundSite` Durable Object, signed session cookies). Generated entries must import `@cloudhal/cloudflare/workerd-prelude` first (ADR-0012).
- `packages/agent-runtime` / `packages/mcp-server` — policy, skills, audit; MCP tools and transports (ADR-0011). `@cloudhal/mcp-server/render` is RFC-0021's `render_page` core: a small DevTools client, the in-page checks and limits; the CLI drives local Chrome with it (`render-chrome.ts`), the admin Worker Browser Rendering (`browser-render.ts`).
- `starters/blog` — the theme boundary: `src/pages/[...path].astro`, `src/layouts`, `src/theme.ts`.
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/0046-release-bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra
- 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.
The list was checked against the installed Astro 7.4.7 and Vite 8.3.5. It lives in `packages/cli/src/commands/deploy/release/theme-check.ts`, whose file comment and AGENTS.md carry the upgrade step.
- 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`.
Expand Down
32 changes: 32 additions & 0 deletions docs/start/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,38 @@ npm install @cloudhal/cli@latest @cloudhal/core-ts@latest @cloudhal/astro-render

Then run `npx cloudhal deploy` to update a deployment. Release notes are in each package's changelog on npm and GitHub.

### Installing the signed release

`npx cloudhal deploy --release` installs the signed release of the CLI's own version instead of building the project (RFC-0046 §11b). It deploys the exact bytes that `release.yml` built and signed, so your machine runs no Astro or Worker build. Your content, config and secrets still come from the project. It takes no version argument: to install another version, run that version of the CLI (`npx @cloudhal/cli@<version> deploy --release`).

**What it checks, in this order.** Nothing is uploaded, and nothing is written outside `.cloudhal/deploy/`, until all of these pass.

1. **The project.**
- It must not join another installation (`installation`) or serve several sites (`sites`).
- It must not run in Cloudflare Workers Builds.
- The version must have a bundle: `v0.9.0` or newer.
2. **The signed key and yank lists** from cloudhal.dev, on every run. It never falls back to an older copy. It remembers what it has seen in `~/.cloudhal/release-trust.json` and refuses a rolled-back list, a replayed key list or a revoked yank key.
3. **The release** must be published (not a draft) with its bundle files. Set `GH_TOKEN` if GitHub's API rate limit gets in the way.
4. **The theme files** must equal the CLI's own starter:
- everything under `src/` and `public/`;
- `astro.config.mjs` and `tsconfig.json`;
- no `skills/` directory (even an empty one) and no `jsconfig.json`;
- no PostCSS config (`postcss.config.*`, `.postcssrc*`, or a `postcss` key in `package.json`) from the project up to its workspace root.

Line endings and `.DS_Store`, `Thumbs.db` and `desktop.ini` are ignored, and so are `README.md`, `.gitignore`, `vite.config.*`, `.env` and `wrangler.jsonc`.

On a difference, either run `cloudhal deploy` without `--release`, or add `--discard-local-theme` to deploy the official theme anyway. The flag lists every file it does not deploy, and changes nothing on disk.
5. **`cloudhal.json` build keys.** `comments.available` must not be `false`. `media.transform` must be unset or `"none"`, unless the release reads it at runtime. Any key the CLI does not know is refused. `--discard-local-theme` does not cover these.
6. **Wrangler** must be at least the version the release was built with.
7. **The release's signature and every file's digest**, with revoked keys and yanked versions refused.
8. **The build-provenance attestation.** It needs the [GitHub CLI](https://cli.github.com) 2.68 or newer, signed in. `--allow-single-factor` continues on the release key alone when `gh` is missing; a failed attestation is always refused.

**Offline.** `--from <dir>` installs from the five release assets plus `keys.json` and `yanked.json` (each with its `.sig`) in a directory, under the same rules.

**Dry run.** `--release --dry-run` verifies, extracts and writes the configs, then stops before wrangler.

`--release` is available once the signed key list is published (the root ceremony in [Release signing keys](/docs/security/release-signing-keys)). Until then the command refuses and says so.

## Next

- [Quickstart](/docs/start/quickstart): run the site, open Studio, connect an agent, publish.
Expand Down
10 changes: 8 additions & 2 deletions packages/astro-renderer/src/cloudflare-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ import { EMBED_HEADER, withEmbedFrames } from "./embed-client.ts";
import { hostRedirect, type RedirectEnv } from "./host-redirect.ts";
import { PREVIEW_PARAM } from "./resolver.ts";
import { SearchLimiter, searchCost } from "./search-limit.ts";
import { type SiteChoice, SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts";
import {
type SiteChoice,
SiteDispatch,
singleSiteId,
unknownHost,
withNoindex,
} from "./site-dispatch.ts";
import {
errorPage,
HEALTH_PATH,
Expand Down Expand Up @@ -174,7 +180,7 @@ const statusHook = themeStatusHook(loadTheme, (m, e) => console.error(m, e));
/** One per isolate: which site a request is for (RFC-0034 §4). */
function siteDispatch(env: Env, siteIdFromBuild: string | undefined): SiteDispatch {
dispatch ??= new SiteDispatch({
siteId: env.CLOUDHAL_SITE_ID ?? (env.CLOUDHAL_SITE_IDS ? undefined : siteIdFromBuild),
siteId: singleSiteId(env, siteIdFromBuild),
siteIds: env.CLOUDHAL_SITE_IDS,
sql: () => new D1Driver(env.DB as D1Database),
});
Expand Down
13 changes: 13 additions & 0 deletions packages/astro-renderer/src/site-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
import type { SqlDriver } from "@cloudhal/storage";
import { parseSiteIds, SiteHostMap } from "./site-hosts.ts";

/**
* The single site a Site Worker serves: its `CLOUDHAL_SITE_ID` var first, so a deployment can
* serve a site other than the one its bundle was built for (RFC-0046 §11b: `deploy --release`
* installs a bundle built with another site id); else, unless `CLOUDHAL_SITE_IDS` makes it
* multi-site, the build's.
*/
export function singleSiteId(
env: { readonly CLOUDHAL_SITE_ID?: string; readonly CLOUDHAL_SITE_IDS?: string },
siteIdFromBuild: string | undefined,
): string | undefined {
return env.CLOUDHAL_SITE_ID ?? (env.CLOUDHAL_SITE_IDS ? undefined : siteIdFromBuild);
}

export type SiteChoice =
| { readonly kind: "site"; readonly siteId: string | undefined; readonly noindex?: boolean }
| { readonly kind: "unknown" };
Expand Down
41 changes: 41 additions & 0 deletions packages/astro-renderer/test/site-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Which site a Site Worker serves (RFC-0034 §4), and the RFC-0046 §11b drift guard behind
* `deploy --release`: a bundle is built with one site id, and the deployment's `CLOUDHAL_SITE_ID`
* var must override it, or every installed project would serve the CI build's site.
*/
import { describe, expect, it } from "vitest";
import { SiteDispatch, singleSiteId } from "../src/site-dispatch.ts";

const noSql = () => {
throw new Error("a single-site Worker never reads site_hosts");
};

describe("the Site Worker's site", () => {
it("serves CLOUDHAL_SITE_ID over the id the bundle was built with", async () => {
const env = { CLOUDHAL_SITE_ID: "site_project1" };
expect(singleSiteId(env, "site_ci00build")).toBe("site_project1");
const d = new SiteDispatch({
siteId: singleSiteId(env, "site_ci00build"),
siteIds: undefined,
sql: noSql,
});
expect(await d.choose(new Request("https://anything.example.com/"))).toEqual({
kind: "site",
siteId: "site_project1",
});
});

it("falls back to the built id alone, and to none when CLOUDHAL_SITE_IDS makes it multi-site", () => {
expect(singleSiteId({}, "site_ci00build")).toBe("site_ci00build");
expect(
singleSiteId({ CLOUDHAL_SITE_IDS: "site_a00001,site_b00002" }, "site_ci00build"),
).toBeUndefined();
// An explicit single id still wins over the list.
expect(
singleSiteId(
{ CLOUDHAL_SITE_ID: "site_a00001", CLOUDHAL_SITE_IDS: "site_b00002" },
undefined,
),
).toBe("site_a00001");
});
});
12 changes: 12 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,22 @@ cloudhal dev live preview (drafts visible)
cloudhal studio API + Studio UI (+ /mcp) for this project
cloudhal mcp [--mode plan|manual|edits|auto] MCP server over stdio for this project
cloudhal deploy site + admin Workers on Cloudflare
cloudhal deploy --release install this CLI version's signed release (no build)
cloudhal push / cloudhal pull local site ↔ deployment
cloudhal export / cloudhal import one site ↔ a .cloudhal file
```

**`deploy --release`** (RFC-0046 §11b) installs the signed release of the CLI's own version instead of building. It verifies the signed key and yank lists from cloudhal.dev (keeping a yank memory in `~/.cloudhal/release-trust.json`), the release's signature and digests, and the build attestation (`gh` 2.68+, or `--allow-single-factor`).

It refuses when:
- the project joins another installation, serves several sites, or runs in Workers Builds;
- the version has no published bundle (`v0.8.0` and older, drafts);
- the theme files (`src/`, `public/`, `astro.config.mjs`, `tsconfig.json`, any `skills/`, `jsconfig.json` or PostCSS config) differ from the CLI's starter, unless `--discard-local-theme` is given, which then lists every file not deployed;
- a `cloudhal.json` build key differs from the starter's, or a key is unknown (the flag does not cover these);
- the installed wrangler is older than the release's.

`--from <dir>` installs offline, and `--release --dry-run` stops before wrangler. Full list: [Installation › Installing the signed release](https://cloudhal.dev/docs/start/installation).

**Studio build.** The published CLI ships Studio prebuilt. Run from the monorepo, `cloudhal deploy` uploads `apps/studio/dist` and first checks that it is current: when it is missing, or its `index.html` is older than the newest file in `apps/studio/src`, `apps/studio/index.html` or `packages/ui/src`, it runs `pnpm --filter @cloudhal/studio build`. With `--no-build-studio` it never builds and fails on a missing or stale build instead.

### Schedules (RFC-0008)
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cloudhal/cli",
"version": "0.9.0",
"description": "cloudhal — create, run and build Cloudhal sites locally",
"description": "cloudhal \u2014 create, run and build Cloudhal sites locally",
"license": "Apache-2.0",
"homepage": "https://cloudhal.dev",
"repository": {
Expand Down Expand Up @@ -38,6 +38,7 @@
"@cloudhal/core-contracts": "workspace:*",
"@cloudhal/core-ts": "workspace:*",
"@cloudhal/mcp-server": "workspace:*",
"@cloudhal/release-verify": "workspace:*",
"@cloudhal/schedules": "workspace:*",
"@cloudhal/storage": "workspace:*",
"@hono/node-server": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export const OPTIONS = {
plan: { type: "string" },
"force-downgrade": { type: "boolean" },
"no-build-studio": { type: "boolean" },
release: { type: "boolean" },
"allow-single-factor": { type: "boolean" },
"discard-local-theme": { type: "boolean" },
"cancel-schedules": { type: "boolean" },
limit: { type: "string" },
models: { type: "string" },
Expand Down
Loading
Loading