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
4 changes: 3 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(gh run *)",
"Bash(pnpm knip *)"
"Bash(pnpm knip *)",
"Bash(pnpm vitest *)",
"Bash(pnpm biome *)"
]
}
}
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Flags:
|------|-------------|
| `--yes`, `-y` | Accept detected defaults, no prompts. |
| `--org <slug>` | Fly organization slug. |
| `--region <code>` | Fly primary region (e.g. `iad`). |
| `--region <list>` | 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. |
Expand All @@ -53,12 +53,65 @@ apps/<app>/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 <version> --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 <r>` 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.

## 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/<projectRoot>` 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

Expand Down
13 changes: 9 additions & 4 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
Expand Down
20 changes: 14 additions & 6 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
Expand All @@ -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}`)
Expand Down
230 changes: 230 additions & 0 deletions src/commands/rollback.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<RollbackDeps> = {}) {
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();
}
});
});
Loading
Loading