From f41802b8ea851d481e14264cbdc16b2404d94202 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:40:30 +0300 Subject: [PATCH 1/3] feat(cli): cloudhal deploy --release installs the signed release (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0046 §11b with the §9 amendments: instead of building, install this CLI version's signed release. - The signed key and yank lists, from cloudhal.dev on every run or from --from, verified with the pinned root and checked against the CLI's yank memory (~/.cloudhal/release-trust.json); no fallback, no override. Before the root ceremony no root is pinned: refused, naming the ceremony. - The release: published (not a draft) with a bundle, v0.9.0 or newer; verifyRelease with the keys reduced by both lists and yanks applied; the build attestation (--allow-single-factor when gh is unavailable). - Before the tarball: the theme check against the CLI's starter (PostCSS up to Vite's workspace root; --discard-local-theme), the cloudhal.json build keys (config-build-keys.ts, a table mapped over ProjectConfig), and the bundle's minimum wrangler. Installations, multi-site and Workers Builds are refused. - Path-safe extraction into a fresh .cloudhal/deploy/release// and no_bundle wrangler configs that upload exactly those bytes; then the usual provisioning, secrets, downgrade guard (manifest level), deploy and push. - Refactors: siteConfigFor, shipWorkers' site config, deploy/finish.ts, downgradeGuard's options object, OWN_KEYS exported. - Tests use release-verify's synthetic signed bundle and no network. Closes #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/deploy-release-227.md | 16 + AGENTS.md | 2 +- docs/rfcs/0046-release-bundles.md | 2 +- docs/start/installation.md | 14 + packages/cli/package.json | 3 +- packages/cli/src/cli.ts | 3 + packages/cli/src/cli/commands-remote.ts | 27 ++ packages/cli/src/cli/help.ts | 11 +- packages/cli/src/commands/deploy.ts | 92 ++--- packages/cli/src/commands/deploy/finish.ts | 119 +++++++ packages/cli/src/commands/deploy/options.ts | 5 +- packages/cli/src/commands/deploy/prepare.ts | 58 ++-- .../cli/src/commands/deploy/release/checks.ts | 93 +++++ .../src/commands/deploy/release/configs.ts | 106 ++++++ .../cli/src/commands/deploy/release/errors.ts | 40 +++ .../src/commands/deploy/release/extract.ts | 95 ++++++ .../commands/deploy/release/fetch-release.ts | 155 +++++++++ .../cli/src/commands/deploy/release/index.ts | 262 ++++++++++++++ .../src/commands/deploy/release/options.ts | 39 +++ .../commands/deploy/release/theme-check.ts | 175 ++++++++++ .../cli/src/commands/deploy/release/trust.ts | 160 +++++++++ .../src/commands/deploy/schedules-guard.ts | 11 +- packages/cli/src/commands/deploy/ship.ts | 8 +- packages/cli/src/config-build-keys.ts | 131 +++++++ packages/cli/src/config.ts | 2 +- packages/cli/test/config-build-keys.test.ts | 142 ++++++++ packages/cli/test/deploy-release.test.ts | 322 ++++++++++++++++++ packages/cli/test/fixtures/release-deploy.ts | 193 +++++++++++ packages/cli/test/release-extract.test.ts | 71 ++++ packages/cli/test/release-theme-check.test.ts | 132 +++++++ pnpm-lock.yaml | 3 + 31 files changed, 2387 insertions(+), 105 deletions(-) create mode 100644 .changeset/deploy-release-227.md create mode 100644 packages/cli/src/commands/deploy/finish.ts create mode 100644 packages/cli/src/commands/deploy/release/checks.ts create mode 100644 packages/cli/src/commands/deploy/release/configs.ts create mode 100644 packages/cli/src/commands/deploy/release/errors.ts create mode 100644 packages/cli/src/commands/deploy/release/extract.ts create mode 100644 packages/cli/src/commands/deploy/release/fetch-release.ts create mode 100644 packages/cli/src/commands/deploy/release/index.ts create mode 100644 packages/cli/src/commands/deploy/release/options.ts create mode 100644 packages/cli/src/commands/deploy/release/theme-check.ts create mode 100644 packages/cli/src/commands/deploy/release/trust.ts create mode 100644 packages/cli/src/config-build-keys.ts create mode 100644 packages/cli/test/config-build-keys.test.ts create mode 100644 packages/cli/test/deploy-release.test.ts create mode 100644 packages/cli/test/fixtures/release-deploy.ts create mode 100644 packages/cli/test/release-extract.test.ts create mode 100644 packages/cli/test/release-theme-check.test.ts diff --git a/.changeset/deploy-release-227.md b/.changeset/deploy-release-227.md new file mode 100644 index 00000000..816e70f1 --- /dev/null +++ b/.changeset/deploy-release-227.md @@ -0,0 +1,16 @@ +--- +"@cloudhal/cli": minor +--- + +`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//`. Both Workers are then deployed with `no_bundle` wrangler configs that upload exactly those bytes. +- **Offline and dry runs.** `--from ` installs offline from the assets and both lists, and `--release --dry-run` stops before wrangler. + +The CLI now depends on `@cloudhal/release-verify`. diff --git a/AGENTS.md b/AGENTS.md index 84400864..d4afb6b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`; 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`. diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index b05a24dc..3139e353 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -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`. diff --git a/docs/start/installation.md b/docs/start/installation.md index 3424d7a0..0a8db232 100644 --- a/docs/start/installation.md +++ b/docs/start/installation.md @@ -94,6 +94,20 @@ 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. + +- **What it checks.** + - The signed key and yank lists from cloudhal.dev, on every run. It never falls back to an older copy, and it remembers what it has seen in `~/.cloudhal/release-trust.json`. + - The release's signature, and every file against its digest. + - The build-provenance attestation, which 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. +- **Theme check.** The theme files (`src/`, `public/`, `astro.config.mjs`, `tsconfig.json`, any PostCSS config) must equal the CLI's own starter. Otherwise, run `cloudhal deploy` without `--release`, or add `--discard-local-theme` to deploy the official theme anyway. Your files stay on disk. +- **Offline.** `--from ` installs from the five release assets plus `keys.json` and `yanked.json` (each with its `.sig`) in a directory. +- **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. diff --git a/packages/cli/package.json b/packages/cli/package.json index d15e418b..e8ac23a1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { @@ -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:", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 34af3c91..19152055 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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" }, diff --git a/packages/cli/src/cli/commands-remote.ts b/packages/cli/src/cli/commands-remote.ts index ca59b0dc..fc8df58d 100644 --- a/packages/cli/src/cli/commands-remote.ts +++ b/packages/cli/src/cli/commands-remote.ts @@ -24,6 +24,28 @@ function deployFlags(values: CliValues): { return { where, ticker, plan }; } +/** RFC-0046 §11b: this CLI's signed release; its two flags are never implied. */ +function releaseFlags(values: CliValues): { + release?: true; + from?: string; + allowSingleFactor?: true; + discardLocalTheme?: true; +} { + const single = values["allow-single-factor"] === true; + const discard = values["discard-local-theme"] === true; + if (!values.release) { + if (values.from || single || discard) + throw new Error("--from, --allow-single-factor and --discard-local-theme go with --release"); + return {}; + } + return { + release: true, + ...(values.from ? { from: values.from } : {}), + ...(single ? { allowSingleFactor: true as const } : {}), + ...(discard ? { discardLocalTheme: true as const } : {}), + }; +} + const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { const { where, ticker, plan } = deployFlags(values); const tty = process.stdin.isTTY && process.stdout.isTTY; @@ -36,10 +58,15 @@ const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { ...(plan ? { plan } : {}), ...(values["force-downgrade"] ? { forceDowngrade: true } : {}), ...(values["no-build-studio"] ? { buildStudio: false } : {}), + ...releaseFlags(values), log: (l) => console.log(l), // The analytics and data-location questions need a person at a terminal. ...(tty ? { prompt: askYesNo, askJurisdiction } : {}), }); + if (r.release) + console.log( + `\nRelease v${r.release.version} (commit ${r.release.commit.slice(0, 12)}): ${r.release.attestation === "verified" ? "signature and build attestation verified" : "signature verified; attestation not checked (--allow-single-factor)"}`, + ); if (r.dryRun) { console.log(`\nDry run OK. Workers: ${r.names.site}, ${r.names.admin}. Nothing was deployed.`); return 0; diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index a611e6d7..1e52eb96 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -42,6 +42,7 @@ const PROJECT = ` cloudhal init [--name ] [--starter blog] create /** deploy, domain, push, pull: the Cloudflare deployment. */ const CLOUDFLARE = ` cloudhal deploy [--name ] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid] [--force-downgrade] [--no-build-studio] + [--release [--from ] [--allow-single-factor] [--discard-local-theme]] site + admin Workers on Cloudflare (uses wrangler login); --jurisdiction eu keeps a newly created D1 database in the EU (cannot be changed later); --scheduler picks @@ -51,7 +52,15 @@ const CLOUDFLARE = ` cloudhal deploy [--name ] [--dry-run] [--jurisdictio --force-downgrade deploys older schedules code over open releases and time gates; from the monorepo a stale apps/studio/dist is rebuilt first - (--no-build-studio: fail instead) + (--no-build-studio: fail instead); + --release installs this CLI version's signed + release instead of building (RFC-0046 §11b): + verified against the signed key and yank lists + and the build attestation (gh 2.68+, or + --allow-single-factor); --from installs + offline from the assets and both lists; + --discard-local-theme deploys the official + theme over changed theme files cloudhal domain [set ] [--studio ] [--redirect ]… | remove [site|studio|redirects|all] custom domains for the next deploy cloudhal push [--replace] [--cancel-schedules] [--admin-url ] diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index f5e74805..2de263f3 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -43,6 +43,10 @@ * `wrangler.jsonc`, the starter seeded when the checkout has none, secrets kept on the Workers * and no setup link printed (`deploy/workers-builds.ts`, docs/guides/deploy-button.md). * + * 14. `--release` (RFC-0046 §11b, `deploy/release/`): instead of building, installs the signed + * release of this CLI's version: the signed key and yank lists, the theme and config checks, + * the verified bundle and its attestation, then the same provisioning, secrets, guard and push. + * * `--dry-run` runs everything up to wrangler's own `--dry-run` (bundles, configs) and skips * provisioning, deployment and push — no Cloudflare account needed. */ @@ -53,6 +57,7 @@ import type { SchedulerTicker } from "../cloudflare/config.ts"; import { must } from "../cloudflare/wrangler.ts"; import { readProjectConfig } from "../config.ts"; import { deployAttached } from "./deploy/attach.ts"; +import { deploySecrets, finishDeploy } from "./deploy/finish.ts"; import type { DeployOptions, DeployResult } from "./deploy/options.ts"; import { analyticsAndComments, @@ -63,12 +68,11 @@ import { provision, studioDirFor, } from "./deploy/prepare.ts"; +import { deployRelease } from "./deploy/release/index.ts"; import { downgradeGuard } from "./deploy/schedules-guard.ts"; import { attachedWorkers } from "./deploy/served-sites.ts"; import { commentsResult } from "./deploy/settings.ts"; -import { afterDeploy, purgeIfSwitched, shipWorkers } from "./deploy/ship.ts"; -import { loadOrCreateSecrets, SITE_SECRET_KEYS, writeDeployState } from "./deploy/state.ts"; -import { buildsSecrets, setupInstructions } from "./deploy/workers-builds.ts"; +import { shipWorkers } from "./deploy/ship.ts"; import { resolveSkillsDir } from "./mcp.ts"; export type { DeployOptions, DeployResult } from "./deploy/options.ts"; @@ -94,21 +98,14 @@ export { SECRET_KEYS, } from "./deploy/state.ts"; -/** What this deploy sets on each Worker, and the values it knows (all of them, outside Workers Builds). */ -async function deploySecrets(ctx: Awaited>) { - if (ctx.workersBuilds && !ctx.options.dryRun) - return buildsSecrets(ctx.run, { cwd: ctx.options.projectDir, dir: ctx.dir, names: ctx.names }); - const all = await loadOrCreateSecrets(ctx.dir); - const site = Object.fromEntries(SITE_SECRET_KEYS.map((k) => [k, all[k]])); - return { site, admin: all, known: all }; -} - export async function deploy(options: DeployOptions): Promise { checkOptions(options); + // RFC-0046 §11b: the signed release of this CLI's version instead of a build. + if (options.release) return deployRelease(options); // RFC-0034 §4: a site Worker that joins another installation deploys only itself. if ((await readProjectConfig(options.projectDir)).installation) return deployAttached(options); const ctx = await loadDeploy(options); - const { cfg, log, previous, base, names } = ctx; + const { cfg, log, previous, names } = ctx; const { analytics, comments } = await analyticsAndComments(ctx); const studioDir = await studioDirFor(ctx); const { withIds, databaseCreated } = await provision(ctx); @@ -116,7 +113,11 @@ export async function deploy(options: DeployOptions): Promise { // ignores holds and gates over open releases const secrets = await deploySecrets(ctx); if (!options.dryRun && previous?.adminUrl && !ctx.workersBuilds) - await downgradeGuard(options, previous.adminUrl, secrets.known.CLOUDHAL_DEPLOY_TOKEN, log); + await downgradeGuard( + options, + { adminUrl: previous.adminUrl, token: secrets.known.CLOUDHAL_DEPLOY_TOKEN as string }, + log, + ); const settings = await deploySettings(ctx); const { plan, schedules, observability } = settings; const site = await buildSite(ctx, { withIds, analytics, comments, observability }); @@ -176,67 +177,22 @@ export async function deploy(options: DeployOptions): Promise { }; } - const { siteUrl, adminUrl, scheduler } = await shipWorkers(ctx, { + const shipped = await shipWorkers(ctx, { siteSecrets: secrets.site, adminSecrets: secrets.admin, admin, prepareAdmin, scheduler: settings.scheduler, }); - // where the database lives, when a deploy of this deployment name created it - const jurisdiction = - databaseCreated ?? (previous?.name === base.name ? previous.jurisdiction : undefined); - await writeDeployState(options.projectDir, cfg.dataDir, { - name: base.name, - ...(adminUrl ? { adminUrl } : {}), - ...(siteUrl ? { siteUrl } : {}), - deployedAt: new Date().toISOString(), - ...(cfg.domains?.site ? { siteDomain: cfg.domains.site } : {}), - analytics: analytics ? "on" : "off", - comments: comments.available ? "on" : "off", - ...(jurisdiction ? { jurisdiction } : {}), - scheduler, + return finishDeploy(ctx, { + shipped, + known: secrets.known, + analytics, + comments, + databaseCreated, plan, - }); - const pushed = await afterDeploy(ctx, { siteUrl, adminUrl }); - // Workers Builds knows no previous deploy, nor (after the first) the purge token: no purge - const purgeToken = secrets.known.CLOUDHAL_PURGE_TOKEN; - const { purged, analyticsChanged, commentsChanged } = purgeToken - ? await purgeIfSwitched(ctx, { siteUrl, analytics, comments, purgeToken }) - : { purged: false, analyticsChanged: false, commentsChanged: false }; - - // First deploy: the owner account can only be created through this one-time link. - const setupToken = secrets.known.CLOUDHAL_SETUP_TOKEN; - const first = pushed && adminUrl; - const setupUrl = - first && setupToken && !ctx.workersBuilds - ? `${adminUrl.replace(/\/$/, "")}/login#setup=${setupToken}` - : undefined; - const instructions = - first && ctx.workersBuilds ? setupInstructions(adminUrl, names.admin) : undefined; - return { - dryRun: false, - names, + schedules, siteConfig: site.siteConfig, adminConfig: admin.configPath, - siteUrl, - adminUrl, - pushed, - ...(setupUrl ? { setupUrl } : {}), - ...(instructions ? { setupInstructions: instructions } : {}), - ...(cfg.domains?.site ? { customSiteUrl: `https://${cfg.domains.site}` } : {}), - ...(cfg.domains?.studio ? { customStudioUrl: `https://${cfg.domains.studio}` } : {}), - scheduler, - schedules, - analytics: analytics - ? { - enabled: true, - dataset: analytics.dataset, - cron: scheduler === "cron", - purged: purged && analyticsChanged, - } - : { enabled: false, ...(purged && analyticsChanged ? { purged } : {}) }, - comments: commentsResult(comments, scheduler, purged && commentsChanged), - ...(databaseCreated ? { databaseCreated } : {}), - }; + }); } diff --git a/packages/cli/src/commands/deploy/finish.ts b/packages/cli/src/commands/deploy/finish.ts new file mode 100644 index 00000000..b5cd5be7 --- /dev/null +++ b/packages/cli/src/commands/deploy/finish.ts @@ -0,0 +1,119 @@ +/** + * What every `cloudhal deploy` does around shipping the two Workers, built or from a release: + * the secrets it sets, and after the deploy the state, the first push, the one-time cache purge, + * the setup link and the result. + */ +import type { + AnalyticsDeployment, + CommentsDeployment, + SchedulerTicker, + SchedulesDeployment, + WorkersPlan, +} from "../../cloudflare/config.ts"; +import type { DeployContext } from "./context.ts"; +import type { DeployResult } from "./options.ts"; +import { commentsResult } from "./settings.ts"; +import { afterDeploy, purgeIfSwitched } from "./ship.ts"; +import { + type DeploySecrets, + type Jurisdiction, + loadOrCreateSecrets, + SITE_SECRET_KEYS, + writeDeployState, +} from "./state.ts"; +import { buildsSecrets, setupInstructions } from "./workers-builds.ts"; + +/** What this deploy sets on each Worker, and the values it knows (all of them, outside Workers Builds). */ +export async function deploySecrets(ctx: DeployContext): Promise<{ + site: Partial; + admin: Partial; + known: Partial; +}> { + if (ctx.workersBuilds && !ctx.options.dryRun) + return buildsSecrets(ctx.run, { cwd: ctx.options.projectDir, dir: ctx.dir, names: ctx.names }); + const all = await loadOrCreateSecrets(ctx.dir); + const site = Object.fromEntries(SITE_SECRET_KEYS.map((k) => [k, all[k]])); + return { site, admin: all, known: all }; +} + +export interface Shipped { + readonly siteUrl: string | undefined; + readonly adminUrl: string | undefined; + readonly scheduler: SchedulerTicker; +} + +/** State, first push, purge and the result, after both Workers are deployed. */ +export async function finishDeploy( + ctx: DeployContext, + input: { + shipped: Shipped; + known: Partial; + analytics: AnalyticsDeployment | undefined; + comments: CommentsDeployment; + databaseCreated: Jurisdiction | undefined; + plan: WorkersPlan; + schedules: SchedulesDeployment; + siteConfig: string; + adminConfig: string; + }, +): Promise { + const { options, cfg, previous, base, names } = ctx; + const { siteUrl, adminUrl, scheduler } = input.shipped; + const { analytics, comments } = input; + // where the database lives, when a deploy of this deployment name created it + const jurisdiction = + input.databaseCreated ?? (previous?.name === base.name ? previous.jurisdiction : undefined); + await writeDeployState(options.projectDir, cfg.dataDir, { + name: base.name, + ...(adminUrl ? { adminUrl } : {}), + ...(siteUrl ? { siteUrl } : {}), + deployedAt: new Date().toISOString(), + ...(cfg.domains?.site ? { siteDomain: cfg.domains.site } : {}), + analytics: analytics ? "on" : "off", + comments: comments.available ? "on" : "off", + ...(jurisdiction ? { jurisdiction } : {}), + scheduler, + plan: input.plan, + }); + const pushed = await afterDeploy(ctx, { siteUrl, adminUrl }); + // Workers Builds knows no previous deploy, nor (after the first) the purge token: no purge + const purgeToken = input.known.CLOUDHAL_PURGE_TOKEN; + const { purged, analyticsChanged, commentsChanged } = purgeToken + ? await purgeIfSwitched(ctx, { siteUrl, analytics, comments, purgeToken }) + : { purged: false, analyticsChanged: false, commentsChanged: false }; + + // First deploy: the owner account can only be created through this one-time link. + const setupToken = input.known.CLOUDHAL_SETUP_TOKEN; + const first = pushed && adminUrl; + const setupUrl = + first && setupToken && !ctx.workersBuilds + ? `${adminUrl.replace(/\/$/, "")}/login#setup=${setupToken}` + : undefined; + const instructions = + first && ctx.workersBuilds ? setupInstructions(adminUrl, names.admin) : undefined; + return { + dryRun: false, + names, + siteConfig: input.siteConfig, + adminConfig: input.adminConfig, + siteUrl, + adminUrl, + pushed, + ...(setupUrl ? { setupUrl } : {}), + ...(instructions ? { setupInstructions: instructions } : {}), + ...(cfg.domains?.site ? { customSiteUrl: `https://${cfg.domains.site}` } : {}), + ...(cfg.domains?.studio ? { customStudioUrl: `https://${cfg.domains.studio}` } : {}), + scheduler, + schedules: input.schedules, + analytics: analytics + ? { + enabled: true, + dataset: analytics.dataset, + cron: scheduler === "cron", + purged: purged && analyticsChanged, + } + : { enabled: false, ...(purged && analyticsChanged ? { purged } : {}) }, + comments: commentsResult(comments, scheduler, purged && commentsChanged), + ...(input.databaseCreated ? { databaseCreated: input.databaseCreated } : {}), + }; +} diff --git a/packages/cli/src/commands/deploy/options.ts b/packages/cli/src/commands/deploy/options.ts index a878962f..d0c0497e 100644 --- a/packages/cli/src/commands/deploy/options.ts +++ b/packages/cli/src/commands/deploy/options.ts @@ -8,9 +8,10 @@ import type { } from "../../cloudflare/config.ts"; import type { WranglerRunner } from "../../cloudflare/wrangler.ts"; import type { StudioBuilder } from "../../studio-build.ts"; +import type { ReleaseDeployOptions, ReleaseResult } from "./release/options.ts"; import type { Jurisdiction } from "./state.ts"; -export interface DeployOptions { +export interface DeployOptions extends ReleaseDeployOptions { readonly projectDir: string; /** Base name for Workers and resources (default: the site slug). */ readonly name?: string; @@ -73,6 +74,8 @@ export interface DeployOptions { export interface DeployResult { dryRun: boolean; + /** `deploy --release`: the signed release that was installed (RFC-0046 §11b). */ + release?: ReleaseResult; names: ReturnType; siteConfig: string; adminConfig: string; diff --git a/packages/cli/src/commands/deploy/prepare.ts b/packages/cli/src/commands/deploy/prepare.ts index 803a53f2..f61a733b 100644 --- a/packages/cli/src/commands/deploy/prepare.ts +++ b/packages/cli/src/commands/deploy/prepare.ts @@ -268,37 +268,43 @@ function logFeatures( ); } -/** - * 3. the site Worker: its config, the theme's block types (RFC-0015 §6), design settings - * (RFC-0017 §1) and DESIGN.md (RFC-0021 §1) checked before anything is built, then the build. - */ -export async function buildSite( - ctx: DeployContext, - input: { - withIds: DeploymentNames; - analytics: AnalyticsDeployment | undefined; - comments: CommentsDeployment; - observability: ObservabilityDeployment; - }, -) { - const { options, cfg, site, log } = ctx; - // RFC-0034 §4–5: the other sites this Worker serves, checked and with their routes. +export interface SiteConfigInput { + withIds: DeploymentNames; + analytics: AnalyticsDeployment | undefined; + comments: CommentsDeployment; + observability: ObservabilityDeployment; +} + +/** 3a. the site Worker's config (RFC-0034 §4–5: with the other sites it serves), written as today. */ +export async function siteConfigFor(ctx: DeployContext, input: SiteConfigInput) { + const { options, cfg, site } = ctx; const served = await servedSites(ctx); + const config = siteWorkerConfig({ + ...input.withIds, + siteId: cfg.siteId, + ...(served.siteIds ? { siteIds: served.siteIds } : {}), + ...(served.hosts.length > 0 ? { domains: served.hosts } : {}), + ...(cfg.domains?.site ? { domain: cfg.domains.site, siteHosts: [cfg.domains.site] } : {}), + ...(cfg.domains?.redirects ? { redirects: cfg.domains.redirects } : {}), + ...(input.analytics ? { analytics: input.analytics } : {}), + comments: input.comments, + observability: input.observability, + }); await writeWranglerConfig( siteConfigPath(options.projectDir), - siteWorkerConfig({ - ...input.withIds, - siteId: cfg.siteId, - ...(served.siteIds ? { siteIds: served.siteIds } : {}), - ...(served.hosts.length > 0 ? { domains: served.hosts } : {}), - ...(cfg.domains?.site ? { domain: cfg.domains.site, siteHosts: [cfg.domains.site] } : {}), - ...(cfg.domains?.redirects ? { redirects: cfg.domains.redirects } : {}), - ...(input.analytics ? { analytics: input.analytics } : {}), - comments: input.comments, - observability: input.observability, - }), + config, `Cloudhal site Worker for ${site.name}`, ); + return config; +} + +/** + * 3. the site Worker: its config, the theme's block types (RFC-0015 §6), design settings + * (RFC-0017 §1) and DESIGN.md (RFC-0021 §1) checked before anything is built, then the build. + */ +export async function buildSite(ctx: DeployContext, input: SiteConfigInput) { + const { options, log } = ctx; + await siteConfigFor(ctx, input); await loadBlockRegistry(options.projectDir); await loadThemeSettings(options.projectDir); const blockTypes = await blockTypesFile(options.projectDir); diff --git a/packages/cli/src/commands/deploy/release/checks.ts b/packages/cli/src/commands/deploy/release/checks.ts new file mode 100644 index 00000000..654cf64d --- /dev/null +++ b/packages/cli/src/commands/deploy/release/checks.ts @@ -0,0 +1,93 @@ +/** + * What `deploy --release` checks before it downloads the tarball (RFC-0046 §11b): the project's + * shape, its theme files against this CLI's starter, its `cloudhal.json` build keys, and the + * wrangler that will upload the bytes. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { installedWranglerVersion, versionAtLeast } from "../../../cloudflare/wrangler.ts"; +import { CONFIG_FILE, type ProjectConfig } from "../../../config.ts"; +import { buildKeyProblems } from "../../../config-build-keys.ts"; +import { inWorkersBuilds } from "../workers-builds.ts"; +import { refuseDeploy, WITHOUT_RELEASE } from "./errors.ts"; +import { + cliStarter, + listDifferences, + type ThemeDifference, + themeDifferences, +} from "./theme-check.ts"; + +/** Another installation, several sites, Workers Builds: refused before anything is fetched. */ +export function checkProjectShape(cfg: ProjectConfig, workersBuilds: boolean | undefined): void { + if (cfg.installation) + refuseDeploy( + "project-unsupported", + `this project's site Worker joins the installation in ${cfg.installation} (cloudhal.json \`installation\`); --release installs a whole deployment. ${WITHOUT_RELEASE}`, + ); + if (cfg.sites) + refuseDeploy( + "project-unsupported", + `this project serves several sites (cloudhal.json \`sites\`), which --release does not install. ${WITHOUT_RELEASE}`, + ); + if (inWorkersBuilds(workersBuilds)) + refuseDeploy( + "project-unsupported", + "--release does not run in Cloudflare Workers Builds: a build there deploys the checkout.", + ); +} + +/** The project's files that differ from the starter: refused, or listed with the flag. */ +export async function checkTheme( + projectDir: string, + discard: boolean | undefined, + log: (line: string) => void, +): Promise { + const diffs = await themeDifferences(projectDir, await cliStarter()); + if (diffs.length === 0) return []; + if (!discard) + refuseDeploy( + "theme-differs", + `--release installs this CLI's official theme, and these build inputs differ from it:\n${listDifferences(diffs)}\nRun \`cloudhal deploy\` without --release to build the project as it is, or add --discard-local-theme to deploy the official theme anyway (your files stay on disk; they are not deployed).`, + ); + log( + `--discard-local-theme: the official theme is deployed; these files of the project are not:\n${listDifferences(diffs)}`, + ); + return diffs; +} + +/** `cloudhal.json` keys the bundle cannot honour (`config-build-keys.ts`). */ +export async function checkBuildKeys( + projectDir: string, + features: Readonly>, +): Promise { + const raw = JSON.parse(await readFile(path.join(projectDir, CONFIG_FILE), "utf8")) as Record< + string, + unknown + >; + const problems = buildKeyProblems(raw, features); + if (problems.length === 0) return; + const lines = problems.map((p) => + p.problem === "unknown" + ? ` ${p.key}: this CLI does not know the key` + : ` ${p.key}: ${JSON.stringify(p.value)} differs from the starter's (the bundle was built without it)`, + ); + refuseDeploy( + "config-build-key", + `cloudhal.json sets keys the signed bundle cannot honour (--discard-local-theme does not cover these):\n${lines.join("\n")}\nChange them, or ${WITHOUT_RELEASE.charAt(0).toLowerCase()}${WITHOUT_RELEASE.slice(1)}`, + ); +} + +/** The wrangler that will run is at least the bundle's (`manifest.toolchain.wrangler`). */ +export async function checkWrangler( + projectDir: string, + required: string | undefined, + known?: string, +): Promise { + if (!required) return; + const installed = known ?? (await installedWranglerVersion(projectDir)); + if (!installed || !versionAtLeast(installed, required)) + refuseDeploy( + "wrangler-too-old", + `--release needs wrangler ${required} or newer, the version the bundle was built with (found ${installed ?? "none"}). Run \`pnpm add -D wrangler@^${required}\`.`, + ); +} diff --git a/packages/cli/src/commands/deploy/release/configs.ts b/packages/cli/src/commands/deploy/release/configs.ts new file mode 100644 index 00000000..8c1e45cf --- /dev/null +++ b/packages/cli/src/commands/deploy/release/configs.ts @@ -0,0 +1,106 @@ +/** + * The wrangler configs that deploy a verified bundle's bytes unchanged (RFC-0046 §11b): the same + * configs `cloudhal deploy` generates today (`siteWorkerConfig`, `adminWorkerConfig`), with + * + * - `main` the extracted main module and `no_bundle: true`; + * - one `rules` entry per module type whose globs are exactly that type's module paths, with + * `fallthrough: false`, and `preserve_file_names`, so wrangler uploads exactly the listed + * modules under the names the bundle's code imports; + * - the compatibility date and flags of the manifest; + * - `assets.directory` the extracted assets, with the manifest's routing config. + * + * They live in `.cloudhal/deploy/release-config/{site,admin}/wrangler.jsonc`, next to nothing else. + */ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { ReleaseManifest } from "@cloudhal/release-verify"; +import { + type adminWorkerConfig, + type siteWorkerConfig, + writeWranglerConfig, +} from "../../../cloudflare/config.ts"; +import type { ExtractedWorker, WorkerRole } from "./extract.ts"; + +/** Manifest module types → wrangler rule types. */ +export const RULE_TYPES: Readonly> = { + esm: "ESModule", + cjs: "CommonJS", + wasm: "CompiledWasm", + text: "Text", + data: "Data", +}; + +type GeneratedConfig = ReturnType | ReturnType; + +const posix = (p: string) => p.split(path.sep).join("/"); + +/** `config` changed to upload `worker`'s extracted bytes as they are (see the file comment). */ +export function releaseConfig( + config: GeneratedConfig, + input: { + role: WorkerRole; + worker: ExtractedWorker; + manifest: ReleaseManifest; + /** The directory the config is written to; every path in it is relative to that. */ + configDir: string; + }, +): Record { + const { $schema: _schema, ...rest } = config as Record & { $schema?: unknown }; + const w = input.worker; + const rel = (p: string) => posix(path.relative(input.configDir, p)) || "."; + const rules = Object.entries(w.modulesByType).map(([type, paths]) => { + const rule = RULE_TYPES[type]; + if (!rule) throw new Error(`the bundle lists a module of unknown type ${type}`); + return { type: rule, globs: [...paths].sort(), fallthrough: false }; + }); + const generatedAssets = + typeof rest.assets === "object" && rest.assets ? (rest.assets as Record) : {}; + const runtime = input.manifest.runtime; + return { + ...rest, + main: rel(path.join(w.modulesDir, w.mainModule)), + base_dir: rel(w.modulesDir), + no_bundle: true, + find_additional_modules: true, + preserve_file_names: true, + rules, + compatibility_date: runtime.compatibilityDate, + compatibility_flags: [...runtime.compatibilityFlags], + assets: { + ...generatedAssets, + binding: "ASSETS", + directory: rel(w.assetsDir), + ...(input.manifest.workers[input.role].assets.config ?? {}), + }, + }; +} + +/** Writes both configs; returns their paths and directories (wrangler runs in each). */ +export async function writeReleaseConfigs(input: { + deployDir: string; + manifest: ReleaseManifest; + site: { config: ReturnType; worker: ExtractedWorker }; + admin: { config: ReturnType; worker: ExtractedWorker }; +}): Promise<{ + site: { dir: string; configPath: string }; + admin: { dir: string; configPath: string }; +}> { + const out = {} as Record; + for (const role of ["site", "admin"] as const) { + const dir = path.join(input.deployDir, "release-config", role); + await mkdir(dir, { recursive: true }); + const configPath = path.join(dir, "wrangler.jsonc"); + await writeWranglerConfig( + configPath, + releaseConfig(input[role].config, { + role, + worker: input[role].worker, + manifest: input.manifest, + configDir: dir, + }), + `Cloudhal ${role} Worker from the signed release v${input.manifest.version} (deploy --release)`, + ); + out[role] = { dir, configPath }; + } + return out; +} diff --git a/packages/cli/src/commands/deploy/release/errors.ts b/packages/cli/src/commands/deploy/release/errors.ts new file mode 100644 index 00000000..79e9fc0c --- /dev/null +++ b/packages/cli/src/commands/deploy/release/errors.ts @@ -0,0 +1,40 @@ +/** + * Refusals of `cloudhal deploy --release` (RFC-0046 §11b). Each carries a stable code, so scripts + * and tests can tell them apart; the verifier's own refusals keep `ReleaseVerifyError`'s codes. + * There is no option that turns a refusal into a warning. + */ +export type ReleaseDeployCode = + /** No published release with a bundle for this version: deploy without `--release`. */ + | "release-unavailable" + /** The signed key and yank lists cannot be checked (no pinned root, a fetch or check failed). */ + | "trust-unavailable" + /** The project's theme files differ from this CLI's starter (`--discard-local-theme`). */ + | "theme-differs" + /** `cloudhal.json` sets a build key the bundle cannot honour, or a key this CLI does not know. */ + | "config-build-key" + /** The project's shape: another installation, several sites, or Workers Builds. */ + | "project-unsupported" + /** The installed wrangler is older than the one the bundle was built with. */ + | "wrangler-too-old" + /** The build-provenance attestation failed, or `gh` is unavailable without the flag. */ + | "attestation-failed" + | "attestation-unavailable" + /** A bundle entry would land outside the extraction directory, or twice. */ + | "extract-unsafe"; + +export class ReleaseDeployError extends Error { + readonly code: ReleaseDeployCode; + constructor(code: ReleaseDeployCode, message: string) { + super(message); + this.name = "ReleaseDeployError"; + this.code = code; + } +} + +export const refuseDeploy = (code: ReleaseDeployCode, message: string): never => { + throw new ReleaseDeployError(code, message); +}; + +/** The way forward every refusal that is not about trust names. */ +export const WITHOUT_RELEASE = + "Run `cloudhal deploy` without --release to build the project instead."; diff --git a/packages/cli/src/commands/deploy/release/extract.ts b/packages/cli/src/commands/deploy/release/extract.ts new file mode 100644 index 00000000..d35410fa --- /dev/null +++ b/packages/cli/src/commands/deploy/release/extract.ts @@ -0,0 +1,95 @@ +/** + * Extraction of a verified release (RFC-0046 §11b) into a fresh `.cloudhal/deploy/release//`: + * removed first and created empty, every target `path.resolve`d and refused unless it lies inside, + * every file created with exclusive create (`wx`, so a duplicate fails), and only entries the + * verifier yielded, so the directory holds exactly the manifest's listings. `_headers` comes from + * the manifest's inline text. Layout: + * + * /site/modules/ /site/assets/ (and admin/…) + */ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { ReleaseManifest, VerifiedRelease } from "@cloudhal/release-verify"; +import { refuseDeploy } from "./errors.ts"; + +export type WorkerRole = "site" | "admin"; + +export interface ExtractedWorker { + /** `//modules`. */ + readonly modulesDir: string; + /** `//assets`. */ + readonly assetsDir: string; + readonly mainModule: string; + /** Module paths by manifest type (`esm`, `cjs`, `wasm`, `text`, `data`). */ + readonly modulesByType: Readonly>; +} + +export interface Extracted { + readonly dir: string; + readonly site: ExtractedWorker; + readonly admin: ExtractedWorker; + readonly files: number; +} + +const ROLES: readonly WorkerRole[] = ["site", "admin"]; + +/** `rel` under `base`, refused unless it stays inside. */ +function inside(base: string, rel: string): string { + const target = path.resolve(base, rel); + if (!target.startsWith(base + path.sep)) + refuseDeploy("extract-unsafe", `the bundle entry ${rel} would land outside ${base}`); + return target; +} + +async function writeNew(base: string, rel: string, bytes: Uint8Array | string): Promise { + const target = inside(base, rel); + await mkdir(path.dirname(target), { recursive: true }); + try { + await writeFile(target, bytes, { flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") + refuseDeploy("extract-unsafe", `the bundle lists ${rel} twice`); + throw e; + } +} + +function workerOf(dir: string, role: WorkerRole, manifest: ReleaseManifest): ExtractedWorker { + const w = manifest.workers[role]; + const byType: Record = {}; + for (const m of w.modules) byType[m.type] = [...(byType[m.type] ?? []), m.path]; + return { + modulesDir: path.join(dir, role, "modules"), + assetsDir: path.join(dir, role, "assets"), + mainModule: w.mainModule, + modulesByType: byType, + }; +} + +/** Writes every verified entry (see the file comment); throws, writing nothing more, on the first refusal. */ +export async function extractRelease(release: VerifiedRelease, dir: string): Promise { + const base = path.resolve(dir); + await rm(base, { recursive: true, force: true }); + await mkdir(base, { recursive: true }); + for (const role of ROLES) { + await mkdir(path.join(base, role, "modules"), { recursive: true }); + await mkdir(path.join(base, role, "assets"), { recursive: true }); + } + let files = 0; + for await (const entry of release.entries()) { + if (entry.kind === "starter" || !entry.worker) continue; // the first push carries content + const kind = entry.kind === "module" ? "modules" : "assets"; + const rel = entry.kind === "asset" ? entry.path.slice(1) : entry.path; + await writeNew(path.join(base, entry.worker, kind), rel, entry.bytes); + files++; + } + for (const role of ROLES) { + const headers = release.manifest.workers[role].assets.headers; + if (headers) await writeNew(path.join(base, role, "assets"), "_headers", headers); + } + return { + dir: base, + site: workerOf(base, "site", release.manifest), + admin: workerOf(base, "admin", release.manifest), + files, + }; +} diff --git a/packages/cli/src/commands/deploy/release/fetch-release.ts b/packages/cli/src/commands/deploy/release/fetch-release.ts new file mode 100644 index 00000000..888e5dcc --- /dev/null +++ b/packages/cli/src/commands/deploy/release/fetch-release.ts @@ -0,0 +1,155 @@ +/** + * The release `deploy --release` installs (RFC-0046 §11b): always this CLI's own version. + * + * - `v0.8.0` and older carry no bundle; a version with no published release, a draft (publish-ci's + * window between attaching assets and publishing; GitHub does not show it to anyone without + * write access), or a release without the bundle assets is `release-unavailable`. It never falls + * back to building. + * - Downloading is discovery only: the assets land in a fresh `.cloudhal/deploy/release-download/ + * /` with byte caps, and `verifyRelease` decides. The tarball is fetched last, after the + * theme and config checks, so a refused project downloads 7 MB for nothing. + * - `--from `: the same files, copied from that directory. + */ +import { createReadStream, existsSync } from "node:fs"; +import { copyFile, mkdir, open, readFile, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { + LIMITS, + MANIFEST_FILE, + RELEASE_REPOSITORY, + releaseAssetNames, + releaseAssetUrls, + SIGNATURE_FILE, + STATEMENT_FILE, + sigstoreName, + tarballName, +} from "@cloudhal/release-verify"; +import { versionAtLeast } from "../../../cloudflare/wrangler.ts"; +import { refuseDeploy, WITHOUT_RELEASE } from "./errors.ts"; + +/** The first version with a bundle: 0.9.0. */ +export const FIRST_BUNDLED_VERSION = "0.9.0"; + +const CAPS: Record = { + [STATEMENT_FILE]: LIMITS.statementBytes, + [SIGNATURE_FILE]: 64, + [MANIFEST_FILE]: LIMITS.manifestBytes, +}; +const capOf = (name: string) => CAPS[name] ?? LIMITS.tarballBytes; + +export interface ReleaseSource { + readonly version: string; + readonly from?: string | undefined; + readonly fetch?: typeof fetch; + /** `.cloudhal/deploy/release-download//`. */ + readonly dir: string; +} + +/** The assets downloaded so far, as bytes (the tarball stays on disk). */ +export interface ReleaseFiles { + readonly dir: string; + readonly statement: Uint8Array; + readonly signature: Uint8Array; + readonly manifest: Uint8Array; + readonly tarballFile: string; + readonly sigstoreFile: string; +} + +const unavailable = (version: string, why: string): never => + refuseDeploy( + "release-unavailable", + `v${version} cannot be installed with --release: ${why}. ${WITHOUT_RELEASE}`, + ); + +/** Refuses versions that never had a bundle, before anything is fetched. */ +export function checkBundledVersion(version: string): void { + releaseAssetNames(version); // throws on a malformed version + if (!versionAtLeast(version.split("-")[0] as string, FIRST_BUNDLED_VERSION)) + unavailable(version, `releases before v${FIRST_BUNDLED_VERSION} have no signed bundle`); +} + +/** The published release `v` and that it lists every bundle asset (GitHub's API). */ +async function checkPublished(s: ReleaseSource): Promise { + const url = `https://api.github.com/repos/${RELEASE_REPOSITORY}/releases/tags/v${s.version}`; + let res: Response; + try { + res = await (s.fetch ?? fetch)(url, { + headers: { accept: "application/vnd.github+json", "user-agent": "cloudhal-cli" }, + }); + } catch (e) { + return unavailable(s.version, `GitHub could not be reached (${(e as Error).message})`); + } + if (res.status === 404) + unavailable( + s.version, + "there is no published release with that tag (a draft is not published yet)", + ); + if (!res.ok) unavailable(s.version, `GitHub answered ${res.status} for the release`); + const release = (await res.json()) as { draft?: unknown; assets?: { name?: unknown }[] }; + if (release.draft !== false) unavailable(s.version, "the release is still a draft"); + const names = new Set((release.assets ?? []).map((a) => a.name)); + const missing = releaseAssetNames(s.version).filter((n) => !names.has(n)); + if (missing.length > 0) + unavailable(s.version, `the release has no bundle (missing ${missing.join(", ")})`); +} + +/** Streams `url` into `file`, refusing more than `cap` bytes. */ +async function download(get: typeof fetch, url: string, file: string, cap: number): Promise { + const res = await get(url); + if (res.status !== 200 || !res.body) throw new Error(`downloading ${url} answered ${res.status}`); + const out = await open(file, "wx"); + try { + let total = 0; + for await (const chunk of res.body as unknown as AsyncIterable) { + total += chunk.length; + if (total > cap) throw new Error(`${url} is above ${cap} bytes`); + await out.write(chunk); + } + } finally { + await out.close(); + } +} + +async function fetchOne(s: ReleaseSource, name: string): Promise { + const file = path.join(s.dir, name); + if (s.from !== undefined) { + const src = path.join(s.from, name); + if (!existsSync(src)) unavailable(s.version, `--from ${s.from} has no ${name}`); + if ((await stat(src)).size > capOf(name)) + throw new Error(`${src} is above ${capOf(name)} bytes`); + await copyFile(src, file); + return file; + } + const asset = releaseAssetUrls(s.version).find((a) => a.name === name); + if (!asset) throw new Error(`no release asset named ${name}`); + await download(s.fetch ?? fetch, asset.url, file, capOf(name)); + return file; +} + +/** Statement, signature, manifest and the Sigstore bundle (the tarball comes with `fetchTarball`). */ +export async function fetchRelease(s: ReleaseSource): Promise { + checkBundledVersion(s.version); + if (s.from === undefined) await checkPublished(s); + await rm(s.dir, { recursive: true, force: true }); + await mkdir(s.dir, { recursive: true }); + const read = async (name: string) => new Uint8Array(await readFile(await fetchOne(s, name))); + const statement = await read(STATEMENT_FILE); + const signature = await read(SIGNATURE_FILE); + const manifest = await read(MANIFEST_FILE); + const sigstoreFile = await fetchOne(s, sigstoreName(s.version)); + return { + dir: s.dir, + statement, + signature, + manifest, + tarballFile: path.join(s.dir, tarballName(s.version)), + sigstoreFile, + }; +} + +/** The tarball, into the same directory; then a factory that streams it (read twice by the verifier). */ +export async function fetchTarball(s: ReleaseSource): Promise<() => ReadableStream> { + const file = await fetchOne(s, tarballName(s.version)); + return () => Readable.toWeb(createReadStream(file)) as unknown as ReadableStream; +} diff --git a/packages/cli/src/commands/deploy/release/index.ts b/packages/cli/src/commands/deploy/release/index.ts new file mode 100644 index 00000000..4d2e73e1 --- /dev/null +++ b/packages/cli/src/commands/deploy/release/index.ts @@ -0,0 +1,262 @@ +/** + * `cloudhal deploy --release` (RFC-0046 §11b): installs this CLI version's signed release into + * the project's Cloudflare account instead of building the project. The site and admin code come + * from the bundle; the project contributes its content (the first push), config and secrets. + * + * 0. refusals: another installation, several sites, Workers Builds, a version with no bundle; + * 1. the signed key and yank lists (online every run, or `--from`), checked against the memory; + * 2. the release's statement, signature and manifest (published, not a draft, with a bundle); + * 3. the theme check, the config build keys and the wrangler version, on that manifest; + * 4. the tarball, then `verifyRelease` (§2 steps 1–5, keys reduced by both lists, yanks); + * 5. the build-provenance attestation (`--allow-single-factor` when `gh` is unavailable); + * 6. extraction into a fresh directory, and the wrangler configs for exactly those bytes; + * 7. as every deploy: resources, secrets, the downgrade guard (the manifest's schedules level), + * both Workers, state, the first push and the cache purge. + * + * Nothing is written outside `.cloudhal/deploy/`, and nothing is uploaded, before step 5 passes. + * `--dry-run` stops after step 6. + */ +import path from "node:path"; +import { + mergeTrust, + PINNED_KEYS, + type ReleaseManifest, + type VerifiedRelease, + verifyRelease, +} from "@cloudhal/release-verify"; +import { cliVersion } from "../../../assets.ts"; +import { + adminWorkerConfig, + readExistingIds, + type SchedulerTicker, +} from "../../../cloudflare/config.ts"; +import { readProjectConfig } from "../../../config.ts"; +import type { DeployContext } from "../context.ts"; +import { deploySecrets, finishDeploy } from "../finish.ts"; +import type { DeployOptions, DeployResult } from "../options.ts"; +import { + analyticsAndComments, + deploySettings, + loadDeploy, + provision, + siteConfigFor, +} from "../prepare.ts"; +import { downgradeGuard } from "../schedules-guard.ts"; +import { attachedWorkers } from "../served-sites.ts"; +import { commentsResult } from "../settings.ts"; +import { shipWorkers } from "../ship.ts"; +import { checkBuildKeys, checkProjectShape, checkTheme, checkWrangler } from "./checks.ts"; +import { writeReleaseConfigs } from "./configs.ts"; +import { type ReleaseDeployCode, refuseDeploy } from "./errors.ts"; +import { type Extracted, extractRelease } from "./extract.ts"; +import { + checkBundledVersion, + fetchRelease, + fetchTarball, + type ReleaseSource, +} from "./fetch-release.ts"; +import type { ReleaseResult } from "./options.ts"; +import { loadTrust } from "./trust.ts"; + +/** The manifest's facts the checks need, before it is verified (verification follows before any use). */ +function unverifiedFacts(bytes: Uint8Array): { + features: Record; + wrangler: string | undefined; +} { + try { + const m = JSON.parse(new TextDecoder().decode(bytes)) as Partial; + const wrangler = m.toolchain?.wrangler; + return { + features: Object.fromEntries( + Object.entries(m.features ?? {}).filter( + (e): e is [string, boolean] => typeof e[1] === "boolean", + ), + ), + wrangler: typeof wrangler === "string" ? wrangler : undefined, + }; + } catch { + return { features: {}, wrangler: undefined }; // verifyRelease refuses it next + } +} + +async function attestation( + options: DeployOptions, + input: { dir: string; version: string; commit: string }, + log: (line: string) => void, +): Promise { + const attest = + options.attest ?? + (async (o: typeof input) => + (await import("@cloudhal/release-verify/attestation")).verifyAttestation(o)); + const r = await attest(input); + if (r.status === "verified") return "verified"; + if (r.status === "failed") + return refuseDeploy( + "attestation-failed" satisfies ReleaseDeployCode, + `the build-provenance attestation of ${r.file} failed (${r.reason}): these files were not built by this repository's bundle-build.yml. Nothing was deployed.`, + ); + if (!options.allowSingleFactor) + return refuseDeploy( + "attestation-unavailable", + `the build-provenance attestation cannot be checked: ${r.reason}. Install the GitHub CLI 2.68 or newer (https://cli.github.com) and run \`gh auth login\`, or add --allow-single-factor to rely on the release key alone.`, + ); + log( + `--allow-single-factor: the attestation is not checked (${r.reason}); this install relies on the release key alone`, + ); + return "single-factor"; +} + +/** Steps 1–6 (see the file comment): the verified, extracted release. */ +async function verifiedRelease(ctx: DeployContext, version: string) { + const { options, log } = ctx; + const trust = await loadTrust({ + from: options.from, + ...(options.fetch ? { fetch: options.fetch } : {}), + ...(options.trustRoots ? { roots: options.trustRoots } : {}), + ...(options.trustStateFile ? { stateFile: options.trustStateFile } : {}), + }); + log( + `trust: keys.json ${trust.keys.sha256.slice(0, 12)}, yanked.json serial ${trust.yanked.document.serial}`, + ); + const source: ReleaseSource = { + version, + from: options.from, + ...(options.fetch ? { fetch: options.fetch } : {}), + dir: path.join(ctx.dir, "release-download", version), + }; + const files = await fetchRelease(source); + const facts = unverifiedFacts(files.manifest); + const notDeployed = await checkTheme(options.projectDir, options.discardLocalTheme, log); + await checkBuildKeys(options.projectDir, facts.features); + await checkWrangler(options.projectDir, facts.wrangler, options.wranglerVersion); + const tarball = await fetchTarball(source); + const release: VerifiedRelease = await verifyRelease({ + request: { version }, + statement: files.statement, + signature: files.signature, + manifest: files.manifest, + tarball, + planVersion: 1, + keys: mergeTrust(options.pinnedKeys ?? PINNED_KEYS, trust), + yanked: trust.yanked, + }); + log( + `verified v${version} (commit ${release.statement.commit.slice(0, 12)}, key ${release.statement.signing.keyId})`, + ); + const attested = await attestation( + options, + { dir: files.dir, version, commit: release.statement.commit }, + log, + ); + const extracted = await extractRelease(release, path.join(ctx.dir, "release", version)); + log( + `extracted ${extracted.files} verified files into ${path.relative(options.projectDir, extracted.dir)}`, + ); + return { release, extracted, attested, notDeployed }; +} + +export async function deployRelease(options: DeployOptions): Promise { + const cfg = await readProjectConfig(options.projectDir); + checkProjectShape(cfg, options.workersBuilds); + const version = options.cliVersion ?? (await cliVersion()); + checkBundledVersion(version); + const ctx = await loadDeploy(options); + const { release, extracted, attested, notDeployed } = await verifiedRelease(ctx, version); + const manifest = release.manifest; + + const { analytics, comments } = await analyticsAndComments(ctx); + const { withIds, databaseCreated } = await provision(ctx); + const secrets = await deploySecrets(ctx); + const previous = ctx.previous; + if (!options.dryRun && previous?.adminUrl) + await downgradeGuard( + options, + { + adminUrl: previous.adminUrl, + token: secrets.known.CLOUDHAL_DEPLOY_TOKEN as string, + localLevel: manifest.levels.schedules, + }, + ctx.log, + ); + const settings = await deploySettings(ctx); + const { plan, schedules, observability } = settings; + const siteConfig = await siteConfigFor(ctx, { withIds, analytics, comments, observability }); + const siteWorkers = await attachedWorkers(ctx); + const existing = await readExistingIds( + path.join(ctx.dir, "release-config", "admin", "wrangler.jsonc"), + ); + const adminConfig = (scheduler: SchedulerTicker) => + adminWorkerConfig({ + ...existing, + ...withIds, + siteId: cfg.siteId, + ...(siteWorkers.length ? { siteWorkers } : {}), + main: "(release)", + studioDir: extracted.admin.assetsDir, + ...(cfg.domains?.studio ? { domain: cfg.domains.studio } : {}), + ...(analytics ? { analytics } : {}), + comments, + scheduler, + schedules, + ...(cfg.mail ? { mail: cfg.mail } : {}), + ...(cfg.ai ? { ai: true } : {}), + ...(cfg.render !== false ? { browser: true } : {}), + observability, + ...(cfg.placement ? { placement: cfg.placement } : {}), + }); + const write = (scheduler: SchedulerTicker) => + writeReleaseConfigs({ + deployDir: ctx.dir, + manifest, + site: { config: siteConfig, worker: extracted.site }, + admin: { config: adminConfig(scheduler), worker: extracted.admin }, + }); + const configs = await write(settings.scheduler); + const result: ReleaseResult = { + version, + commit: release.statement.commit, + attestation: attested, + files: extracted.files, + ...(notDeployed.length > 0 ? { notDeployed: notDeployed.map((d) => d.path) } : {}), + }; + if (options.dryRun) { + ctx.log( + "dry run complete: the verified release and its configs are in .cloudhal/deploy (nothing was deployed)", + ); + return { + dryRun: true, + names: ctx.names, + siteConfig: configs.site.configPath, + adminConfig: configs.admin.configPath, + scheduler: settings.scheduler, + schedules, + analytics: analytics + ? { enabled: true, dataset: analytics.dataset, cron: settings.scheduler === "cron" } + : { enabled: false }, + comments: commentsResult(comments, settings.scheduler), + release: result, + }; + } + const shipped = await shipWorkers(ctx, { + siteSecrets: secrets.site, + adminSecrets: secrets.admin, + site: configs.site, + admin: configs.admin, + prepareAdmin: async (ticker) => (await write(ticker)).admin, + scheduler: settings.scheduler, + }); + const done = await finishDeploy(ctx, { + shipped, + known: secrets.known, + analytics, + comments, + databaseCreated, + plan, + schedules, + siteConfig: configs.site.configPath, + adminConfig: configs.admin.configPath, + }); + return { ...done, release: result }; +} + +export type { Extracted }; diff --git a/packages/cli/src/commands/deploy/release/options.ts b/packages/cli/src/commands/deploy/release/options.ts new file mode 100644 index 00000000..ced2f0eb --- /dev/null +++ b/packages/cli/src/commands/deploy/release/options.ts @@ -0,0 +1,39 @@ +/** The options `cloudhal deploy --release` adds (RFC-0046 §11b), and what it reports. */ +import type { ReleaseKeys, RootKeys } from "@cloudhal/release-verify"; +import type { AttestationResult } from "@cloudhal/release-verify/attestation"; + +export interface ReleaseDeployOptions { + /** Install this CLI version's signed release instead of building the project. */ + readonly release?: boolean; + /** `--from `: the five release assets, `keys.json` and `yanked.json` (with `.sig`) offline. */ + readonly from?: string; + /** Continue on the release key alone when `gh` is unavailable (never implied). */ + readonly allowSingleFactor?: boolean; + /** Deploy the official theme although the project's theme files differ (never implied). */ + readonly discardLocalTheme?: boolean; + /** Tests only: the version to install (default: this CLI's). */ + readonly cliVersion?: string; + /** Tests only: the release keys to start from (default: `PINNED_KEYS`). */ + readonly pinnedKeys?: ReleaseKeys; + /** Tests only: the roots `keys.json` must verify with (default: `PINNED_ROOTS`). */ + readonly trustRoots?: RootKeys; + /** Tests only: where the yank memory lives (default: `~/.cloudhal/release-trust.json`). */ + readonly trustStateFile?: string; + /** Tests only: the attestation check (default: `gh attestation verify`). */ + readonly attest?: (o: { + dir: string; + version: string; + commit: string; + }) => Promise; +} + +export interface ReleaseResult { + readonly version: string; + readonly commit: string; + /** `verified`, or `single-factor` with `--allow-single-factor` and no `gh`. */ + readonly attestation: "verified" | "single-factor"; + /** With `--discard-local-theme`: the project's files the official theme replaced. */ + readonly notDeployed?: readonly string[]; + /** Files extracted into `.cloudhal/deploy/release//`. */ + readonly files: number; +} diff --git a/packages/cli/src/commands/deploy/release/theme-check.ts b/packages/cli/src/commands/deploy/release/theme-check.ts new file mode 100644 index 00000000..8587b035 --- /dev/null +++ b/packages/cli/src/commands/deploy/release/theme-check.ts @@ -0,0 +1,175 @@ +/** + * The theme check of `deploy --release` (RFC-0046 §11b): the bundle's site and admin code were + * built from this CLI version's own starter, so a project whose build inputs differ from that + * starter would silently lose its changes. Compared, as normalised relative paths, byte for byte + * after CRLF → LF: + * + * - every file under `src/` and `public/` (and `skills/`, which `resolveSkillsDir` prefers and the + * admin build bakes in; the starter has none); + * - the root build inputs `astro.config.mjs`, `tsconfig.json` and `jsconfig.json`; + * - every PostCSS config Vite would load, in every directory from the project up to Vite's + * workspace root (`searchForWorkspaceRoot`: the nearest directory with `pnpm-workspace.yaml`, + * `lerna.json`, a `package.json` with `workspaces`, or a `deno.json(c)` with `workspace`; without + * one, the project alone), including a `postcss` key in `package.json`. + * + * Checked against Astro 7.4.7 and Vite 8.3.5 (`resolvePostcssConfig`, the `postcss-load-config` + * 6.0.1 search places). Astro passes `configFile: false` to Vite, so `vite.config.*` is never read; + * `.env*` files are not compared (the starter's code reads no `import.meta.env`). Recheck this list + * on every Astro or Vite major upgrade. + * + * `.DS_Store`, `Thumbs.db` and `desktop.ini` are skipped wherever they appear (exact basenames). + */ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { assetDir } from "../../../assets.ts"; +import { starterFilter } from "../../../starter-files.ts"; + +const TREES = ["src", "public", "skills"]; +const ROOT_FILES = ["astro.config.mjs", "tsconfig.json", "jsconfig.json"]; +const JS_EXT = ["js", "cjs", "mjs", "ts", "cts", "mts"]; +/** `postcss-load-config` 6.0.1's search places (the `package.json` key aside). */ +export const POSTCSS_FILES = [ + ...JS_EXT.map((e) => `postcss.config.${e}`), + ".postcssrc", + ...["json", "yaml", "yml", ...JS_EXT].map((e) => `.postcssrc.${e}`), +]; +const OS_METADATA = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]); + +export interface ThemeDifference { + /** Relative to the project (`../postcss.config.cjs` above it). */ + readonly path: string; + readonly change: "changed" | "added" | "missing"; +} + +type Keep = (abs: string) => boolean; + +async function filesUnder(root: string, rel: string, keep: Keep): Promise { + const dir = path.join(root, rel); + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const r = `${rel}/${entry.name}`; + if (OS_METADATA.has(entry.name) || !keep(path.join(root, r))) continue; + if (entry.isDirectory()) out.push(...(await filesUnder(root, r, keep))); + else out.push(r); + } + return out; +} + +/** The compared files of `root`: the trees and the root build inputs. */ +async function comparedFiles(root: string, keep: Keep): Promise> { + const files = new Set(); + for (const tree of TREES) for (const f of await filesUnder(root, tree, keep)) files.add(f); + for (const f of [...ROOT_FILES, ...POSTCSS_FILES]) + if (existsSync(path.join(root, f)) && keep(path.join(root, f))) files.add(f); + return files; +} + +/** The file's bytes with every CRLF as LF, so a Windows checkout of the starter compares equal. */ +async function normalised(file: string): Promise { + const bytes = new Uint8Array(await readFile(file)); + const out = new Uint8Array(bytes.length); + let n = 0; + for (let i = 0; i < bytes.length; i++) + if (!(bytes[i] === 0x0d && bytes[i + 1] === 0x0a)) out[n++] = bytes[i] as number; + return out.subarray(0, n); +} + +const sameBytes = (a: Uint8Array, b: Uint8Array) => + a.length === b.length && a.every((x, i) => x === b[i]); + +const postcssKey = async (dir: string): Promise => { + const file = path.join(dir, "package.json"); + if (!existsSync(file)) return undefined; + try { + const pkg = JSON.parse(await readFile(file, "utf8")) as Record; + return Object.hasOwn(pkg, "postcss") ? JSON.stringify(pkg.postcss) : undefined; + } catch { + return undefined; + } +}; + +async function isWorkspaceRoot(dir: string): Promise { + if (existsSync(path.join(dir, "pnpm-workspace.yaml")) || existsSync(path.join(dir, "lerna.json"))) + return true; + const has = async (name: string, key: string) => { + const file = path.join(dir, name); + if (!existsSync(file)) return false; + try { + return Object.hasOwn(JSON.parse(await readFile(file, "utf8")) as object, key); + } catch { + return false; // deno.jsonc with comments: Vite strips them; a parse failure is no workspace + } + }; + return ( + (await has("package.json", "workspaces")) || + (await has("deno.json", "workspace")) || + (await has("deno.jsonc", "workspace")) + ); +} + +/** The directories Vite searches for a PostCSS config: the project up to its workspace root. */ +export async function postcssSearchDirs(projectDir: string): Promise { + const dirs: string[] = []; + let cur = path.resolve(projectDir); + for (;;) { + dirs.push(cur); + if (await isWorkspaceRoot(cur)) return dirs; + const parent = path.dirname(cur); + if (parent === cur) return [path.resolve(projectDir)]; // no workspace: the project alone + cur = parent; + } +} + +/** This CLI version's starter (the one `cloudhal init` copies): its directory and file filter. */ +export async function cliStarter(name = "blog"): Promise<{ dir: string; keep: Keep }> { + const starters = await assetDir("starters"); + const dir = starters ? path.join(starters, name) : undefined; + if (!dir || !existsSync(dir)) throw new Error(`this CLI ships no starter "${name}"`); + return { dir, keep: await starterFilter(dir) }; +} + +/** Every difference between the project's build inputs and the starter's (see the file comment). */ +export async function themeDifferences( + projectDir: string, + starter: { dir: string; keep: Keep }, +): Promise { + const mine = await comparedFiles(projectDir, () => true); + const theirs = await comparedFiles(starter.dir, starter.keep); + const out: ThemeDifference[] = []; + for (const f of [...new Set([...mine, ...theirs])].sort()) { + if (!theirs.has(f)) out.push({ path: f, change: "added" }); + else if (!mine.has(f)) out.push({ path: f, change: "missing" }); + else if ( + !sameBytes( + await normalised(path.join(projectDir, f)), + await normalised(path.join(starter.dir, f)), + ) + ) + out.push({ path: f, change: "changed" }); + } + // `postcss` in the project's package.json, compared with the starter's. + const [own, starters] = [await postcssKey(projectDir), await postcssKey(starter.dir)]; + if (own !== starters) + out.push({ + path: "package.json#postcss", + change: starters === undefined ? "added" : own === undefined ? "missing" : "changed", + }); + // Above the project, up to Vite's workspace root: any PostCSS config at all. + for (const dir of (await postcssSearchDirs(projectDir)).slice(1)) { + const rel = path.relative(projectDir, dir).split(path.sep).join("/"); + for (const f of POSTCSS_FILES) + if (existsSync(path.join(dir, f))) out.push({ path: `${rel}/${f}`, change: "added" }); + if ((await postcssKey(dir)) !== undefined) + out.push({ path: `${rel}/package.json#postcss`, change: "added" }); + } + return out; +} + +/** The differences as lines: the first 20, then a count. */ +export function listDifferences(diffs: readonly ThemeDifference[]): string { + const lines = diffs.slice(0, 20).map((d) => ` ${d.change}: ${d.path}`); + if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more`); + return lines.join("\n"); +} diff --git a/packages/cli/src/commands/deploy/release/trust.ts b/packages/cli/src/commands/deploy/release/trust.ts new file mode 100644 index 00000000..96303c1b --- /dev/null +++ b/packages/cli/src/commands/deploy/release/trust.ts @@ -0,0 +1,160 @@ +/** + * The signed key list and yank list for `cloudhal deploy --release` (RFC-0046 §9, §11b). + * + * - Online: both lists (each with its `.sig`) from cloudhal.dev on every run. There is no fallback + * to a release asset or a cached copy: a copy can be older than a revocation. + * - `--from `: both lists from that directory, under the same rules. + * - `keys.json` must verify with the root this CLI pins and be unexpired; `yanked.json` with the + * yank key it lists, unexpired. Both are checked against the CLI's `YankMemory` (a replayed + * older key list, a revoked or replaced yank key, a lower serial, a dropped entry), and the new + * memory is stored only after both pass. + * - Before the owner's root ceremony no root is pinned, so nothing can be checked: refused. + */ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + KEYS_FILE, + PINNED_ROOTS, + type RootKeys, + rememberYanks, + TRUST_BASE_URL, + type VerifiedKeyList, + type VerifiedYankList, + verifyKeys, + verifyYanked, + YANKED_FILE, + type YankMemory, +} from "@cloudhal/release-verify"; +import { refuseDeploy } from "./errors.ts"; + +/** Where the CLI keeps its `YankMemory`: one per user, shared by every project. */ +export const defaultTrustStateFile = () => + path.join(os.homedir(), ".cloudhal", "release-trust.json"); + +const MAX_LIST_BYTES = 256 * 1024; + +export interface TrustInput { + /** `--from `, else the canonical URLs. */ + readonly from?: string | undefined; + readonly fetch?: typeof fetch; + /** Tests only: roots other than the pinned ones. */ + readonly roots?: RootKeys; + /** Default: `defaultTrustStateFile()`. */ + readonly stateFile?: string; + readonly now?: Date; +} + +export interface Trust { + readonly keys: VerifiedKeyList; + readonly yanked: VerifiedYankList; +} + +const CEREMONY = + "no root key is pinned in this CLI yet: the owner's root ceremony (docs/security/release-signing-keys.md › First-time setup) has not happened, so the signed key and yank lists cannot be checked and revocations would go unseen"; + +async function readList(input: TrustInput, name: string): Promise { + if (input.from !== undefined) { + const file = path.join(input.from, name); + if (!existsSync(file)) + return refuseDeploy( + "trust-unavailable", + `--from ${input.from} has no ${name}: an offline install needs keys.json and yanked.json, each with its .sig, next to the release assets`, + ); + const bytes = new Uint8Array(await readFile(file)); + if (bytes.length > MAX_LIST_BYTES) + return refuseDeploy("trust-unavailable", `${file} is above ${MAX_LIST_BYTES} bytes`); + return bytes; + } + const url = TRUST_BASE_URL + name; + let res: Response; + try { + res = await (input.fetch ?? fetch)(url); + } catch (e) { + return refuseDeploy( + "trust-unavailable", + `could not fetch ${url} (${(e as Error).message}); --release never falls back to an older copy`, + ); + } + if (res.status !== 200) + return refuseDeploy( + "trust-unavailable", + `${url} answered ${res.status}; --release never falls back to an older copy`, + ); + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.length > MAX_LIST_BYTES) + return refuseDeploy("trust-unavailable", `${url} is above ${MAX_LIST_BYTES} bytes`); + return bytes; +} + +async function readMemory(file: string): Promise { + if (!existsSync(file)) return undefined; + try { + const m = JSON.parse(await readFile(file, "utf8")) as { yankMemory?: YankMemory }; + const y = m.yankMemory; + if ( + !y || + typeof y.keyId !== "string" || + typeof y.serial !== "number" || + typeof y.keysIssuedAt !== "string" || + !Array.isArray(y.versions) || + !Array.isArray(y.revokedKeys) || + !Array.isArray(y.retiredYankKeys) + ) + throw new Error("not a yank memory"); + return y; + } catch (e) { + // A damaged memory is never silently dropped: that would undo what it protects. + return refuseDeploy( + "trust-unavailable", + `${file} cannot be read (${(e as Error).message}); move it aside only if you know why it changed`, + ); + } +} + +async function writeMemory(file: string, memory: YankMemory): Promise { + await mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify({ yankMemory: memory }, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, file); +} + +/** Both lists, verified and checked against the memory, which is then updated (see the file comment). */ +export async function loadTrust(input: TrustInput): Promise { + const roots = input.roots ?? PINNED_ROOTS; + if (Object.keys(roots).length === 0) refuseDeploy("trust-unavailable", CEREMONY); + const stateFile = input.stateFile ?? defaultTrustStateFile(); + const memory = await readMemory(stateFile); + const now = input.now ?? new Date(); + const [keysDoc, keysSig, yankDoc, yankSig] = await Promise.all( + [KEYS_FILE, `${KEYS_FILE}.sig`, YANKED_FILE, `${YANKED_FILE}.sig`].map((n) => + readList(input, n), + ), + ); + try { + const keys = await verifyKeys({ + document: keysDoc as Uint8Array, + signature: keysSig as Uint8Array, + roots, + now, + ...(memory ? { memory } : {}), + }); + const yanked = await verifyYanked({ + document: yankDoc as Uint8Array, + signature: yankSig as Uint8Array, + keys, + now, + ...(memory ? { memory } : {}), + }); + await writeMemory(stateFile, rememberYanks(yanked, keys, memory)); + return { keys, yanked }; + } catch (e) { + const code = (e as { code?: string }).code; + if (!code) throw e; + return refuseDeploy( + "trust-unavailable", + `the signed key and yank lists do not verify (${code}: ${(e as Error).message}); there is no override`, + ); + } +} diff --git a/packages/cli/src/commands/deploy/schedules-guard.ts b/packages/cli/src/commands/deploy/schedules-guard.ts index d93fe8a0..75d0c9b3 100644 --- a/packages/cli/src/commands/deploy/schedules-guard.ts +++ b/packages/cli/src/commands/deploy/schedules-guard.ts @@ -53,10 +53,15 @@ export interface SchedulesStatus { */ export async function downgradeGuard( options: DeployOptions, - adminUrl: string, - token: string, + target: { + readonly adminUrl: string; + readonly token: string; + /** The schedules level of the code deployed (`deploy --release`: the manifest's). */ + readonly localLevel?: number; + }, log: (line: string) => void, ): Promise { + const { adminUrl, token } = target; let status: SchedulesStatus; try { const res = await (options.fetch ?? fetch)(new URL("/api/admin/schedules/status", adminUrl), { @@ -77,7 +82,7 @@ export async function downgradeGuard( return; } const remote = status.engine?.schedulesLevel ?? 0; - const local = await projectSchedulesLevel(options.projectDir); + const local = target.localLevel ?? (await projectSchedulesLevel(options.projectDir)); if (remote <= local) return; const open = (status.sites ?? []).reduce((n, s) => n + (s.openReleases?.length ?? 0), 0); const gated = (status.sites ?? []).reduce((n, s) => n + (s.gatedNodes ?? 0), 0); diff --git a/packages/cli/src/commands/deploy/ship.ts b/packages/cli/src/commands/deploy/ship.ts index ebc8a646..71d39a6d 100644 --- a/packages/cli/src/commands/deploy/ship.ts +++ b/packages/cli/src/commands/deploy/ship.ts @@ -18,7 +18,7 @@ import { purgeSite, waitForHealth, whileTokenPropagates } from "./resources.ts"; import { CRON_REFUSED_MESSAGE } from "./schedules-guard.ts"; import type { DeploySecrets } from "./state.ts"; -type PreparedAdmin = Awaited>; +type PreparedAdmin = Pick>, "dir" | "configPath">; /** * `wrangler deploy` for the site Worker, then the admin Worker. When Cloudflare refuses the cron @@ -34,6 +34,8 @@ export async function shipWorkers( admin: PreparedAdmin; prepareAdmin: (ticker: SchedulerTicker) => Promise; scheduler: SchedulerTicker; + /** `deploy --release`: the site Worker's own config (default: the project's, as built). */ + site?: { readonly dir: string; readonly configPath: string }; }, ): Promise<{ siteUrl: string | undefined; @@ -59,7 +61,9 @@ export async function shipWorkers( const siteArgs = await secretsArgs(siteSecretsFile, input.siteSecrets); const adminSecrets = await secretsArgs(adminSecretsFile, input.adminSecrets); log(`deploying ${names.site}`); - siteDeploy = await must(run, ["deploy", ...siteArgs], options.projectDir); + siteDeploy = input.site + ? await must(run, ["deploy", "-c", input.site.configPath, ...siteArgs], input.site.dir) + : await must(run, ["deploy", ...siteArgs], options.projectDir); log(`deploying ${names.admin}`); const adminArgs = ["deploy", "-c", admin.configPath, ...adminSecrets]; adminDeploy = await run(adminArgs, { cwd: admin.dir }); diff --git a/packages/cli/src/config-build-keys.ts b/packages/cli/src/config-build-keys.ts new file mode 100644 index 00000000..3479f43b --- /dev/null +++ b/packages/cli/src/config-build-keys.ts @@ -0,0 +1,131 @@ +/** + * Which `cloudhal.json` keys a build bakes in (RFC-0046 §11b, "Config build keys"). A release + * bundle was built from this CLI version's starter with `cloudhal init`'s `cloudhal.json`, so + * `deploy --release` refuses a project whose `build` keys differ from that, and any key this CLI + * does not know. Every key, and every sub-key of the objects whose sub-keys differ in class, has an + * explicit class; the types below are mapped over `ProjectConfig` (plus `media`, which only + * `astro.config.mjs` reads), so a new key without a class fails `tsc`. + * + * - `deploy`: the key becomes a binding, a var or a resource; no build reads it. + * - `runtime`: a build reads it, but the deployed Worker overrides it. + * - `build`: a build reads it and nothing overrides it: it must equal the starter's value. + * - `refused`: handled by the other refusals (`installation`, `sites`). + * + * Update this table, and its tests, with every key `cloudhal.json` gains. + */ +import type { AnalyticsConfig, CommentsConfig, ProjectConfig } from "./config.ts"; + +export type KeyClass = "deploy" | "runtime" | "build" | "refused"; + +/** `media.transform`'s class depends on the bundle: `runtime` when it reads a var, else `build`. */ +export type FeatureClass = { readonly build: true; readonly runtimeWith: "mediaTransformVar" }; + +/** `cloudhal.json` `media`, read only by the starter's `astro.config.mjs` (RFC-0004 §7.5). */ +export interface MediaConfig { + readonly transform?: "cdn-cgi" | "none"; +} + +type Mixed = "analytics" | "comments"; +type Classes = { readonly [K in keyof Required]-?: KeyClass | FeatureClass }; + +export type ConfigKeyTable = { + readonly [K in Exclude, Mixed>]-?: KeyClass; +} & { + readonly analytics: Classes; + readonly comments: Classes; + readonly media: Classes; +}; + +export const CONFIG_KEY_CLASSES: ConfigKeyTable = { + siteId: "runtime", // the Site Worker's CLOUDHAL_SITE_ID(S) override the baked id + sites: "refused", + installation: "refused", + dataDir: "runtime", // the cloudflare target never reads it + domains: "deploy", + analytics: { + enabled: "runtime", // the Site Worker reads its vars instead + uniqueVisitors: "runtime", + vitalsOneIn: "runtime", + queryBudget: "deploy", + rawRetentionDays: "deploy", + rateLimit: "deploy", + dev: "runtime", // only `cloudhal dev` reads it + }, + comments: { + available: "build", + rateLimit: "deploy", // only commentsRateLimitDeployment + }, + schedules: "deploy", + mail: "deploy", + ai: "deploy", + render: "deploy", + observability: "deploy", + placement: "deploy", + search: "deploy", // only searchDeploymentOf + media: { + transform: { build: true, runtimeWith: "mediaTransformVar" }, + }, +}; + +/** + * The value each `build` key has in the project `cloudhal init` creates, which the bundle was + * built from, and the value a missing key means. A project may leave it out or set either. + */ +export const STARTER_BUILD_VALUES: Readonly> = { + "comments.available": [undefined, true], + "media.transform": [undefined, "none"], +}; + +export interface BuildKeyProblem { + readonly key: string; + readonly problem: "unknown" | "build"; + readonly value?: unknown; +} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +function classOf( + c: KeyClass | FeatureClass, + features: Readonly>, +): KeyClass { + if (typeof c === "string") return c; + return features[c.runtimeWith] === true ? "runtime" : "build"; +} + +/** + * The keys of a raw `cloudhal.json` the bundle cannot honour: unknown keys (and unknown sub-keys + * of `analytics`, `comments` and `media`), and `build` keys set to anything but the starter's. + */ +export function buildKeyProblems( + raw: Readonly>, + features: Readonly>, +): BuildKeyProblem[] { + const out: BuildKeyProblem[] = []; + const check = (key: string, c: KeyClass | FeatureClass, value: unknown) => { + if (classOf(c, features) !== "build") return; + const allowed = STARTER_BUILD_VALUES[key] ?? [undefined]; + if (!allowed.includes(value)) out.push({ key, problem: "build", value }); + }; + const table = CONFIG_KEY_CLASSES as unknown as Record< + string, + KeyClass | Record + >; + for (const [key, value] of Object.entries(raw)) { + if (!Object.hasOwn(table, key)) { + out.push({ key, problem: "unknown" }); + continue; + } + const c = table[key] as KeyClass | Record; + if (typeof c === "string") { + check(key, c, value); + continue; + } + // A mixed object: each sub-key on its own; a value of another shape leaves them all unset. + const sub = isObject(value) ? value : {}; + for (const k of Object.keys(sub)) + if (!Object.hasOwn(c, k)) out.push({ key: `${key}.${k}`, problem: "unknown" }); + for (const [k, kc] of Object.entries(c)) check(`${key}.${k}`, kc, sub[k]); + } + return out; +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index f95ace07..dd8e45ea 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -164,7 +164,7 @@ export interface ProjectConfig { export const CONFIG_FILE = "cloudhal.json"; /** The keys `ProjectConfig` owns; others (e.g. `media`, read by the starter) are kept on write. */ -const OWN_KEYS = new Set([ +export const OWN_KEYS: ReadonlySet = new Set([ "siteId", "sites", "installation", diff --git a/packages/cli/test/config-build-keys.test.ts b/packages/cli/test/config-build-keys.test.ts new file mode 100644 index 00000000..bd93fe98 --- /dev/null +++ b/packages/cli/test/config-build-keys.test.ts @@ -0,0 +1,142 @@ +/** + * The `cloudhal.json` key classes of `deploy --release` (RFC-0046 §11b, "Config build keys") and + * their drift guards: every key and mixed sub-key is classed (`tsc`, through the mapped table type, + * and the integration's option types below), every key `config.ts` owns and every `project.` + * the starter's `astro.config.mjs` reads is in the table, and the admin build bakes in nothing + * from the config. + */ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CloudhalRendererConfig } from "@cloudhal/astro-renderer/integration"; +import { describe, expect, it } from "vitest"; +import { prepareAdminWorker } from "../src/cloudflare/admin.ts"; +import { OWN_KEYS } from "../src/config.ts"; +import { buildKeyProblems, CONFIG_KEY_CLASSES } from "../src/config-build-keys.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const NO_FEATURES = { mediaTransformVar: false }; + +// Every sub-key the integration's options declare for media, analytics and comments is classed +// (a missing one is a type error here). +type Sub = keyof Required>; +const _media: Record, unknown> = CONFIG_KEY_CLASSES.media; +const _analytics: Record< + Sub, + unknown +> = CONFIG_KEY_CLASSES.analytics; +const _comments: Record< + Sub, + unknown +> = CONFIG_KEY_CLASSES.comments; +void [_media, _analytics, _comments]; + +describe("the config build keys", () => { + it("passes what `cloudhal init` writes, and deploy and runtime keys", () => { + const init = { + siteId: "site_abc123", + dataDir: ".cloudhal", + analytics: { enabled: true }, + comments: { available: true }, + }; + expect(buildKeyProblems(init, NO_FEATURES)).toEqual([]); + const more = { + ...init, + search: { provider: "ai-search" }, + comments: { available: true, rateLimit: true }, + analytics: { + enabled: false, + uniqueVisitors: true, + queryBudget: 10, + rateLimit: { limit: 5 }, + dev: true, + }, + domains: { site: "example.com" }, + mail: { from: "a@example.com" }, + ai: {}, + render: false, + observability: { logs: 0.5 }, + placement: "smart", + schedules: { plan: "paid" }, + media: { transform: "none" }, + }; + expect(buildKeyProblems(more, NO_FEATURES)).toEqual([]); + }); + + it("refuses comments.available false, and media.transform unless the bundle reads it as a var", () => { + expect(buildKeyProblems({ siteId: "s", comments: { available: false } }, NO_FEATURES)).toEqual([ + { key: "comments.available", problem: "build", value: false }, + ]); + const cdn = { siteId: "s", media: { transform: "cdn-cgi" } }; + expect(buildKeyProblems(cdn, NO_FEATURES)).toEqual([ + { key: "media.transform", problem: "build", value: "cdn-cgi" }, + ]); + expect(buildKeyProblems(cdn, { mediaTransformVar: true })).toEqual([]); + }); + + it("refuses keys and mixed sub-keys this CLI does not know", () => { + expect( + buildKeyProblems({ siteId: "s", theme: "x", comments: { moderation: true } }, NO_FEATURES), + ).toEqual([ + { key: "theme", problem: "unknown" }, + { key: "comments.moderation", problem: "unknown" }, + ]); + }); + + it("classes every key config.ts owns", () => { + for (const key of OWN_KEYS) expect(Object.keys(CONFIG_KEY_CLASSES)).toContain(key); + }); + + it("classes every project. the starter's astro.config.mjs reads", async () => { + const text = await readFile( + path.resolve(here, "../../../starters/blog/astro.config.mjs"), + "utf8", + ); + const keys = new Set(); + // The repository's own file, not external input; `project.` followed by a JS identifier. + for (const m of text.matchAll(/\bproject\.([A-Za-z_$][\w$]*)/g)) keys.add(m[1] as string); + expect([...keys].sort()).toEqual(["analytics", "comments", "dataDir", "media", "siteId"]); + for (const key of keys) expect(Object.keys(CONFIG_KEY_CLASSES)).toContain(key); + }); + + it("the admin build bakes in nothing from the config: only its wrangler config differs", async () => { + const project = path.resolve(here, "../../../starters/blog"); + const out: Record[] = []; + for (const variant of [ + {}, + { + mail: { from: "a@example.com" }, + ai: true, + browser: true, + observability: { logs: 0.5, traces: 0.1 }, + placement: "smart" as const, + domain: "studio.example.com", + siteId: "site_other1", + }, + ]) { + const dataDir = await mkdtemp(path.join(os.tmpdir(), "admin-determinism-")); + const a = await prepareAdminWorker({ + projectDir: project, + dataDir: path.relative(project, dataDir), + names: { name: "x" }, + studioDir: path.join(dataDir, "studio"), + themeDesign: "# Design\n", + ...variant, + }); + const read = (f: string) => readFile(path.join(a.dir, f), "utf8"); + out.push({ + worker: await read("worker.ts"), + skills: await read("skills.ts"), + design: await read("theme-design.ts"), + config: await read("wrangler.jsonc"), + }); + await rm(dataDir, { recursive: true, force: true }); + } + const [plain, varied] = out as [Record, Record]; + expect(varied.worker).toBe(plain.worker); + expect(varied.skills).toBe(plain.skills); + expect(varied.design).toBe(plain.design); + expect(varied.config).not.toBe(plain.config); + }); +}); diff --git a/packages/cli/test/deploy-release.test.ts b/packages/cli/test/deploy-release.test.ts new file mode 100644 index 00000000..e13402b3 --- /dev/null +++ b/packages/cli/test/deploy-release.test.ts @@ -0,0 +1,322 @@ +/** + * `cloudhal deploy --release` (RFC-0046 §11b) end to end with the synthetic signed bundle: no + * network (every URL comes from a recording fake), a throwaway root and yank key, a recording + * wrangler. Covers the refusals in their order, both trust sources, the attestation outcomes, + * extraction, the generated configs and the yank memory. + */ +import { existsSync } from "node:fs"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { DeployOptions } from "../src/commands/deploy/options.ts"; +import { deploy } from "../src/index.ts"; +import { + bundle, + cleanup, + offlineDir, + project, + recordingWrangler, + serve, + tmp, + trustWorld, + VERSION, +} from "./fixtures/release-deploy.ts"; + +afterEach(cleanup); + +const jsonc = async (file: string) => + JSON.parse((await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "")) as Record< + string, + unknown + >; + +/** Every file under `dir`, relative, sorted. */ +async function tree(dir: string, rel = ""): Promise { + const out: string[] = []; + for (const e of await readdir(path.join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) out.push(...(await tree(dir, r))); + else out.push(r); + } + return out.sort(); +} + +async function setup(over: { bundle?: Awaited> } = {}) { + const t = await trustWorld(); + const b = over.bundle ?? (await bundle()); + const world = { + trust: { keys: t.keys, yanked: t.yanked }, + release: "published", + bundle: b, + } as Parameters[0]; + const net = serve(world); + const projectDir = await project(); + const stateFile = path.join(tmp("release-deploy-state-"), "release-trust.json"); + const w = recordingWrangler(); + const options: DeployOptions = { + projectDir, + release: true, + dryRun: true, + cliVersion: VERSION, + pinnedKeys: b.keys, + trustRoots: t.roots, + trustStateFile: stateFile, + attest: async () => ({ status: "verified" }), + fetch: net.fetch, + wrangler: w.run, + wranglerVersion: "4.158.0", + }; + return { t, b, world, net, projectDir, stateFile, w, options }; +} + +const codeOf = async (p: Promise) => { + try { + await p; + return "ok"; + } catch (e) { + const code = (e as { code?: string }).code; + if (!code) throw e; + return code; + } +}; + +describe("deploy --release: trust", () => { + it("refuses before the root ceremony, naming it, and fetches nothing", async () => { + const s = await setup(); + const { trustRoots: _roots, ...options } = s.options; + await expect(deploy(options)).rejects.toMatchObject({ + code: "trust-unavailable", + message: expect.stringMatching(/root ceremony.*First-time setup/), + }); + expect(s.net.urls).toEqual([]); + }); + + it("fetches both lists on every run and refuses, with no fallback, when they are unreachable", async () => { + const s = await setup(); + s.world.trust = "down"; + await expect(deploy(s.options)).rejects.toMatchObject({ + code: "trust-unavailable", + message: expect.stringContaining("never falls back"), + }); + expect(s.net.urls.some((u) => u.includes("api.github.com"))).toBe(false); + }); + + it("stores the yank memory and refuses a rolled-back yank list on the next run", async () => { + const s = await setup(); + const next = await s.t.resign({ published: s.t.yanked }); + s.world.trust = { keys: s.t.keys, yanked: next }; + await deploy(s.options); + const memory = JSON.parse(await readFile(s.stateFile, "utf8")) as { + yankMemory: { serial: number }; + }; + expect(memory.yankMemory.serial).toBe(2); + // The older, validly signed list (serial 1) replayed: refused, no override. + s.world.trust = { keys: s.t.keys, yanked: s.t.yanked }; + expect(await codeOf(deploy(s.options))).toBe("trust-unavailable"); + }); + + it("refuses a release signed by a key the yank list revokes, and a yanked version", async () => { + const s = await setup(); + const revoked = await s.t.resign({ + revokedKeys: [ + { keyId: "test-release-key", revokedAt: "2026-11-14T00:00:00Z", reason: "Exposed." }, + ], + }); + s.world.trust = { keys: s.t.keys, yanked: revoked }; + expect(await codeOf(deploy({ ...s.options, trustStateFile: path.join(tmp(), "a.json") }))).toBe( + "key-revoked", + ); + const yanked = await s.t.resign({ + versions: [{ version: VERSION, yankedAt: "2026-11-14T00:00:00Z", reason: "Broken." }], + }); + s.world.trust = { keys: s.t.keys, yanked }; + expect(await codeOf(deploy({ ...s.options, trustStateFile: path.join(tmp(), "b.json") }))).toBe( + "yanked", + ); + }); +}); + +describe("deploy --release: which release", () => { + it("refuses v0.8.0 and older before anything is fetched", async () => { + const s = await setup(); + expect(await codeOf(deploy({ ...s.options, cliVersion: "0.8.0" }))).toBe("release-unavailable"); + expect(s.net.urls).toEqual([]); + }); + + it("refuses a missing release, a draft and a release without bundle assets", async () => { + for (const release of ["missing", "draft", "no-bundle"] as const) { + const s = await setup(); + s.world.release = release; + await expect(deploy(s.options)).rejects.toMatchObject({ + code: "release-unavailable", + message: expect.stringContaining("without --release"), + }); + expect(s.net.urls.some((u) => u.includes("/releases/download/"))).toBe(false); + } + }); + + it("refuses a tarball that does not match the signed statement, extracting nothing", async () => { + const good = await bundle(); + const s = await setup({ + bundle: { ...good, tarball: new Uint8Array([...good.tarball].reverse()) }, + }); + expect(await codeOf(deploy(s.options))).toBe("tarball-mismatch"); + expect(existsSync(path.join(s.projectDir, ".cloudhal/deploy/release", VERSION))).toBe(false); + }); +}); + +describe("deploy --release: the second factor", () => { + it("refuses a failed attestation, and an unavailable one without --allow-single-factor", async () => { + const s = await setup(); + const failed = { + ...s.options, + attest: async () => ({ status: "failed" as const, file: "manifest.json", reason: "exit 1" }), + }; + expect(await codeOf(deploy(failed))).toBe("attestation-failed"); + const unavailable = { + ...s.options, + attest: async () => ({ status: "unavailable" as const, reason: "gh is not installed" }), + }; + await expect(deploy(unavailable)).rejects.toMatchObject({ + code: "attestation-unavailable", + message: expect.stringMatching(/gh auth login.*--allow-single-factor/), + }); + expect(existsSync(path.join(s.projectDir, ".cloudhal/deploy/release", VERSION))).toBe(false); + const lines: string[] = []; + const r = await deploy({ ...unavailable, allowSingleFactor: true, log: (l) => lines.push(l) }); + expect(r.release?.attestation).toBe("single-factor"); + expect(lines.join("\n")).toContain("relies on the release key alone"); + }); +}); + +describe("deploy --release: extraction and configs", () => { + it("--dry-run verifies, extracts exactly the listed files and writes configs for those bytes", async () => { + const s = await setup(); + const r = await deploy(s.options); + expect(r.dryRun).toBe(true); + expect(r.release).toMatchObject({ version: VERSION, attestation: "verified", files: 6 }); + const dir = path.join(s.projectDir, ".cloudhal/deploy/release", VERSION); + expect(await tree(dir)).toEqual([ + "admin/assets/_headers", + "admin/assets/app.js", + "admin/modules/worker.js", + "site/assets/_headers", + "site/assets/fonts/v1/a.woff2", + "site/assets/index.html", + "site/modules/chunks/page.mjs", + "site/modules/entry.mjs", + ]); + const site = await jsonc(r.siteConfig); + expect(site).toMatchObject({ + name: "release-me-site", + main: "../../release/0.9.0/site/modules/entry.mjs", + base_dir: "../../release/0.9.0/site/modules", + no_bundle: true, + find_additional_modules: true, + preserve_file_names: true, + rules: [{ type: "ESModule", globs: ["chunks/page.mjs", "entry.mjs"], fallthrough: false }], + compatibility_date: "2026-10-01", + assets: { + binding: "ASSETS", + directory: "../../release/0.9.0/site/assets", + not_found_handling: "none", + }, + }); + expect(site.$schema).toBeUndefined(); + const admin = await jsonc(r.adminConfig); + expect(admin).toMatchObject({ + name: "release-me-admin", + main: "../../release/0.9.0/admin/modules/worker.js", + no_bundle: true, + rules: [{ type: "ESModule", globs: ["worker.js"], fallthrough: false }], + assets: { + binding: "ASSETS", + directory: "../../release/0.9.0/admin/assets", + run_worker_first: ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"], + }, + }); + // A dry run runs no wrangler at all. + expect(s.w.calls).toEqual([]); + }); + + it("deploys both Workers with the release configs (and wrangler never bundles)", async () => { + const s = await setup(); + const r = await deploy({ ...s.options, dryRun: false, healthTimeoutMs: 1 }); + expect(r.dryRun).toBe(false); + const deploys = s.w.calls.filter((c) => c.args[0] === "deploy"); + expect(deploys.map((c) => c.args.slice(0, 3))).toEqual([ + ["deploy", "-c", r.siteConfig], + ["deploy", "-c", r.adminConfig], + ]); + expect(deploys[0]?.cwd).toBe(path.dirname(r.siteConfig)); + }); + + it("installs offline with --from, fetching nothing", async () => { + const s = await setup(); + const from = offlineDir(s.b, s.t); + const r = await deploy({ ...s.options, from }); + expect(r.release?.version).toBe(VERSION); + expect(s.net.urls).toEqual([]); + await writeFile(path.join(from, "yanked.json"), ""); + expect( + await codeOf(deploy({ ...s.options, from, trustStateFile: path.join(tmp(), "c.json") })), + ).toBe("trust-unavailable"); + }); +}); + +describe("deploy --release: the project", () => { + it("refuses another installation, several sites and Workers Builds", async () => { + const s = await setup(); + const file = path.join(s.projectDir, "cloudhal.json"); + const cfg = JSON.parse(await readFile(file, "utf8")) as Record; + await writeFile(file, JSON.stringify({ ...cfg, installation: "../main" })); + expect(await codeOf(deploy(s.options))).toBe("project-unsupported"); + await writeFile(file, JSON.stringify({ ...cfg, sites: [cfg.siteId, "site_other2"] })); + expect(await codeOf(deploy(s.options))).toBe("project-unsupported"); + await writeFile(file, JSON.stringify(cfg)); + expect(await codeOf(deploy({ ...s.options, workersBuilds: true }))).toBe("project-unsupported"); + expect(s.net.urls).toEqual([]); + }); + + it("refuses a changed theme before downloading the tarball; --discard-local-theme lists what is not deployed", async () => { + const s = await setup(); + await writeFile(path.join(s.projectDir, "src/DESIGN.md"), "# Mine\n"); + await writeFile(path.join(s.projectDir, "public/extra.txt"), "x\n"); + await expect(deploy(s.options)).rejects.toMatchObject({ + code: "theme-differs", + message: expect.stringMatching( + /added: public\/extra\.txt\n {2}changed: src\/DESIGN\.md[\s\S]*--discard-local-theme/, + ), + }); + expect(s.net.urls.some((u) => u.endsWith(".tar.gz"))).toBe(false); + const lines: string[] = []; + const r = await deploy({ ...s.options, discardLocalTheme: true, log: (l) => lines.push(l) }); + expect(r.release?.notDeployed).toEqual(["public/extra.txt", "src/DESIGN.md"]); + expect(lines.join("\n")).toContain("these files of the project are not"); + // Nothing on disk was discarded. + expect(await readFile(path.join(s.projectDir, "src/DESIGN.md"), "utf8")).toBe("# Mine\n"); + }); + + it("refuses a build key the bundle cannot honour, with or without --discard-local-theme", async () => { + const s = await setup(); + const file = path.join(s.projectDir, "cloudhal.json"); + const cfg = JSON.parse(await readFile(file, "utf8")) as Record; + await writeFile(file, JSON.stringify({ ...cfg, comments: { available: false } })); + expect(await codeOf(deploy(s.options))).toBe("config-build-key"); + expect(await codeOf(deploy({ ...s.options, discardLocalTheme: true }))).toBe( + "config-build-key", + ); + await writeFile(file, JSON.stringify({ ...cfg, media: { transform: "cdn-cgi" } })); + expect(await codeOf(deploy(s.options))).toBe("config-build-key"); + await writeFile(file, JSON.stringify({ ...cfg, extra: 1 })); + await expect(deploy(s.options)).rejects.toThrow(/extra: this CLI does not know the key/); + }); + + it("refuses a wrangler older than the bundle's", async () => { + const s = await setup(); + await expect(deploy({ ...s.options, wranglerVersion: "4.150.0" })).rejects.toMatchObject({ + code: "wrangler-too-old", + message: expect.stringContaining("4.158.0"), + }); + }); +}); diff --git a/packages/cli/test/fixtures/release-deploy.ts b/packages/cli/test/fixtures/release-deploy.ts new file mode 100644 index 00000000..22ab37ed --- /dev/null +++ b/packages/cli/test/fixtures/release-deploy.ts @@ -0,0 +1,193 @@ +/** + * A world for `cloudhal deploy --release` tests (RFC-0046 §11b), with no network: a project from + * `cloudhal init`, a throwaway root and yank key with signed `keys.json` and `yanked.json`, the + * synthetic signed bundle of `@cloudhal/release-verify`'s tests, a fetch that serves all of it + * (and records every URL), and a wrangler stand-in that records its calls. + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { releaseAssetUrls, TRUST_BASE_URL } from "@cloudhal/release-verify"; +import { rootInit, signKeys, signYanked, yankInit } from "../../../../scripts/release-keys.ts"; +import { + type Synthetic, + type SyntheticOptions, + synthetic, +} from "../../../release-verify/test/helpers.ts"; +import type { WranglerRunner } from "../../src/cloudflare/wrangler.ts"; +import { init } from "../../src/index.ts"; + +export const VERSION = "0.9.0"; +const NOW = Math.floor(Date.now() / 1000) * 1000; +const iso = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z"); +const DAY = 86_400_000; +const quiet = () => {}; + +const dirs: string[] = []; +export const tmp = (prefix = "release-deploy-") => { + const d = mkdtempSync(path.join(os.tmpdir(), prefix)); + dirs.push(d); + return d; +}; +export const cleanup = () => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}; + +export interface TrustWorld { + readonly roots: Readonly>; + readonly keys: { document: Uint8Array; signature: Uint8Array }; + readonly yanked: { document: Uint8Array; signature: Uint8Array }; + /** Signs another yank list (same keys), e.g. one that yanks or revokes. */ + readonly resign: (o: { + versions?: { version: string; yankedAt: string; reason: string }[]; + revokedKeys?: { keyId: string; revokedAt: string; reason: string }[]; + published?: { document: Uint8Array; signature: Uint8Array }; + }) => Promise<{ document: Uint8Array; signature: Uint8Array }>; +} + +/** A root, a yank key, and both signed lists. */ +export async function trustWorld(): Promise { + const keysDir = tmp("release-deploy-keys-"); + const root = rootInit({ + keyId: "cloudhal-root-2026a", + out: keysDir, + passphrase: "a long passphrase", + log: quiet, + }); + const yank = yankInit({ keyId: "cloudhal-yank-2026a", out: keysDir, log: quiet }); + const keys = await signKeys({ + rootKey: readFileSync(path.join(keysDir, "cloudhal-root-2026a.key.enc"), "utf8"), + passphrase: "a long passphrase", + rootPub: root, + source: { + keys: [ + { + keyId: "cloudhal-yank-2026a", + publicKey: yank.publicKey, + role: "yank", + notBefore: iso(NOW - DAY), + notAfter: iso(NOW + 300 * DAY), + status: "active", + }, + ], + }, + out: tmp(), + now: NOW, + }); + const roots = { [root.keyId]: { publicKey: root.publicKey } }; + const resign: TrustWorld["resign"] = (o) => + signYanked({ + keyId: "cloudhal-yank-2026a", + keyPem: readFileSync(path.join(keysDir, "cloudhal-yank-2026a.pem"), "utf8"), + keyList: keys, + roots, + source: { versions: o.versions ?? [], revokedKeys: o.revokedKeys ?? [] }, + published: o.published, + out: tmp(), + now: NOW, + }); + return { roots, keys, yanked: await resign({}), resign }; +} + +export interface Served { + readonly fetch: typeof fetch; + readonly urls: string[]; +} + +/** What the fake network serves; any other URL answers 404 (and is recorded). */ +export interface World { + trust: { keys: TrustWorld["keys"]; yanked: TrustWorld["yanked"] } | "down"; + release: "published" | "draft" | "missing" | "no-bundle"; + bundle: Synthetic; +} + +export function serve(world: World): Served { + const urls: string[] = []; + const f = async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + urls.push(url); + if (url.startsWith(TRUST_BASE_URL)) { + if (world.trust === "down") return new Response("bad gateway", { status: 502 }); + const name = url.slice(TRUST_BASE_URL.length); + const t = world.trust; + const body = { + "keys.json": t.keys.document, + "keys.json.sig": t.keys.signature, + "yanked.json": t.yanked.document, + "yanked.json.sig": t.yanked.signature, + }[name]; + return body ? new Response(body) : new Response("no", { status: 404 }); + } + if (url === `https://api.github.com/repos/cloudhal-dev/cloudhal/releases/tags/v${VERSION}`) { + if (world.release === "missing") return new Response("{}", { status: 404 }); + const names = + world.release === "no-bundle" + ? ["cloudhal-cli.tgz"] + : releaseAssetUrls(VERSION).map((a) => a.name); + return Response.json({ + draft: world.release === "draft", + assets: names.map((name) => ({ name })), + }); + } + const asset = releaseAssetUrls(VERSION).find((a) => a.url === url); + if (asset) return new Response(assetBytes(world.bundle, asset.name)); + return new Response("not found", { status: 404 }); + }; + return { fetch: f as typeof fetch, urls }; +} + +/** The bytes of one release asset of the synthetic bundle. */ +export function assetBytes(b: Synthetic, name: string): Uint8Array { + const i = b.input; + const map: Record = { + [`cloudhal-release-${VERSION}.tar.gz`]: b.tarball, + "manifest.json": i.manifest, + "release-statement.json": i.statement, + "release-statement.sig": i.signature, + [`cloudhal-release-${VERSION}.sigstore.json`]: new TextEncoder().encode("{}\n"), + }; + const bytes = map[name]; + if (!bytes) throw new Error(`no asset ${name}`); + return bytes; +} + +/** Every file `--from ` reads, written into a fresh directory. */ +export function offlineDir(b: Synthetic, t: Pick): string { + const dir = tmp("release-deploy-from-"); + for (const a of releaseAssetUrls(VERSION)) + writeFileSync(path.join(dir, a.name), assetBytes(b, a.name)); + writeFileSync(path.join(dir, "keys.json"), t.keys.document); + writeFileSync(path.join(dir, "keys.json.sig"), t.keys.signature); + writeFileSync(path.join(dir, "yanked.json"), t.yanked.document); + writeFileSync(path.join(dir, "yanked.json.sig"), t.yanked.signature); + return dir; +} + +export const bundle = (opts?: SyntheticOptions) => synthetic(opts); + +/** A fresh project from `cloudhal init`. */ +export async function project(): Promise { + const dir = path.join(tmp("release-deploy-project-"), "site"); + await init({ dir, name: "Release Me" }); + return dir; +} + +/** A wrangler stand-in: D1, R2 and deploys succeed; every call is recorded. */ +export function recordingWrangler(): { + run: WranglerRunner; + calls: { args: string[]; cwd: string }[]; +} { + const calls: { args: string[]; cwd: string }[] = []; + let created = false; + const run: WranglerRunner = async (args, { cwd }) => { + calls.push({ args, cwd }); + const ok = (stdout = "") => ({ code: 0, stdout, stderr: "", records: [] }); + if (args[0] === "d1" && args[1] === "info") + return created + ? ok(JSON.stringify({ uuid: "11111111-2222-3333-4444-555555555555" })) + : { code: 1, stdout: "", stderr: "not found", records: [] }; + if (args[0] === "d1" && args[1] === "create") created = true; + return ok(); + }; + return { run, calls }; +} diff --git a/packages/cli/test/release-extract.test.ts b/packages/cli/test/release-extract.test.ts new file mode 100644 index 00000000..1a3695ca --- /dev/null +++ b/packages/cli/test/release-extract.test.ts @@ -0,0 +1,71 @@ +/** + * Extraction of a verified release (RFC-0046 §11b): a fresh directory every time (no stale + * files), nothing outside it, no file written twice, `_headers` from the manifest. The verifier + * already refuses unsafe paths and duplicates; these are the deployer's own second line. + */ +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { VerifiedEntry, VerifiedRelease } from "@cloudhal/release-verify"; +import { afterEach, describe, expect, it } from "vitest"; +import { extractRelease } from "../src/commands/deploy/release/extract.ts"; +import { cleanup, tmp } from "./fixtures/release-deploy.ts"; + +afterEach(cleanup); + +const bytes = (s: string) => new TextEncoder().encode(s); +const worker = (main: string, headers = "") => ({ + mainModule: main, + modules: [{ path: main, type: "esm", bytes: 1, sha256: "0".repeat(64) }], + assets: { count: 0, bytes: 0, files: [], headers }, +}); + +function release(entries: Partial[]): VerifiedRelease { + return { + statement: {} as VerifiedRelease["statement"], + manifest: { + workers: { site: worker("entry.mjs", "/*\n X-A: 1\n"), admin: worker("worker.js") }, + } as unknown as VerifiedRelease["manifest"], + async *entries() { + for (const e of entries) yield { tarPath: "", bytes: bytes("x"), ...e } as VerifiedEntry; + }, + }; +} + +describe("extractRelease", () => { + it("writes into a fresh directory: stale files go, _headers comes from the manifest", async () => { + const dir = path.join(tmp("extract-"), "release/0.9.0"); + await mkdir(path.join(dir, "site/assets"), { recursive: true }); + await writeFile(path.join(dir, "site/assets/stale.html"), "old"); + const r = await extractRelease( + release([ + { worker: "site", kind: "module", path: "entry.mjs" }, + { worker: "site", kind: "asset", path: "/index.html" }, + { worker: null, kind: "starter", path: "starter/site.cloudhal" }, + ]), + dir, + ); + expect(r.files).toBe(2); + expect(existsSync(path.join(dir, "site/assets/stale.html"))).toBe(false); + expect(await readFile(path.join(dir, "site/assets/_headers"), "utf8")).toBe("/*\n X-A: 1\n"); + expect(existsSync(path.join(dir, "admin/assets/_headers"))).toBe(false); + expect(r.site.modulesByType).toEqual({ esm: ["entry.mjs"] }); + }); + + it("refuses an entry outside the directory and an entry written twice", async () => { + const dir = path.join(tmp("extract-"), "release/0.9.0"); + await expect( + extractRelease(release([{ worker: "site", kind: "module", path: "../../escape.mjs" }]), dir), + ).rejects.toMatchObject({ code: "extract-unsafe" }); + expect(existsSync(path.join(dir, "../escape.mjs"))).toBe(false); + await expect( + extractRelease( + release([ + { worker: "site", kind: "asset", path: "/a.txt" }, + { worker: "site", kind: "asset", path: "/a.txt" }, + ]), + dir, + ), + ).rejects.toMatchObject({ code: "extract-unsafe", message: expect.stringContaining("twice") }); + }); +}); diff --git a/packages/cli/test/release-theme-check.test.ts b/packages/cli/test/release-theme-check.test.ts new file mode 100644 index 00000000..2352d4f8 --- /dev/null +++ b/packages/cli/test/release-theme-check.test.ts @@ -0,0 +1,132 @@ +/** + * The theme check of `deploy --release` (RFC-0046 §11b, "Tests for the check"): a copy of this + * CLI's own starter passes (also with CRLF line endings); every build input that differs is + * named; OS metadata files, `vite.config.*`, `.env` and non-build files pass; PostCSS configs are + * searched from the project up to Vite's workspace root, and no further. + */ +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + cliStarter, + postcssSearchDirs, + type ThemeDifference, + themeDifferences, +} from "../src/commands/deploy/release/theme-check.ts"; +import { cleanup, tmp } from "./fixtures/release-deploy.ts"; + +let starter: Awaited>; +beforeAll(async () => { + starter = await cliStarter(); +}); +afterEach(cleanup); + +/** A copy of the starter as `cloudhal init` copies it, standalone (its own pnpm-workspace.yaml). */ +async function copy(inWorkspace?: string): Promise { + const parent = inWorkspace ?? tmp("theme-check-"); + const dir = path.join(parent, "site"); + await cp(starter.dir, dir, { recursive: true, filter: starter.keep }); + if (!inWorkspace) await writeFile(path.join(dir, "pnpm-workspace.yaml"), "packages: []\n"); + await writeFile(path.join(dir, "cloudhal.json"), "{}\n"); + return dir; +} + +const named = (diffs: ThemeDifference[]) => diffs.map((d) => `${d.change}: ${d.path}`); + +describe("the theme check", () => { + it("passes an unmodified starter, also with CRLF line endings", async () => { + const dir = await copy(); + expect(await themeDifferences(dir, starter)).toEqual([]); + const file = path.join(dir, "astro.config.mjs"); + await writeFile(file, (await readFile(file, "utf8")).replaceAll("\n", "\r\n")); + expect(await themeDifferences(dir, starter)).toEqual([]); + }); + + it("names an edited src file, an added public file, a deleted starter file, an edited astro.config.mjs and an added skill", async () => { + const dir = await copy(); + await writeFile(path.join(dir, "src/DESIGN.md"), "# Mine\n"); + await writeFile(path.join(dir, "public/extra.txt"), "x\n"); + await rm(path.join(dir, "src/blocks.ts")); + await writeFile(path.join(dir, "astro.config.mjs"), "export default {};\n"); + await mkdir(path.join(dir, "skills/x"), { recursive: true }); + await writeFile(path.join(dir, "skills/x/SKILL.md"), "---\nname: x\n---\n"); + expect(named(await themeDifferences(dir, starter))).toEqual([ + "changed: astro.config.mjs", + "added: public/extra.txt", + "added: skills/x/SKILL.md", + "changed: src/DESIGN.md", + "missing: src/blocks.ts", + ]); + }); + + it("names an edited tsconfig.json, an added jsconfig.json, PostCSS files and a postcss key", async () => { + const dir = await copy(); + await writeFile(path.join(dir, "tsconfig.json"), "{}\n"); + await writeFile(path.join(dir, "jsconfig.json"), "{}\n"); + await writeFile(path.join(dir, "postcss.config.mjs"), "export default {};\n"); + await writeFile(path.join(dir, ".postcssrc.json"), "{}\n"); + const pkgFile = path.join(dir, "package.json"); + const pkg = JSON.parse(await readFile(pkgFile, "utf8")) as Record; + await writeFile(pkgFile, JSON.stringify({ ...pkg, postcss: { plugins: {} } })); + expect(named(await themeDifferences(dir, starter))).toEqual([ + "added: .postcssrc.json", + "added: jsconfig.json", + "added: postcss.config.mjs", + "changed: tsconfig.json", + "added: package.json#postcss", + ]); + }); + + it("searches PostCSS configs up to a pnpm workspace root, including package.json keys between", async () => { + const ws = tmp("theme-check-ws-"); + await writeFile(path.join(ws, "pnpm-workspace.yaml"), "packages: ['apps/*']\n"); + await writeFile(path.join(ws, "postcss.config.cjs"), "module.exports = {};\n"); + await mkdir(path.join(ws, "apps")); + await writeFile(path.join(ws, "apps/package.json"), JSON.stringify({ postcss: {} })); + const dir = await copy(path.join(ws, "apps")); + expect(await postcssSearchDirs(dir)).toEqual([dir, path.join(ws, "apps"), ws]); + expect(named(await themeDifferences(dir, starter))).toEqual([ + "added: ../package.json#postcss", + "added: ../../postcss.config.cjs", + ]); + }); + + it("ignores PostCSS configs above the workspace root, and above a standalone project", async () => { + const outer = tmp("theme-check-outer-"); + await writeFile(path.join(outer, "postcss.config.cjs"), "module.exports = {};\n"); + const ws = path.join(outer, "ws"); + await mkdir(ws); + await writeFile(path.join(ws, "pnpm-workspace.yaml"), "packages: []\n"); + const inside = await copy(ws); + expect(await themeDifferences(inside, starter)).toEqual([]); + // Standalone (its own pnpm-workspace.yaml, as `cloudhal init` writes): the project alone. + const alone = path.join(outer, "alone"); + await mkdir(alone); + const dir = await copy(alone); + expect(await postcssSearchDirs(dir)).toEqual([dir]); + expect(await themeDifferences(dir, starter)).toEqual([]); + }); + + it("passes OS metadata files, vite.config.ts, .env and files outside the build inputs", async () => { + const dir = await copy(); + await mkdir(path.join(dir, "public/fonts/v1"), { recursive: true }); + await writeFile(path.join(dir, "public/fonts/v1/.DS_Store"), "x"); + await writeFile(path.join(dir, "src/Thumbs.db"), "x"); + await writeFile(path.join(dir, "src/desktop.ini"), "x"); + await writeFile(path.join(dir, "vite.config.ts"), "export default {};\n"); + await writeFile(path.join(dir, ".env"), "X=1\n"); + await writeFile(path.join(dir, "README.md"), "# Mine\n"); + await writeFile(path.join(dir, ".gitignore"), "x\n"); + await writeFile(path.join(dir, "wrangler.jsonc"), "{}\n"); + expect(await themeDifferences(dir, starter)).toEqual([]); + }); + + it("refuses a starter from another version until --discard-local-theme (a changed starter file)", async () => { + // An older project's starter differs wherever the starter changed between the versions; the + // check sees that as a difference like any edit. + const dir = await copy(); + const file = path.join(dir, "src/blocks.ts"); + await writeFile(file, `${await readFile(file, "utf8")}\n// from an older starter\n`); + expect(named(await themeDifferences(dir, starter))).toEqual(["changed: src/blocks.ts"]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 086f1db9..2393662c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,6 +766,9 @@ importers: '@cloudhal/mcp-server': specifier: workspace:* version: link:../mcp-server + '@cloudhal/release-verify': + specifier: workspace:* + version: link:../release-verify '@cloudhal/schedules': specifier: workspace:* version: link:../schedules From ccee8a340c1b80def20f50cc1d22635aa8aa78ba Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:31:12 +0300 Subject: [PATCH 2/3] fix(cli): PR #238 review round 1 (#227) - release-verify typechecks under every consumer's lib settings (create-cloudhal: @types/node only): ArrayBuffer-backed WebCrypto inputs, the gzip pipe typed through, the reader's result type inferred. - deploy --release refuses a version argument (npx @cloudhal/cli@). - --discard-local-theme lists every file not deployed. - Steps 1-6 (trust, lookup, checks, verify, attestation, extract) run before loadDeploy: no prompt, cloudhal.json write or store open before then. - The yank memory is re-read and merged monotonically before the rename; a failed save is trust-state, naming the file. - An attestation that could not finish (timeout, signal, rate limit, network) says so; still a refusal. - GH_TOKEN/GITHUB_TOKEN for the API lookup only; 403/429 is github-rate-limited. - An existing skills/ (even empty) is a theme difference. - Docs: every check and refusal in installation.md; a README entry. - Drift guard: astro-renderer's singleSiteId, CLOUDHAL_SITE_ID over the built id (test). The dataDir guard is #239. - Tests: URL checks compare hostnames (CodeQL). Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/deploy-release-227.md | 4 + docs/start/installation.md | 36 +++-- .../astro-renderer/src/cloudflare-entry.ts | 10 +- packages/astro-renderer/src/site-dispatch.ts | 13 ++ .../astro-renderer/test/site-dispatch.test.ts | 41 +++++ packages/cli/README.md | 12 ++ packages/cli/src/cli/commands-remote.ts | 14 +- .../cli/src/commands/deploy/release/checks.ts | 2 +- .../cli/src/commands/deploy/release/errors.ts | 4 + .../commands/deploy/release/fetch-release.ts | 15 +- .../cli/src/commands/deploy/release/index.ts | 40 ++++- .../commands/deploy/release/theme-check.ts | 16 +- .../cli/src/commands/deploy/release/trust.ts | 45 +++++- packages/cli/test/deploy-release.test.ts | 147 +++++++++++++++++- packages/cli/test/fixtures/release-deploy.ts | 11 +- packages/cli/test/release-theme-check.test.ts | 10 ++ packages/release-verify/src/crypto.ts | 24 ++- packages/release-verify/src/tar.ts | 8 +- 18 files changed, 405 insertions(+), 47 deletions(-) create mode 100644 packages/astro-renderer/test/site-dispatch.test.ts diff --git a/.changeset/deploy-release-227.md b/.changeset/deploy-release-227.md index 816e70f1..6c16ae2f 100644 --- a/.changeset/deploy-release-227.md +++ b/.changeset/deploy-release-227.md @@ -1,5 +1,7 @@ --- "@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). @@ -14,3 +16,5 @@ - **Offline and dry runs.** `--from ` 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. diff --git a/docs/start/installation.md b/docs/start/installation.md index 0a8db232..4a3e1660 100644 --- a/docs/start/installation.md +++ b/docs/start/installation.md @@ -96,17 +96,35 @@ Then run `npx cloudhal deploy` to update a deployment. Release notes are in each ### 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. +`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@ deploy --release`). -- **What it checks.** - - The signed key and yank lists from cloudhal.dev, on every run. It never falls back to an older copy, and it remembers what it has seen in `~/.cloudhal/release-trust.json`. - - The release's signature, and every file against its digest. - - The build-provenance attestation, which 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. -- **Theme check.** The theme files (`src/`, `public/`, `astro.config.mjs`, `tsconfig.json`, any PostCSS config) must equal the CLI's own starter. Otherwise, run `cloudhal deploy` without `--release`, or add `--discard-local-theme` to deploy the official theme anyway. Your files stay on disk. -- **Offline.** `--from ` installs from the five release assets plus `keys.json` and `yanked.json` (each with its `.sig`) in a directory. -- **Dry run.** `--release --dry-run` verifies, extracts and writes the configs, then stops before wrangler. +**What it checks, in this order.** Nothing is uploaded, and nothing is written outside `.cloudhal/deploy/`, until all of these pass. -`--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. +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 ` 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 diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index 741c53fb..4b17f569 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -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, @@ -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), }); diff --git a/packages/astro-renderer/src/site-dispatch.ts b/packages/astro-renderer/src/site-dispatch.ts index 1519fe51..cfc0e8b2 100644 --- a/packages/astro-renderer/src/site-dispatch.ts +++ b/packages/astro-renderer/src/site-dispatch.ts @@ -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" }; diff --git a/packages/astro-renderer/test/site-dispatch.test.ts b/packages/astro-renderer/test/site-dispatch.test.ts new file mode 100644 index 00000000..17dc1920 --- /dev/null +++ b/packages/astro-renderer/test/site-dispatch.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index d439005f..31f4c1a8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ` 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) diff --git a/packages/cli/src/cli/commands-remote.ts b/packages/cli/src/cli/commands-remote.ts index fc8df58d..984e0330 100644 --- a/packages/cli/src/cli/commands-remote.ts +++ b/packages/cli/src/cli/commands-remote.ts @@ -25,7 +25,10 @@ function deployFlags(values: CliValues): { } /** RFC-0046 §11b: this CLI's signed release; its two flags are never implied. */ -function releaseFlags(values: CliValues): { +export function releaseFlags( + values: CliValues, + rest: readonly string[], +): { release?: true; from?: string; allowSingleFactor?: true; @@ -38,6 +41,11 @@ function releaseFlags(values: CliValues): { throw new Error("--from, --allow-single-factor and --discard-local-theme go with --release"); return {}; } + // `deploy --release 0.9.0` would install this CLI's version, not 0.9.0: refuse the argument. + if (rest.length > 0) + throw new Error( + `--release installs this CLI's own version and takes no argument (got "${rest.join(" ")}"). To install another version, run that version of the CLI: npx @cloudhal/cli@ deploy --release`, + ); return { release: true, ...(values.from ? { from: values.from } : {}), @@ -46,7 +54,7 @@ function releaseFlags(values: CliValues): { }; } -const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { +const deployCommand: CliCommand = async (rest, values, { projectDir }) => { const { where, ticker, plan } = deployFlags(values); const tty = process.stdin.isTTY && process.stdout.isTTY; const r = await deploy({ @@ -58,7 +66,7 @@ const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { ...(plan ? { plan } : {}), ...(values["force-downgrade"] ? { forceDowngrade: true } : {}), ...(values["no-build-studio"] ? { buildStudio: false } : {}), - ...releaseFlags(values), + ...releaseFlags(values, rest), log: (l) => console.log(l), // The analytics and data-location questions need a person at a terminal. ...(tty ? { prompt: askYesNo, askJurisdiction } : {}), diff --git a/packages/cli/src/commands/deploy/release/checks.ts b/packages/cli/src/commands/deploy/release/checks.ts index 654cf64d..27663133 100644 --- a/packages/cli/src/commands/deploy/release/checks.ts +++ b/packages/cli/src/commands/deploy/release/checks.ts @@ -50,7 +50,7 @@ export async function checkTheme( `--release installs this CLI's official theme, and these build inputs differ from it:\n${listDifferences(diffs)}\nRun \`cloudhal deploy\` without --release to build the project as it is, or add --discard-local-theme to deploy the official theme anyway (your files stay on disk; they are not deployed).`, ); log( - `--discard-local-theme: the official theme is deployed; these files of the project are not:\n${listDifferences(diffs)}`, + `--discard-local-theme: the official theme is deployed; these ${diffs.length} files of the project are not:\n${listDifferences(diffs, true)}`, ); return diffs; } diff --git a/packages/cli/src/commands/deploy/release/errors.ts b/packages/cli/src/commands/deploy/release/errors.ts index 79e9fc0c..15cc3a83 100644 --- a/packages/cli/src/commands/deploy/release/errors.ts +++ b/packages/cli/src/commands/deploy/release/errors.ts @@ -6,8 +6,12 @@ export type ReleaseDeployCode = /** No published release with a bundle for this version: deploy without `--release`. */ | "release-unavailable" + /** GitHub's API refused the lookup (403/429): its rate limit, not the release. */ + | "github-rate-limited" /** The signed key and yank lists cannot be checked (no pinned root, a fetch or check failed). */ | "trust-unavailable" + /** The lists verified, but the CLI's yank memory could not be saved. */ + | "trust-state" /** The project's theme files differ from this CLI's starter (`--discard-local-theme`). */ | "theme-differs" /** `cloudhal.json` sets a build key the bundle cannot honour, or a key this CLI does not know. */ diff --git a/packages/cli/src/commands/deploy/release/fetch-release.ts b/packages/cli/src/commands/deploy/release/fetch-release.ts index 888e5dcc..cd5aa624 100644 --- a/packages/cli/src/commands/deploy/release/fetch-release.ts +++ b/packages/cli/src/commands/deploy/release/fetch-release.ts @@ -42,6 +42,8 @@ export interface ReleaseSource { readonly version: string; readonly from?: string | undefined; readonly fetch?: typeof fetch; + /** A GitHub token for the API lookup only (default: `GH_TOKEN`, else `GITHUB_TOKEN`). */ + readonly githubToken?: string | undefined; /** `.cloudhal/deploy/release-download//`. */ readonly dir: string; } @@ -74,8 +76,14 @@ async function checkPublished(s: ReleaseSource): Promise { const url = `https://api.github.com/repos/${RELEASE_REPOSITORY}/releases/tags/v${s.version}`; let res: Response; try { + // The token goes to api.github.com only (a higher rate limit), never to a download URL. + const token = s.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; res = await (s.fetch ?? fetch)(url, { - headers: { accept: "application/vnd.github+json", "user-agent": "cloudhal-cli" }, + headers: { + accept: "application/vnd.github+json", + "user-agent": "cloudhal-cli", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, }); } catch (e) { return unavailable(s.version, `GitHub could not be reached (${(e as Error).message})`); @@ -85,6 +93,11 @@ async function checkPublished(s: ReleaseSource): Promise { s.version, "there is no published release with that tag (a draft is not published yet)", ); + if (res.status === 403 || res.status === 429) + refuseDeploy( + "github-rate-limited", + `GitHub's API refused the release lookup (${res.status}, most likely its rate limit for unauthenticated requests). Try again later, or set GH_TOKEN (any token, no scopes needed) and run again.`, + ); if (!res.ok) unavailable(s.version, `GitHub answered ${res.status} for the release`); const release = (await res.json()) as { draft?: unknown; assets?: { name?: unknown }[] }; if (release.draft !== false) unavailable(s.version, "the release is still a draft"); diff --git a/packages/cli/src/commands/deploy/release/index.ts b/packages/cli/src/commands/deploy/release/index.ts index 4d2e73e1..bdb5f659 100644 --- a/packages/cli/src/commands/deploy/release/index.ts +++ b/packages/cli/src/commands/deploy/release/index.ts @@ -16,6 +16,7 @@ * Nothing is written outside `.cloudhal/deploy/`, and nothing is uploaded, before step 5 passes. * `--dry-run` stops after step 6. */ +import { mkdir } from "node:fs/promises"; import path from "node:path"; import { mergeTrust, @@ -31,7 +32,6 @@ import { type SchedulerTicker, } from "../../../cloudflare/config.ts"; import { readProjectConfig } from "../../../config.ts"; -import type { DeployContext } from "../context.ts"; import { deploySecrets, finishDeploy } from "../finish.ts"; import type { DeployOptions, DeployResult } from "../options.ts"; import { @@ -45,6 +45,7 @@ import { downgradeGuard } from "../schedules-guard.ts"; import { attachedWorkers } from "../served-sites.ts"; import { commentsResult } from "../settings.ts"; import { shipWorkers } from "../ship.ts"; +import { deployDir } from "../state.ts"; import { checkBuildKeys, checkProjectShape, checkTheme, checkWrangler } from "./checks.ts"; import { writeReleaseConfigs } from "./configs.ts"; import { type ReleaseDeployCode, refuseDeploy } from "./errors.ts"; @@ -79,6 +80,19 @@ function unverifiedFacts(bytes: Uint8Array): { } } +/** + * Why the attestation of `file` failed, in words: a check that could not finish (a timeout, a + * signal, GitHub's rate limit, the network) is not evidence about the files, but still a refusal. + * Only literal alternatives are matched, so the scan is linear in `reason`. + */ +export function attestationFailure(file: string, reason: string): string { + const incomplete = + /no answer within|stopped by|rate limit|HTTP 403|HTTP 429|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|getaddrinfo|dial tcp|TLS handshake|connection reset|network/i; + if (reason.length <= 2000 && incomplete.test(reason)) + return `the build-provenance attestation of ${file} could not be checked (${reason}). This is still a refusal: nothing was deployed. Try again later.`; + return `the build-provenance attestation of ${file} failed (${reason}): these files were not built by this repository's bundle-build.yml. Nothing was deployed.`; +} + async function attestation( options: DeployOptions, input: { dir: string; version: string; commit: string }, @@ -93,7 +107,7 @@ async function attestation( if (r.status === "failed") return refuseDeploy( "attestation-failed" satisfies ReleaseDeployCode, - `the build-provenance attestation of ${r.file} failed (${r.reason}): these files were not built by this repository's bundle-build.yml. Nothing was deployed.`, + attestationFailure(r.file, r.reason), ); if (!options.allowSingleFactor) return refuseDeploy( @@ -107,8 +121,11 @@ async function attestation( } /** Steps 1–6 (see the file comment): the verified, extracted release. */ -async function verifiedRelease(ctx: DeployContext, version: string) { - const { options, log } = ctx; +async function verifiedRelease( + options: DeployOptions, + where: { dir: string; version: string; log: (line: string) => void }, +) { + const { dir, version, log } = where; const trust = await loadTrust({ from: options.from, ...(options.fetch ? { fetch: options.fetch } : {}), @@ -122,7 +139,7 @@ async function verifiedRelease(ctx: DeployContext, version: string) { version, from: options.from, ...(options.fetch ? { fetch: options.fetch } : {}), - dir: path.join(ctx.dir, "release-download", version), + dir: path.join(dir, "release-download", version), }; const files = await fetchRelease(source); const facts = unverifiedFacts(files.manifest); @@ -148,7 +165,7 @@ async function verifiedRelease(ctx: DeployContext, version: string) { { dir: files.dir, version, commit: release.statement.commit }, log, ); - const extracted = await extractRelease(release, path.join(ctx.dir, "release", version)); + const extracted = await extractRelease(release, path.join(dir, "release", version)); log( `extracted ${extracted.files} verified files into ${path.relative(options.projectDir, extracted.dir)}`, ); @@ -160,8 +177,17 @@ export async function deployRelease(options: DeployOptions): Promise {}); + const { release, extracted, attested, notDeployed } = await verifiedRelease(options, { + dir, + version, + log, + }); const ctx = await loadDeploy(options); - const { release, extracted, attested, notDeployed } = await verifiedRelease(ctx, version); const manifest = release.manifest; const { analytics, comments } = await analyticsAndComments(ctx); diff --git a/packages/cli/src/commands/deploy/release/theme-check.ts b/packages/cli/src/commands/deploy/release/theme-check.ts index 8587b035..15b288e4 100644 --- a/packages/cli/src/commands/deploy/release/theme-check.ts +++ b/packages/cli/src/commands/deploy/release/theme-check.ts @@ -20,7 +20,7 @@ * `.DS_Store`, `Thumbs.db` and `desktop.ini` are skipped wherever they appear (exact basenames). */ import { existsSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { assetDir } from "../../../assets.ts"; import { starterFilter } from "../../../starter-files.ts"; @@ -47,6 +47,7 @@ type Keep = (abs: string) => boolean; async function filesUnder(root: string, rel: string, keep: Keep): Promise { const dir = path.join(root, rel); if (!existsSync(dir)) return []; + if (!(await stat(dir)).isDirectory()) return [rel]; // e.g. a file named `skills` const out: string[] = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const r = `${rel}/${entry.name}`; @@ -149,6 +150,10 @@ export async function themeDifferences( ) out.push({ path: f, change: "changed" }); } + // `resolveSkillsDir` takes `skills/` whenever it exists, even empty: that alone is a difference. + const skills = (root: string) => existsSync(path.join(root, "skills")); + if (skills(projectDir) && !skills(starter.dir) && !out.some((d) => d.path.startsWith("skills"))) + out.push({ path: "skills/", change: "added" }); // `postcss` in the project's package.json, compared with the starter's. const [own, starters] = [await postcssKey(projectDir), await postcssKey(starter.dir)]; if (own !== starters) @@ -167,9 +172,10 @@ export async function themeDifferences( return out; } -/** The differences as lines: the first 20, then a count. */ -export function listDifferences(diffs: readonly ThemeDifference[]): string { - const lines = diffs.slice(0, 20).map((d) => ` ${d.change}: ${d.path}`); - if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more`); +/** The differences as lines: the first 20 and a count, or with `all` every one. */ +export function listDifferences(diffs: readonly ThemeDifference[], all = false): string { + const shown = all ? diffs : diffs.slice(0, 20); + const lines = shown.map((d) => ` ${d.change}: ${d.path}`); + if (diffs.length > shown.length) lines.push(` … and ${diffs.length - shown.length} more`); return lines.join("\n"); } diff --git a/packages/cli/src/commands/deploy/release/trust.ts b/packages/cli/src/commands/deploy/release/trust.ts index 96303c1b..d1988818 100644 --- a/packages/cli/src/commands/deploy/release/trust.ts +++ b/packages/cli/src/commands/deploy/release/trust.ts @@ -113,11 +113,46 @@ async function readMemory(file: string): Promise { } } +const union = (a: readonly string[], b: readonly string[]) => [...new Set([...a, ...b])].sort(); +const later = (a: string, b: string) => (Date.parse(a) >= Date.parse(b) ? a : b); + +/** + * Two memories combined so neither loses what it saw: for the same yank key the higher serial, + * the newer key list and the union of every entry; across a rotation the memory of the newer key + * list, still carrying every retired yank key of both. + */ +export function mergeMemory(disk: YankMemory | undefined, next: YankMemory): YankMemory { + if (!disk) return next; + const retiredYankKeys = union(disk.retiredYankKeys, next.retiredYankKeys); + if (disk.keyId !== next.keyId) { + const newer = Date.parse(disk.keysIssuedAt) > Date.parse(next.keysIssuedAt) ? disk : next; + return { ...newer, retiredYankKeys: retiredYankKeys.filter((k) => k !== newer.keyId) }; + } + return { + keyId: next.keyId, + serial: Math.max(disk.serial, next.serial), + versions: union(disk.versions, next.versions), + revokedKeys: union(disk.revokedKeys, next.revokedKeys), + keysIssuedAt: later(disk.keysIssuedAt, next.keysIssuedAt), + retiredYankKeys, + }; +} + +/** Stores `memory`, merged with whatever another run stored meanwhile (re-read just before). */ async function writeMemory(file: string, memory: YankMemory): Promise { - await mkdir(path.dirname(file), { recursive: true }); - const tmp = `${file}.${process.pid}.tmp`; - await writeFile(tmp, `${JSON.stringify({ yankMemory: memory }, null, 2)}\n`, { mode: 0o600 }); - await rename(tmp, file); + try { + await mkdir(path.dirname(file), { recursive: true }); + const merged = mergeMemory(await readMemory(file), memory); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, `${JSON.stringify({ yankMemory: merged }, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, file); + } catch (e) { + if ((e as { code?: string }).code === "trust-unavailable") throw e; + refuseDeploy( + "trust-state", + `the lists verified, but the yank memory could not be saved to ${file} (${(e as Error).message}); nothing was deployed`, + ); + } } /** Both lists, verified and checked against the memory, which is then updated (see the file comment). */ @@ -151,7 +186,7 @@ export async function loadTrust(input: TrustInput): Promise { return { keys, yanked }; } catch (e) { const code = (e as { code?: string }).code; - if (!code) throw e; + if (!code || code === "trust-state") throw e; return refuseDeploy( "trust-unavailable", `the signed key and yank lists do not verify (${code}: ${(e as Error).message}); there is no override`, diff --git a/packages/cli/test/deploy-release.test.ts b/packages/cli/test/deploy-release.test.ts index e13402b3..558fc654 100644 --- a/packages/cli/test/deploy-release.test.ts +++ b/packages/cli/test/deploy-release.test.ts @@ -8,7 +8,10 @@ import { existsSync } from "node:fs"; import { readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { releaseFlags } from "../src/cli/commands-remote.ts"; import type { DeployOptions } from "../src/commands/deploy/options.ts"; +import { attestationFailure } from "../src/commands/deploy/release/index.ts"; +import { mergeMemory } from "../src/commands/deploy/release/trust.ts"; import { deploy } from "../src/index.ts"; import { bundle, @@ -98,7 +101,7 @@ describe("deploy --release: trust", () => { code: "trust-unavailable", message: expect.stringContaining("never falls back"), }); - expect(s.net.urls.some((u) => u.includes("api.github.com"))).toBe(false); + expect(s.net.urls.some((u) => new URL(u).hostname === "api.github.com")).toBe(false); }); it("stores the yank memory and refuses a rolled-back yank list on the next run", async () => { @@ -151,7 +154,12 @@ describe("deploy --release: which release", () => { code: "release-unavailable", message: expect.stringContaining("without --release"), }); - expect(s.net.urls.some((u) => u.includes("/releases/download/"))).toBe(false); + expect( + s.net.urls.some((u) => { + const url = new URL(u); + return url.hostname === "github.com" && url.pathname.includes("/releases/download/"); + }), + ).toBe(false); } }); @@ -288,11 +296,11 @@ describe("deploy --release: the project", () => { /added: public\/extra\.txt\n {2}changed: src\/DESIGN\.md[\s\S]*--discard-local-theme/, ), }); - expect(s.net.urls.some((u) => u.endsWith(".tar.gz"))).toBe(false); + expect(s.net.urls.some((u) => new URL(u).pathname.endsWith(".tar.gz"))).toBe(false); const lines: string[] = []; const r = await deploy({ ...s.options, discardLocalTheme: true, log: (l) => lines.push(l) }); expect(r.release?.notDeployed).toEqual(["public/extra.txt", "src/DESIGN.md"]); - expect(lines.join("\n")).toContain("these files of the project are not"); + expect(lines.join("\n")).toMatch(/these 2 files of the project are not/); // Nothing on disk was discarded. expect(await readFile(path.join(s.projectDir, "src/DESIGN.md"), "utf8")).toBe("# Mine\n"); }); @@ -320,3 +328,134 @@ describe("deploy --release: the project", () => { }); }); }); + +describe("deploy --release: review round 1", () => { + it("refuses a version argument: --release installs this CLI's own version", () => { + expect(() => releaseFlags({ release: true }, ["0.9.0"])).toThrow( + /takes no argument[\s\S]*npx @cloudhal\/cli@ deploy --release/, + ); + expect(releaseFlags({ release: true }, [])).toEqual({ release: true }); + expect(() => releaseFlags({ "discard-local-theme": true }, [])).toThrow(/go with --release/); + }); + + it("asks nothing and writes nothing outside .cloudhal/deploy before the release verifies", async () => { + const s = await setup(); + const file = path.join(s.projectDir, "cloudhal.json"); + const cfg = JSON.parse(await readFile(file, "utf8")) as Record; + const { analytics: _a, ...withoutAnalytics } = cfg; + const before = JSON.stringify(withoutAnalytics); + await writeFile(file, before); + s.world.trust = "down"; + let asked = 0; + const prompt = async () => { + asked++; + return true; + }; + expect(await codeOf(deploy({ ...s.options, prompt }))).toBe("trust-unavailable"); + expect(asked).toBe(0); + expect(await readFile(file, "utf8")).toBe(before); + expect(existsSync(path.join(s.projectDir, "wrangler.jsonc"))).toBe(false); + }); + + it("lists every file not deployed under --discard-local-theme, not just 20", async () => { + const s = await setup(); + for (let i = 0; i < 25; i++) + await writeFile( + path.join(s.projectDir, `public/extra-${String(i).padStart(2, "0")}.txt`), + "x\n", + ); + const lines: string[] = []; + const r = await deploy({ ...s.options, discardLocalTheme: true, log: (l) => lines.push(l) }); + expect(r.release?.notDeployed).toHaveLength(25); + const text = lines.join("\n"); + expect(text).toContain("these 25 files of the project are not"); + expect(text).toContain("public/extra-24.txt"); + expect(text).not.toContain("more"); + }); + + it("keeps the yank memory monotonic when two runs save it", () => { + const base = { + keyId: "k1", + serial: 5, + versions: ["0.9.3"], + revokedKeys: [], + keysIssuedAt: "2026-11-20T00:00:00Z", + retiredYankKeys: ["k0"], + }; + expect(mergeMemory({ ...base, serial: 7, versions: ["0.9.4"] }, base)).toEqual({ + ...base, + serial: 7, + versions: ["0.9.3", "0.9.4"], + }); + // Across a rotation the newer key list's memory wins, keeping every retired key. + const rotated = { + ...base, + keyId: "k2", + serial: 1, + versions: [], + keysIssuedAt: "2026-12-01T00:00:00Z", + retiredYankKeys: ["k1"], + }; + expect(mergeMemory(rotated, base)).toEqual({ ...rotated, retiredYankKeys: ["k0", "k1"] }); + expect(mergeMemory(undefined, base)).toEqual(base); + }); + + it("reports a yank memory that cannot be saved as such, not as lists that do not verify", async () => { + const s = await setup(); + const blocker = path.join(tmp(), "a-file"); + await writeFile(blocker, "x"); + await expect( + deploy({ ...s.options, trustStateFile: path.join(blocker, "release-trust.json") }), + ).rejects.toMatchObject({ + code: "trust-state", + message: expect.stringContaining("could not be saved"), + }); + }); + + it("words an attestation that could not finish as such; it is still a refusal", async () => { + expect(attestationFailure("manifest.json", "no answer within 60 s")).toMatch( + /could not be checked[\s\S]*still a refusal/, + ); + expect(attestationFailure("manifest.json", "HTTP 429: API rate limit exceeded")).toMatch( + /could not be checked/, + ); + expect( + attestationFailure("manifest.json", "verification failed: no matching attestations"), + ).toMatch(/were not built by this repository's bundle-build\.yml/); + const s = await setup(); + const timedOut = { + ...s.options, + attest: async () => ({ + status: "failed" as const, + file: "manifest.json", + reason: "no answer within 60 s", + }), + }; + await expect(deploy(timedOut)).rejects.toMatchObject({ + code: "attestation-failed", + message: expect.stringContaining("could not be checked"), + }); + }); + + it("sends GH_TOKEN to api.github.com only, and reports a rate limit as such", async () => { + const s = await setup(); + const saved = process.env.GH_TOKEN; + process.env.GH_TOKEN = "test-token-not-a-secret"; + try { + await deploy(s.options); + } finally { + if (saved === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = saved; + } + const sent = s.net.headers.filter((h) => h.authorization !== null); + expect(sent.map((h) => new URL(h.url).hostname)).toEqual(["api.github.com"]); + expect(sent[0]?.authorization).toBe("Bearer test-token-not-a-secret"); + s.world.release = "rate-limited"; + await expect( + deploy({ ...s.options, trustStateFile: path.join(tmp(), "d.json") }), + ).rejects.toMatchObject({ + code: "github-rate-limited", + message: expect.stringContaining("GH_TOKEN"), + }); + }); +}); diff --git a/packages/cli/test/fixtures/release-deploy.ts b/packages/cli/test/fixtures/release-deploy.ts index 22ab37ed..ba6bd7a7 100644 --- a/packages/cli/test/fixtures/release-deploy.ts +++ b/packages/cli/test/fixtures/release-deploy.ts @@ -92,20 +92,24 @@ export async function trustWorld(): Promise { export interface Served { readonly fetch: typeof fetch; readonly urls: string[]; + /** The `authorization` header each request carried. */ + readonly headers: { url: string; authorization: string | null }[]; } /** What the fake network serves; any other URL answers 404 (and is recorded). */ export interface World { trust: { keys: TrustWorld["keys"]; yanked: TrustWorld["yanked"] } | "down"; - release: "published" | "draft" | "missing" | "no-bundle"; + release: "published" | "draft" | "missing" | "no-bundle" | "rate-limited"; bundle: Synthetic; } export function serve(world: World): Served { const urls: string[] = []; - const f = async (input: string | URL | Request) => { + const headers: { url: string; authorization: string | null }[] = []; + const f = async (input: string | URL | Request, init?: RequestInit) => { const url = String(input instanceof Request ? input.url : input); urls.push(url); + headers.push({ url, authorization: new Headers(init?.headers).get("authorization") }); if (url.startsWith(TRUST_BASE_URL)) { if (world.trust === "down") return new Response("bad gateway", { status: 502 }); const name = url.slice(TRUST_BASE_URL.length); @@ -120,6 +124,7 @@ export function serve(world: World): Served { } if (url === `https://api.github.com/repos/cloudhal-dev/cloudhal/releases/tags/v${VERSION}`) { if (world.release === "missing") return new Response("{}", { status: 404 }); + if (world.release === "rate-limited") return new Response("{}", { status: 403 }); const names = world.release === "no-bundle" ? ["cloudhal-cli.tgz"] @@ -133,7 +138,7 @@ export function serve(world: World): Served { if (asset) return new Response(assetBytes(world.bundle, asset.name)); return new Response("not found", { status: 404 }); }; - return { fetch: f as typeof fetch, urls }; + return { fetch: f as typeof fetch, urls, headers }; } /** The bytes of one release asset of the synthetic bundle. */ diff --git a/packages/cli/test/release-theme-check.test.ts b/packages/cli/test/release-theme-check.test.ts index 2352d4f8..cde47fdc 100644 --- a/packages/cli/test/release-theme-check.test.ts +++ b/packages/cli/test/release-theme-check.test.ts @@ -130,3 +130,13 @@ describe("the theme check", () => { expect(named(await themeDifferences(dir, starter))).toEqual(["changed: src/blocks.ts"]); }); }); + +describe("the theme check: skills/", () => { + it("refuses a skills/ directory even when it is empty", async () => { + const dir = await copy(); + await mkdir(path.join(dir, "skills")); + expect(named(await themeDifferences(dir, starter))).toEqual(["added: skills/"]); + await writeFile(path.join(dir, "skills/.DS_Store"), "x"); + expect(named(await themeDifferences(dir, starter))).toEqual(["added: skills/"]); + }); +}); diff --git a/packages/release-verify/src/crypto.ts b/packages/release-verify/src/crypto.ts index 5f86cb80..db037e9e 100644 --- a/packages/release-verify/src/crypto.ts +++ b/packages/release-verify/src/crypto.ts @@ -12,6 +12,16 @@ const toHex = (bytes: Uint8Array): string => { return s; }; +/** + * `bytes` as WebCrypto's `BufferSource` under every consumer's lib settings: the same view when it + * is backed by an `ArrayBuffer`, else a copy (a `SharedArrayBuffer` is never handed to WebCrypto). + */ +function buffer(bytes: Uint8Array): Uint8Array { + return bytes.buffer instanceof ArrayBuffer + ? (bytes as Uint8Array) + : new Uint8Array(bytes); +} + /** Pure Ed25519 over the exact `message` bytes. */ export async function verifyEd25519( publicKey: Uint8Array, @@ -19,10 +29,14 @@ export async function verifyEd25519( signature: Uint8Array, ): Promise { try { - const key = await crypto.subtle.importKey("raw", publicKey, { name: "Ed25519" }, false, [ - "verify", - ]); - return await crypto.subtle.verify({ name: "Ed25519" }, key, signature, message); + const key = await crypto.subtle.importKey( + "raw", + buffer(publicKey), + { name: "Ed25519" }, + false, + ["verify"], + ); + return await crypto.subtle.verify({ name: "Ed25519" }, key, buffer(signature), buffer(message)); } catch { // A key that is not a valid Ed25519 point verifies nothing. return false; @@ -31,7 +45,7 @@ export async function verifyEd25519( /** SHA-256 of `bytes`, lowercase hex. */ export async function sha256Hex(bytes: Uint8Array): Promise { - return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))); + return toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", buffer(bytes)))); } export interface IncrementalSha256 { diff --git a/packages/release-verify/src/tar.ts b/packages/release-verify/src/tar.ts index cd290b62..3ddfa87d 100644 --- a/packages/release-verify/src/tar.ts +++ b/packages/release-verify/src/tar.ts @@ -123,12 +123,16 @@ export async function* gunzip( const src = sourceStream(source); const reader = src.stream .pipeThrough(gzipHeaderCheck()) - .pipeThrough(new DecompressionStream("gzip")) + // Typed through explicitly: lib.dom and @types/node disagree on DecompressionStream's chunk types. + .pipeThrough( + new DecompressionStream("gzip") as unknown as TransformStream, + ) .getReader(); let total = 0; try { for (;;) { - let next: ReadableStreamReadResult; + // The reader's own result type: not every lib names `ReadableStreamReadResult`. + let next: Awaited>; try { next = await reader.read(); } catch (e) { From f35d86ab2341968dfb2c502dcb0ddf4f9b3e767b Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:35:50 +0300 Subject: [PATCH 3/3] fix(cli): retry the release lookup without a token GitHub rejects (#227) An expired or revoked GH_TOKEN/GITHUB_TOKEN gets 401 even on public endpoints, which fell through to release-unavailable. On a 401 the lookup now retries once without the header; when that succeeds it goes on with a one-line warning, otherwise the retry's own status decides. Refs #227 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .../commands/deploy/release/fetch-release.ts | 23 ++++++--- .../cli/src/commands/deploy/release/index.ts | 1 + packages/cli/test/deploy-release.test.ts | 47 +++++++++++++++++++ packages/cli/test/fixtures/release-deploy.ts | 8 ++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/deploy/release/fetch-release.ts b/packages/cli/src/commands/deploy/release/fetch-release.ts index cd5aa624..21a1da9f 100644 --- a/packages/cli/src/commands/deploy/release/fetch-release.ts +++ b/packages/cli/src/commands/deploy/release/fetch-release.ts @@ -44,6 +44,7 @@ export interface ReleaseSource { readonly fetch?: typeof fetch; /** A GitHub token for the API lookup only (default: `GH_TOKEN`, else `GITHUB_TOKEN`). */ readonly githubToken?: string | undefined; + readonly log?: (line: string) => void; /** `.cloudhal/deploy/release-download//`. */ readonly dir: string; } @@ -74,17 +75,27 @@ export function checkBundledVersion(version: string): void { /** The published release `v` and that it lists every bundle asset (GitHub's API). */ async function checkPublished(s: ReleaseSource): Promise { const url = `https://api.github.com/repos/${RELEASE_REPOSITORY}/releases/tags/v${s.version}`; - let res: Response; - try { - // The token goes to api.github.com only (a higher rate limit), never to a download URL. - const token = s.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; - res = await (s.fetch ?? fetch)(url, { + // The token goes to api.github.com only (a higher rate limit), never to a download URL. + const token = s.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + const lookup = (withToken: boolean) => + (s.fetch ?? fetch)(url, { headers: { accept: "application/vnd.github+json", "user-agent": "cloudhal-cli", - ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(withToken && token ? { authorization: `Bearer ${token}` } : {}), }, }); + let res: Response; + try { + res = await lookup(true); + // An expired or revoked token gets 401 even on public endpoints: ask once more without it. + if (res.status === 401 && token) { + res = await lookup(false); + if (res.ok) + (s.log ?? (() => {}))( + "GitHub rejected GH_TOKEN/GITHUB_TOKEN (401); the release lookup went on without it", + ); + } } catch (e) { return unavailable(s.version, `GitHub could not be reached (${(e as Error).message})`); } diff --git a/packages/cli/src/commands/deploy/release/index.ts b/packages/cli/src/commands/deploy/release/index.ts index bdb5f659..660b00b8 100644 --- a/packages/cli/src/commands/deploy/release/index.ts +++ b/packages/cli/src/commands/deploy/release/index.ts @@ -140,6 +140,7 @@ async function verifiedRelease( from: options.from, ...(options.fetch ? { fetch: options.fetch } : {}), dir: path.join(dir, "release-download", version), + log, }; const files = await fetchRelease(source); const facts = unverifiedFacts(files.manifest); diff --git a/packages/cli/test/deploy-release.test.ts b/packages/cli/test/deploy-release.test.ts index 558fc654..91a52acc 100644 --- a/packages/cli/test/deploy-release.test.ts +++ b/packages/cli/test/deploy-release.test.ts @@ -459,3 +459,50 @@ describe("deploy --release: review round 1", () => { }); }); }); + +describe("deploy --release: a stale GitHub token", () => { + const withToken = async (run: () => Promise) => { + const saved = process.env.GH_TOKEN; + process.env.GH_TOKEN = "expired-token-not-a-secret"; + try { + return await run(); + } finally { + if (saved === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = saved; + } + }; + const lookups = (urls: string[]) => + urls.filter((u) => new URL(u).hostname === "api.github.com").length; + + it("retries once without a token GitHub rejects (401), warns, and goes on", async () => { + const s = await setup(); + s.world.rejectToken = true; + const lines: string[] = []; + const r = (await withToken(() => deploy({ ...s.options, log: (l) => lines.push(l) }))) as { + release?: { version: string }; + }; + expect(r.release?.version).toBe(VERSION); + expect(lookups(s.net.urls)).toBe(2); + expect( + s.net.headers + .filter((h) => new URL(h.url).hostname === "api.github.com") + .map((h) => h.authorization), + ).toEqual(["Bearer expired-token-not-a-secret", null]); + expect(lines.filter((l) => l.includes("rejected GH_TOKEN"))).toHaveLength(1); + }); + + it("reports the retry's own status when it fails too", async () => { + const s = await setup(); + s.world.rejectToken = true; + s.world.anonymousStatus = 404; + await expect(withToken(() => deploy(s.options))).rejects.toMatchObject({ + code: "release-unavailable", + message: expect.stringContaining("no published release"), + }); + expect(lookups(s.net.urls)).toBe(2); + const t = await setup(); + t.world.rejectToken = true; + t.world.anonymousStatus = 429; + expect(await codeOf(withToken(() => deploy(t.options)))).toBe("github-rate-limited"); + }); +}); diff --git a/packages/cli/test/fixtures/release-deploy.ts b/packages/cli/test/fixtures/release-deploy.ts index ba6bd7a7..6091f7fe 100644 --- a/packages/cli/test/fixtures/release-deploy.ts +++ b/packages/cli/test/fixtures/release-deploy.ts @@ -100,6 +100,10 @@ export interface Served { export interface World { trust: { keys: TrustWorld["keys"]; yanked: TrustWorld["yanked"] } | "down"; release: "published" | "draft" | "missing" | "no-bundle" | "rate-limited"; + /** The API answers 401 to any request carrying an `authorization` header (a stale token). */ + rejectToken?: boolean; + /** The API answers this status to the retry without a token too. */ + anonymousStatus?: number; bundle: Synthetic; } @@ -125,6 +129,10 @@ export function serve(world: World): Served { if (url === `https://api.github.com/repos/cloudhal-dev/cloudhal/releases/tags/v${VERSION}`) { if (world.release === "missing") return new Response("{}", { status: 404 }); if (world.release === "rate-limited") return new Response("{}", { status: 403 }); + const authorized = new Headers(init?.headers).has("authorization"); + if (world.rejectToken && authorized) return new Response("{}", { status: 401 }); + if (world.anonymousStatus && !authorized) + return new Response("{}", { status: world.anonymousStatus }); const names = world.release === "no-bundle" ? ["cloudhal-cli.tgz"]