diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 5ae2f42..57ab9f6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -7,9 +7,11 @@ name: Claude Code Review # don't burn a review. # • Review behavior is tuned in REVIEW.md at the repo root, not here. # -# Setup: add ANTHROPIC_API_KEY as a repository secret -# (Settings → Secrets and variables → Actions) and install the Claude GitHub -# App at https://github.com/apps/claude. +# Setup: run `claude setup-token` (needs a Claude subscription) and store the +# token it prints as the CLAUDE_CODE_OAUTH_TOKEN repository secret +# (Settings → Secrets and variables → Actions), then install the Claude GitHub +# App at https://github.com/apps/claude. To bill against API credits instead, +# swap the input below for `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}`. on: pull_request: types: [opened, synchronize] @@ -41,7 +43,7 @@ jobs: - uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: > Review this pull request. First read REVIEW.md at the repo root and follow it as the source of truth for what to flag and how to rank diff --git a/README.md b/README.md index d3c1472..75dc042 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,12 @@ deploykit.config.ts source of truth — every other file regenerates .github/workflows/deploy.yml changes → preview / teardown / staging / production jobs apps//Dockerfile multi-stage, turbo-prune (or nx build) based apps//fly.toml per-app Fly config with a health check +DEPLOYMENTS.md the map: every URL, plus deep links into Fly / + Cloudflare / GitHub — see below ``` +`DEPLOYMENTS.md` is written for the teammate who didn't run `deploykit init`: a table of every app environment (URL, Fly app, what deploys it), a mermaid diagram of the pipeline, and a link straight to each Fly app's logs, Cloudflare's DNS page for your zone, and the GitHub environments/secrets screens — so nothing has to be hunted down console by console. It lists secret **names** and where they're wired, never values, which is why it's a committed file rather than a gitignored one. See [`examples/DEPLOYMENTS.md`](examples/DEPLOYMENTS.md) for a real one. + > **See it for real:** the [`examples/`](examples/) directory contains the **actual, byte-for-byte output** deploykit produces for a two-app Turbo monorepo (a React Router SSR `web` app with Prisma + a custom domain + multi-region, and a static Astro `marketing` app). Every file there is generated from a single [`examples/deploykit.config.ts`](examples/deploykit.config.ts). Every file carries a `# Generated by deploykit` header and is **yours to edit and commit**. Re-running `init` or `generate` skips files you've changed unless you pass `--force`, so a hand-edited Dockerfile is never overwritten by surprise. diff --git a/examples/DEPLOYMENTS.md b/examples/DEPLOYMENTS.md new file mode 100644 index 0000000..7ee2967 --- /dev/null +++ b/examples/DEPLOYMENTS.md @@ -0,0 +1,70 @@ +# Deployments + + + +Where every app runs, what deploys it, and where to click in each dashboard. + +```mermaid +flowchart LR + pr["pull request"] --> preview["preview
web, marketing"] + pr -. "PR closed" .-> destroy["apps destroyed"] + main["merge to main"] --> staging["staging
web, marketing"] + staging --> gate{"manual approval"} + gate --> production["production
web"] +``` + +## web — `apps/web` + +| Environment | URL | Fly app | Deploys | +| --- | --- | --- | --- | +| preview | `https://acme-web-pr-{pr}.fly.dev` | `acme-web-pr-{pr}` | on every pull request | +| staging | | `acme-web-staging` | on merge to main | +| production | | `acme-web-prod` | manual approval | + +## marketing — `apps/marketing` + +| Environment | URL | Fly app | Deploys | +| --- | --- | --- | --- | +| preview | `https://acme-marketing-pr-{pr}.fly.dev` | `acme-marketing-pr-{pr}` | on every pull request | +| staging | | `acme-marketing-staging` | on merge to main | + +## Where to look + +| What | Where | +| --- | --- | +| Fly org — every app, billing, deploy tokens | | +| Fly deploy token (`FLY_API_TOKEN`) — revoke/rotate here | | +| `acme-web-staging` — overview | | +| `acme-web-staging` — metrics & logs | | +| `acme-web-staging` — runtime secrets | | +| `acme-web-prod` — overview | | +| `acme-web-prod` — metrics & logs | | +| `acme-web-prod` — runtime secrets | | +| `acme-marketing-staging` — overview | | +| `acme-marketing-staging` — metrics & logs | | +| `acme-marketing-staging` — runtime secrets | | +| Cloudflare DNS — example.com | | +| Cloudflare SSL/TLS — example.com | | + +_No GitHub remote was found when this file was generated, so the GitHub links are omitted. Re-run `deploykit generate` once the remote is set._ + +## Secrets + +Values live in GitHub Actions secrets and are never stored in this repo. Set or update them with `deploykit init`, or in the GitHub UI. + +| Name | Wired as | Used by | +| --- | --- | --- | +| `DATABASE_URL` | runtime — `flyctl secrets set` | web | +| `SESSION_SECRET` | runtime — `flyctl secrets set` | web | +| `VITE_API_URL` | build-time — baked into the image (`--build-arg`) | web | + +Per-environment values (a staging vs production `DATABASE_URL`) go on the matching GitHub environment; anything set at the repository level applies to every environment that doesn't override it. + +## Good to know + +- **Preview apps don't exist until a PR opens.** The workflow creates them on first deploy and destroys them when the PR closes, so you won't find them on the Fly dashboard in between. +- **Production waits for a human.** It only deploys after the `production` GitHub environment is approved — add required reviewers there, or the gate approves itself. +- **Custom domains resolve through Cloudflare.** DNS records point at Fly and the certificate is issued by Fly; the `*.fly.dev` address keeps working either way, which makes it the thing to test against when a domain looks wrong. +- **A failed health check keeps the old release serving.** Fly only shifts traffic once the new machines pass their check, so a bad deploy stalls instead of taking the app down. To undo a release that passed but misbehaves, run `deploykit rollback`. +- **Everything here is regenerable.** `deploykit.config.ts` is the source of truth — edit it and run `deploykit generate` to update the Dockerfiles, `fly.toml` files, the workflow, and this file. diff --git a/examples/README.md b/examples/README.md index 48cfc01..e99da2f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ A **Turbo** monorepo (`pnpm`, Node 20) with the Fly app-name prefix `acme`, a pr | [`apps/web/fly.toml`](apps/web/fly.toml) | Health check + the **commented-out** Prisma migration hook. | | [`apps/marketing/Dockerfile`](apps/marketing/Dockerfile) | Static build served with `serve`. | | [`apps/marketing/fly.toml`](apps/marketing/fly.toml) | Health check (no migration hook — no Prisma). | +| [`DEPLOYMENTS.md`](DEPLOYMENTS.md) | The map of what was created: every environment's URL and Fly app, a mermaid view of the pipeline, deep links into the Fly/Cloudflare/GitHub consoles, and the secret names (never values). Generated here without a GitHub remote, so the GitHub link rows are omitted — in your repo they're filled in from `origin`. | ## Regenerate them yourself diff --git a/examples/deploykit.config.ts b/examples/deploykit.config.ts index b7983c5..e0a83af 100644 --- a/examples/deploykit.config.ts +++ b/examples/deploykit.config.ts @@ -50,15 +50,15 @@ export default defineConfig({ buildEnv: ["VITE_API_URL"], environments: { preview: { - name: "web-pr-{pr}", + name: "acme-web-pr-{pr}", trigger: "pr", }, staging: { - name: "web-staging", + name: "acme-web-staging", trigger: "push:main", }, production: { - name: "web-prod", + name: "acme-web-prod", trigger: "manual", hostname: "shop.example.com", }, @@ -76,11 +76,11 @@ export default defineConfig({ secrets: [], environments: { preview: { - name: "marketing-pr-{pr}", + name: "acme-marketing-pr-{pr}", trigger: "pr", }, staging: { - name: "marketing-staging", + name: "acme-marketing-staging", trigger: "push:main", }, }, diff --git a/src/commands/init.ts b/src/commands/init.ts index 5586f5f..a08f78d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,8 @@ import * as p from "@clack/prompts"; import { ensureAuth } from "../auth.js"; -import type { DeploykitConfig, EnvironmentKind, Trigger } from "../config.js"; -import { firstDeploy, flyUrl } from "../deploy.js"; +import type { DeploykitConfig } from "../config.js"; +import { firstDeploy } from "../deploy.js"; +import { destinationsForApp, TRIGGER_LABEL } from "../destinations.js"; import { detect } from "../detect.js"; import { type GeneratedFile, @@ -744,38 +745,13 @@ async function confirm({ * on "here's your destination", not a generic checklist. */ export function renderDestinations(config: DeploykitConfig): string { - const order: EnvironmentKind[] = ["preview", "staging", "production"]; - const triggerLabel: Record = { - pr: "on every pull request", - "push:main": "on merge to main", - manual: "manual approval", - }; - - interface Row { - env: string; - url: string; - fly: string; - meta: string; - } - const blocks: { app: string; rows: Row[] }[] = []; - for (const [app, cfg] of Object.entries(config.apps)) { - const rows: Row[] = []; - for (const kind of order) { - const env = cfg.environments[kind]; - if (!env) continue; - rows.push({ - env: kind, - url: env.hostname ? `https://${env.hostname}` : flyUrl(env.name), - fly: env.name, - meta: triggerLabel[env.trigger], - }); - } - if (rows.length) blocks.push({ app, rows }); - } + const blocks = Object.keys(config.apps) + .map((app) => ({ app, rows: destinationsForApp({ app, config }) })) + .filter((b) => b.rows.length > 0); if (blocks.length === 0) return pc.dim("No environments configured."); const allRows = blocks.flatMap((b) => b.rows); - const envW = Math.max(...allRows.map((r) => r.env.length)); + const envW = Math.max(...allRows.map((r) => r.kind.length)); const urlW = Math.max(...allRows.map((r) => r.url.length)); const lines: string[] = []; @@ -785,8 +761,9 @@ export function renderDestinations(config: DeploykitConfig): string { for (const r of b.rows) { // Pad the raw text, then colour — colouring first would let ANSI codes // inflate the measured width and break column alignment. + const meta = `${TRIGGER_LABEL[r.trigger]} · fly: ${r.flyApp}`; lines.push( - ` ${r.env.padEnd(envW)} ${pc.cyan(r.url.padEnd(urlW))} ${pc.dim(`${r.meta} · fly: ${r.fly}`)}`, + ` ${r.kind.padEnd(envW)} ${pc.cyan(r.url.padEnd(urlW))} ${pc.dim(meta)}`, ); } }); diff --git a/src/deploy.ts b/src/deploy.ts index 477eab9..c3c1dbe 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,5 +1,6 @@ import * as p from "@clack/prompts"; import { type DeploykitConfig, extraRegions } from "./config.js"; +import { flyUrl } from "./destinations.js"; import { exec, execInteractive } from "./util/exec.js"; import { pc } from "./util/log.js"; @@ -91,7 +92,7 @@ export function deployArgs(target: DeployTarget): string[] { } /** The public URL a freshly deployed staging app answers on (always valid immediately). */ -export const flyUrl = (flyApp: string) => `https://${flyApp}.fly.dev`; +export { flyUrl }; /** Injected IO seams, so the orchestration is testable without a real deploy. */ export interface DeployDeps { diff --git a/src/destinations.ts b/src/destinations.ts new file mode 100644 index 0000000..f8430df --- /dev/null +++ b/src/destinations.ts @@ -0,0 +1,68 @@ +import type { DeploykitConfig, EnvironmentKind, Trigger } from "./config.js"; + +/** Public address of a Fly app on its default domain. */ +export const flyUrl = (flyApp: string) => `https://${flyApp}.fly.dev`; + +/** Environments in deploy order — how every destination list is sorted. */ +export const ENV_ORDER: EnvironmentKind[] = [ + "preview", + "staging", + "production", +]; + +/** Human phrasing for what makes an environment deploy. */ +export const TRIGGER_LABEL: Record = { + pr: "on every pull request", + "push:main": "on merge to main", + manual: "manual approval", +}; + +/** One app environment resolved to the place it actually runs. */ +export interface Destination { + /** Logical app name (the config key), e.g. "web". */ + app: string; + kind: EnvironmentKind; + /** Fly app name; previews keep the `{pr}` placeholder. */ + flyApp: string; + /** Final public URL — the custom domain when set, else `*.fly.dev`. */ + url: string; + /** Custom domain, when this environment serves one. */ + hostname?: string; + trigger: Trigger; +} + +/** Every configured environment of one app, in deploy order. */ +export function destinationsForApp({ + app, + config, +}: { + app: string; + config: DeploykitConfig; +}): Destination[] { + const cfg = config.apps[app]; + if (!cfg) return []; + const out: Destination[] = []; + for (const kind of ENV_ORDER) { + const env = cfg.environments[kind]; + if (!env) continue; + out.push({ + app, + kind, + flyApp: env.name, + url: env.hostname ? `https://${env.hostname}` : flyUrl(env.name), + hostname: env.hostname, + trigger: env.trigger, + }); + } + return out; +} + +/** + * Every environment of every app — the shared model behind the end-of-run + * terminal map and the generated `DEPLOYMENTS.md`, so the two can't disagree. + */ +export function destinations(config: DeploykitConfig): Destination[] { + return Object.keys(config.apps).flatMap((app) => + destinationsForApp({ app, config }), + ); +} diff --git a/src/generate/index.test.ts b/src/generate/index.test.ts index 539938b..1f950d9 100644 --- a/src/generate/index.test.ts +++ b/src/generate/index.test.ts @@ -33,6 +33,16 @@ describe("planFiles classification", () => { expect(files.every((f) => f.status === "new")).toBe(true); }); + it("plans a DEPLOYMENTS.md alongside the generated files", () => { + const root = emptyRepo(); + const summary = planFiles({ config: sampleConfig, cwd: root }).find( + (f) => f.path === "DEPLOYMENTS.md", + ); + // Written from the repo's own remote, so an empty temp dir gets no GitHub + // links — the file is still generated. + expect(summary?.contents).toContain("## web"); + }); + it("marks a byte-for-byte match identical", () => { const root = emptyRepo(); const planned = planFiles({ config: sampleConfig, cwd: root }); diff --git a/src/generate/index.ts b/src/generate/index.ts index 609d980..8a25a82 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -2,10 +2,12 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { DeploykitConfig } from "../config.js"; import { readText } from "../util/fsx.js"; +import { readGithubRepo } from "../util/git.js"; import { generateConfigFile } from "./configfile.js"; import { generateDockerfile } from "./dockerfile.js"; import { generateDockerignore } from "./dockerignore.js"; import { generateFlyToml } from "./flytoml.js"; +import { generateSummary } from "./summary.js"; import { generateWorkflow } from "./workflow.js"; /** @@ -58,6 +60,7 @@ export function planFiles({ add("deploykit.config.ts", generateConfigFile(config)); add(".dockerignore", generateDockerignore()); add(".github/workflows/deploy.yml", generateWorkflow(config)); + add("DEPLOYMENTS.md", generateSummary({ config, repo: readGithubRepo(cwd) })); for (const [name, app] of Object.entries(config.apps)) { add(`${app.root}/Dockerfile`, generateDockerfile({ name, app, config })); diff --git a/src/generate/summary.test.ts b/src/generate/summary.test.ts new file mode 100644 index 0000000..4544fe9 --- /dev/null +++ b/src/generate/summary.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { DeploykitConfig } from "../config.js"; +import { + sampleApiApp, + sampleConfig, + sampleWebApp, +} from "../testing/fixtures.js"; +import type { GithubRepo } from "../util/git.js"; +import { generateSummary } from "./summary.js"; + +const repo: GithubRepo = { + owner: "acme", + name: "shop", + url: "https://github.com/acme/shop", +}; + +describe("generateSummary", () => { + const md = generateSummary({ config: sampleConfig, repo }); + + it("lists every environment of every app with its URL and Fly app", () => { + expect(md).toContain("## web — `apps/web`"); + expect(md).toContain("| staging | |"); + expect(md).toContain("`web-prod`"); + expect(md).toContain("## api — `apps/api`"); + expect(md).toContain("`api-staging`"); + }); + + it("renders a preview URL as code, since the {pr} placeholder isn't clickable", () => { + expect(md).toContain("`https://web-pr-{pr}.fly.dev`"); + expect(md).not.toContain(""); + }); + + it("draws a mermaid flow of the triggers that are configured", () => { + expect(md).toContain("```mermaid"); + expect(md).toContain('pr["pull request"]'); + expect(md).toContain('main["merge to main"]'); + expect(md).toContain('gate{"manual approval"}'); + }); + + it("omits lanes for environments that aren't configured", () => { + const stagingOnly: DeploykitConfig = { + ...sampleConfig, + apps: { api: sampleApiApp }, + }; + const out = generateSummary({ config: stagingOnly, repo }); + expect(out).toContain('main["merge to main"]'); + expect(out).not.toContain('pr["pull request"]'); + expect(out).not.toContain("manual approval"); + }); + + it("deep-links the Fly org, tokens page, and each long-lived app", () => { + expect(md).toContain("https://fly.io/dashboard/acme"); + expect(md).toContain("https://fly.io/dashboard/acme/tokens"); + expect(md).toContain("https://fly.io/apps/web-staging/monitoring"); + expect(md).toContain("https://fly.io/apps/web-prod/secrets"); + }); + + it("has no dashboard link for preview apps, which don't exist between PRs", () => { + expect(md).not.toContain("https://fly.io/apps/web-pr-{pr}"); + }); + + it("deep-links GitHub actions, environments and secrets when a remote is known", () => { + expect(md).toContain( + "https://github.com/acme/shop/actions/workflows/deploy.yml", + ); + expect(md).toContain("https://github.com/acme/shop/settings/environments"); + expect(md).toContain( + "https://github.com/acme/shop/settings/secrets/actions", + ); + }); + + it("omits GitHub links (and says so) when there's no remote", () => { + const out = generateSummary({ config: sampleConfig, repo: null }); + expect(out).not.toContain("github.com"); + expect(out).toContain("No GitHub remote was found"); + }); + + it("lists secret names with their wiring, never values", () => { + expect(md).toContain("| `DATABASE_URL` | runtime"); + expect(md).toContain("| `NEXT_PUBLIC_API_URL` | build-time"); + expect(md).toContain("| Name | Wired as | Used by |"); + }); + + it("drops the secrets section when no app declares any", () => { + const noSecrets: DeploykitConfig = { + ...sampleConfig, + apps: { api: { ...sampleApiApp, secrets: [] } }, + }; + expect(generateSummary({ config: noSecrets, repo })).not.toContain( + "## Secrets", + ); + }); + + it("links the Cloudflare zone through the account-resolving form", () => { + const withCf: DeploykitConfig = { + ...sampleConfig, + cloudflare: { + zone: "example.com", + proxied: true, + ssl: "strict", + alwaysUseHttps: true, + minTlsVersion: "1.2", + security: true, + cache: true, + }, + apps: { + web: { + ...sampleWebApp, + environments: { + ...sampleWebApp.environments, + production: { + name: "web-prod", + trigger: "manual", + hostname: "example.com", + }, + }, + }, + }, + }; + const out = generateSummary({ config: withCf, repo }); + expect(out).toContain( + "https://dash.cloudflare.com/?to=/:account/example.com/dns", + ); + // The custom domain replaces the *.fly.dev address in the app table. + expect(out).toContain("| production | |"); + }); + + it("explains the preview lifecycle and the production gate", () => { + expect(md).toContain("Preview apps don't exist until a PR opens"); + expect(md).toContain("Production waits for a human"); + }); +}); diff --git a/src/generate/summary.ts b/src/generate/summary.ts new file mode 100644 index 0000000..9c7973b --- /dev/null +++ b/src/generate/summary.ts @@ -0,0 +1,286 @@ +import type { DeploykitConfig, EnvironmentKind } from "../config.js"; +import { + type Destination, + destinations, + destinationsForApp, + ENV_ORDER, + TRIGGER_LABEL, +} from "../destinations.js"; +import type { GithubRepo } from "../util/git.js"; + +/** Workflow file the generated links point at. Kept in sync with `planFiles`. */ +const WORKFLOW_FILE = "deploy.yml"; + +/** + * `DEPLOYMENTS.md` — the map of what got created and where to find it. + * + * The end-of-run terminal output only helps whoever ran `init`; this is the + * same picture for everyone who arrives later, so it's a committed file rather + * than an untracked one. It holds names, hostnames and dashboard links only — + * never secret values. + */ +export function generateSummary({ + config, + repo, +}: { + config: DeploykitConfig; + repo: GithubRepo | null; +}): string { + const apps = Object.keys(config.apps); + const sections = [ + header(), + flowDiagram(config), + ...apps.map((app) => appSection({ app, config })), + dashboards({ config, repo }), + secrets({ config, repo }), + goodToKnow(config), + ]; + return `${sections.filter(Boolean).join("\n\n")}\n`; +} + +function header() { + return `# Deployments + + + +Where every app runs, what deploys it, and where to click in each dashboard.`; +} + +/** + * A mermaid flow of trigger → environment. GitHub renders it inline, so the + * shape of the pipeline is readable without running anything. Only the + * environment kinds that are actually configured get a lane. + */ +function flowDiagram(config: DeploykitConfig): string { + const present = ENV_ORDER.filter((kind) => + Object.values(config.apps).some((a) => a.environments[kind]), + ); + if (present.length === 0) return ""; + + const label = (kind: EnvironmentKind) => { + const names = Object.entries(config.apps) + .filter(([, a]) => a.environments[kind]) + .map(([name]) => name) + .join(", "); + return `${kind}
${names}`; + }; + + const lines = ["```mermaid", "flowchart LR"]; + if (present.includes("preview")) { + lines.push(` pr["pull request"] --> preview["${label("preview")}"]`); + lines.push(` pr -. "PR closed" .-> destroy["apps destroyed"]`); + } + if (present.includes("staging")) { + lines.push(` main["merge to main"] --> staging["${label("staging")}"]`); + } + if (present.includes("production")) { + const from = present.includes("staging") ? "staging" : "main"; + if (!present.includes("staging")) lines.push(` main["merge to main"]`); + lines.push(` ${from} --> gate{"manual approval"}`); + lines.push(` gate --> production["${label("production")}"]`); + } + lines.push("```"); + return lines.join("\n"); +} + +/** Per-app table of every environment: URL, Fly app, and what deploys it. */ +function appSection({ + app, + config, +}: { + app: string; + config: DeploykitConfig; +}): string { + const rows = destinationsForApp({ app, config }); + if (rows.length === 0) return ""; + const cfg = config.apps[app]; + const source = cfg ? ` — \`${cfg.root}\`` : ""; + + const lines = [ + `## ${app}${source}`, + "", + "| Environment | URL | Fly app | Deploys |", + "| --- | --- | --- | --- |", + ]; + for (const d of rows) { + lines.push( + `| ${d.kind} | ${linkOrCode(d)} | \`${d.flyApp}\` | ${TRIGGER_LABEL[d.trigger]} |`, + ); + } + return lines.join("\n"); +} + +/** + * Preview URLs carry a `{pr}` placeholder and aren't clickable, so they render + * as code; concrete environments render as links. + */ +function linkOrCode(d: Destination): string { + return d.flyApp.includes("{pr}") ? `\`${d.url}\`` : `<${d.url}>`; +} + +/** + * The section this file exists for: deep links into every console, so nobody + * has to hunt an app down org by org. + */ +function dashboards({ + config, + repo, +}: { + config: DeploykitConfig; + repo: GithubRepo | null; +}): string { + const org = config.provider.org; + const rows: [string, string][] = [ + [ + "Fly org — every app, billing, deploy tokens", + `https://fly.io/dashboard/${org}`, + ], + [ + "Fly deploy token (`FLY_API_TOKEN`) — revoke/rotate here", + `https://fly.io/dashboard/${org}/tokens`, + ], + ]; + + // Preview apps are created on the fly by the workflow, so there's no stable + // URL to link — only the long-lived environments get per-app rows. + for (const d of destinations(config).filter( + (x) => !x.flyApp.includes("{pr}"), + )) { + rows.push([ + `\`${d.flyApp}\` — overview`, + `https://fly.io/apps/${d.flyApp}`, + ]); + rows.push([ + `\`${d.flyApp}\` — metrics & logs`, + `https://fly.io/apps/${d.flyApp}/monitoring`, + ]); + rows.push([ + `\`${d.flyApp}\` — runtime secrets`, + `https://fly.io/apps/${d.flyApp}/secrets`, + ]); + } + + const zone = config.cloudflare?.zone; + if (zone) { + // The `?to=/:account/…` form lets Cloudflare resolve the account id, so the + // link works without knowing it. + rows.push([ + `Cloudflare DNS — ${zone}`, + `https://dash.cloudflare.com/?to=/:account/${zone}/dns`, + ]); + rows.push([ + `Cloudflare SSL/TLS — ${zone}`, + `https://dash.cloudflare.com/?to=/:account/${zone}/ssl-tls`, + ]); + } + + if (repo) { + rows.push([ + "GitHub Actions — deploy runs", + `${repo.url}/actions/workflows/${WORKFLOW_FILE}`, + ]); + rows.push([ + "GitHub environments — approvals & env secrets", + `${repo.url}/settings/environments`, + ]); + rows.push([ + "GitHub secrets — repository level", + `${repo.url}/settings/secrets/actions`, + ]); + } + + const lines = ["## Where to look", "", "| What | Where |", "| --- | --- |"]; + for (const [what, url] of rows) lines.push(`| ${what} | <${url}> |`); + if (!repo) { + lines.push(""); + lines.push( + "_No GitHub remote was found when this file was generated, so the GitHub links are omitted. Re-run `deploykit generate` once the remote is set._", + ); + } + return lines.join("\n"); +} + +/** + * Which variables the pipeline expects and how each is wired — names only, so + * this file stays safe to commit. + */ +function secrets({ + config, + repo, +}: { + config: DeploykitConfig; + repo: GithubRepo | null; +}): string { + interface Row { + name: string; + kind: "runtime" | "build-time"; + apps: string[]; + } + const byName = new Map(); + const add = (name: string, kind: Row["kind"], app: string) => { + const row = byName.get(name) ?? { name, kind, apps: [] }; + if (!row.apps.includes(app)) row.apps.push(app); + byName.set(name, row); + }; + for (const [app, cfg] of Object.entries(config.apps)) { + for (const name of cfg.secrets) add(name, "runtime", app); + for (const name of cfg.buildEnv ?? []) add(name, "build-time", app); + } + if (byName.size === 0) return ""; + + const where = repo ? `${repo.url}/settings/secrets/actions` : null; + const lines = [ + "## Secrets", + "", + `Values live in GitHub Actions secrets${where ? ` (<${where}>)` : ""} and are never stored in this repo. Set or update them with \`deploykit init\`, or in the GitHub UI.`, + "", + "| Name | Wired as | Used by |", + "| --- | --- | --- |", + ]; + for (const row of [...byName.values()].sort((a, b) => + a.name.localeCompare(b.name), + )) { + const wiring = + row.kind === "runtime" + ? "runtime — `flyctl secrets set`" + : "build-time — baked into the image (`--build-arg`)"; + lines.push(`| \`${row.name}\` | ${wiring} | ${row.apps.join(", ")} |`); + } + lines.push(""); + lines.push( + "Per-environment values (a staging vs production `DATABASE_URL`) go on the matching GitHub environment; anything set at the repository level applies to every environment that doesn't override it.", + ); + return lines.join("\n"); +} + +/** The handful of facts that otherwise turn into "why isn't it there?" questions. */ +function goodToKnow(config: DeploykitConfig): string { + const items: string[] = []; + const has = (kind: EnvironmentKind) => + Object.values(config.apps).some((a) => a.environments[kind]); + + if (has("preview")) { + items.push( + "**Preview apps don't exist until a PR opens.** The workflow creates them on first deploy and destroys them when the PR closes, so you won't find them on the Fly dashboard in between.", + ); + } + if (has("production")) { + items.push( + "**Production waits for a human.** It only deploys after the `production` GitHub environment is approved — add required reviewers there, or the gate approves itself.", + ); + } + if (config.cloudflare) { + items.push( + `**Custom domains resolve through Cloudflare.** DNS records point at Fly and the certificate is issued by Fly; the \`*.fly.dev\` address keeps working either way, which makes it the thing to test against when a domain looks wrong.`, + ); + } + items.push( + "**A failed health check keeps the old release serving.** Fly only shifts traffic once the new machines pass their check, so a bad deploy stalls instead of taking the app down. To undo a release that passed but misbehaves, run `deploykit rollback`.", + ); + items.push( + "**Everything here is regenerable.** `deploykit.config.ts` is the source of truth — edit it and run `deploykit generate` to update the Dockerfiles, `fly.toml` files, the workflow, and this file.", + ); + + return ["## Good to know", "", ...items.map((i) => `- ${i}`)].join("\n"); +} diff --git a/src/util/git.test.ts b/src/util/git.test.ts new file mode 100644 index 0000000..a55b070 --- /dev/null +++ b/src/util/git.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { writeTree } from "../testing/fixtures.js"; +import { parseGithubRemote, readGithubRepo } from "./git.js"; + +describe("parseGithubRemote", () => { + it("parses an HTTPS remote", () => { + expect(parseGithubRemote("https://github.com/acme/shop.git")).toEqual({ + owner: "acme", + name: "shop", + url: "https://github.com/acme/shop", + }); + }); + + it("parses an SSH remote", () => { + expect(parseGithubRemote("git@github.com:acme/shop.git")?.name).toBe( + "shop", + ); + }); + + it("keeps a name that contains dots", () => { + expect(parseGithubRemote("git@github.com:acme/shop.dev.git")?.name).toBe( + "shop.dev", + ); + }); + + it("returns null for a non-GitHub remote", () => { + expect(parseGithubRemote("git@gitlab.com:acme/shop.git")).toBeNull(); + }); +}); + +describe("readGithubRepo", () => { + it("finds the GitHub remote in .git/config", () => { + const { root, cleanup } = writeTree({ + files: { + ".git/config": `[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = git@github.com:acme/shop.git\n`, + }, + }); + expect(readGithubRepo(root)?.url).toBe("https://github.com/acme/shop"); + cleanup(); + }); + + it("returns null when there's no GitHub remote", () => { + const { root, cleanup } = writeTree({ + files: { ".git/config": '[remote "origin"]\n\turl = /srv/mirror.git\n' }, + }); + expect(readGithubRepo(root)).toBeNull(); + cleanup(); + }); + + it("returns null outside a git repo", () => { + const { root, cleanup } = writeTree({ files: { "package.json": "{}" } }); + expect(readGithubRepo(root)).toBeNull(); + cleanup(); + }); +}); diff --git a/src/util/git.ts b/src/util/git.ts new file mode 100644 index 0000000..e5cc628 --- /dev/null +++ b/src/util/git.ts @@ -0,0 +1,39 @@ +import { join } from "node:path"; +import { readText } from "./fsx.js"; + +export interface GithubRepo { + owner: string; + name: string; + /** Browser URL of the repo, e.g. "https://github.com/acme/shop". */ + url: string; +} + +/** + * Resolve the GitHub repo from `.git/config`, without shelling out. + * + * `gh repo view` is the authoritative lookup (see `getRepo`), but it's async + * and needs auth — generators are sync and must work offline, so the remote is + * parsed straight off disk. Returns null for a repo with no GitHub remote (or + * a worktree/submodule, where `.git` is a file); callers then omit the + * GitHub-specific links rather than guessing a URL. + */ +export function readGithubRepo(cwd: string): GithubRepo | null { + const text = readText(join(cwd, ".git", "config")); + if (text === null) return null; + for (const line of text.split("\n")) { + const m = line.match(/^\s*url\s*=\s*(\S+)/); + if (!m?.[1]) continue; + const repo = parseGithubRemote(m[1]); + if (repo) return repo; + } + return null; +} + +/** Parse an SSH or HTTPS GitHub remote into its owner/name. */ +export function parseGithubRemote(remote: string): GithubRepo | null { + const m = remote.match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i); + const owner = m?.[1]; + const name = m?.[2]; + if (!owner || !name) return null; + return { owner, name, url: `https://github.com/${owner}/${name}` }; +}