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
63 changes: 63 additions & 0 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# gent.cvr.im (apps/site): `prod` on main, a `pr-<n>` 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 16 additions & 152 deletions apps/site/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -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 <name> --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=<token>`, whether Railway
* hands the token with its prefix or without it.
*/
const verifyValue = (token: Option.Option<string>) =>
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<typeof Server>
type DeployedProject = Effect.Success<typeof GentProject>

/** `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 }
}),
)
1 change: 1 addition & 0 deletions apps/site/install.sh
7 changes: 3 additions & 4 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
}
}
108 changes: 0 additions & 108 deletions apps/site/src/deploy.ts

This file was deleted.

Loading
Loading