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
10 changes: 6 additions & 4 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app>/Dockerfile multi-stage, turbo-prune (or nx build) based
apps/<app>/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.
Expand Down
70 changes: 70 additions & 0 deletions examples/DEPLOYMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Deployments

<!-- Generated by deploykit. Edit `deploykit.config.ts` and run `deploykit generate`
to update this file — hand edits are overwritten. -->

Where every app runs, what deploys it, and where to click in each dashboard.

```mermaid
flowchart LR
pr["pull request"] --> preview["preview<br/>web, marketing"]
pr -. "PR closed" .-> destroy["apps destroyed"]
main["merge to main"] --> staging["staging<br/>web, marketing"]
staging --> gate{"manual approval"}
gate --> production["production<br/>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 | <https://acme-web-staging.fly.dev> | `acme-web-staging` | on merge to main |
| production | <https://shop.example.com> | `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 | <https://acme-marketing-staging.fly.dev> | `acme-marketing-staging` | on merge to main |

## Where to look

| What | Where |
| --- | --- |
| Fly org — every app, billing, deploy tokens | <https://fly.io/dashboard/acme> |
| Fly deploy token (`FLY_API_TOKEN`) — revoke/rotate here | <https://fly.io/dashboard/acme/tokens> |
| `acme-web-staging` — overview | <https://fly.io/apps/acme-web-staging> |
| `acme-web-staging` — metrics & logs | <https://fly.io/apps/acme-web-staging/monitoring> |
| `acme-web-staging` — runtime secrets | <https://fly.io/apps/acme-web-staging/secrets> |
| `acme-web-prod` — overview | <https://fly.io/apps/acme-web-prod> |
| `acme-web-prod` — metrics & logs | <https://fly.io/apps/acme-web-prod/monitoring> |
| `acme-web-prod` — runtime secrets | <https://fly.io/apps/acme-web-prod/secrets> |
| `acme-marketing-staging` — overview | <https://fly.io/apps/acme-marketing-staging> |
| `acme-marketing-staging` — metrics & logs | <https://fly.io/apps/acme-marketing-staging/monitoring> |
| `acme-marketing-staging` — runtime secrets | <https://fly.io/apps/acme-marketing-staging/secrets> |
| Cloudflare DNS — example.com | <https://dash.cloudflare.com/?to=/:account/example.com/dns> |
| Cloudflare SSL/TLS — example.com | <https://dash.cloudflare.com/?to=/:account/example.com/ssl-tls> |

_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.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions examples/deploykit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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",
},
},
Expand Down
41 changes: 9 additions & 32 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Trigger, string> = {
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[] = [];
Expand All @@ -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)}`,
);
}
});
Expand Down
3 changes: 2 additions & 1 deletion src/deploy.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions src/destinations.ts
Original file line number Diff line number Diff line change
@@ -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<Trigger, string> = {
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 }),
);
}
10 changes: 10 additions & 0 deletions src/generate/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions src/generate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 }));
Expand Down
Loading
Loading