From f32e11a5d81a7ee403db052a1fa241a28fa8e946 Mon Sep 17 00:00:00 2001 From: abrulic Date: Sun, 19 Jul 2026 18:36:46 +0200 Subject: [PATCH 1/2] added rollbacks, multiregion and more fixes --- .claude/settings.json | 4 +- README.md | 36 +++- src/commands/generate.ts | 13 +- src/commands/init.ts | 20 +- src/commands/rollback.test.ts | 230 +++++++++++++++++++++ src/commands/rollback.ts | 324 ++++++++++++++++++++++++++++++ src/config.test.ts | 31 +++ src/config.ts | 31 +++ src/deploy.test.ts | 56 +++++- src/deploy.ts | 31 ++- src/generate/dockerignore.test.ts | 7 + src/generate/dockerignore.ts | 6 + src/generate/flytoml.test.ts | 17 ++ src/generate/flytoml.ts | 12 ++ src/generate/index.test.ts | 99 +++++++++ src/generate/index.ts | 39 +++- src/generate/workflow.test.ts | 33 +++ src/generate/workflow.ts | 24 ++- src/index.ts | 53 ++++- src/plan.ts | 7 +- src/pr.test.ts | 209 +++++++++++++++++++ src/pr.ts | 124 +++++++++--- src/prompts.ts | 46 ++++- 23 files changed, 1390 insertions(+), 62 deletions(-) create mode 100644 src/commands/rollback.test.ts create mode 100644 src/commands/rollback.ts create mode 100644 src/config.test.ts create mode 100644 src/generate/index.test.ts create mode 100644 src/pr.test.ts diff --git a/.claude/settings.json b/.claude/settings.json index 2c2c110..dcb749e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(gh run *)", - "Bash(pnpm knip *)" + "Bash(pnpm knip *)", + "Bash(pnpm vitest *)", + "Bash(pnpm biome *)" ] } } diff --git a/README.md b/README.md index 5c8510a..dfd03fa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Flags: |------|-------------| | `--yes`, `-y` | Accept detected defaults, no prompts. | | `--org ` | Fly organization slug. | -| `--region ` | Fly primary region (e.g. `iad`). | +| `--region ` | Fly region(s), comma-separated. The first is the primary; any others are extra **stateless** regions the app is scaled into after each deploy (e.g. `iad,lhr,fra`). | | `--dry-run` | Detect and print the plan, but write nothing. | | `--provision` | Create Fly apps and set the `FLY_API_TOKEN` GitHub secret (each step confirmed). | | `--pr` | Commit the generated files on a branch and open a PR. | @@ -53,6 +53,40 @@ apps//fly.toml deploykit.config.ts source of truth for every decision ``` +Each `fly.toml` includes an HTTP health check (`/` by default; set +`healthCheckPath` per app in `deploykit.config.ts` for an API that 404s at `/`). +Fly waits for it before shifting traffic to a new release and keeps the old +machines running if it fails — so a bad deploy rolls itself back. + +## Rolling back + +When a release deployed cleanly but turned out bad, redeploy a previous image: + +```bash +deploykit rollback --app web --env production +``` + +It lists the environment's Fly releases, lets you pick one, shows the exact +`flyctl deploy --image …` it will run, and asks before doing it. Use +`--to --yes` to script it. This rolls back the **app image only** — it +does **not** undo database migrations, so an older image may not run against a +schema a newer release migrated. + +## Multiple regions + +Pass more than one region and the extras become **stateless** regions the app is +scaled into after each staging/production deploy (previews stay single-region): + +```bash +deploykit init --region iad,lhr,fra # primary iad, plus lhr and fra +``` + +You can also set `regions` under `provider` in `deploykit.config.ts`. Each extra +region gets one machine via `flyctl scale count 1 --region ` after the deploy. +This is for **stateless** apps: deploykit does not model database locality, so a +far-region machine still talks to whatever single-region `DATABASE_URL` you set — +expect high write latency. Read replicas / `fly-replay` are out of scope. + ## Scope (v1) - **Turbo** monorepos — full support (`turbo prune` multi-stage builds). diff --git a/src/commands/generate.ts b/src/commands/generate.ts index dfa42ee..67fb963 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -28,10 +28,15 @@ export async function runGenerate(opts: InitOptions) { p.note( files - .map( - (f) => - ` ${f.path}${f.exists ? pc.yellow(" (overwrite)") : pc.green(" (new)")}`, - ) + .map((f) => { + const tag = + f.status === "new" + ? pc.green(" (new)") + : f.status === "identical" + ? pc.dim(" (unchanged)") + : pc.yellow(" (overwrite)"); + return ` ${f.path}${tag}`; + }) .join("\n"), `Regenerate from ${CONFIG_FILE}`, ); diff --git a/src/commands/init.ts b/src/commands/init.ts index d824c10..f00d762 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -3,7 +3,11 @@ import { ensureAuth } from "../auth.js"; import type { DeploykitConfig, EnvironmentKind, Trigger } from "../config.js"; import { firstDeploy, flyUrl } from "../deploy.js"; import { detect } from "../detect.js"; -import { planFiles, writeFiles } from "../generate/index.js"; +import { + type GeneratedFile, + planFiles, + writeFiles, +} from "../generate/index.js"; import { flyAppNames, mergeSecretTargets, @@ -159,7 +163,11 @@ export async function runInit(opts: InitOptions) { // ── Open PR ── if (opts.pr) { phases.begin("Open PR"); - await maybeOpenPr({ opts, written, ghReady }); + await maybeOpenPr({ + opts, + files: files.filter((f) => written.includes(f.path)), + ghReady, + }); } p.note(renderDestinations(config), "Destinations"); @@ -641,18 +649,18 @@ async function provisionSecrets({ async function maybeOpenPr({ opts, - written, + files, ghReady, }: { opts: InitOptions; - written: string[]; + files: GeneratedFile[]; ghReady: boolean; }) { if (!ghReady) { p.log.warn("Skipping PR — `gh` is not authenticated."); return; } - if (written.length === 0) { + if (files.length === 0) { p.log.warn("Skipping PR — no files were written."); return; } @@ -661,7 +669,7 @@ async function maybeOpenPr({ const s = p.spinner(); s.start("Opening pull request"); - const res = await openPr({ cwd: opts.cwd, paths: written }); + const res = await openPr({ cwd: opts.cwd, files }); s.stop( res.ok ? pc.green(`PR opened: ${res.url}`) diff --git a/src/commands/rollback.test.ts b/src/commands/rollback.test.ts new file mode 100644 index 0000000..542c145 --- /dev/null +++ b/src/commands/rollback.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { generateConfigFile } from "../generate/configfile.js"; +import type { InitOptions } from "../prompts.js"; +import { sampleConfig, writeTree } from "../testing/fixtures.js"; +import { + parseReleases, + type RollbackDeps, + rollbackCandidates, + rollbackDeployArgs, + runRollback, +} from "./rollback.js"; + +describe("parseReleases", () => { + it("normalizes camelCase, snake_case and PascalCase keys", () => { + const camel = parseReleases( + JSON.stringify([{ version: 5, status: "complete", imageRef: "img:5" }]), + ); + const snake = parseReleases( + JSON.stringify([{ version: 5, status: "complete", image_ref: "img:5" }]), + ); + const pascal = parseReleases( + JSON.stringify([{ Version: 5, Status: "complete", ImageRef: "img:5" }]), + ); + for (const r of [camel, snake, pascal]) { + expect(r).toHaveLength(1); + expect(r[0]?.version).toBe(5); + expect(r[0]?.image).toBe("img:5"); + } + }); + + it("drops entries without a numeric version and tolerates junk JSON", () => { + expect(parseReleases("not json")).toEqual([]); + expect(parseReleases(JSON.stringify({ not: "an array" }))).toEqual([]); + expect( + parseReleases(JSON.stringify([{ status: "complete" }, null, 3])), + ).toEqual([]); + }); +}); + +describe("rollbackCandidates", () => { + const releases = parseReleases( + JSON.stringify([ + { version: 10, status: "complete", imageRef: "img:10" }, // current + { version: 9, status: "complete", imageRef: "img:9" }, + { version: 8, status: "failed", imageRef: "" }, // no image + { version: 7, status: "complete", imageRef: "img:7" }, + ]), + ); + + it("excludes the current release and any without an image, newest first", () => { + const c = rollbackCandidates(releases); + expect(c.map((r) => r.version)).toEqual([9, 7]); + }); + + it("returns nothing when there is only the current release", () => { + expect( + rollbackCandidates( + parseReleases(JSON.stringify([{ version: 1, imageRef: "img:1" }])), + ), + ).toEqual([]); + }); +}); + +describe("rollbackDeployArgs", () => { + it("redeploys the given image against the env's Fly app and repo fly.toml", () => { + expect( + rollbackDeployArgs({ + flyApp: "web-prod", + root: "apps/web", + image: "img:9", + }), + ).toEqual([ + "deploy", + "--app", + "web-prod", + "--image", + "img:9", + "--config", + "apps/web/fly.toml", + ]); + }); +}); + +function baseOpts(cwd: string, over: Partial = {}): InitOptions { + return { + yes: false, + dryRun: false, + provision: false, + deploy: false, + pr: false, + force: false, + cwd, + ...over, + }; +} + +/** A temp repo whose deploykit.config.ts is the sample config. */ +function repoWithConfig() { + const { root, cleanup } = writeTree({ + files: { "deploykit.config.ts": generateConfigFile(sampleConfig) }, + }); + return { root, cleanup }; +} + +const RELEASES = JSON.stringify([ + { version: 41, status: "complete", imageRef: "img:41" }, + { version: 40, status: "complete", imageRef: "img:40" }, + { version: 39, status: "complete", imageRef: "img:39" }, +]); + +function fakeDeps(over: Partial = {}) { + const calls: { deploy?: string[] } = {}; + const deps: RollbackDeps = { + listReleases: async () => RELEASES, + runDeploy: async (args) => { + calls.deploy = args; + return 0; + }, + select: async () => null, + confirm: async () => true, + log: { + info: () => {}, + warn: () => {}, + success: () => {}, + error: () => {}, + step: () => {}, + }, + ...over, + }; + return { deps, calls }; +} + +describe("runRollback", () => { + it("redeploys the chosen prior image for the target env", async () => { + const { root, cleanup } = repoWithConfig(); + try { + const { deps, calls } = fakeDeps(); + const code = await runRollback( + baseOpts(root, { app: "web", env: "production", to: "40", yes: true }), + deps, + ); + expect(code).toBe(0); + expect(calls.deploy).toEqual([ + "deploy", + "--app", + "web-prod", + "--image", + "img:40", + "--config", + "apps/web/fly.toml", + ]); + } finally { + cleanup(); + } + }); + + it("fails non-interactively without an explicit --to", async () => { + const { root, cleanup } = repoWithConfig(); + try { + const { deps, calls } = fakeDeps(); + const code = await runRollback( + baseOpts(root, { app: "web", env: "production", yes: true }), + deps, + ); + expect(code).toBe(1); + expect(calls.deploy).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("rejects an unknown app", async () => { + const { root, cleanup } = repoWithConfig(); + try { + const { deps, calls } = fakeDeps(); + const code = await runRollback( + baseOpts(root, { app: "nope", env: "production", yes: true }), + deps, + ); + expect(code).toBe(1); + expect(calls.deploy).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("rejects an env the app doesn't have", async () => { + const { root, cleanup } = repoWithConfig(); + try { + // sampleConfig's `api` app only has staging. + const { deps, calls } = fakeDeps(); + const code = await runRollback( + baseOpts(root, { app: "api", env: "production", to: "40", yes: true }), + deps, + ); + expect(code).toBe(1); + expect(calls.deploy).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("surfaces a flyctl deploy failure", async () => { + const { root, cleanup } = repoWithConfig(); + try { + const { deps } = fakeDeps({ runDeploy: async () => 1 }); + const code = await runRollback( + baseOpts(root, { app: "web", env: "production", to: "40", yes: true }), + deps, + ); + expect(code).toBe(1); + } finally { + cleanup(); + } + }); + + it("fails cleanly when there is no config file", async () => { + const { root, cleanup } = writeTree({ files: { "README.md": "x" } }); + try { + const { deps } = fakeDeps(); + const code = await runRollback( + baseOpts(root, { app: "web", env: "production", to: "40", yes: true }), + deps, + ); + expect(code).toBe(1); + } finally { + cleanup(); + } + }); +}); diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts new file mode 100644 index 0000000..edb449d --- /dev/null +++ b/src/commands/rollback.ts @@ -0,0 +1,324 @@ +import * as p from "@clack/prompts"; +import type { DeploykitConfig, EnvironmentKind } from "../config.js"; +import { loadConfigFile } from "../config-file.js"; +import type { InitOptions } from "../prompts.js"; +import { execInteractive, tryExec } from "../util/exec.js"; +import { pc } from "../util/log.js"; + +/** Environments with a concrete (non-placeholder) Fly app name we can redeploy. */ +const ROLLBACKABLE: readonly EnvironmentKind[] = ["staging", "production"]; + +/** A past Fly release, normalized from `flyctl releases --json`. */ +export interface Release { + version: number; + status: string; + description: string; + /** Docker image reference to redeploy, e.g. registry.fly.io/app@sha256:… */ + image: string; + createdAt: string; + stable: boolean; +} + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); + +/** + * Parse `flyctl releases --json` defensively. flyctl's key casing has varied + * across versions (ImageRef / imageRef / image_ref), so keys are normalized to + * lowercase-without-underscores before lookup. Anything unparseable is dropped + * rather than guessed at. + */ +export function parseReleases(json: string): Release[] { + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(raw)) return []; + + const releases: Release[] = []; + for (const item of raw) { + if (typeof item !== "object" || item === null) continue; + const norm: Record = {}; + for (const [k, v] of Object.entries(item)) + norm[k.toLowerCase().replace(/_/g, "")] = v; + + const version = Number(norm.version); + if (!Number.isFinite(version)) continue; + + releases.push({ + version, + status: str(norm.status), + description: str(norm.description), + image: str(norm.imageref) || str(norm.image) || str(norm.dockerimage), + createdAt: str(norm.createdat) || str(norm.timestamp), + stable: Boolean(norm.stable), + }); + } + return releases; +} + +/** + * Prior releases we can roll back to: those carrying a redeployable image, + * excluding the current (highest-version) release, newest first. + */ +export function rollbackCandidates(releases: Release[]): Release[] { + if (releases.length === 0) return []; + const current = Math.max(...releases.map((r) => r.version)); + return releases + .filter((r) => r.image !== "" && r.version < current) + .sort((a, b) => b.version - a.version); +} + +/** + * The `flyctl deploy` argv that redeploys a prior image — no rebuild. The repo's + * fly.toml is applied so the app runs with its committed service config, and the + * environment's concrete Fly app is targeted with `--app`. + */ +export function rollbackDeployArgs({ + flyApp, + root, + image, +}: { + flyApp: string; + root: string; + image: string; +}): string[] { + return [ + "deploy", + "--app", + flyApp, + "--image", + image, + "--config", + `${root}/fly.toml`, + ]; +} + +/** Injected IO seams, so the orchestration is testable without a real Fly. */ +export interface RollbackDeps { + /** Raw stdout of `flyctl releases --app --json`, or null on failure. */ + listReleases: (flyApp: string, cwd: string) => Promise; + runDeploy: (args: string[], cwd: string) => Promise; + select: ( + message: string, + options: { value: string; label: string }[], + ) => Promise; + confirm: (message: string) => Promise; + log: { + info: (s: string) => void; + warn: (s: string) => void; + success: (s: string) => void; + error: (s: string) => void; + step: (s: string) => void; + }; +} + +const defaultDeps: RollbackDeps = { + listReleases: (flyApp, cwd) => + tryExec({ + cmd: "flyctl", + args: ["releases", "--app", flyApp, "--json"], + cwd, + }), + runDeploy: (args, cwd) => execInteractive({ cmd: "flyctl", args, cwd }), + select: async (message, options) => { + const r = await p.select({ message, options }); + return typeof r === "string" ? r : null; + }, + confirm: async (message) => + (await p.confirm({ message, initialValue: false })) === true, + log: { + info: (s) => p.log.info(s), + warn: (s) => p.log.warn(s), + success: (s) => p.log.success(pc.green(s)), + error: (s) => p.log.error(s), + step: (s) => p.log.step(s), + }, +}; + +/** + * `deploykit rollback` — redeploy a prior image for one environment's Fly app. + * + * This rolls back the APP only. It does not undo database migrations: if the + * release you are leaving ran a forward migration, redeploying an older image + * against the migrated schema can break — so the target and the exact command + * are shown and confirmed before anything runs. + */ +export async function runRollback( + opts: InitOptions, + depsOverride?: Partial, +) { + const deps = { ...defaultDeps, ...depsOverride }; + p.intro(pc.bgCyan(pc.black(" deploykit rollback "))); + + const loaded = loadConfigFile(opts.cwd); + if (loaded.error !== undefined) { + deps.log.error(loaded.error); + p.outro(pc.red("Rollback failed.")); + return 1; + } + const config = loaded.config; + + const target = await resolveTarget({ config, opts, deps }); + if ("error" in target) { + deps.log.error(target.error); + p.outro(pc.red("Rollback failed.")); + return 1; + } + const { appName, env, flyApp, root } = target; + deps.log.step( + `Rolling back ${pc.bold(appName)} · ${pc.bold(env)} → Fly app ${pc.bold(flyApp)}`, + ); + + const raw = await deps.listReleases(flyApp, opts.cwd); + if (raw === null) { + deps.log.error( + `Couldn't list releases for ${flyApp} (is flyctl authenticated and the app provisioned?).`, + ); + p.outro(pc.red("Rollback failed.")); + return 1; + } + const candidates = rollbackCandidates(parseReleases(raw)); + if (candidates.length === 0) { + deps.log.error( + "No prior release with a redeployable image was found — nothing to roll back to.", + ); + p.outro(pc.red("Rollback failed.")); + return 1; + } + + const chosen = await chooseRelease({ candidates, opts, deps }); + if (!chosen) { + p.outro(pc.dim("Rollback cancelled.")); + return 1; + } + + const args = rollbackDeployArgs({ flyApp, root, image: chosen.image }); + deps.log.warn( + "This redeploys the app image only. It does NOT undo database migrations — " + + "if a newer release migrated the schema, an older image may not run against it.", + ); + deps.log.info(`Will run: ${pc.dim(`flyctl ${args.join(" ")}`)}`); + + if ( + !opts.yes && + !(await deps.confirm(`Roll ${flyApp} back to v${chosen.version}?`)) + ) { + p.outro(pc.dim("Rollback cancelled.")); + return 1; + } + + const code = await deps.runDeploy(args, opts.cwd); + if (code !== 0) { + deps.log.error("flyctl deploy failed."); + p.outro(pc.red("Rollback failed.")); + return 1; + } + deps.log.success(`Rolled ${flyApp} back to v${chosen.version}.`); + p.outro(pc.green("Rollback complete.")); + return 0; +} + +type ResolvedTarget = + | { appName: string; env: EnvironmentKind; flyApp: string; root: string } + | { error: string }; + +/** Resolve which app + environment (and its concrete Fly app) to roll back. */ +async function resolveTarget({ + config, + opts, + deps, +}: { + config: DeploykitConfig; + opts: InitOptions; + deps: RollbackDeps; +}): Promise { + const appNames = Object.keys(config.apps); + let appName = opts.app; + if (!appName) { + if (appNames.length === 1) appName = appNames[0]; + else if (!opts.yes) + appName = + (await deps.select( + "Which app?", + appNames.map((a) => ({ value: a, label: a })), + )) ?? undefined; + } + const app = appName ? config.apps[appName] : undefined; + if (!appName || !app) { + return { + error: appName + ? `Unknown app "${appName}". Known apps: ${appNames.join(", ")}.` + : `--app is required (known apps: ${appNames.join(", ")}).`, + }; + } + + const available = ROLLBACKABLE.filter((e) => app.environments[e]); + if (available.length === 0) + return { + error: `App "${appName}" has no rollbackable environment (staging/production).`, + }; + + let env = opts.env; + if (!env) { + if (available.length === 1) env = available[0]; + else if (!opts.yes) + env = + ((await deps.select( + "Which environment?", + available.map((e) => ({ value: e, label: e })), + )) as EnvironmentKind | null) ?? undefined; + } + if (!env || !available.includes(env)) { + return { + error: env + ? `Environment "${env}" isn't rollbackable for "${appName}" (has: ${available.join(", ")}).` + : `--env is required (available: ${available.join(", ")}).`, + }; + } + + return { + appName, + env, + flyApp: app.environments[env]?.name ?? "", + root: app.root, + }; +} + +/** Pick a release: `--to ` non-interactively, else prompt. */ +async function chooseRelease({ + candidates, + opts, + deps, +}: { + candidates: Release[]; + opts: InitOptions; + deps: RollbackDeps; +}): Promise { + if (opts.to) { + const wanted = Number(opts.to); + const match = candidates.find((r) => r.version === wanted); + if (!match) + deps.log.error( + `No rollbackable release v${opts.to} (candidates: ${candidates + .map((r) => `v${r.version}`) + .join(", ")}).`, + ); + return match ?? null; + } + if (opts.yes) { + deps.log.error( + "Non-interactive rollback needs an explicit --to .", + ); + return null; + } + const picked = await deps.select( + "Roll back to which release?", + candidates.map((r) => ({ + value: String(r.version), + label: `v${r.version} · ${r.status || "?"}${r.createdAt ? ` · ${r.createdAt}` : ""}`, + })), + ); + return candidates.find((r) => String(r.version) === picked) ?? null; +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..18de555 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { extraRegions, type ProviderConfig } from "./config.js"; + +const provider = (over: Partial = {}): ProviderConfig => ({ + type: "fly", + org: "acme", + region: "iad", + ...over, +}); + +describe("extraRegions", () => { + it("is empty for a single-region provider (unset regions)", () => { + expect(extraRegions(provider())).toEqual([]); + }); + + it("is empty when regions only repeats the primary", () => { + expect(extraRegions(provider({ regions: ["iad"] }))).toEqual([]); + }); + + it("returns regions beyond the primary, primary excluded and deduped", () => { + expect( + extraRegions(provider({ regions: ["iad", "lhr", "fra", "lhr"] })), + ).toEqual(["lhr", "fra"]); + }); + + it("handles the primary appearing anywhere in the list", () => { + expect(extraRegions(provider({ regions: ["lhr", "iad"] }))).toEqual([ + "lhr", + ]); + }); +}); diff --git a/src/config.ts b/src/config.ts index a8609dd..060ced1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,30 @@ export interface ProviderConfig { org: string; /** Default primary region, e.g. "iad". */ region: string; + /** + * Extra Fly regions to also run in, beyond `region`. After each non-preview + * deploy, one machine is scaled up in every region here that isn't already the + * primary. Omitted/empty → single-region (the default, byte-identical output). + * + * For STATELESS apps only: deploykit does not model database locality, so a + * machine here still talks to whatever single-region `DATABASE_URL` you set — + * expect high write latency from far regions. Don't use this for stateful apps + * without a read-replica / fly-replay strategy of your own. + */ + regions?: string[]; +} + +/** + * Regions to scale into beyond the primary: `provider.regions` with the primary + * removed and duplicates dropped. Empty (the default) means single-region, and + * every generator falls back to today's exact output. + */ +export function extraRegions(provider: ProviderConfig): string[] { + const out: string[] = []; + for (const r of provider.regions ?? []) { + if (r && r !== provider.region && !out.includes(r)) out.push(r); + } + return out; } export interface AppEnvironment { @@ -126,6 +150,13 @@ export interface AppConfig { prisma?: PrismaTarget[]; /** Internal port the server listens on inside the container. */ port: number; + /** + * HTTP path Fly polls to decide a release is healthy. A failing check keeps + * the previous machines serving (auto-rollback on a bad deploy). Defaults to + * "/", which most apps answer with a 2xx/3xx; set it to a lightweight endpoint + * (e.g. "/health") for an API whose "/" 404s, or it would wedge the deploy. + */ + healthCheckPath?: string; /** Names of internal workspace packages this app depends on. */ internalDeps: string[]; /** diff --git a/src/deploy.test.ts b/src/deploy.test.ts index 4af33c2..c2e6fef 100644 --- a/src/deploy.test.ts +++ b/src/deploy.test.ts @@ -174,14 +174,16 @@ function deps(over: Partial = {}) { const success = vi.fn(); const info = vi.fn(); const step = vi.fn(); + const scaleRegion = vi.fn(async () => true); const base: DeployDeps = { confirm: vi.fn(async () => true), stageSecret: vi.fn(async () => true), runDeploy: vi.fn(async () => 0), + scaleRegion, log: { warn, success, info, step }, ...over, }; - return { d: base, warn, success, info, step }; + return { d: base, warn, success, info, step, scaleRegion }; } describe("firstDeploy", () => { @@ -274,4 +276,56 @@ describe("firstDeploy", () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining("didn't finish")); expect(success).not.toHaveBeenCalled(); }); + + it("does not scale when no extra regions are configured", async () => { + const { d, scaleRegion } = deps(); + await firstDeploy({ + config: stagingCfg(), + cwd: ".", + flyReady: true, + assumeYes: true, + deps: d, + }); + expect(scaleRegion).not.toHaveBeenCalled(); + }); + + it("scales into each extra region after a successful deploy", async () => { + const cfg = stagingCfg(); + cfg.provider.regions = ["iad", "lhr", "fra"]; + const { d, scaleRegion } = deps(); + await firstDeploy({ + config: cfg, + cwd: "/repo", + flyReady: true, + assumeYes: true, + deps: d, + }); + expect(scaleRegion).toHaveBeenCalledTimes(2); + expect(scaleRegion).toHaveBeenCalledWith( + "acme-web-staging", + "lhr", + "/repo", + ); + expect(scaleRegion).toHaveBeenCalledWith( + "acme-web-staging", + "fra", + "/repo", + ); + }); + + it("warns but continues when a region scale fails", async () => { + const cfg = stagingCfg(); + cfg.provider.regions = ["iad", "lhr"]; + const { d, warn } = deps({ scaleRegion: vi.fn(async () => false) }); + await firstDeploy({ + config: cfg, + cwd: ".", + flyReady: true, + assumeYes: true, + deps: d, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Couldn't scale"), + ); + }); }); diff --git a/src/deploy.ts b/src/deploy.ts index 76ffc5f..477eab9 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,5 +1,5 @@ import * as p from "@clack/prompts"; -import type { DeploykitConfig } from "./config.js"; +import { type DeploykitConfig, extraRegions } from "./config.js"; import { exec, execInteractive } from "./util/exec.js"; import { pc } from "./util/log.js"; @@ -102,6 +102,12 @@ export interface DeployDeps { cwd: string, ) => Promise; runDeploy: (args: string[], cwd: string) => Promise; + /** Scale one machine into an extra region after the deploy. */ + scaleRegion: ( + flyApp: string, + region: string, + cwd: string, + ) => Promise; log: { warn: (s: string) => void; success: (s: string) => void; @@ -129,6 +135,23 @@ const defaultDeps: DeployDeps = { }) ).code === 0, runDeploy: (args, cwd) => execInteractive({ cmd: "flyctl", args, cwd }), + scaleRegion: async (flyApp, region, cwd) => + ( + await exec({ + cmd: "flyctl", + args: [ + "scale", + "count", + "1", + "--region", + region, + "--app", + flyApp, + "--yes", + ], + cwd, + }) + ).code === 0, log: { warn: (s) => p.log.warn(s), success: (s) => p.log.success(pc.green(s)), @@ -202,6 +225,12 @@ export async function firstDeploy({ const code = await d.runDeploy(deployArgs(t), cwd); if (code === 0) { + // Mirror CI: fan the app out to any configured extra regions. + for (const r of extraRegions(config.provider)) { + const ok = await d.scaleRegion(t.flyApp, r, cwd); + if (ok) d.log.info(pc.dim(`scaled ${t.flyApp} into ${r}`)); + else d.log.warn(`Couldn't scale ${t.flyApp} into ${r} — continuing.`); + } if (t.hostname) { // Custom domain is the real destination — lead with it, keep the // fly.dev address as the dim fallback that answers immediately. diff --git a/src/generate/dockerignore.test.ts b/src/generate/dockerignore.test.ts index 0f658af..a1b274b 100644 --- a/src/generate/dockerignore.test.ts +++ b/src/generate/dockerignore.test.ts @@ -14,4 +14,11 @@ describe("generateDockerignore", () => { expect(out).toContain("**/.env"); expect(out).toContain("!**/.env.example"); }); + + it("ignores deploykit's own plaintext credential/secret files", () => { + // These live at the repo root (the Docker build context) and hold the Fly + // deploy token, Cloudflare token and app secret values in plaintext. They + // must never reach the remote builder or the shipped image. + expect(out).toContain(".deploykit/"); + }); }); diff --git a/src/generate/dockerignore.ts b/src/generate/dockerignore.ts index 0f1236f..5abfcd7 100644 --- a/src/generate/dockerignore.ts +++ b/src/generate/dockerignore.ts @@ -26,6 +26,12 @@ export function generateDockerignore(): string { **/.env.* !**/.env.example +# deploykit's own plaintext credential/secret files (see src/secrets-file.ts). +# Gitignored already, but the Docker build context is the repo root, so they +# must be excluded here too — otherwise they upload to the remote builder and, +# for apps that COPY the whole context, bake into the shipped image. +.deploykit/ + Dockerfile **/Dockerfile .dockerignore diff --git a/src/generate/flytoml.test.ts b/src/generate/flytoml.test.ts index 05f773f..fc5cbab 100644 --- a/src/generate/flytoml.test.ts +++ b/src/generate/flytoml.test.ts @@ -22,4 +22,21 @@ describe("generateFlyToml", () => { expect(toml).toContain('processes = ["app"]'); expect(toml).toContain("[[vm]]"); }); + + it("adds an HTTP health check on / by default (gates Fly auto-rollback)", () => { + expect(toml).toContain("[[http_service.checks]]"); + expect(toml).toContain('method = "GET"'); + expect(toml).toContain('path = "/"'); + expect(toml).toContain('grace_period = "10s"'); + }); + + it("uses a configured healthCheckPath when set", () => { + const custom = generateFlyToml({ + name: "api", + app: { ...sampleWebApp, healthCheckPath: "/health" }, + config: sampleConfig, + }); + expect(custom).toContain('path = "/health"'); + expect(custom).not.toContain('path = "/"'); + }); }); diff --git a/src/generate/flytoml.ts b/src/generate/flytoml.ts index 4138a22..ed28294 100644 --- a/src/generate/flytoml.ts +++ b/src/generate/flytoml.ts @@ -5,6 +5,7 @@ import type { GenerateAppFileInput } from "./types.js"; * the concrete per-environment app (web-staging, web-pr-42, …) with `--app`. */ export function generateFlyToml({ name, app, config }: GenerateAppFileInput) { + const healthPath = app.healthCheckPath ?? "/"; return `# Generated by deploykit — safe to edit and commit. # CI overrides the app name per environment via \`flyctl deploy --app \`. app = "${name}" @@ -20,6 +21,17 @@ primary_region = "${config.provider.region}" min_machines_running = 0 processes = ["app"] + # Health check: Fly waits for this to pass before shifting traffic to a new + # release, and keeps the old machines running if it fails — auto-rollback on a + # bad deploy. If "${healthPath}" 404s (e.g. an API with no root route), point + # it at a lightweight endpoint via \`healthCheckPath\` in deploykit.config.ts. + [[http_service.checks]] + method = "GET" + path = "${healthPath}" + interval = "15s" + timeout = "5s" + grace_period = "10s" + [[vm]] size = "shared-cpu-1x" memory = "512mb" diff --git a/src/generate/index.test.ts b/src/generate/index.test.ts new file mode 100644 index 0000000..539938b --- /dev/null +++ b/src/generate/index.test.ts @@ -0,0 +1,99 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { sampleConfig, writeTree } from "../testing/fixtures.js"; +import { planFiles, writeFiles } from "./index.js"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const c of cleanups.splice(0)) c(); +}); + +/** A fresh empty repo dir. */ +function emptyRepo() { + const { root, cleanup } = writeTree({ files: {} }); + cleanups.push(cleanup); + return root; +} + +function write(root: string, rel: string, contents: string) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, contents, "utf8"); +} + +const statusOf = (root: string, path: string) => + planFiles({ config: sampleConfig, cwd: root }).find((f) => f.path === path) + ?.status; + +describe("planFiles classification", () => { + it("marks every file new in an empty repo", () => { + const files = planFiles({ config: sampleConfig, cwd: emptyRepo() }); + expect(files.length).toBeGreaterThan(0); + expect(files.every((f) => f.status === "new")).toBe(true); + }); + + it("marks a byte-for-byte match identical", () => { + const root = emptyRepo(); + const planned = planFiles({ config: sampleConfig, cwd: root }); + const target = planned.find((f) => f.path === ".dockerignore"); + if (!target) throw new Error("no .dockerignore in plan"); + write(root, target.path, target.contents); + expect(statusOf(root, ".dockerignore")).toBe("identical"); + }); + + it("ignores trailing-whitespace / CRLF differences", () => { + const root = emptyRepo(); + const target = planFiles({ config: sampleConfig, cwd: root }).find( + (f) => f.path === ".dockerignore", + ); + if (!target) throw new Error("no .dockerignore in plan"); + write(root, target.path, `${target.contents.replace(/\n/g, "\r\n")}\n\n `); + expect(statusOf(root, ".dockerignore")).toBe("identical"); + }); + + it("marks a hand-edited file modified", () => { + const root = emptyRepo(); + const target = planFiles({ config: sampleConfig, cwd: root }).find( + (f) => f.path === ".dockerignore", + ); + if (!target) throw new Error("no .dockerignore in plan"); + write(root, target.path, `${target.contents}\n# my hand edit\n`); + expect(statusOf(root, ".dockerignore")).toBe("modified"); + }); +}); + +describe("writeFiles honors classification", () => { + it("never clobbers a modified file without force, but writes new ones", () => { + const root = emptyRepo(); + write(root, ".dockerignore", "# my hand edit\n"); + + const { written, skipped } = writeFiles({ + files: planFiles({ config: sampleConfig, cwd: root }), + cwd: root, + force: false, + }); + + expect(skipped).toContain(".dockerignore"); + expect(readFileSync(join(root, ".dockerignore"), "utf8")).toBe( + "# my hand edit\n", + ); + // A file that didn't exist was written. + expect(written).toContain("deploykit.config.ts"); + }); + + it("overwrites a modified file with force", () => { + const root = emptyRepo(); + write(root, ".dockerignore", "# my hand edit\n"); + + writeFiles({ + files: planFiles({ config: sampleConfig, cwd: root }), + cwd: root, + force: true, + }); + + expect(readFileSync(join(root, ".dockerignore"), "utf8")).not.toContain( + "my hand edit", + ); + }); +}); diff --git a/src/generate/index.ts b/src/generate/index.ts index dfa6ff5..609d980 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -1,19 +1,42 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { DeploykitConfig } from "../config.js"; -import { fileExists } from "../util/fsx.js"; +import { readText } from "../util/fsx.js"; import { generateConfigFile } from "./configfile.js"; import { generateDockerfile } from "./dockerfile.js"; import { generateDockerignore } from "./dockerignore.js"; import { generateFlyToml } from "./flytoml.js"; import { generateWorkflow } from "./workflow.js"; +/** + * How a generated file compares to what's already on disk: + * - `new`: nothing there yet. + * - `identical`: the file matches what deploykit would generate (a no-op). + * - `modified`: a file is there but differs — hand-edited or an older template. + * deploykit never silently overwrites this; the plan surfaces it. + */ +export type FileStatus = "new" | "identical" | "modified"; + export interface GeneratedFile { /** Repo-relative path. */ path: string; contents: string; - /** Whether a file already exists there (so we can avoid clobbering). */ - exists: boolean; + /** How the on-disk file compares to `contents` (drives skip vs warn). */ + status: FileStatus; +} + +/** True for a file that exists on disk, whether or not it matches. */ +export const fileOnDisk = (f: GeneratedFile) => f.status !== "new"; + +/** + * Classify generated content against what's on disk. Line endings and trailing + * whitespace are normalized so a stray CRLF or final newline from an editor + * doesn't read as a real edit. + */ +function classify(generated: string, existing: string | null): FileStatus { + if (existing === null) return "new"; + const norm = (s: string) => s.replace(/\r\n/g, "\n").trimEnd(); + return norm(generated) === norm(existing) ? "identical" : "modified"; } /** Compute every file deploykit would write, without touching disk. */ @@ -26,7 +49,11 @@ export function planFiles({ }) { const files: GeneratedFile[] = []; const add = (path: string, contents: string) => - files.push({ path, contents, exists: fileExists(join(cwd, path)) }); + files.push({ + path, + contents, + status: classify(contents, readText(join(cwd, path))), + }); add("deploykit.config.ts", generateConfigFile(config)); add(".dockerignore", generateDockerignore()); @@ -61,7 +88,9 @@ export function writeFiles({ const written: string[] = []; const skipped: string[] = []; for (const f of files) { - if (f.exists && !force) { + // An identical file is a no-op, so skipping it loses nothing; a modified + // file is left alone unless forced, so a hand-edit is never clobbered. + if (fileOnDisk(f) && !force) { skipped.push(f.path); continue; } diff --git a/src/generate/workflow.test.ts b/src/generate/workflow.test.ts index 60aff3d..4ce1dd9 100644 --- a/src/generate/workflow.test.ts +++ b/src/generate/workflow.test.ts @@ -151,4 +151,37 @@ describe("generateWorkflow", () => { const parsed: ParsedWorkflow = parseYaml(generateWorkflow(stagingOnly)); expect(Object.keys(parsed.jobs).sort()).toEqual(["changes", "staging"]); }); + + it("emits no scale step and is byte-identical when no extra regions are set", () => { + // A redundant regions list (only the primary) must not change a thing. + const onlyPrimary: DeploykitConfig = { + ...sampleConfig, + provider: { ...sampleConfig.provider, regions: ["iad"] }, + }; + expect(yaml).not.toContain("flyctl scale count"); + expect(generateWorkflow(onlyPrimary)).toBe(yaml); + }); + + it("scales into extra regions for staging/production but not preview", () => { + const multi: DeploykitConfig = { + ...sampleConfig, + provider: { ...sampleConfig.provider, regions: ["iad", "lhr", "fra"] }, + }; + const out = generateWorkflow(multi); + // Still valid YAML with the scale loop present. + expect(() => parseYaml(out)).not.toThrow(); + expect(out).toContain("for R in lhr fra; do"); + // Best-effort: guarded with `|| echo ::warning::` so a transient scale + // failure under `set -e` never fails an already-successful deploy. + expect(out).toContain( + 'flyctl scale count 1 --region "$R" --app "$FLY_APP" --yes || echo "::warning::could not scale $FLY_APP into $R"', + ); + // Preview blocks stay single-region: the scale loop appears once per + // non-preview env (staging + production), never inside the preview job. + const previewJob = out.slice( + out.indexOf(" preview:"), + out.indexOf(" teardown:"), + ); + expect(previewJob).not.toContain("flyctl scale count"); + }); }); diff --git a/src/generate/workflow.ts b/src/generate/workflow.ts index 2cd53e3..4679e16 100644 --- a/src/generate/workflow.ts +++ b/src/generate/workflow.ts @@ -1,4 +1,8 @@ -import type { DeploykitConfig, EnvironmentKind } from "../config.js"; +import { + type DeploykitConfig, + type EnvironmentKind, + extraRegions, +} from "../config.js"; /** Render a GitHub Actions expression: gh("secrets.X") -> "${{ secrets.X }}". */ // biome-ignore lint/style/useTemplate: a template literal collides with the `${{` GitHub Actions delimiter @@ -239,6 +243,22 @@ function deployStep({ const secretEnvLines = allSecretNames(config) .map((s) => ` SECRET_${s}: ${gh(`secrets.${s}`)}\n`) .join(""); + // Stateless multi-region: after the deploy, ensure one machine in each extra + // region. Previews stay single-region (ephemeral, not worth the extra cost). + // Empty when no extra regions are configured, so single-region output is + // byte-for-byte unchanged. + const extras = env === "preview" ? [] : extraRegions(config.provider); + // Best-effort: the deploy has already succeeded by here, so a transient scale + // error must not fail the job (set -euo pipefail is active) or skip the rest + // of the regions — mirror the local firstDeploy path, which warns and + // continues. `|| echo ::warning::` surfaces it without a red build. + const scaleBlock = extras.length + ? ` + # Run in extra regions too (stateless multi-region), best-effort. + for R in ${extras.join(" ")}; do + flyctl scale count 1 --region "$R" --app "$FLY_APP" --yes || echo "::warning::could not scale $FLY_APP into $R" + done` + : ""; return ` - name: Deploy (${env}) ${guard} env: FLY_API_TOKEN: ${gh("secrets.FLY_API_TOKEN")} @@ -254,7 +274,7 @@ ${rootAndSecretsCase({ config, indent: 10 })} --config "$ROOT/fly.toml" \\ --dockerfile "$ROOT/Dockerfile" \\ --app "$FLY_APP" \\ - --remote-only${haFlag} \${BUILD_ARGS[@]+"\${BUILD_ARGS[@]}"} + --remote-only${haFlag} \${BUILD_ARGS[@]+"\${BUILD_ARGS[@]}"}${scaleBlock} `; } diff --git a/src/index.ts b/src/index.ts index 6b18dcb..c1bd475 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { runGenerate } from "./commands/generate.js"; import { runInit } from "./commands/init.js"; +import { runRollback } from "./commands/rollback.js"; import type { InitOptions } from "./prompts.js"; import { log, pc } from "./util/log.js"; import { PKG } from "./util/pkg.js"; @@ -11,11 +12,14 @@ ${pc.bold("Usage")} deploykit init [options] Detect the monorepo and set everything up deploykit generate [options] Regenerate Dockerfiles/workflow/fly.toml from deploykit.config.ts (overwrites them) + deploykit rollback [options] Redeploy a prior image for one environment's Fly + app (app only — does not undo DB migrations) ${pc.bold("Options")} -y, --yes Accept detected defaults, no prompts --org Fly organization slug - --region Fly primary region (default: iad) + --region Fly region(s), comma-separated; first is primary, the + rest are extra stateless regions (default: iad) --envs Environments to configure, comma-separated (preview,staging,production — default: all) --dry-run Detect and print the plan, write nothing @@ -26,6 +30,9 @@ ${pc.bold("Options")} --pr Commit generated files on a branch and open a PR --force Overwrite existing generated files instead of skipping --cwd Run against a different directory + --app (rollback) App to roll back (defaults to the sole app) + --env (rollback) Environment: staging or production + --to (rollback) Release version to redeploy (non-interactive) -h, --help Show this help -v, --version Show version @@ -33,6 +40,8 @@ ${pc.bold("Examples")} deploykit init deploykit init --yes --org my-org --region iad --dry-run deploykit init --yes --org my-org --envs preview,staging + deploykit rollback --app web --env production + deploykit rollback --app web --env production --to 41 --yes `; const ENV_KINDS = ["preview", "staging", "production"] as const; @@ -105,9 +114,9 @@ function parseArgs(argv: string[]) { if (!opts.org) return { command, opts, help, version, error: "--org needs a value" }; break; - case "--region": - opts.region = args[++i]; - if (!opts.region) + case "--region": { + const raw = args[++i]; + if (!raw) return { command, opts, @@ -115,7 +124,16 @@ function parseArgs(argv: string[]) { version, error: "--region needs a value", }; + // Accept a comma-separated list; the first is the primary region and + // any others become extra (stateless multi-region) regions. + const list = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + opts.region = list[0]; + if (list.length > 1) opts.regions = list; break; + } case "--envs": { const raw = args[++i]; if (!raw) @@ -140,6 +158,31 @@ function parseArgs(argv: string[]) { opts.cwd = resolve(dir); break; } + case "--app": + opts.app = args[++i]; + if (!opts.app) + return { command, opts, help, version, error: "--app needs a value" }; + break; + case "--env": { + const kind = args[++i]; + if (!kind) + return { command, opts, help, version, error: "--env needs a value" }; + if (!ENV_KINDS.some((k) => k === kind)) + return { + command, + opts, + help, + version, + error: `--env: unknown environment ${kind} (valid: ${ENV_KINDS.join(", ")})`, + }; + opts.env = kind as (typeof ENV_KINDS)[number]; + break; + } + case "--to": + opts.to = args[++i]; + if (!opts.to) + return { command, opts, help, version, error: "--to needs a value" }; + break; case "-h": case "--help": help = true; @@ -178,6 +221,8 @@ async function main() { return runInit(parsed.opts); case "generate": return runGenerate(parsed.opts); + case "rollback": + return runRollback(parsed.opts); default: log.error(`Unknown command: ${parsed.command}`); log.info(HELP); diff --git a/src/plan.ts b/src/plan.ts index 5d1e7f1..f99da65 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -33,7 +33,12 @@ export function renderPlan({ config, files, opts }: RenderPlanInput) { lines.push(""); lines.push(pc.bold("Files")); for (const f of files) { - const tag = f.exists ? pc.yellow(" (exists — skip)") : pc.green(" (new)"); + const tag = + f.status === "new" + ? pc.green(" (new)") + : f.status === "identical" + ? pc.dim(" (exists — unchanged)") + : pc.yellow(" (exists — differs, kept; use --force to overwrite)"); lines.push(` ${f.path}${tag}`); } diff --git a/src/pr.test.ts b/src/pr.test.ts new file mode 100644 index 0000000..d1f0a3f --- /dev/null +++ b/src/pr.test.ts @@ -0,0 +1,209 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GeneratedFile } from "./generate/index.js"; +import { openPr, type PrDeps } from "./pr.js"; + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" }).trim(); + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const c of cleanups.splice(0)) c(); +}); + +/** A working repo (on `main`, one commit) wired to a bare `origin` so push works. */ +function setupRepo() { + const remote = mkdtempSync(join(tmpdir(), "deploykit-remote-")); + execFileSync("git", ["init", "--bare", "-b", "main", remote], { + stdio: "pipe", + }); + const root = mkdtempSync(join(tmpdir(), "deploykit-repo-")); + git(root, "init", "-b", "main"); + git(root, "config", "user.email", "t@t.co"); + git(root, "config", "user.name", "t"); + writeFileSync(join(root, "README.md"), "readme\n"); + git(root, "add", "."); + git(root, "commit", "-m", "init"); + git(root, "remote", "add", "origin", remote); + git(root, "push", "-u", "origin", "main"); + cleanups.push(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(remote, { recursive: true, force: true }); + }); + return { root, remote }; +} + +/** Build the GeneratedFile[] and (as `init` does) pre-write them into the work tree. */ +function stageGenerated(root: string, contents: string): GeneratedFile[] { + const files: GeneratedFile[] = [ + { path: "apps/web/Dockerfile", contents, status: "new" }, + { + path: "deploykit.config.ts", + contents: "export default {}\n", + status: "new", + }, + ]; + for (const f of files) { + const abs = join(root, f.path); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, f.contents, "utf8"); + } + return files; +} + +/** Fake `gh`: records createPr calls and remembers the "open" PR URL. */ +function fakeGh(): { + deps: PrDeps; + state: { url: string | null; created: number }; +} { + const state = { url: null as string | null, created: 0 }; + return { + state, + deps: { + findOpenPr: async () => state.url, + createPr: async () => { + state.created += 1; + state.url = "https://github.com/acme/repo/pull/1"; + return { url: state.url }; + }, + }, + }; +} + +const commitCount = (root: string, branch: string) => + Number(git(root, "rev-list", "--count", branch)); + +describe("openPr", () => { + it("first run: commits on the setup branch, opens a PR, returns to the original branch", async () => { + const { root } = setupRepo(); + const { deps, state } = fakeGh(); + const files = stageGenerated(root, "FROM node\n"); + + const res = await openPr({ cwd: root, files, deps }); + + expect(res.ok).toBe(true); + expect(res.url).toBe("https://github.com/acme/repo/pull/1"); + expect(res.restoredTo).toBe("main"); + expect(state.created).toBe(1); + expect(git(root, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); + // The generated files were committed on the branch, not left on main. + expect(git(root, "show", "deploykit/ci-setup:apps/web/Dockerfile")).toBe( + "FROM node", + ); + }); + + it("is re-runnable: an identical second run reuses the PR and adds no commit", async () => { + const { root } = setupRepo(); + const { deps, state } = fakeGh(); + + await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps, + }); + const before = commitCount(root, "deploykit/ci-setup"); + + // Second run: init re-writes the same files into the work tree, then openPr. + const res = await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps, + }); + + expect(res.ok).toBe(true); + expect(res.url).toBe("https://github.com/acme/repo/pull/1"); + expect(state.created).toBe(1); // reused, not re-created + expect(commitCount(root, "deploykit/ci-setup")).toBe(before); // no empty commit + }); + + it("a re-run with changed content updates the branch and reuses the PR", async () => { + const { root } = setupRepo(); + const { deps, state } = fakeGh(); + + await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps, + }); + const before = commitCount(root, "deploykit/ci-setup"); + + const res = await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node:20\nRUN echo new\n"), + deps, + }); + + expect(res.ok).toBe(true); + expect(state.created).toBe(1); + expect(commitCount(root, "deploykit/ci-setup")).toBe(before + 1); + expect(git(root, "show", "deploykit/ci-setup:apps/web/Dockerfile")).toBe( + "FROM node:20\nRUN echo new", + ); + }); + + it("preserves the user's other uncommitted work across a re-run", async () => { + const { root } = setupRepo(); + const { deps } = fakeGh(); + + await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps, + }); + + // Back on main, the user has unrelated uncommitted work. + writeFileSync(join(root, "README.md"), "readme\nmy important edit\n"); + writeFileSync(join(root, "NOTES.txt"), "scratch\n"); + + await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps, + }); + + expect(git(root, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); + expect(readFileSync(join(root, "README.md"), "utf8")).toContain( + "my important edit", + ); + expect(existsSync(join(root, "NOTES.txt"))).toBe(true); + }); + + it("returns to the original branch when the PR step fails", async () => { + const { root } = setupRepo(); + const { deps } = fakeGh(); + const failing: Partial = { + createPr: async () => ({ + url: null, + detail: "gh pr create failed: boom", + }), + }; + + const res = await openPr({ + cwd: root, + files: stageGenerated(root, "FROM node\n"), + deps: { ...deps, ...failing }, + }); + + expect(res.ok).toBe(false); + expect(res.detail).toContain("boom"); + expect(res.restoredTo).toBe("main"); + expect(git(root, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); + }); + + it("does nothing when there are no files", async () => { + const { root } = setupRepo(); + const res = await openPr({ cwd: root, files: [], deps: fakeGh().deps }); + expect(res.ok).toBe(false); + expect(res.detail).toBe("nothing to commit"); + }); +}); diff --git a/src/pr.ts b/src/pr.ts index 0d2caac..596dddf 100644 --- a/src/pr.ts +++ b/src/pr.ts @@ -1,3 +1,6 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { type GeneratedFile, writeFiles } from "./generate/index.js"; import { exec, tryExec } from "./util/exec.js"; export interface PrResult { @@ -9,6 +12,7 @@ export interface PrResult { } const BRANCH = "deploykit/ci-setup"; +const TITLE = "ci: add deploykit CI/CD"; const PR_BODY = [ "Automated CI/CD setup generated by `deploykit`.", @@ -20,20 +24,68 @@ const PR_BODY = [ "Review the generated Dockerfiles, fly.toml files and workflow before merging.", ].join("\n"); +/** Injected `gh` seams, so the git flow is testable without a real GitHub. */ +export interface PrDeps { + /** URL of an already-open PR for the branch, or null if none. */ + findOpenPr: (branch: string, cwd: string) => Promise; + /** Open a PR; returns the new PR URL, or null on failure (with `detail`). */ + createPr: (cwd: string) => Promise<{ url: string | null; detail?: string }>; +} + +const defaultDeps: PrDeps = { + findOpenPr: (branch, cwd) => + tryExec({ + cmd: "gh", + args: [ + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--json", + "url", + "--jq", + ".[0].url // empty", + ], + cwd, + }).then((out) => out || null), + createPr: async (cwd) => { + const pr = await exec({ + cmd: "gh", + args: ["pr", "create", "--title", TITLE, "--body", PR_BODY], + cwd, + }); + return pr.code === 0 + ? { url: pr.stdout.trim() } + : { url: null, detail: `gh pr create failed: ${pr.stderr.trim()}` }; + }, +}; + /** * Commit the generated files on a branch and open a PR. Assumes `gh` is * authenticated and the repo has a GitHub remote. The working tree is returned * to the branch the user started on — success or failure — so the run doesn't * leave them stranded on the setup branch. + * + * Safe to re-run: an existing setup branch is reused (not recreated), the files + * are re-materialised from `files` so their content is authoritative, an + * unchanged re-run skips the commit instead of failing on "nothing to commit", + * and an already-open PR for the branch is reused instead of erroring on a + * duplicate. The caller's other uncommitted work is never force-discarded. */ export async function openPr({ cwd, - paths, + files, + deps, }: { cwd: string; - paths: string[]; + files: GeneratedFile[]; + deps?: Partial; }): Promise { - if (paths.length === 0) return { ok: false, detail: "nothing to commit" }; + if (files.length === 0) return { ok: false, detail: "nothing to commit" }; + const { findOpenPr, createPr } = { ...defaultDeps, ...deps }; + const paths = files.map((f) => f.path); // Remember where the user was; "HEAD" means detached (nothing to restore to). const original = await tryExec({ @@ -54,20 +106,34 @@ export async function openPr({ restoredTo: await restore(), }); - const onBranch = await switchToBranch(cwd); - if (!onBranch.ok) - return { ok: false, detail: onBranch.detail ?? "could not switch branch" }; + const onBranch = await switchToBranch({ cwd, paths }); + if (!onBranch.ok) return fail(onBranch.detail ?? "could not switch branch"); + + // Re-materialise the generated files on the branch so their content is + // authoritative — this makes a re-run with a changed config update the branch + // rather than silently keeping stale files, and makes an unchanged re-run a + // clean no-op (the diff below stays empty). + writeFiles({ files, cwd, force: true }); const add = await exec({ cmd: "git", args: ["add", ...paths], cwd }); if (add.code !== 0) return fail(`git add failed: ${add.stderr.trim()}`); - const commit = await exec({ + // `git diff --cached --quiet` exits 0 when nothing is staged, 1 when there is + // a diff. Skip the commit on a no-op re-run instead of failing on it. + const staged = await exec({ cmd: "git", - args: ["commit", "-m", "ci: add deploykit CI/CD"], + args: ["diff", "--cached", "--quiet"], cwd, }); - if (commit.code !== 0) - return fail(`git commit failed: ${commit.stderr.trim()}`); + if (staged.code !== 0) { + const commit = await exec({ + cmd: "git", + args: ["commit", "-m", TITLE], + cwd, + }); + if (commit.code !== 0) + return fail(`git commit failed: ${commit.stderr.trim()}`); + } const push = await exec({ cmd: "git", @@ -76,25 +142,30 @@ export async function openPr({ }); if (push.code !== 0) return fail(`git push failed: ${push.stderr.trim()}`); - const pr = await exec({ - cmd: "gh", - args: [ - "pr", - "create", - "--title", - "ci: add deploykit CI/CD", - "--body", - PR_BODY, - ], - cwd, - }); - if (pr.code !== 0) return fail(`gh pr create failed: ${pr.stderr.trim()}`); + // Reuse an open PR for this branch rather than erroring on a duplicate. + const existing = await findOpenPr(BRANCH, cwd); + if (existing) return { ok: true, url: existing, restoredTo: await restore() }; - return { ok: true, url: pr.stdout.trim(), restoredTo: await restore() }; + const pr = await createPr(cwd); + if (!pr.url) return fail(pr.detail ?? "gh pr create failed"); + + return { ok: true, url: pr.url, restoredTo: await restore() }; } -/** Create the setup branch, or switch to it if it already exists. */ -async function switchToBranch(cwd: string) { +/** + * Create the setup branch, or switch to it if it already exists. When it exists, + * the generated files this run just wrote are untracked and would block the + * checkout ("untracked working tree files would be overwritten"); remove them + * first — they are re-written from `files` immediately after the switch, so + * nothing is lost, and only deploykit's own generated paths are touched. + */ +async function switchToBranch({ + cwd, + paths, +}: { + cwd: string; + paths: string[]; +}) { const created = await exec({ cmd: "git", args: ["checkout", "-b", BRANCH], @@ -102,6 +173,7 @@ async function switchToBranch(cwd: string) { }); if (created.code === 0) return { ok: true, detail: undefined }; + for (const p of paths) rmSync(join(cwd, p), { force: true }); const switched = await exec({ cmd: "git", args: ["checkout", BRANCH], cwd }); if (switched.code === 0) return { ok: true, detail: undefined }; diff --git a/src/prompts.ts b/src/prompts.ts index 2f0e896..2bb8a24 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -5,12 +5,14 @@ import { listCloudflareZones, verifyToken, } from "./cloudflare.js"; -import type { - AppConfig, - AppEnvironment, - CloudflareConfig, - DeploykitConfig, - EnvironmentKind, +import { + type AppConfig, + type AppEnvironment, + type CloudflareConfig, + type DeploykitConfig, + type EnvironmentKind, + extraRegions, + type ProviderConfig, } from "./config.js"; import type { DetectedApp, Detection } from "./detect.js"; import { listFlyOrgs } from "./fly.js"; @@ -25,6 +27,12 @@ export interface InitOptions { yes: boolean; org?: string; region?: string; + /** + * Full desired Fly region set from `--region a,b,c` (first is the primary). + * Extra regions beyond the primary become `provider.regions`. Unset or a + * single region → single-region (byte-identical output). + */ + regions?: string[]; /** Environments to configure (skips the prompt). Defaults to all in --yes mode. */ envs?: EnvironmentKind[]; dryRun: boolean; @@ -35,6 +43,12 @@ export interface InitOptions { /** Overwrite files that already exist instead of skipping them. */ force: boolean; cwd: string; + /** `rollback` only: which app to roll back (defaults to the sole app). */ + app?: string; + /** `rollback` only: which environment to roll back (staging/production). */ + env?: EnvironmentKind; + /** `rollback` only: target release version, for non-interactive rollback. */ + to?: string; } const COMMON_REGIONS = [ @@ -128,7 +142,7 @@ function buildFromDefaults({ detection, apps: deployable, envs: opts.envs ?? ALL_ENVS, - provider: { org, region: opts.region ?? "iad" }, + provider: { org, region: opts.region ?? "iad", regions: opts.regions }, namePrefix: defaultNamePrefix(opts.cwd), }); } @@ -222,7 +236,9 @@ async function pickProvider(opts: InitOptions, flyReady: boolean) { }); if (p.isCancel(region)) return null; - return { org, region }; + // Extra regions aren't prompted (keeps the flow unchanged); they come from + // `--region a,b,c` or hand-editing deploykit.config.ts. + return { org, region, regions: opts.regions }; } /** @@ -493,7 +509,7 @@ function assemble({ detection: Detection; apps: DetectedApp[]; envs: EnvironmentKind[]; - provider: { org: string; region: string }; + provider: { org: string; region: string; regions?: string[] }; /** Prefix for every Fly app name; "" → no prefix. */ namePrefix?: string; cloudflare?: CloudflareConfig; @@ -508,11 +524,21 @@ function assemble({ hosts: hostnames?.[a.name], }); + const providerConfig: ProviderConfig = { + type: "fly", + org: provider.org, + region: provider.region, + }; + // Only record regions when there's a real extra beyond the primary, so a + // single-region config stays byte-identical to before this field existed. + if (extraRegions({ ...providerConfig, regions: provider.regions }).length) + providerConfig.regions = provider.regions; + const config: DeploykitConfig = { tool: detection.tool, packageManager: detection.packageManager, nodeVersion: detection.nodeVersion, - provider: { type: "fly", org: provider.org, region: provider.region }, + provider: providerConfig, apps: appMap, }; if (namePrefix) config.namePrefix = namePrefix; From 121b0432387d8d6994ef887eb18da984c631a0bc Mon Sep 17 00:00:00 2001 From: abrulic Date: Sun, 19 Jul 2026 18:52:30 +0200 Subject: [PATCH 2/2] migrations support --- README.md | 21 +++++++++++++- src/generate/flytoml.test.ts | 53 ++++++++++++++++++++++++++++++++++++ src/generate/flytoml.ts | 32 +++++++++++++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dfd03fa..696c656 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,31 @@ This is for **stateless** apps: deploykit does not model database locality, so a far-region machine still talks to whatever single-region `DATABASE_URL` you set — expect high write latency. Read replicas / `fly-replay` are out of scope. +## Database migrations + +deploykit does **not** run migrations — a bad one causes irreversible data loss, +and owning that is out of scope. Instead, when it detects a Prisma schema in an +app it writes a **commented-out** hook into that app's `fly.toml`: + +```toml +# [deploy] +# release_command = "(cd packages/db && npx prisma migrate deploy --schema ./prisma/schema.prisma)" +``` + +`[deploy].release_command` is Fly's idiomatic migration hook: it runs once per +release, before new machines take traffic. Uncomment it only against a database +you own, and make sure the Prisma CLI and schema are present in your runtime +image. Note that `deploykit rollback` reverts the **image only** — it does not +undo a migration this hook applied, so prefer additive (expand/contract) +migrations. Using another tool (Drizzle, Knex, …)? Uncomment and swap the +command for its migrate step. + ## Scope (v1) - **Turbo** monorepos — full support (`turbo prune` multi-stage builds). - **Nx** monorepos — supported via `nx build` + `dist/` output. Node-server and static (Vite/Astro) apps are solid; Next/SSR Dockerfiles follow Nx conventions but are worth a glance before your first deploy. - **Fly.io** as the deploy target. -- **No database provisioning** — databases are a separate concern; see the roadmap. +- **No database provisioning** — deploykit provisions no database. It detects Prisma and writes a commented, opt-in migration hook (see [Database migrations](#database-migrations)); the database itself is yours to create and own. ## Security & Privacy diff --git a/src/generate/flytoml.test.ts b/src/generate/flytoml.test.ts index fc5cbab..68fdb5c 100644 --- a/src/generate/flytoml.test.ts +++ b/src/generate/flytoml.test.ts @@ -39,4 +39,57 @@ describe("generateFlyToml", () => { expect(custom).toContain('path = "/health"'); expect(custom).not.toContain('path = "/"'); }); + + it("emits no migration hook for an app without Prisma", () => { + // sampleWebApp has no prisma target, so nothing DB-related is added. + expect(toml).not.toContain("release_command"); + expect(toml).not.toContain("migrate deploy"); + }); + + it("emits a COMMENTED, opt-in Prisma migration hook when detected", () => { + const withPrisma = generateFlyToml({ + name: "web", + app: { + ...sampleWebApp, + prisma: [ + { + packageName: "@acme/db", + root: "packages/db", + schema: "prisma/schema.prisma", + hasConfig: false, + }, + ], + }, + config: sampleConfig, + }); + // The hook is present but entirely commented out — deploykit never runs it. + expect(withPrisma).toContain("# [deploy]"); + expect(withPrisma).toContain( + '# release_command = "(cd packages/db && npx prisma migrate deploy --schema ./prisma/schema.prisma)"', + ); + for (const line of withPrisma.split("\n")) { + expect(line).not.toMatch(/^\s*release_command/); // never uncommented + expect(line).not.toMatch(/^\s*\[deploy\]/); + } + }); + + it("omits --schema in the hook when the package has a prisma config", () => { + const withCfg = generateFlyToml({ + name: "web", + app: { + ...sampleWebApp, + prisma: [ + { + packageName: "@acme/db", + root: "packages/db", + schema: "prisma/schema.prisma", + hasConfig: true, + }, + ], + }, + config: sampleConfig, + }); + expect(withCfg).toContain("migrate deploy)"); + expect(withCfg).not.toContain("--schema"); + }); }); diff --git a/src/generate/flytoml.ts b/src/generate/flytoml.ts index ed28294..9fc9109 100644 --- a/src/generate/flytoml.ts +++ b/src/generate/flytoml.ts @@ -1,5 +1,35 @@ +import type { AppConfig } from "../config.js"; import type { GenerateAppFileInput } from "./types.js"; +/** + * A COMMENTED-OUT `[deploy] release_command` for apps with a detected Prisma + * schema — Fly's idiomatic per-release migration hook. Emitted only as guidance: + * deploykit never runs migrations (a bad one causes irreversible data loss), so + * it's inert until the user uncomments it against a database they own. Empty for + * apps with no Prisma target, so their fly.toml is byte-for-byte unchanged. + */ +function migrationHint(app: AppConfig): string { + const targets = app.prisma ?? []; + if (targets.length === 0) return ""; + const cmd = targets + .map( + (t) => + `(cd ${t.root} && npx prisma migrate deploy${t.hasConfig ? "" : ` --schema ./${t.schema}`})`, + ) + .join(" && "); + return `# Database migrations: [deploy].release_command runs once per release, +# before new machines take traffic — the idiomatic place for migrations. +# deploykit generated this COMMENTED OUT and never runs it. Uncomment only +# against a database you own, and confirm the Prisma CLI + schema are present in +# the runtime image. A destructive migration here can cause irreversible data +# loss, and it is not undone by \`deploykit rollback\` (which reverts the image +# only). See the Database migrations section of the README. +# [deploy] +# release_command = "${cmd}" + +`; +} + /** * Per-app fly.toml. The `app` field is only a local default — CI always targets * the concrete per-environment app (web-staging, web-pr-42, …) with `--app`. @@ -13,7 +43,7 @@ primary_region = "${config.provider.region}" # Build context is the repo root; CI passes --dockerfile ${app.root}/Dockerfile. -[http_service] +${migrationHint(app)}[http_service] internal_port = ${app.port} force_https = true auto_stop_machines = "stop"