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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/deploy-workers-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/cli": minor
"@cloudhal/core-ts": minor
---

`cloudhal deploy` runs in Cloudflare Workers Builds (`WORKERS_CI=1`), so a Deploy to Cloudflare button can set up a whole installation (docs/guides/deploy-button.md). Names come from the committed `wrangler.jsonc`, and a database id there is used without any D1 call. The starter site is seeded under `cloudhal.json`'s `siteId` when the checkout has none. Secrets stay on the Workers: only missing ones are generated, and the deployment token is new on every build. The setup link is never printed; the first build explains how the owner sets their own setup token. `seedStarterSite` takes an optional `siteId`.
45 changes: 45 additions & 0 deletions docs/guides/deploy-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Deploy to Cloudflare button

Cloudflare's [Deploy to Cloudflare button](https://developers.cloudflare.com/workers/configuration/deploy-button/) copies a public repository into your GitHub account, creates the resources its root `wrangler.jsonc` declares and connects the repository to [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), which then runs the repository's `deploy` script. A button deploys one Worker; a Cloudhal installation is two (the site Worker and the admin Worker with Studio, API and MCP), one D1 database and one R2 bucket. So the button points at [`cloudhal-dev/cloudhal-template`](https://github.com/cloudhal-dev/cloudhal-template), a blog project whose `deploy` script is `cloudhal deploy`: the button provisions the site Worker's database and bucket, and `cloudhal deploy` makes the rest.

## What one click creates

- a copy of the template in your GitHub account; every push to its default branch deploys again
- the site Worker (the name you choose in the form, `cloudhal-site` by default) and `<base>-admin`, where `<base>` is that name without `-site`
- the D1 database and R2 bucket named in the form, used by both Workers
- the starter blog, pushed to the deployment on the first build only

## Creating the owner account

A local `cloudhal deploy` prints a one-time setup link. In Workers Builds the build log is kept, so the link is never printed. The first build ends with these steps instead:

1. In the Cloudflare dashboard, open **Workers & Pages › `<base>-admin` › Settings › Variables and Secrets**, edit `CLOUDHAL_SETUP_TOKEN` and enter a random value of your own (32 characters or more). Saving deploys it. Or run `npx wrangler secret put CLOUDHAL_SETUP_TOKEN --name <base>-admin`.
2. Open `https://<base>-admin.<your-subdomain>.workers.dev/login#setup=<that value>` and create the owner account.

Until an owner exists, sign-up needs that token. Once the owner exists, sign-up is closed whatever the token is.

## How `cloudhal deploy` behaves in Workers Builds

Workers Builds sets `WORKERS_CI=1`. Every build starts from a fresh clone, with no `.cloudhal/` folder: no local site, no `secrets.json`, no `state.json`. With `WORKERS_CI=1`, `cloudhal deploy`:

- **takes its names from the committed `wrangler.jsonc`.** It reads the Worker name (Workers Builds requires the config to name the connected Worker), the `DB` database's name and id, and the `BLOBS` bucket's name. A database id in that file is used without any D1 call, because the Workers Builds token may not have D1 permission. An all-zero placeholder id counts as no id. The deploy rewrites the file in the build's checkout and never commits it.
- **seeds the starter site** under `cloudhal.json`'s `siteId` when the checkout has none. It is pushed only when the deployment does not have that site yet. After that, the deployment is the source of truth, as it is for any deploy.
- **leaves the secrets on the Workers.** It lists each Worker's secrets (`wrangler secret list`). Any secret a Worker already has is kept, since wrangler keeps every secret a deploy does not set. A missing secret is generated and set. The secrets both Workers share are set on both together. If listing fails for any reason other than "Worker not found", the deploy stops, because generating new secrets would make data they encrypt unreadable (for example commenters' email addresses).
- **creates a new deployment token on every build.** The CLI uses it for the first push and nothing else.

## Limits

- **Existing deployments are not checked before new code goes out.** Workers Builds has no record of the previous deploy, so the schedules downgrade check (RFC-0008) and the cache purge after analytics or comments are switched on or off do not run. Cached pages pick up such a switch as they expire.
- **The scheduler starts on cron in every build.** If the account has no free cron slot, the build falls back to the Durable Object alarm each time (one extra admin upload). The Workers plan is `free` unless `cloudhal.json` sets `schedules.plan`.
- **Deploy through Git, work through Studio.** A clone of your copy has no `.cloudhal/` folder and no deployment token, because every build replaces the token. So a local `cloudhal deploy` stops with "site … not found in the local store", before it touches any secret, and `cloudhal push`, `pull` and `owner transfer` have no token to use. Edit content in Studio, connect agents with keys created in Studio (remote MCP), and deploy code changes by pushing to the default branch.
- **Custom domains** need zone permissions the default Workers Builds token lacks. Set them with `cloudhal domain set` and give the build token *Zone › Workers Routes* and *DNS* edit permissions on that zone, or add the domain in the dashboard.
- **Preview branches**: Workers Builds runs `wrangler versions upload` for non-default branches, which uploads only the site Worker. Keep deploying from the default branch.
- **`--jurisdiction eu`** has no effect: the button creates the database before `cloudhal deploy` runs.

## Making your own template

Start from `npm create cloudhal@latest`, then:

- commit `cloudhal.json` and a root `wrangler.jsonc` that names the site Worker, a `DB` D1 database (placeholder id `00000000-0000-0000-0000-000000000000`) and a `BLOBS` R2 bucket
- remove the `build` script (Workers Builds would run it before `deploy`), keep `"deploy": "cloudhal deploy"`
- commit a lockfile and a `.node-version` of `24`. The CLI runs on Node 24.
2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ Locally, `cloudhal studio` reads the project's SQLite (`analytics_events`), roll

`cloudhal deploy` uses **your** wrangler login (`npx wrangler login`, or `CLOUDFLARE_API_TOKEN`); Cloudhal never reads those credentials. It creates the D1 database and R2 bucket if needed (with a lifecycle rule that expires the snapshot bundles under `bundles/` after 30 days, RFC-0010), generates the deployment secrets once into `.cloudhal/deploy/secrets.json` (0600 — keep it out of git), builds and deploys the **site Worker** (Astro on demand, edge-cached) and the **admin Worker** (Studio, API, auth, MCP), and on the first deploy pushes your local site.

**From Git (Workers Builds).** With `WORKERS_CI=1`, which Cloudflare sets in [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), `cloudhal deploy` works from a fresh clone. It takes the names from the committed `wrangler.jsonc`, seeds the starter site when the checkout has none, keeps the secrets on the Workers and does not print the setup link. This is what the Deploy to Cloudflare button runs: see [docs/guides/deploy-button.md](../../docs/guides/deploy-button.md).

**Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname, so preview links use it (canonical URLs in the starter are not rendered yet). The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it.

**Email (RFC-0011).** Add `"mail": { "from": "studio@example.com", "fromName": "Example Studio" }` to `cloudhal.json` and the next `cloudhal deploy` gives the admin Worker a [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) `send_email` binding locked to that address. Invites can then be emailed, and staff can reset a forgotten password from the sign-in screen. Onboard the address's domain in Email Service first. Sending to any recipient needs Workers Paid (3,000 emails a month included); on Free, Email Service delivers only to verified destination addresses of your account. Locally, `cloudhal studio` prints these emails in the terminal instead of sending them.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/commands-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const deployCommand: CliCommand = async (_rest, values, { projectDir }) => {
);
if (r.setupUrl)
console.log(`\nLocal site pushed. Create the owner account (once) at:\n ${r.setupUrl}`);
if (r.setupInstructions) console.log(`\nStarter site pushed. ${r.setupInstructions}`);
return 0;
};

Expand Down
47 changes: 33 additions & 14 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
* `apps/studio/src`, `apps/studio/index.html` or `packages/ui/src`, the deploy runs
* `pnpm --filter @cloudhal/studio build` first; `--no-build-studio` fails instead. A published
* install ships Studio prebuilt and is never checked.
* 13. Workers Builds (`WORKERS_CI=1`, the Deploy to Cloudflare button): names from the committed
* `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).
*
* `--dry-run` runs everything up to wrangler's own `--dry-run` (bundles, configs) and skips
* provisioning, deployment and push — no Cloudflare account needed.
Expand All @@ -64,7 +67,8 @@ 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, writeDeployState } from "./deploy/state.ts";
import { loadOrCreateSecrets, SITE_SECRET_KEYS, writeDeployState } from "./deploy/state.ts";
import { buildsSecrets, setupInstructions } from "./deploy/workers-builds.ts";
import { resolveSkillsDir } from "./mcp.ts";

export type { DeployOptions, DeployResult } from "./deploy/options.ts";
Expand All @@ -90,6 +94,15 @@ 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<ReturnType<typeof loadDeploy>>) {
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<DeployResult> {
checkOptions(options);
// RFC-0034 §4: a site Worker that joins another installation deploys only itself.
Expand All @@ -99,10 +112,11 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
const { analytics, comments } = await analyticsAndComments(ctx);
const studioDir = await studioDirFor(ctx);
const { withIds, databaseCreated } = await provision(ctx);
// 2. secrets; 2b. RFC-0008: never deploy code that ignores holds and gates over open releases
const secrets = await loadOrCreateSecrets(ctx.dir);
if (!options.dryRun && previous?.adminUrl)
await downgradeGuard(options, previous.adminUrl, secrets.CLOUDHAL_DEPLOY_TOKEN, log);
// 2. secrets (Workers Builds: kept on the Workers); 2b. RFC-0008: never deploy code that
// 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);
const settings = await deploySettings(ctx);
const { plan, schedules, observability } = settings;
const site = await buildSite(ctx, { withIds, analytics, comments, observability });
Expand Down Expand Up @@ -163,7 +177,8 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
}

const { siteUrl, adminUrl, scheduler } = await shipWorkers(ctx, {
secrets,
siteSecrets: secrets.site,
adminSecrets: secrets.admin,
admin,
prepareAdmin,
scheduler: settings.scheduler,
Expand All @@ -184,18 +199,21 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
plan,
});
const pushed = await afterDeploy(ctx, { siteUrl, adminUrl });
const { purged, analyticsChanged, commentsChanged } = await purgeIfSwitched(ctx, {
siteUrl,
analytics,
comments,
purgeToken: secrets.CLOUDHAL_PURGE_TOKEN,
});
// 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 =
pushed && adminUrl
? `${adminUrl.replace(/\/$/, "")}/login#setup=${secrets.CLOUDHAL_SETUP_TOKEN}`
first && setupToken && !ctx.workersBuilds
? `${adminUrl.replace(/\/$/, "")}/login#setup=${setupToken}`
: undefined;
const instructions =
first && ctx.workersBuilds ? setupInstructions(adminUrl, names.admin) : undefined;
return {
dryRun: false,
names,
Expand All @@ -205,6 +223,7 @@ export async function deploy(options: DeployOptions): Promise<DeployResult> {
adminUrl,
pushed,
...(setupUrl ? { setupUrl } : {}),
...(instructions ? { setupInstructions: instructions } : {}),
...(cfg.domains?.site ? { customSiteUrl: `https://${cfg.domains.site}` } : {}),
...(cfg.domains?.studio ? { customStudioUrl: `https://${cfg.domains.studio}` } : {}),
scheduler,
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/deploy/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ async function installationOf(projectDir: string, folder: string) {
export async function deployAttached(options: DeployOptions): Promise<DeployResult> {
const own = await readProjectConfig(options.projectDir);
const inst = await installationOf(options.projectDir, own.installation as string);
const loaded = await loadDeploy({ ...options, name: inst.state.name });
// the installation lives in another local project, never in a Workers Builds checkout
const loaded = await loadDeploy({ ...options, name: inst.state.name, workersBuilds: false });
const base = { ...loaded.base, siteName: `${inst.state.name}-site-${loaded.site.slug}` };
const previous = {
...(loaded.previous ?? inst.state),
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/deploy/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ export interface DeployContext {
readonly previous: DeployState | undefined;
readonly base: DeploymentNames;
readonly names: ReturnType<typeof workerNames>;
/** Running in Cloudflare Workers Builds (`workers-builds.ts`). */
readonly workersBuilds: boolean;
}
8 changes: 8 additions & 0 deletions packages/cli/src/commands/deploy/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface DeployOptions {
readonly buildStudio?: boolean;
/** Injected in tests: runs the Studio build. */
readonly studioBuilder?: StudioBuilder;
/**
* Deploying from Cloudflare Workers Builds (the Deploy to Cloudflare button): names from the
* committed `wrangler.jsonc`, the starter seeded when missing, secrets kept on the Workers.
* Default: `WORKERS_CI=1` in the environment.
*/
readonly workersBuilds?: boolean;
}

export interface DeployResult {
Expand All @@ -75,6 +81,8 @@ export interface DeployResult {
pushed?: boolean;
/** First deploy only: open it to create the owner account. */
setupUrl?: string;
/** First deploy from Workers Builds: how to create the owner account (no link is printed). */
setupInstructions?: string;
/** Custom domains from cloudhal.json, routed by this deploy. */
customSiteUrl?: string;
customStudioUrl?: string;
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/commands/deploy/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
rateLimitDeployment,
} from "./settings.ts";
import { deployDir, exists, type Jurisdiction, readDeployState } from "./state.ts";
import { committedNames, inWorkersBuilds, seedIfMissing } from "./workers-builds.ts";

export function checkOptions(options: DeployOptions): void {
if (options.jurisdiction !== undefined && !["auto", "eu"].includes(options.jurisdiction))
Expand All @@ -80,24 +81,30 @@ export async function loadDeploy(options: DeployOptions): Promise<DeployContext>
const dir = deployDir(options.projectDir, cfg.dataDir);
await mkdir(dir, { recursive: true });
const run = options.wrangler ?? wranglerRunner(options.projectDir, log);
// Workers Builds: the button's names, and the starter site in a checkout that has none
const workersBuilds = inWorkersBuilds(options.workersBuilds);
const committed = workersBuilds
? await committedNames(siteConfigPath(options.projectDir))
: undefined;
if (committed) await seedIfMissing(options.projectDir, cfg, committed.name, log);

const local = await openLocalStore(path.join(options.projectDir, cfg.dataDir));
const site = await local.store.getSite(cfg.siteId);
await local.close();
if (!site) throw new Error(`site ${cfg.siteId} not found in the local store`);
const previous = await readDeployState(options.projectDir, cfg.dataDir);
const name = options.name ?? previous?.name ?? site.slug;
const name = options.name ?? committed?.name ?? previous?.name ?? site.slug;
// RFC-0029 §3: the search provider's instance and bucket, bound by both Workers.
const search = searchDeploymentOf(cfg.search, name);
const base: DeploymentNames = { name, ...(search ? { search } : {}) };
const base: DeploymentNames = { ...committed, name, ...(search ? { search } : {}) };
if (search)
log(
`search: Cloudflare AI Search instance ${search.instance} over R2 bucket ${search.bucket} (the instance must exist, read that bucket and declare the text metadata fields locale and kind); ${search.monthlyLimit} semantic searches a month for the deployment (Cloudflare includes 1,000 per account), then FTS5 alone until the month ends`,
);
const names = workerNames(base);
// 0. analytics: decided before anything is generated; the wrangler version gates the configs
const decided = await decideAnalytics(options, cfg, site.name, log);
return { options, log, cfg: decided, dir, run, site, previous, base, names };
return { options, log, cfg: decided, dir, run, site, previous, base, names, workersBuilds };
}

/** 0. analytics bindings (wrangler ≥ 4.145); 0b. reader comments (RFC-0007 §15.4). */
Expand Down Expand Up @@ -159,15 +166,19 @@ export async function provision(
ctx: DeployContext,
): Promise<{ withIds: DeploymentNames; databaseCreated: Jurisdiction | undefined }> {
const { options, names, log, base } = ctx;
// Workers Builds: the database the button provisioned (its token may not read D1)
const known = ctx.workersBuilds ? base.databaseId : undefined;
const existing = await readExistingIds(siteConfigPath(options.projectDir));
let databaseId = existing.databaseId;
// (a placeholder id in the committed file is no id: never carried into the config)
let databaseId = ctx.workersBuilds ? known : existing.databaseId;
let databaseCreated: Jurisdiction | undefined;
if (!options.dryRun) {
const r = await ensureResources(ctx.run, {
cwd: options.projectDir,
names,
log,
where: jurisdictionChooser(options, log),
...(known ? { databaseId: known } : {}),
});
databaseId = r.databaseId;
databaseCreated = r.created;
Expand Down
Loading
Loading