diff --git a/AGENTS.md b/AGENTS.md index db6de2ee..f80d864b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ applications — both are workspace globs in `pnpm-workspace.yaml`. Turbo declar ## CI +- This repo is a template and holds no credentials; CI proves everything locally. Gate: review of the workflow diff; a job that needs a secret carries `if: ${{ !github.event.repository.is_template }}`. - Every job runs on GitHub-hosted `ubuntu-latest` runners. This repository is public, and the org's self-hosted fleet runner group admits only private repositories (fork safety), so a `[self-hosted, systemfsoftware-runner, *]` job here queues forever. Gate: review of the workflow diff. - Heavy suites split across parallel hosted jobs instead of a larger runner: Linux `check:ci` runs as one job per part (format, lint, typecheck, test, dist), and mutation on `main` runs one job per package. Gate: review of the workflow diff. diff --git a/README.md b/README.md index ed9f301c..40836dd6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,25 @@ pnpm build pnpm check:ci ``` +### 4. Run It Locally + +```bash +pnpm dev # the whole app at http://localhost:1337, Alchemy's local emulation, no cloud +pnpm journeys # the end-to-end journeys in a real browser against pnpm dev +``` + +### 5. Deploy + +Your copy deploys to your own Cloudflare account; the template holds no credentials. Set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, and optionally `SITE_DOMAIN` (a hostname in a zone on that account) to serve production there instead of on `workers.dev`: + +```bash +pnpm run deploy # stage prod +ALCHEMY_STAGE=pr-12 pnpm run deploy # a preview stage +ALCHEMY_STAGE=pr-12 pnpm run destroy +``` + +`pnpm run deploy`, not `pnpm deploy`: the bare form is pnpm's own built-in command. + --- ## 🚦 Verification Gates diff --git a/apps/site/alchemy.run.ts b/apps/site/alchemy.run.ts index 216141f0..4465fcb2 100644 --- a/apps/site/alchemy.run.ts +++ b/apps/site/alchemy.run.ts @@ -1,20 +1,36 @@ import * as Alchemy from 'alchemy' import * as Cloudflare from 'alchemy/Cloudflare' import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' import { siteWorker } from './site-worker.ts' -export const Site = Cloudflare.Website.Vite('Site', { - main: siteWorker.main, - compatibility: { date: siteWorker.compatibilityDate, flags: [...siteWorker.compatibilityFlags] }, - dev: { port: 1337, strictPort: true }, -}) +export const isCloudStage = (stage: string): boolean => stage === 'prod' || /^pr-\d+$/.test(stage) + +const productionDomain = (stage: string): { readonly domain?: string } => { + const domain = process.env['SITE_DOMAIN'] + return stage === 'prod' && domain !== undefined && domain !== '' ? { domain } : {} +} + +export const Site = Cloudflare.Website.Vite( + 'Site', + Effect.map(Alchemy.Stage, (stage) => ({ + main: siteWorker.main, + compatibility: { date: siteWorker.compatibilityDate, flags: [...siteWorker.compatibilityFlags] }, + dev: { port: 1337, strictPort: true }, + ...productionDomain(stage), + })), +) + +const stateOfStage = Layer.unwrap( + Effect.map(Alchemy.Stage, (stage) => isCloudStage(stage) ? Cloudflare.state() : Alchemy.localState()), +) export default Alchemy.Stack( 'Endgame', - { providers: Cloudflare.providers(), state: Alchemy.localState() }, + { providers: Cloudflare.providers(), state: stateOfStage }, Effect.gen(function*() { const site = yield* Site - return { url: site.url } + return { url: site.url, workerName: site.workerName } }), ) diff --git a/bin/cloud b/bin/cloud new file mode 100755 index 00000000..1ae183c5 --- /dev/null +++ b/bin/cloud @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run a Cloudflare-facing command (an alchemy deploy, destroy or state read) +# inside the sandbox. This is the one place the Cloudflare credentials enter a +# sandbox, and the one list of hosts such a command may reach: Cloudflare's +# API, and the account's workers.dev hosts, where alchemy's state store +# answers. Telemetry stays off, so nothing else needs to be reachable. +set -euo pipefail + +export DO_NOT_TRACK=1 +exec sandbox \ + --allow-host api.cloudflare.com \ + --allow-host '*.workers.dev' \ + --pass-env CLOUDFLARE_API_TOKEN \ + --pass-env CLOUDFLARE_ACCOUNT_ID \ + --pass-env SITE_DOMAIN \ + --pass-env DO_NOT_TRACK \ + --pass-env CI \ + -- "$@" diff --git a/package.json b/package.json index 395b08d9..c8c5ba4e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "check:ci": "s=0; pnpm format:check || s=1; pnpm check:sfs-sources || s=1; TURBO_CONCURRENCY=${TURBO_CONCURRENCY:-100%} pnpm gate:tasks || s=1; pnpm gate:dist || s=1; pnpm test:sandbox || s=1; exit $s", "check:sfs-sources": "sandbox --allow-host jsr.io -- ./scripts/check-sfs-sources.ts", "clean": "sandbox -- turbo clean", + "deploy": "./bin/cloud pnpm --filter @endgame/site exec alchemy deploy --stage \"${ALCHEMY_STAGE:-prod}\" --no-input --yes", + "destroy": "./bin/cloud pnpm --filter @endgame/site exec alchemy destroy --stage \"${ALCHEMY_STAGE:?set ALCHEMY_STAGE, e.g. pr-}\" --no-input --yes", "format": "./bin/dprint fmt", "format:check": "./bin/dprint check", "gate:dist": "sandbox -- turbo --concurrency=${TURBO_CONCURRENCY:-100%} build",