From 5359fb0d008ba5fd6ae3a17af5296dcc2d54404e Mon Sep 17 00:00:00 2001 From: cevr Date: Thu, 15 Oct 2026 13:56:05 -0400 Subject: [PATCH 1/4] chore(tooling): a deploy lane runs bun test without the test preload A test:deploy script deploys a throwaway stage to real infrastructure, so it needs the network and the real home's cloud credentials, which the preload takes away. The test lane defaults guard leaves it alone. --- packages/tooling/src/guards.ts | 11 ++++++++++- packages/tooling/tests/guards.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/tooling/src/guards.ts b/packages/tooling/src/guards.ts index 75d041664..800f06e8b 100644 --- a/packages/tooling/src/guards.ts +++ b/packages/tooling/src/guards.ts @@ -1545,9 +1545,18 @@ const TEST_PRELOAD = /--preload\s+\S*\/src\/test-preload\.ts(?:\s|$)/ /** The gamut fixture is a user's project that gamut opens; its `bun test` is not a gent lane. */ const GAMUT_FIXTURE = /^testbeds\/gamut\/fixture\// +/** + * A deploy lane deploys a throwaway stage to real infrastructure, probes it + * and destroys it (`apps/site` `test:deploy`). It needs the network and the + * real home's cloud credentials, which the preload takes away, and its one + * test sets its own bun timeout. The gate never runs it. + */ +const DEPLOY_LANE = "test:deploy" + /** * Guard: every package script that runs `bun test` loads the test preload, * and one that runs without `--parallel` also passes the preload's timeout. + * A deploy lane is the one exception. */ export const findTestLaneDefaults = (file: string, text: string): ReadonlyArray => { if (!isManifest(file) || GAMUT_FIXTURE.test(file)) return [] @@ -1556,7 +1565,7 @@ export const findTestLaneDefaults = (file: string, text: string): ReadonlyArray< onSome: (manifest) => Object.entries(manifest.scripts ?? {}), }) return scripts.flatMap(([name, script]) => { - if (!/\bbun test\b/.test(script)) return [] + if (!/\bbun test\b/.test(script) || name === DEPLOY_LANE) return [] const line = lineAt(text, text.indexOf(`"${name}":`)) const words = script.split(/\s+/) const findings: Array = [] diff --git a/packages/tooling/tests/guards.test.ts b/packages/tooling/tests/guards.test.ts index 63655a7da..752d9a229 100644 --- a/packages/tooling/tests/guards.test.ts +++ b/packages/tooling/tests/guards.test.ts @@ -910,6 +910,20 @@ describe("test lane defaults guard", () => { ).toEqual([[3, true]]) }) + test("a deploy lane, which reaches real infrastructure, runs without the preload", () => { + const manifest = [ + "{", + ' "scripts": {', + ' "test:deploy": "bun test ./tests/deploy.integration.ts",', + ' "test:live": "bun test ./tests/live.integration.ts"', + " }", + "}", + ].join("\n") + expect( + findTestLaneDefaults("apps/site/package.json", manifest).map((finding) => finding.line), + ).toEqual([4, 4]) + }) + test("a script that runs no bun test, and the gamut fixture's own project, pass", () => { expect(laneFindings("packages/core/package.json", "tsc --noEmit")).toEqual([]) expect(laneFindings("testbeds/gamut/fixture/package.json", "bun test")).toEqual([]) From 96fb7c91983ab26206f9d881d1566ea24f5deb83 Mon Sep 17 00:00:00 2001 From: cevr Date: Thu, 15 Oct 2026 13:56:08 -0400 Subject: [PATCH 2/4] feat(site): serve gent.cvr.im from a Cloudflare Worker Replace the Railway project, service, custom domain and the two cvr.im DNS records with one Cloudflare.Worker declared with Alchemy (2.0.0-beta.83). - The Worker's fetch is the existing HttpRouter routes (toHttpEffect); the routes stay Alchemy-free and reach GitHub through FetchHttpClient. - The repo install.sh is inlined into the bundle as text through the apps/site/install.sh link, checked at plan time; no runtime file read. - prod: Worker gent-site, custom domain gent.cvr.im, workers.dev off. Other stages: a throwaway workers.dev URL. - State moves to Cloudflare.state(). @distilled.cloud/railway is gone. - tests/deploy.integration.ts (bun run test:deploy) deploys, probes /, /healthz and /install.sh, and destroys. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- apps/site/alchemy.run.ts | 168 +++----------------------- apps/site/install.sh | 1 + apps/site/package.json | 7 +- apps/site/src/deploy.ts | 108 ----------------- apps/site/src/shell.d.ts | 4 + apps/site/src/site.ts | 21 ++-- apps/site/src/worker.ts | 57 +++++++++ apps/site/tests/deploy.integration.ts | 47 +++++++ apps/site/tests/site.test.ts | 4 +- bun.lock | 64 +++++----- 12 files changed, 174 insertions(+), 311 deletions(-) create mode 120000 apps/site/install.sh delete mode 100644 apps/site/src/deploy.ts create mode 100644 apps/site/src/shell.d.ts create mode 100644 apps/site/src/worker.ts create mode 100644 apps/site/tests/deploy.integration.ts diff --git a/AGENTS.md b/AGENTS.md index 1379a4c53..c64c8fe89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ packages/sdk/ # Client wrappers packages/tooling/ # gent lint rules and guards packages/e2e/ # PTY and server-process lifecycle tests, drive scripts for live checks apps/tui/ # @opentui/solid TUI -apps/site/ # gent.cvr.im (landing page, install.sh): `bun run plan` / `deploy` there, an Alchemy stack on Railway +apps/site/ # gent.cvr.im (landing page, install.sh): `bun run plan` / `deploy` there, an Alchemy stack: one Cloudflare Worker ``` ## Testing diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ebafa0056..9294bcde6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -380,7 +380,7 @@ names the decision that left it open. ```text apps/ ├── tui/ # OpenTUI client over the shared transport contract -└── site/ # gent.cvr.im: landing page and install.sh, an Alchemy stack on Railway +└── site/ # gent.cvr.im: landing page and install.sh, an Alchemy stack: one Cloudflare Worker packages/ ├── core/ # entries: extensions/api, extensions/branch-tools, protocol, host, host-bun, test-utils diff --git a/apps/site/alchemy.run.ts b/apps/site/alchemy.run.ts index 3fcae6693..056f9216a 100644 --- a/apps/site/alchemy.run.ts +++ b/apps/site/alchemy.run.ts @@ -1,165 +1,29 @@ /** - * gent.cvr.im on Railway, declared as an Alchemy stack. Run it from this - * directory: `bun run plan`, `bun run deploy` (both `--stage prod`). + * gent.cvr.im, declared as an Alchemy stack: one Cloudflare Worker serving + * the landing page, `/healthz` and `/install.sh` (`src/worker.ts`). Run it + * from this directory: `bun run plan`, `bun run deploy` (both `--stage prod`). * - * - Every stage gets its own Railway project and service (`src/deploy.ts`). - * - `prod` also owns the hostname: the `gent.cvr.im` Railway custom domain, - * and in the `cvr.im` Cloudflare zone the two records Railway asks for, a - * DNS-only CNAME to the domain's edge host and the `_railway-verify` TXT - * record. The zone is not a resource of this stack: its id is read from - * Cloudflare (`lookupZoneId`), so no plan can create, change or delete it, and - * records the stack does not declare are left alone. - * - Earlier prod state held the zone as an adopted, retained resource. With - * its declaration gone, the next deploy plans it as `orphaned`: Alchemy - * drops the state row and never calls the provider's delete, because the - * row's removal policy is `retain`. - * - Prod adopts what it finds under its own names (the `gent` project, the - * `site` service, the domain, the two records), so a deploy after lost - * local state takes them back instead of failing or making copies. - * - The records are DNS-only (`proxied: false`). Railway terminates TLS and - * issues the Let's Encrypt certificate itself, which needs the hostname to - * resolve to Railway's edge. - * - Any other stage (`agent-*`, `test_$USER`) is a throwaway with a generated - * `*.up.railway.app` URL and no domain or DNS record. + * - `prod` owns `gent.cvr.im` as the Worker's custom domain: Cloudflare makes + * the DNS record and the certificate in the `cvr.im` zone itself. + * - Any other stage (`agent-*`, `test_$USER`) is a throwaway on a generated + * `*.workers.dev` URL with no domain, destroyed with + * `bunx alchemy destroy alchemy.run.ts --stage --yes`. + * - State lives in the Cloudflare state store, so every machine and CI see + * the same resources. */ -import { Query } from "@distilled.cloud/core/query" -import { Railway } from "@distilled.cloud/railway" import * as Alchemy from "alchemy" import * as Cloudflare from "alchemy/Cloudflare" -import * as Output from "alchemy/Output" -import { CustomDomain } from "alchemy/Railway/CustomDomain" -import { providers as railwayProviders } from "alchemy/Railway/Providers" -import { Array as Arr, Effect, Layer, Option } from "effect" -import Server, { adoptInProd, GentProject, PORT } from "./src/deploy" - -const ZONE = "cvr.im" -const DOMAIN = `gent.${ZONE}` -const VERIFY_PREFIX = "railway-verify=" - -/** The records Railway lists for one custom domain. */ -const dnsRecords = Query.fn((id: string, projectId: string) => - Railway.customDomain({ id, projectId }).status.dnsRecords.pipe( - Query.map((record) => ({ - recordType: record.recordType, - purpose: record.purpose, - requiredValue: record.requiredValue, - })), - ), -) - -/** - * The CNAME target Railway requires for the domain. A domain without one is a - * Railway change this stack does not understand, so the plan stops. - */ -const trafficTarget = (customDomainId: string, projectId: string) => - dnsRecords(customDomainId, projectId).pipe( - Effect.map((records) => - Arr.findFirst( - records, - (record) => - record.purpose === "DNS_RECORD_PURPOSE_TRAFFIC_ROUTE" && - record.recordType === "DNS_RECORD_TYPE_CNAME" && - record.requiredValue.length > 0, - ), - ), - Effect.flatMap( - Option.match({ - onNone: () => Effect.die(new Error(`Railway lists no CNAME target for ${DOMAIN}`)), - onSome: (route) => Effect.succeed(route.requiredValue), - }), - ), - Effect.orDie, - ) - -/** - * The TXT value Railway checks: `railway-verify=`, whether Railway - * hands the token with its prefix or without it. - */ -const verifyValue = (token: Option.Option) => - token.pipe( - Option.map((value) => value.replace(/^railway-verify=/, "")), - Option.filter((value) => value.length > 0), - Option.map((value) => `${VERIFY_PREFIX}${value}`), - Option.match({ - onNone: () => Effect.die(new Error(`Railway lists no verification token for ${DOMAIN}`)), - onSome: Effect.succeed, - }), - ) - -/** - * The id of the `cvr.im` zone, read from Cloudflare (`GET /zones?name=`). The - * zone belongs to the owner's account, not to this stack: a missing zone stops - * the plan instead of creating one. - */ -const lookupZoneId = Effect.gen(function* () { - const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment - const zone = yield* Cloudflare.Zone.findZoneByName({ accountId, name: ZONE }) - return yield* Option.fromUndefinedOr(zone).pipe( - Option.match({ - onNone: () => Effect.die(new Error(`Cloudflare has no ${ZONE} zone in this account`)), - onSome: (found) => Effect.succeed(found.id), - }), - ) -}).pipe(Effect.orDie) - -/** The deployed site service and its project, as the stack yields them. */ -type DeployedSite = Effect.Success -type DeployedProject = Effect.Success - -/** `gent.cvr.im`: the Railway custom domain and its two Cloudflare records. */ -const Hostname = Effect.fn("Site.hostname")(function* ( - service: DeployedSite, - project: DeployedProject, -) { - const zoneId = yield* lookupZoneId - // The yielded project, not its declaration: the domain reads the - // environment id from the project's attributes, and a declaration passed - // as a prop resolves to none (`CustomDomainNotCreated`). - const domain = yield* CustomDomain("Domain", { - service, - environment: project, - domain: DOMAIN, - targetPort: PORT, - }) - yield* Cloudflare.DNS.Record("Cname", { - zoneId, - name: DOMAIN, - type: "CNAME", - content: Output.all(domain.customDomainId, domain.projectId).pipe( - Output.mapEffect(([customDomainId, projectId]) => trafficTarget(customDomainId, projectId)), - ), - proxied: false, - comment: "Railway custom domain (alchemy: gent apps/site)", - }) - yield* Cloudflare.DNS.Record("Verify", { - zoneId, - name: `_railway-verify.${DOMAIN}`, - type: "TXT", - content: domain.verificationToken.pipe( - Output.mapEffect((token) => verifyValue(Option.fromUndefinedOr(token))), - ), - comment: "Railway domain verification (alchemy: gent apps/site)", - }) - return domain -}, Alchemy.AdoptPolicy.adopt(true)) +import { Effect } from "effect" +import Site from "./src/worker" /** The stack's providers. */ -export const providers = Cloudflare.providers().pipe(Layer.provideMerge(railwayProviders())) +export const providers = Cloudflare.providers() export default Alchemy.Stack( "gent-site", - { providers, state: Alchemy.localState() }, + { providers, state: Cloudflare.state() }, Effect.gen(function* () { - const { stage } = yield* Alchemy.Stack - const project = yield* GentProject - const service = yield* Server.pipe(adoptInProd) - if (stage !== "prod") return { url: service.url, serviceId: service.serviceId } - const domain = yield* Hostname(service, project) - return { - url: domain.url, - serviceId: service.serviceId, - deploymentStatus: service.deploymentStatus, - certificateStatus: domain.certificateStatus, - } + const site = yield* Site + return { url: site.url } }), ) diff --git a/apps/site/install.sh b/apps/site/install.sh new file mode 120000 index 000000000..c6fc13af3 --- /dev/null +++ b/apps/site/install.sh @@ -0,0 +1 @@ +../../install.sh \ No newline at end of file diff --git a/apps/site/package.json b/apps/site/package.json index 016f12f3e..2458545db 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -7,17 +7,16 @@ "typecheck": "tsc --noEmit", "test": "env -u FORCE_COLOR NO_COLOR=1 bun test --preload ../../packages/tooling/src/test-preload.ts --reporter=dots --parallel tests", "plan": "alchemy plan alchemy.run.ts --stage prod", - "deploy": "alchemy deploy alchemy.run.ts --stage prod" + "deploy": "alchemy deploy alchemy.run.ts --stage prod", + "test:deploy": "bun test ./tests/deploy.integration.ts" }, "dependencies": { "effect": "catalog:" }, "devDependencies": { - "@distilled.cloud/core": "1.0.0-rc.13", - "@distilled.cloud/railway": "1.0.0-rc.13", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "alchemy": "2.0.0-beta.81", + "alchemy": "2.0.0-beta.83", "effect-bun-test": "catalog:" } } diff --git a/apps/site/src/deploy.ts b/apps/site/src/deploy.ts deleted file mode 100644 index 685008189..000000000 --- a/apps/site/src/deploy.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The site on Railway: its project and its service, Effect-native with no - * Dockerfile. - * - * - `main: import.meta.url` makes this file the bundle entry. Alchemy bundles - * it with Rolldown and runs it on `node:26-slim` as `/app/index.mjs`. Alchemy - * provides the platform services (`FileSystem`, `Path`, `HttpClient`) to the - * props and to the constructor, on the deploy machine and in the image. - * - The repo root `install.sh`, when it exists at deploy time, is copied - * beside the bundle (`/app/install.sh`). Its contents are hashed, so a - * change to it redeploys. The site serves it when GitHub has no release. - * - `prod` names the project `gent` and the service `site`, and serves only - * through `gent.cvr.im` (`alchemy.run.ts`). Every other stage is a - * throwaway with generated names and a generated `*.up.railway.app` URL. - */ -import * as Alchemy from "alchemy" -// Subpath imports: the `alchemy/Railway` index re-exports `Website`, whose -// framework adapters import the optional `@alchemy.run/frontend-frameworks`. -import { Project } from "alchemy/Railway/Project" -import { Service } from "alchemy/Railway/Service" -import { Effect, FileSystem, Path } from "effect" -import { HttpRouter } from "effect/http" -import * as Site from "./site" - -/** The port the service listens on and the custom domain targets. */ -export const PORT = 8080 - -/** The owner's Railway workspace, where the other cvr.im services run. */ -const WORKSPACE = "e7bf0e51-695c-4c12-aea3-d46949594a0f" - -/** The installer's name beside the bundle (`/app/install.sh`). */ -const BUNDLED_INSTALLER = "install.sh" - -/** True on the one stage that owns `gent.cvr.im`. */ -const isProd = (stage: string) => stage === "prod" - -/** - * Prod adopts the project and service it finds under its own names, so a - * deploy after lost local state takes them back instead of making copies. - * Throwaway stages have generated names and adopt nothing. - */ -export const adoptInProd = Alchemy.AdoptPolicy.adopt( - Alchemy.Stack.useSync((stack) => isProd(stack.stage)), -) - -/** The directory of this module: `apps/site/src` on the deploy machine, `/app` in the image. */ -const moduleDirectory = Effect.gen(function* () { - const path = yield* Path.Path - // A module URL that is not a file URL is a broken bundle: a defect. - return path.dirname(yield* Effect.orDie(path.fromFileUrl(new URL(import.meta.url)))) -}) - -/** - * The repo root `install.sh` to copy into the image, when the repo has one. - * A copy that fails the site's installer check stops the deploy: the site - * would otherwise ship a fallback that is not gent's installer. - */ -const bundledInstallerFile = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const source = path.join(yield* moduleDirectory, "..", "..", "..", "install.sh") - if (!(yield* Effect.orDie(fs.exists(source)))) return [] - yield* Site.checkInstaller(yield* Effect.orDie(fs.readFileString(source))).pipe( - Effect.catchTag("InstallerRejected", (rejected) => - Effect.die(new Error(`${source} is not gent's installer: ${rejected.reason}`)), - ), - ) - return [{ source, dest: BUNDLED_INSTALLER }] -}) - -/** - * The project. A different `workspaceId` would replace it, so it is pinned. - */ -export const GentProject = Project( - "GentProject", - Alchemy.Stack.useSync((stack) => { - if (isProd(stack.stage)) return { name: "gent", workspaceId: WORKSPACE } - return { workspaceId: WORKSPACE } - }), -).pipe(adoptInProd) - -export default class Server extends Service()( - "Site", - Effect.gen(function* () { - const { stage } = yield* Alchemy.Stack - const common = { - project: GentProject, - main: import.meta.url, - port: PORT, - healthcheck: "/healthz", - restartPolicyType: "ON_FAILURE" as const, - restartPolicyMaxRetries: 10, - extraFiles: yield* bundledInstallerFile, - } - // `gent.cvr.im` serves prod. Throwaway stages need the generated URL. - if (isProd(stage)) return { ...common, name: "site", publicDomain: false } - return { ...common, publicDomain: true } - }), - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const bundledInstaller = yield* Effect.option( - fs.readFileString(path.join(yield* moduleDirectory, BUNDLED_INSTALLER)), - ) - const handler = yield* HttpRouter.toHttpEffect(Site.layer({ bundledInstaller })) - return { fetch: handler } - }), -) {} diff --git a/apps/site/src/shell.d.ts b/apps/site/src/shell.d.ts new file mode 100644 index 000000000..e0aab6cef --- /dev/null +++ b/apps/site/src/shell.d.ts @@ -0,0 +1,4 @@ +declare module "*.sh" { + const content: string + export default content +} diff --git a/apps/site/src/site.ts b/apps/site/src/site.ts index ce6537eb5..0131269ae 100644 --- a/apps/site/src/site.ts +++ b/apps/site/src/site.ts @@ -1,18 +1,18 @@ /** - * gent.cvr.im: the site's HTTP surface. The Railway service - * (`src/deploy.ts`) and the tests mount this one layer. + * gent.cvr.im: the site's HTTP surface. The Cloudflare Worker + * (`src/worker.ts`) and the tests mount this one layer. * * - `GET /` is the landing page: what gent is, the install line, links. * - `GET /install.sh` and `GET /install` serve the installer itself, never a * redirect, so `curl -fsSL https://gent.cvr.im/install.sh | sh` runs it. - * Use HTTPS: Railway's edge answers plain HTTP with an empty 301 before the - * request reaches this app, so `curl gent.cvr.im/install.sh | sh` without - * `-L` runs nothing. The script is the `install.sh` asset of the latest - * GitHub release, when GitHub answers with a complete installer. When there - * is no release, or GitHub fails or answers with anything else, it is the - * copy bundled at deploy time; with no copy, a script that says so and exits - * 1. The `x-gent-install-source` header names which one answered. - * - `GET /healthz` answers `ok` (Railway's health check). + * Use HTTPS: an edge that redirects plain HTTP answers with an empty 301, + * so `curl gent.cvr.im/install.sh | sh` without `-L` runs nothing. The + * script is the `install.sh` asset of the latest GitHub release, when + * GitHub answers with a complete installer. When there is no release, or + * GitHub fails or answers with anything else, it is the copy bundled at + * deploy time; with no copy, a script that says so and exits 1. The + * `x-gent-install-source` header names which one answered. + * - `GET /healthz` answers `ok` (a liveness probe). */ import { Cache, Duration, Effect, Exit, Layer, Option, Schema, Stream } from "effect" import { @@ -282,6 +282,7 @@ export const layer = (options: SiteOptions) => // One GitHub read serves every request for a few minutes; a miss is // kept for less time, so a new release shows up soon after it ships. + // On a Worker the cache lives as long as its isolate. // The cache runs the read in a fiber of its own, so a visitor who hangs // up mid-read does not leave an interruption for the next one. const releaseInstaller = yield* Cache.makeWith( diff --git a/apps/site/src/worker.ts b/apps/site/src/worker.ts new file mode 100644 index 000000000..775de93be --- /dev/null +++ b/apps/site/src/worker.ts @@ -0,0 +1,57 @@ +/** + * The site on Cloudflare: one Worker whose `fetch` is the site's routes + * (`./site.ts`). + * + * - `main: import.meta.url` makes this file the bundle entry. Alchemy bundles + * it with Rolldown at deploy time; the Worker reaches GitHub through + * `FetchHttpClient`, the Worker's own `fetch`. + * - The repo root `install.sh`, through the `apps/site/install.sh` link, is + * inlined into the bundle as text (the `.sh` module type below, and + * `with { type: "text" }` for Bun at plan time). A change to it changes + * the bundle hash, so it redeploys. A copy that fails the site's installer + * check stops the plan: the site would otherwise ship a fallback that is + * not gent's installer. + * - `prod` takes the Worker name `gent-site` and the `gent.cvr.im` custom + * domain, and turns its `workers.dev` host off. Any other stage is a + * throwaway on a generated `workers.dev` name and never touches the domain. + */ +import * as Cloudflare from "alchemy/Cloudflare" +import { Stage } from "alchemy/Stage" +import { Effect, Layer, Option } from "effect" +import { FetchHttpClient, HttpRouter } from "effect/http" +import installer from "../install.sh" with { type: "text" } +import * as Site from "./site" + +export const DOMAIN = "gent.cvr.im" + +/** Rolldown reads `.sh` imports as text: the bundled installer. */ +const BUILD = { input: { moduleTypes: { ".sh": "text" } } } as const + +/** The props, by stage. The running Worker reads them without one. */ +const props = Effect.gen(function* () { + const stage = yield* Effect.serviceOption(Stage) + const base = { main: import.meta.url, build: BUILD } + if (!Option.contains(stage, "prod")) return base + return { ...base, name: "gent-site", workersDev: false, domain: DOMAIN } +}) + +/** The repo's installer, checked once: at plan time on the deploy machine, then per isolate. */ +const bundledInstaller = Site.checkInstaller(installer).pipe( + Effect.catchTag("InstallerRejected", (rejected) => + Effect.die(new Error(`install.sh is not gent's installer: ${rejected.reason}`)), + ), +) + +export default class Worker extends Cloudflare.Worker()( + "Site", + props, + Effect.gen(function* () { + const script = yield* bundledInstaller + const handler = yield* HttpRouter.toHttpEffect( + Site.layer({ bundledInstaller: Option.some(script) }).pipe( + Layer.provide(FetchHttpClient.layer), + ), + ) + return { fetch: handler } + }), +) {} diff --git a/apps/site/tests/deploy.integration.ts b/apps/site/tests/deploy.integration.ts new file mode 100644 index 000000000..15d45a3c5 --- /dev/null +++ b/apps/site/tests/deploy.integration.ts @@ -0,0 +1,47 @@ +/** + * The deploy, checked against real Cloudflare: a throwaway `test_$USER` stage + * is deployed, probed and destroyed in one scope, so a failure destroys it too. + * + * Outside the test lane's file pattern on purpose, so the gate never deploys. + * Run it with `bun run test:deploy` before handing off a change to the stack, + * the Worker, or the Alchemy or Effect versions. + */ +import { expect } from "bun:test" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Test from "alchemy/Test/Bun" +import { Effect, Option, Schedule } from "effect" +import { HttpClient } from "effect/http" +import Stack, { providers } from "../alchemy.run" +import * as Site from "../src/site" + +const { test, deploy, destroy } = Test.make({ providers, state: Cloudflare.state() }) + +const deployed = Effect.acquireRelease(deploy(Stack), () => destroy(Stack).pipe(Effect.orDie)) + +test( + "the Worker serves the landing page, the health check and the installer", + Effect.gen(function* () { + const outputs = yield* deployed + const url = yield* Effect.fromOption(Option.fromNullishOr(outputs.url)) + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk) + // A fresh workers.dev host can answer 404 for a few seconds after deploy. + const get = (path: string) => + client + .get(`${url}${path}`) + .pipe(Effect.retry({ schedule: Schedule.exponential("1 second"), times: 8 })) + + const home = yield* get("/") + expect(home.headers["content-type"]).toContain("text/html") + expect(yield* home.text).toContain(Site.INSTALL_LINE) + + const health = yield* get("/healthz") + expect(yield* health.text).toBe("ok") + + // The release copy when GitHub has one, else the copy bundled at deploy: + // never the "no release yet" script, since the repo has an installer. + const install = yield* get("/install.sh") + expect(install.headers["x-gent-install-source"]).toMatch(/^(release|bundled)$/) + expect(yield* install.text).toContain(Site.INSTALLER_MARKER) + }).pipe(Effect.scoped), + { timeout: 600_000 }, +) diff --git a/apps/site/tests/site.test.ts b/apps/site/tests/site.test.ts index ff80e354b..c725ae034 100644 --- a/apps/site/tests/site.test.ts +++ b/apps/site/tests/site.test.ts @@ -1,5 +1,5 @@ -// The site as a visitor and as `curl | sh` meet it: the routes the Railway -// service mounts, served over real HTTP on this machine, with GitHub replaced +// The site as a visitor and as `curl | sh` meet it: the routes the Worker +// mounts, served over real HTTP on this machine, with GitHub replaced // by a fake client that answers the release download the way `fetch` does. import { BunHttpServer, BunServices } from "@effect/platform-bun" import { diff --git a/bun.lock b/bun.lock index 7d110ce02..9e1cc190f 100644 --- a/bun.lock +++ b/bun.lock @@ -32,11 +32,9 @@ "effect": "catalog:", }, "devDependencies": { - "@distilled.cloud/core": "1.0.0-rc.13", - "@distilled.cloud/railway": "1.0.0-rc.13", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "alchemy": "2.0.0-beta.81", + "alchemy": "2.0.0-beta.83", "effect-bun-test": "catalog:", }, }, @@ -195,11 +193,11 @@ "solid-js": "^1.9.15", }, "packages": { - "@alchemy.run/cloudflare-runtime": ["@alchemy.run/cloudflare-runtime@2.0.0-beta.81", "", { "dependencies": { "@alchemy.run/node-utils": "2.0.0-beta.81", "@cloudflare/unenv-preset": "^2.16.1", "@puppeteer/browsers": "^3.2.0", "capnp-es": "^0.0.16", "magic-string": "^0.30.21", "mime": "^4.0.7", "sharp": "^0.35.3", "unenv": "^2.0.0-rc.24", "workerd": "1.20260918.1", "yauzl": "^3.4.0" }, "peerDependencies": { "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "effect": "^4.0.0", "rolldown": "1.2.5", "vite": "^7.0.0 || ^8.0.0" }, "optionalPeers": ["@effect/platform-bun", "@effect/platform-node", "rolldown", "vite"] }, "sha512-gGmshJZSFbeyK94UMRgRcrE0prwJrp3H3kQ8aZmGOuWzwJMMxaoCuf4Nulf8nGsvHcjMEAfFi45AXRRQBgbgQQ=="], + "@alchemy.run/cloudflare-runtime": ["@alchemy.run/cloudflare-runtime@2.0.0-beta.83", "", { "dependencies": { "@alchemy.run/node-utils": "2.0.0-beta.83", "@cloudflare/unenv-preset": "^2.16.1", "@puppeteer/browsers": "^3.2.0", "capnp-es": "^0.0.16", "magic-string": "^0.30.21", "mime": "^4.0.7", "sharp": "^0.35.3", "unenv": "^2.0.0-rc.24", "workerd": "1.20261002.1", "yauzl": "^3.4.0" }, "peerDependencies": { "@distilled.cloud/cloudflare": "1.0.0-rc.15", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "effect": "^4.0.0", "rolldown": "1.2.5", "vite": "^7.0.0 || ^8.0.0" }, "optionalPeers": ["@effect/platform-bun", "@effect/platform-node", "rolldown", "vite"] }, "sha512-tchwc16KccR3Q8fmEZSqJcXRPJ0mV0Y3LEu7VbdE6R3e0VqKFNnz5xYiCfQWUAahB+QtJDtTu3/nkjOajE3ggQ=="], - "@alchemy.run/floci": ["@alchemy.run/floci@2.0.0-beta.81", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-0mYZqFrQLlRwDUsoJlI/ArnEaAaFid2ZPsJWvUyC5llAScWxlST7pdjQd1r/t1YIFFkhOV9JQO1wa7FI1nY6EQ=="], + "@alchemy.run/floci": ["@alchemy.run/floci@2.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-QVWGFvko0QYEMsR298PW8OQ/TkyRoEnLlz5iGNXar/wdBC1FNrBHHMmfG7pY9Vc5BbCR1WtggD/ZYUwtyyUgQw=="], - "@alchemy.run/node-utils": ["@alchemy.run/node-utils@2.0.0-beta.81", "", { "dependencies": { "chokidar": "^5.0.0", "rolldown": "1.2.5" } }, "sha512-AcxxGMFbbdiKww7OUbBd/fsSDyRJjoICAIqGwgkJxrIQkkaNt6+9SObrznmnOx70OonA2GH87RGnEaTtORHffw=="], + "@alchemy.run/node-utils": ["@alchemy.run/node-utils@2.0.0-beta.83", "", { "dependencies": { "chokidar": "^5.0.0", "rolldown": "1.2.5" } }, "sha512-UiRTmZN95URcMgQQtuatgaV2YcYZ5TDoZtRgnd7Kz7R7pCeFCUhJXM2ZYwM32j3wqnObThpiFKFDJmqDsrosIA=="], "@alchemy.run/sigil": ["@alchemy.run/sigil@0.1.0-alpha.1", "", { "peerDependencies": { "@types/react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-RtHELG2w+GluVewRsuKm66idtPzC+4STptMr/61X1Vy5uBkDzcCzjzV8G6jUa4iBv6Il0hHV7UL6wvv//9nD/Q=="], @@ -273,49 +271,53 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20261002.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-PW3AYxxO6W2cCYCPrx0ZOqivb/2/R2zGtPgNiUgzZBGDl2MtVxWKvzDDXvTGVCmuL4aiTCziNcZktDpCcR7NnQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20261002.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vv6t7a11NBEX9eS9mfMRvUymJszXVnnWCH8VEq1f65sxsFli3O4sI4pzFcIvG+P1BKVZZDBv4gwvxWLQXuM37g=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20261002.1", "", { "os": "linux", "cpu": "x64" }, "sha512-uOU1eCBoDnR4CQLkm4tqBlM1lHMgzW14VNEWWVOohi/qvmbLAwapwyLDCytxpb4U55+mXgcMFc91y+Ibh1EssA=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20261002.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-p3Zcrj4EAYc1+yDUjRZ3ygjPUj/8Dg03ux+QyfL0UT5d1OG0BJnBSto2OVJuGQ3J3cCRsjhjYs7gzB727eKBTw=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261002.1", "", { "os": "win32", "cpu": "x64" }, "sha512-S9lnFnKRUNZ6umIBjCCbdfo5cTk1fR7SKi2BcQuCUKTmOzAjLsIFV49yiwPLSH33c0TDUJn8eavX4S7bORwfBA=="], "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261004.1", "", {}, "sha512-K9D4/Y6bSIDNYFzjqzFMNoWK99pLgfgFxqSa76kfW++3VCkTSzJbzsQRffir366G3kmNyjoigC6ekp7DxIkxkw=="], - "@distilled.cloud/acme": ["@distilled.cloud/acme@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-3rmzH125jQxk0zpCvXGNEybSaqpAolyCX+8LWDDrXf+wIKs67GflkJyQLjOOh0lKARe29hdUZGgqEbbvpp0LNA=="], + "@distilled.cloud/acme": ["@distilled.cloud/acme@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-SzQZoPTonWolW6yHartHKIc1sEOlrnq3XdCfCT6RUXb1iAK0YyboRE0BgROzhg4FfxqSypb1BBB0D0vwhm+qjQ=="], - "@distilled.cloud/aws": ["@distilled.cloud/aws@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13", "@smithy/types": "^4.17.2" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-6ZGcp0LkniB0I2l8Gr7vKRQgTZTch0/07iuvuMZv9Nu+rWwNuZG9VHTUdlIoarWgCHFUNN8yvSYBGOjaeN99tQ=="], + "@distilled.cloud/aws": ["@distilled.cloud/aws@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-5VBbmBxTLrTe0/MhcDqul0+rsptUc5rpPR2ymI6v+I//FOLE/x/2YlX3utm6ziGacTEeZaVpwdxhkt6SdJ28Pw=="], - "@distilled.cloud/axiom": ["@distilled.cloud/axiom@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-aRoJtvfXgWi5dtfjuJvDwnArRKvv2MPYHCxYeZcxZbqe62IaQA5yu/khft7TJvxeDyJoSDnVXZu3RyJjNmBA4g=="], + "@distilled.cloud/axiom": ["@distilled.cloud/axiom@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-R4jcq23GdjcNVwJXwSqKmT13ME6k0Zz14i7VOiqj8dYa2jIMyNIDXqHY3bHiq3+x2vimCJ3B2VlgstoXd8G8sw=="], - "@distilled.cloud/cloudflare": ["@distilled.cloud/cloudflare@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Py5FoA6azyYExDb91VaeC+9x+ravOBSRhXeUmpcQjCtgqmOdiXgc2YZGMMPI68DF+kMuHRVIrR+2uRZNDOhYjw=="], + "@distilled.cloud/cloudflare": ["@distilled.cloud/cloudflare@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-UBnEpZGkf+lnMF33IJXcu7DD99vC+3314N01pnWNuKxLtsVNNLF/DxFbPxBU1Cb+aLE3Cs35aSiHZrYO7EiR0g=="], - "@distilled.cloud/core": ["@distilled.cloud/core@1.0.0-rc.13", "", { "dependencies": { "graphql": "16.11.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-RYyrxW+F5zrTuu1UWV/JoycholBCIkXIusYJdDniDwCdmbjdKGl07KlkkeeY774uBawNGf+pt1ESNGeLVnNHsg=="], + "@distilled.cloud/core": ["@distilled.cloud/core@1.0.0-rc.15", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-TPXxuwcxQ85Pvm0g7ZQSLTQkPc1Wmcb6p6KOk+Be2fP14Xpj5a+7HOSUyPNwbqOP+vPK5Sj0ZLN7M6spKnO2LA=="], - "@distilled.cloud/doppler": ["@distilled.cloud/doppler@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-GgOEUCc4Bx0C+lYNfZ4OqaA3ULvqDUQTJC8gobSh1L0jfrWclv/kRcSr43v4IXyStld+saUvDfDZgeGzlE74OA=="], + "@distilled.cloud/doppler": ["@distilled.cloud/doppler@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-w72a8B07IQhGEG09Cg9OZuJbCEV/Qjf7DNRI6r5+ATN88575gv8xOFyAi7DuwfbR245BLN8VGEgZ7I0exMMEmw=="], - "@distilled.cloud/fly-io": ["@distilled.cloud/fly-io@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Ipp+9nvs+PzqwDPhZq2/BEHNUeWzXKhrvjfT/eBLFHo/QpH0HgdMH8MSQe6/LGszZoormwA6ZrTv2wxaFO7ZiQ=="], + "@distilled.cloud/fly-io": ["@distilled.cloud/fly-io@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-lQ1DuktDE+69snRw/ZIZ1Tlc6fVx9gNlCIHX2CA8p8DVtpIg389vligmfFDAtmyQVnix/0ZJydo5OFnk1Ip53A=="], - "@distilled.cloud/gcp": ["@distilled.cloud/gcp@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-un8jqil9ejt96oN+OfEDUsbrEOpC6Ut4B/73DqKDttvt0Btal1OeBNTTQsftVCpeEzXAvkYKGRIT2AeEu1J5jA=="], + "@distilled.cloud/gcp": ["@distilled.cloud/gcp@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-NTxSKZWmFa+psZZh/aTOIKVlayU2SIlgOKEqA2R80Ys3fcI0JVXjfkCPLfMURfV19YDjJ6jbWQ4cV+xqSC/x1w=="], - "@distilled.cloud/hetzner": ["@distilled.cloud/hetzner@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-FvqofgMypNkeZbnjGjWMZqvqGd9GxcMzdbi/6JrbcPaHMJG+CCBGt/mAa7mLlEovzwVaGCsBnOXJ6rP5pZjB4Q=="], + "@distilled.cloud/hetzner": ["@distilled.cloud/hetzner@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-lUPogzlQeMGp29thsiF7bDBsRfnN5PzTqiujdDc/ZjWDBpq3teudkzbdMMsL12RB5qvB9xb3INwi9JWqv1lQcQ=="], - "@distilled.cloud/infisical": ["@distilled.cloud/infisical@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Zz8ksA/Y8UUUnWag2UBxB5WYA6wLqOS78ugWJ3cOr9Ot+4bC0NJYWanq35uFInozLXWhun84USU2wOI+mdwQUQ=="], + "@distilled.cloud/iceberg": ["@distilled.cloud/iceberg@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-qNLBVGyuIP/OdgvRht41u/QPOEvJ2kUntC7++bL6YGPbB6TPbDDPd72xeP/gXogIgLsQj8RP1gETjJCQREtl2Q=="], - "@distilled.cloud/neon": ["@distilled.cloud/neon@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-2JvbBFj1kBfkDVPnWcJZ7TJ1cJwWBtScAY7SNJ2qNBUuAK1Wf2kjJXU0/j16eCJ48BQigPArQFWL51wVu/EUQQ=="], + "@distilled.cloud/infisical": ["@distilled.cloud/infisical@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-nDN7k8cxXkKkgFL42u3YSNySpjzGWhy2QgRzLbweSVxmhNNjIpxAsW/ct8HUf4Y9CM9Pg8KtQItsMyf8C+NgGg=="], - "@distilled.cloud/planetscale": ["@distilled.cloud/planetscale@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-AM97YGRnDfZdqsxFgQMQ83p7ZlRqxS6/foTTJA5P590UMbbOyrQyFyd33BD0agul8a3ngZde3Oej6v7BN55aWw=="], + "@distilled.cloud/neon": ["@distilled.cloud/neon@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-lRZMqLuYNALiMEjJRISKyeiF/7SVW8F4JeTfqk/QAl0MY5SxJvKpkgrCoKNj2kVI06IH1D8iTt3sxf5syXRLEQ=="], - "@distilled.cloud/prisma": ["@distilled.cloud/prisma@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-+UmHSp9JV8XQl5t5tTdQBY+dWZ9abrEoc7leYEctA0Ar1TrKZEl2EaeQDoumrg4RyGaacRlirEbxaDFI4SOPtQ=="], + "@distilled.cloud/planetscale": ["@distilled.cloud/planetscale@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-7ZghpGNi63Wfp6tU17Oex7aRjZ5O+ARFfl071S3JaeC7ube13Ji6LS/H10MG+DMpCq/vFVDtVB2ZNtevUcAI3w=="], - "@distilled.cloud/railway": ["@distilled.cloud/railway@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-yXalRS3GFLo3hWM0HXptqudi23NVCM4FRD20db2/Ob9FFcNrMJik1fopv+74HOiV+BuxfkCP609KsEK6LVdB/g=="], + "@distilled.cloud/prisma": ["@distilled.cloud/prisma@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-we2sbP5XHuq2XXi1NOp7ierK8apy3wZOrzIw2bi8CidQYn8OWCUIi9IgUBX6V3kCT1iRq0Y1FwiW6c9jJwtGsw=="], - "@distilled.cloud/stripe": ["@distilled.cloud/stripe@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-jLTJ1mg4U39lHZb9EGYBJLR91yB3LSG6fVxix+r1BbqaTovWzE+NuJ7fu9fRSeax9jRAqsGLLxe9IxJbOtwQnQ=="], + "@distilled.cloud/railway": ["@distilled.cloud/railway@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-Z/ebbgxozAuj9vog8Ar05BoFrQwKuKKGsocsLpMlQr26VT+x9GL71P3uAhpF+Fhg7FFS3NjYB9R6MAIxPP62tA=="], - "@distilled.cloud/zerossl": ["@distilled.cloud/zerossl@1.0.0-rc.13", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.13" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-dnxhg39lZjpTdZbHCVYPoUC9E2r8eSHbqiYDhSq2EurVPJcc9YRXlxZR1bP1nniSp078XV8GA+aUECt/Z56aQg=="], + "@distilled.cloud/stripe": ["@distilled.cloud/stripe@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-sYIPEg1MKZrj6aSy4o08anO5dVpyFJHzgEk0WfiMm5BT0oEFtldUuPe/FjEOoY+mFvZ+o+26lJ5voEnWLJSCUA=="], + + "@distilled.cloud/turso": ["@distilled.cloud/turso@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-hvudbvmdatgi198Exlid3zgF/WGBD3UDa0A2F4QPD3IYR08Y+O5ZFcovPcV841JDIREJN0hKmgsrmpoFczxbDA=="], + + "@distilled.cloud/zerossl": ["@distilled.cloud/zerossl@1.0.0-rc.15", "", { "dependencies": { "@distilled.cloud/core": "1.0.0-rc.15" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-lu69h33Ib18kATq0W1c1zw6CH3YkUvKWGj1QUi8lbuINXPXHb2/88ATzehDQr5GM/qxtO+azgglhs/NVOf4UVw=="], "@effect/ai-anthropic": ["@effect/ai-anthropic@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-/xZYmvRE9yDLKiz1pOIx0CVaB+o0zdJd6sTG9KQbFSkM+Ule0U/6yeN+Q6KD9I7Z73t3WxhK9Xmg1eqU6+IVmQ=="], @@ -781,8 +783,6 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="], @@ -853,7 +853,7 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "alchemy": ["alchemy@2.0.0-beta.81", "", { "dependencies": { "@alchemy.run/cloudflare-runtime": "2.0.0-beta.81", "@alchemy.run/floci": "2.0.0-beta.81", "@alchemy.run/node-utils": "2.0.0-beta.81", "@alchemy.run/sigil": "0.1.0-alpha.1", "@distilled.cloud/acme": "1.0.0-rc.13", "@distilled.cloud/aws": "1.0.0-rc.13", "@distilled.cloud/axiom": "1.0.0-rc.13", "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@distilled.cloud/core": "1.0.0-rc.13", "@distilled.cloud/doppler": "1.0.0-rc.13", "@distilled.cloud/fly-io": "1.0.0-rc.13", "@distilled.cloud/gcp": "1.0.0-rc.13", "@distilled.cloud/hetzner": "1.0.0-rc.13", "@distilled.cloud/infisical": "1.0.0-rc.13", "@distilled.cloud/neon": "1.0.0-rc.13", "@distilled.cloud/planetscale": "1.0.0-rc.13", "@distilled.cloud/prisma": "1.0.0-rc.13", "@distilled.cloud/railway": "1.0.0-rc.13", "@distilled.cloud/stripe": "1.0.0-rc.13", "@distilled.cloud/zerossl": "1.0.0-rc.13", "@effect/sql-d1": "^4.0.0", "@effect/sql-sqlite-do": "^4.0.0", "@libsql/client": "^0.17.0", "@neon/functions": "0.11.0", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@prisma/dev": "^0.20.0", "@types/aws-lambda": "^8.10.152", "capnweb": "^0.12.0", "fflate": "^0.8.3", "libsodium-wrappers": "^0.8.3", "pathe": "^2.0.3", "picomatch": "^4.0.4", "rolldown": "1.2.5", "tinyglobby": "^0.2.17", "yaml": "^2.9.0" }, "peerDependencies": { "@alchemy.run/frontend-frameworks": "2.0.0-beta.81", "@aws/durable-execution-sdk-js": "^2.1.0", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "@effect/sql-mysql2": "^4.0.0", "@effect/sql-pg": "^4.0.0", "@effect/vitest": "^4.0.0", "@prisma/orm-postgres": "8.0.0-rc.11", "@vercel/nft": "^1.10.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": "^4.0.0", "mongodb": "^6.10.0", "mysql2": "^3.24.2", "pg": "^8.22.0", "prisma": "8.0.0-rc.15", "vite": "^8.0.7", "ws": "^8.21.0" }, "optionalPeers": ["@alchemy.run/frontend-frameworks", "@aws/durable-execution-sdk-js", "@effect/platform-bun", "@effect/platform-node", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/vitest", "@prisma/orm-postgres", "@vercel/nft", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "pg", "prisma", "vite", "ws"], "bin": { "alchemy": "./bin/cli.js" } }, "sha512-AxEUQSYSln9NgZMuYnSz9Q7ERR8BrKrGpx9vOwJtWity7Ze8/7vI4k62u2EAoJRpBbaOTnI2Bp67Au1y9J7FTA=="], + "alchemy": ["alchemy@2.0.0-beta.83", "", { "dependencies": { "@alchemy.run/cloudflare-runtime": "2.0.0-beta.83", "@alchemy.run/floci": "2.0.0-beta.83", "@alchemy.run/node-utils": "2.0.0-beta.83", "@alchemy.run/sigil": "0.1.0-alpha.1", "@distilled.cloud/acme": "1.0.0-rc.15", "@distilled.cloud/aws": "1.0.0-rc.15", "@distilled.cloud/axiom": "1.0.0-rc.15", "@distilled.cloud/cloudflare": "1.0.0-rc.15", "@distilled.cloud/core": "1.0.0-rc.15", "@distilled.cloud/doppler": "1.0.0-rc.15", "@distilled.cloud/fly-io": "1.0.0-rc.15", "@distilled.cloud/gcp": "1.0.0-rc.15", "@distilled.cloud/hetzner": "1.0.0-rc.15", "@distilled.cloud/iceberg": "1.0.0-rc.15", "@distilled.cloud/infisical": "1.0.0-rc.15", "@distilled.cloud/neon": "1.0.0-rc.15", "@distilled.cloud/planetscale": "1.0.0-rc.15", "@distilled.cloud/prisma": "1.0.0-rc.15", "@distilled.cloud/railway": "1.0.0-rc.15", "@distilled.cloud/stripe": "1.0.0-rc.15", "@distilled.cloud/turso": "1.0.0-rc.15", "@distilled.cloud/zerossl": "1.0.0-rc.15", "@effect/sql-d1": "^4.0.0", "@effect/sql-sqlite-do": "^4.0.0", "@libsql/client": "^0.17.0", "@neon/functions": "0.11.0", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", "@prisma/dev": "^0.20.0", "@types/aws-lambda": "^8.10.152", "capnweb": "^0.12.0", "fflate": "^0.8.3", "libsodium-wrappers": "^0.8.3", "pathe": "^2.0.3", "picomatch": "^4.0.4", "rolldown": "1.2.5", "tinyglobby": "^0.2.17", "yaml": "^2.9.0" }, "peerDependencies": { "@alchemy.run/frontend-frameworks": "2.0.0-beta.83", "@aws/durable-execution-sdk-js": "^2.1.0", "@effect/platform-bun": "^4.0.0", "@effect/platform-node": "^4.0.0", "@effect/sql-libsql": "^4.0.0", "@effect/sql-mysql2": "^4.0.0", "@effect/sql-pg": "^4.0.0", "@effect/vitest": "^4.0.0", "@prisma/orm-postgres": "^8.0.0-rc.11", "@vercel/nft": "^1.10.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": "^4.0.0", "mongodb": "^6.10.0", "mysql2": "^3.24.2", "pg": "^8.22.0", "prisma": "^8.0.0-rc.15", "vite": "^8.0.7", "ws": "^8.21.0" }, "optionalPeers": ["@alchemy.run/frontend-frameworks", "@aws/durable-execution-sdk-js", "@effect/platform-bun", "@effect/platform-node", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/vitest", "@prisma/orm-postgres", "@vercel/nft", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "pg", "prisma", "vite", "ws"], "bin": { "alchemy": "./bin/cli.js" } }, "sha512-t0N4FwLU1JxLnKZGdsBNdqiycWDM5HEYmmIguMImplE07Vh+FPpMR8ypk3K3TY5Tg0sFFppXFVwtwiDp6xlX9w=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -1019,8 +1019,6 @@ "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], - "graphql": ["graphql@16.11.0", "", {}, "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], @@ -1299,7 +1297,7 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], + "workerd": ["workerd@1.20261002.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261002.1", "@cloudflare/workerd-darwin-arm64": "1.20261002.1", "@cloudflare/workerd-linux-64": "1.20261002.1", "@cloudflare/workerd-linux-arm64": "1.20261002.1", "@cloudflare/workerd-windows-64": "1.20261002.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-HFTb9FGAEWB5AVnHP/czY/vy9tqtyV7rreb9MKntsAJi8aElBkyq7P0MLPQLgTtKOk6A+EivoS39gDH/lwzu3Q=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], From f66e0707e670085abd128666d33884de59e41d35 Mon Sep 17 00:00:00 2001 From: cevr Date: Thu, 15 Oct 2026 14:22:47 -0400 Subject: [PATCH 3/4] ci(site): deploy prod on main and a preview per pull request --- .github/workflows/deploy-site.yml | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/deploy-site.yml diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 000000000..a3f5699ce --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,63 @@ +# gent.cvr.im (apps/site): `prod` on main, a `pr-` preview per pull +# request that touches it, and a teardown when that pull request closes. +# ci.yml runs the whole gate; this workflow checks the site itself before it +# deploys. CI owns `prod`: a local deploy builds a bundle that hashes +# differently, so use a throwaway stage for local work. +name: Deploy site + +on: + push: + branches: [main] + paths: ['apps/site/**', 'bun.lock', 'package.json', '.github/workflows/deploy-site.yml'] + pull_request: + types: [opened, synchronize, reopened, closed] + paths: ['apps/site/**', 'bun.lock', 'package.json', '.github/workflows/deploy-site.yml'] + +concurrency: + group: deploy-site-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }} + cancel-in-progress: false + +permissions: + contents: read + +env: + LEFTHOOK: 0 + STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }} + # Provider credentials. Profiles are never read when CI=true. + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + +defaults: + run: + working-directory: apps/site + +jobs: + deploy: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + environment: ${{ github.event_name == 'pull_request' && 'preview' || 'production' }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run test + - run: bunx alchemy provider check-env --provider Cloudflare + - run: bunx alchemy deploy alchemy.run.ts --stage "$STAGE" --yes + + cleanup: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + environment: preview + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: bun install --frozen-lockfile + - name: Destroy the preview stage + run: | + case "$STAGE" in pr-*) ;; *) echo "refusing to destroy $STAGE"; exit 1 ;; esac + bunx alchemy destroy alchemy.run.ts --stage "$STAGE" --yes From 6dcb18fe9c81e1830559e1168102d3234049cdfb Mon Sep 17 00:00:00 2001 From: cevr Date: Thu, 15 Oct 2026 14:23:40 -0400 Subject: [PATCH 4/4] style: format the deploy workflow --- .github/workflows/deploy-site.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index a3f5699ce..65f66daf7 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -8,10 +8,10 @@ name: Deploy site on: push: branches: [main] - paths: ['apps/site/**', 'bun.lock', 'package.json', '.github/workflows/deploy-site.yml'] + paths: ["apps/site/**", "bun.lock", "package.json", ".github/workflows/deploy-site.yml"] pull_request: types: [opened, synchronize, reopened, closed] - paths: ['apps/site/**', 'bun.lock', 'package.json', '.github/workflows/deploy-site.yml'] + paths: ["apps/site/**", "bun.lock", "package.json", ".github/workflows/deploy-site.yml"] concurrency: group: deploy-site-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'prod' }}