Public: anyone can read this; the crew can change it.
Self-deploy, step 2: a landing on the forge's own main checks the landed tree out into releases/<sha>/ and preflights it, without swapping #1730
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/1662-self-deploy-step-2-a-landing-on-the-forg → main 2 commits fork
11 files
changed +522
−1
README.md +2
| @@ -38,6 +38,8 @@ bun run src/main.ts # web on :3000, ssh on :2222 (FORGE_SSH_PORT=0 turns it off | ||
| 38 | 38 | bun src/main.ts --preflight # can this tree boot? config parses, migrations run on a copy of the database; one line, exit 0 or 1, no socket opened. Run it before the swap, not under load: the copy is not atomic against a live writer, and a `preflight-*` directory a killed run left behind is swept by the next run once an hour old, and a missing data directory is created, as boot would |
| 39 | 39 | |
| 40 | 40 | |
| 41 | Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands there is written to `$FORGE_DATA/releases/<sha>/` (the newest `FORGE_RELEASES_KEEP`, default 5, are kept), preflighted with that tree's own `--preflight`, and recorded in the `release` table (sha, landed pull, verdict, its line, when). A red preflight is asked again once (the copy of the live database can tear under load); a second red is logged and told to the landed pull. Other repositories are untouched, and nothing is swapped. `FORGE_REPO` also names the parent code that spawned child forges fork. | |
| 42 | ||
| 41 | 43 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
| 42 | 44 | forge you work for — this forge takes it as its own canonical address and |
| 43 | 45 | refuses every form a browser posts to `localhost`: run it with |
scripts/gate.ts +1
| @@ -64,6 +64,7 @@ export const GATE: readonly string[] = [ | ||
| 64 | 64 | "test/refresh-meta.test.ts", |
| 65 | 65 | "test/restore-card.test.ts", |
| 66 | 66 | "test/recovery-doc.test.ts", |
| 67 | "test/release-checkout.test.ts", | |
| 67 | 68 | "test/release-preflight.test.ts", |
| 68 | 69 | "test/threats-doc.test.ts", |
| 69 | 70 | "test/returns.test.ts", |
src/app.ts +4
| @@ -148,8 +148,12 @@ export interface ForgeConfig { | ||
| 148 | 148 | * (`FORGE_REPO`, e.g. `trevato-apps/forge`). A spawned child forks it, |
| 149 | 149 | * so every child starts from the forge's code and can follow its |
| 150 | 150 | * parent's updates. Unset, children are spawned without the fork. |
| 151 | * It also marks the repository whose landings are checked out into | |
| 152 | * `releases/<sha>/` and preflighted (`release/`, #1662). | |
| 151 | 153 | */ |
| 152 | 154 | forgeRepo?: string; |
| 155 | /** How many `releases/<sha>/` checkouts to keep (`FORGE_RELEASES_KEEP`). @default 5 */ | |
| 156 | releasesKeep?: number; | |
| 153 | 157 | } |
| 154 | 158 | |
| 155 | 159 | export interface App { |
src/checks/landing.ts +88
| @@ -8,6 +8,18 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { parseCheck } from "../apps/manifest.ts"; |
| 10 | 10 | import { readFileAt } from "../apps/checkout.ts"; |
| 11 | import { addComment } from "../actions/issues.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { getIssueById } from "../core/issues.ts"; | |
| 14 | import { repoDir } from "../git/repos.ts"; | |
| 15 | import { | |
| 16 | checkoutRelease, | |
| 17 | DEFAULT_KEEP, | |
| 18 | preflightRelease, | |
| 19 | type Verdict, | |
| 20 | pruneReleases, | |
| 21 | } from "../release/checkout.ts"; | |
| 22 | import { recordRelease, type Release } from "../release/record.ts"; | |
| 11 | 23 | import type { Pull } from "../core/pulls.ts"; |
| 12 | 24 | import type { Repo } from "../core/repos.ts"; |
| 13 | 25 | import { runGit } from "../git/env.ts"; |
| @@ -58,3 +70,79 @@ export async function gateOnLand( | ||
| 58 | 70 | if (!verdict.lands) addLearned(app.config.dataDir, pull.id, verdict.learn); |
| 59 | 71 | return verdict; |
| 60 | 72 | } |
| 73 | ||
| 74 | /** | |
| 75 | * A pull landed on the forge's own repository (`FORGE_REPO`): write the | |
| 76 | * landed tree to `releases/<sha>/`, preflight it, record the verdict. A red | |
| 77 | * preflight is one line in the log and a comment on the landed pull. Any | |
| 78 | * other repository is untouched (`undefined`). Nothing is swapped. | |
| 79 | */ | |
| 80 | export function releaseOnLand( | |
| 81 | app: App, | |
| 82 | by: User, | |
| 83 | repo: Repo, | |
| 84 | pull: Pull, | |
| 85 | sha: string, | |
| 86 | ): Promise<Release | undefined> { | |
| 87 | const run = turn.then(() => cut(app, by, repo, pull, sha)); | |
| 88 | turn = run.then( | |
| 89 | () => undefined, | |
| 90 | () => undefined, | |
| 91 | ); | |
| 92 | return run; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * The preflight copies the live database while the crew writes, so a torn copy | |
| 97 | * can read red. A red is asked again once; only a second red is believed. | |
| 98 | */ | |
| 99 | async function preflightTrusted(dir: string, dataDir: string): Promise<Verdict> { | |
| 100 | const first = await preflightRelease(dir, dataDir); | |
| 101 | return first.ok ? first : preflightRelease(dir, dataDir); | |
| 102 | } | |
| 103 | ||
| 104 | /** Landings queue behind one another: two checkouts never share releases/. */ | |
| 105 | let turn: Promise<void> = Promise.resolve(); | |
| 106 | ||
| 107 | async function cut( | |
| 108 | app: App, | |
| 109 | by: User, | |
| 110 | repo: Repo, | |
| 111 | pull: Pull, | |
| 112 | sha: string, | |
| 113 | ): Promise<Release | undefined> { | |
| 114 | if (app.config.forgeRepo !== `${repo.ownerName}/${repo.name}`) return undefined; | |
| 115 | const dataDir = app.config.dataDir; | |
| 116 | const out = await checkoutRelease( | |
| 117 | dataDir, | |
| 118 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 119 | sha, | |
| 120 | ); | |
| 121 | const verdict = out.ok | |
| 122 | ? await preflightTrusted(out.value, dataDir) | |
| 123 | : { ok: false, line: `checkout failed: ${out.error}` }; | |
| 124 | const release = recordRelease(app.db, { | |
| 125 | sha, | |
| 126 | repoId: repo.id, | |
| 127 | pullNumber: pull.number, | |
| 128 | ok: verdict.ok, | |
| 129 | line: verdict.line, | |
| 130 | }); | |
| 131 | pruneReleases(dataDir, app.config.releasesKeep ?? DEFAULT_KEEP, sha); | |
| 132 | if (!verdict.ok) { | |
| 133 | app.log.warn("release preflight failed", { | |
| 134 | sha: sha.slice(0, 12), | |
| 135 | pull: pull.number, | |
| 136 | line: verdict.line, | |
| 137 | }); | |
| 138 | const landed = getIssueById(app.db, pull.id) ?? pull; | |
| 139 | addComment( | |
| 140 | app, | |
| 141 | by, | |
| 142 | repo, | |
| 143 | landed, | |
| 144 | `The landed tree ${sha.slice(0, 12)} failed its preflight, so it will not be released: ${verdict.line}`, | |
| 145 | ); | |
| 146 | } | |
| 147 | return release; | |
| 148 | } |
src/core/schema.ts +16
| @@ -1705,6 +1705,22 @@ export const migrations: readonly string[] = [ | ||
| 1705 | 1705 | ); |
| 1706 | 1706 | CREATE INDEX passkey_user ON passkey(user_id); |
| 1707 | 1707 | `, |
| 1708 | // 83 — releases (#1662): one row per landing on the forge's own repository | |
| 1709 | // that was checked out and preflighted (`release/`). `ok` is the preflight | |
| 1710 | // verdict, `line` its one line; the pull is kept as a number so the row | |
| 1711 | // outlives a deleted pull. | |
| 1712 | ` | |
| 1713 | CREATE TABLE release ( | |
| 1714 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 1715 | sha TEXT NOT NULL, | |
| 1716 | repo_id TEXT NOT NULL, | |
| 1717 | pull_number INTEGER, | |
| 1718 | ok INTEGER NOT NULL, | |
| 1719 | line TEXT NOT NULL, | |
| 1720 | created_at TEXT NOT NULL | |
| 1721 | ); | |
| 1722 | CREATE INDEX release_created ON release(created_at); | |
| 1723 | `, | |
| 1708 | 1724 | ]; |
| 1709 | 1725 | |
| 1710 | 1726 | /** |
src/crew/autonomy.ts +9 −1
| @@ -21,7 +21,7 @@ import { | ||
| 21 | 21 | setIssueState, |
| 22 | 22 | } from "../actions/issues.ts"; |
| 23 | 23 | import type { AccessError } from "../actions/access.ts"; |
| 24 | import { gateOnLand } from "../checks/landing.ts"; | |
| 24 | import { gateOnLand, releaseOnLand } from "../checks/landing.ts"; | |
| 25 | 25 | import { mergeable, resolveOid } from "../git/merge.ts"; |
| 26 | 26 | import { repoDir } from "../git/repos.ts"; |
| 27 | 27 | import { mergePull } from "../actions/pulls.ts"; |
| @@ -1420,6 +1420,14 @@ export async function landIfGreen( | ||
| 1420 | 1420 | } |
| 1421 | 1421 | conflicted.delete(pull.id); |
| 1422 | 1422 | resolveOpen(app.db, pull.id, "land", "shipped"); |
| 1423 | // On the forge's own code, the landed tree is checked out and preflighted | |
| 1424 | // off to the side; the landing does not wait on it (#1662). | |
| 1425 | void releaseOnLand(app, by, repo, pull, merged.value.mergeOid).catch((error) => | |
| 1426 | log.warn("release checkout failed", { | |
| 1427 | pull: pull.number, | |
| 1428 | why: error instanceof Error ? error.message : String(error), | |
| 1429 | }), | |
| 1430 | ); | |
| 1423 | 1431 | // Said once it has landed, so the note wakes no builder: the review was |
| 1424 | 1432 | // asked for and never came. |
| 1425 | 1433 | if (review === "lapsed") { |
src/main.ts +4
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { releaseEnv } from "./release/env.ts"; | |
| 7 | 8 | import { createApp } from "./app.ts"; |
| 8 | 9 | import { reconcile } from "./core/reconcile.ts"; |
| 9 | 10 | import { syncAllPillars } from "./actions/pillars.ts"; |
| @@ -133,6 +134,9 @@ const app = createApp({ | ||
| 133 | 134 | ...(hosted && (process.env.FORGE_APP_SSH ?? "").trim() !== "" |
| 134 | 135 | ? { appSsh: (process.env.FORGE_APP_SSH ?? "").trim() } |
| 135 | 136 | : {}), |
| 137 | // The repository holding this forge's own code: a landing there is | |
| 138 | // checked out under releases/ and preflighted (#1662). | |
| 139 | ...releaseEnv(process.env), | |
| 136 | 140 | // The one knob on the crew's bill: FORGE_CREW_CEILING_USD, dollars a day |
| 137 | 141 | // across every repository. Unset is no forge-wide ceiling. |
| 138 | 142 | ...(process.env.FORGE_MIGRATE_PRIVATE_HOSTS === "1" |
src/release/checkout.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * A landed commit as a release candidate: its tree written into | |
| 3 | * `releases/<sha>/` under the data directory, then asked whether it can boot | |
| 4 | * (`--preflight`, #1674). Nothing is swapped here; this only proves the tree | |
| 5 | * and keeps the last few. | |
| 6 | * | |
| 7 | * Idempotent: a sha already checked out is not written again. The tree is | |
| 8 | * exported beside its final place and renamed in, so a killed run leaves no | |
| 9 | * half-written `releases/<sha>/` for the next one to trust. | |
| 10 | */ | |
| 11 | ||
| 12 | import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, utimesSync } from "node:fs"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { exportTree } from "../apps/checkout.ts"; | |
| 15 | import { childEnv } from "../apps/process.ts"; | |
| 16 | import { isSafeOid } from "../git/guard.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | ||
| 19 | export const DEFAULT_KEEP = 5; | |
| 20 | const PREFLIGHT_MS = 120_000; | |
| 21 | /** A staging directory untouched this long was left by a killed run. */ | |
| 22 | const PARTIAL_STALE_MS = 10 * 60 * 1000; | |
| 23 | ||
| 24 | export const releasesRoot = (dataDir: string): string => join(dataDir, "releases"); | |
| 25 | ||
| 26 | export const releaseDir = (dataDir: string, sha: string): string => | |
| 27 | join(releasesRoot(dataDir), sha); | |
| 28 | ||
| 29 | /** Write `sha`'s tree to its release directory; returns the directory. */ | |
| 30 | export async function checkoutRelease( | |
| 31 | dataDir: string, | |
| 32 | bareDir: string, | |
| 33 | sha: string, | |
| 34 | ): Promise<Result<string, string>> { | |
| 35 | if (!isSafeOid(sha) || sha.length !== 40) return err("not a commit id"); | |
| 36 | const target = releaseDir(dataDir, sha); | |
| 37 | if (existsSync(target)) { | |
| 38 | // Touched, so it is the newest and pruning keeps it. | |
| 39 | const now = new Date(); | |
| 40 | utimesSync(target, now, now); | |
| 41 | return ok(target); | |
| 42 | } | |
| 43 | const root = releasesRoot(dataDir); | |
| 44 | mkdirSync(root, { recursive: true }); | |
| 45 | const staging = join(root, `.${sha}.partial`); | |
| 46 | try { | |
| 47 | const exported = await exportTree(bareDir, sha, staging); | |
| 48 | if (!exported.ok) return exported; | |
| 49 | renameSync(staging, target); | |
| 50 | return ok(target); | |
| 51 | } finally { | |
| 52 | rmSync(staging, { recursive: true, force: true }); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** Remove all but the `keep` newest releases, and `keep` is at least one. Returns what went. */ | |
| 57 | export function pruneReleases(dataDir: string, keep: number, current: string): string[] { | |
| 58 | const root = releasesRoot(dataDir); | |
| 59 | if (!existsSync(root)) return []; | |
| 60 | const limit = Math.max(1, Math.floor(keep)); | |
| 61 | const found = readdirSync(root) | |
| 62 | .filter((name) => /^[0-9a-f]{40}$/.test(name)) | |
| 63 | .map((name) => ({ name, at: statSync(join(root, name)).mtimeMs })) | |
| 64 | .sort((a, b) => b.at - a.at || (a.name < b.name ? -1 : 1)); | |
| 65 | const kept = new Set(found.slice(0, limit).map((r) => r.name)); | |
| 66 | kept.add(current); | |
| 67 | const gone: string[] = []; | |
| 68 | for (const { name } of found) { | |
| 69 | if (kept.has(name)) continue; | |
| 70 | rmSync(join(root, name), { recursive: true, force: true }); | |
| 71 | gone.push(name); | |
| 72 | } | |
| 73 | // A partial export left by a killed run. One younger than the bound may be | |
| 74 | // another landing's checkout still writing: leave it. | |
| 75 | for (const name of readdirSync(root)) { | |
| 76 | if (!name.endsWith(".partial")) continue; | |
| 77 | const path = join(root, name); | |
| 78 | try { | |
| 79 | if (Date.now() - statSync(path).mtimeMs > PARTIAL_STALE_MS) | |
| 80 | rmSync(path, { recursive: true, force: true }); | |
| 81 | } catch { | |
| 82 | // Renamed into place between the listing and the look. | |
| 83 | } | |
| 84 | } | |
| 85 | return gone; | |
| 86 | } | |
| 87 | ||
| 88 | export type Verdict = { ok: boolean; line: string }; | |
| 89 | ||
| 90 | /** Run the release's own `--preflight` against this forge's data directory. */ | |
| 91 | export async function preflightRelease(dir: string, dataDir: string): Promise<Verdict> { | |
| 92 | const entry = join(dir, "src", "main.ts"); | |
| 93 | if (!existsSync(entry)) return { ok: false, line: "preflight failed: the tree has no src/main.ts" }; | |
| 94 | const proc = Bun.spawn([process.execPath, entry, "--preflight"], { | |
| 95 | cwd: dir, | |
| 96 | env: childEnv({ FORGE_DATA: dataDir, FORGE_APP_URL: "" }), | |
| 97 | stdin: "ignore", | |
| 98 | stdout: "pipe", | |
| 99 | stderr: "pipe", | |
| 100 | }); | |
| 101 | const timer = setTimeout(() => proc.kill("SIGKILL"), PREFLIGHT_MS); | |
| 102 | try { | |
| 103 | const [out, errText, code] = await Promise.all([ | |
| 104 | new Response(proc.stdout).text(), | |
| 105 | new Response(proc.stderr).text(), | |
| 106 | proc.exited, | |
| 107 | ]); | |
| 108 | const line = | |
| 109 | `${out}\n${errText}` | |
| 110 | .split("\n") | |
| 111 | .map((l) => l.trim()) | |
| 112 | .filter((l) => l !== "") | |
| 113 | .pop() ?? ""; | |
| 114 | if (code === 0) return { ok: true, line: line || "preflight ok" }; | |
| 115 | return { ok: false, line: (line || `preflight failed: exit ${code}`).slice(0, 300) }; | |
| 116 | } finally { | |
| 117 | clearTimeout(timer); | |
| 118 | } | |
| 119 | } |
src/release/env.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** The release knobs from the environment, validated; what is malformed is left unset. */ | |
| 2 | ||
| 3 | export function releaseEnv(env: Record<string, string | undefined>): { | |
| 4 | forgeRepo?: string; | |
| 5 | releasesKeep?: number; | |
| 6 | } { | |
| 7 | const repo = env.FORGE_REPO ?? ""; | |
| 8 | const keep = Number(env.FORGE_RELEASES_KEEP); | |
| 9 | return { | |
| 10 | ...(/^[\w.-]+\/[\w.-]+$/.test(repo) ? { forgeRepo: repo } : {}), | |
| 11 | ...(Number.isInteger(keep) && keep >= 1 ? { releasesKeep: keep } : {}), | |
| 12 | }; | |
| 13 | } |
src/release/record.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** The release rows: one per checked-out landing, with the preflight verdict. */ | |
| 2 | ||
| 3 | import type { Database } from "bun:sqlite"; | |
| 4 | ||
| 5 | export interface Release { | |
| 6 | readonly id: number; | |
| 7 | readonly sha: string; | |
| 8 | readonly repoId: string; | |
| 9 | readonly pullNumber: number | null; | |
| 10 | readonly ok: boolean; | |
| 11 | readonly line: string; | |
| 12 | readonly createdAt: string; | |
| 13 | } | |
| 14 | ||
| 15 | interface Row { | |
| 16 | id: number; | |
| 17 | sha: string; | |
| 18 | repo_id: string; | |
| 19 | pull_number: number | null; | |
| 20 | ok: number; | |
| 21 | line: string; | |
| 22 | created_at: string; | |
| 23 | } | |
| 24 | ||
| 25 | const toRelease = (r: Row): Release => ({ | |
| 26 | id: r.id, | |
| 27 | sha: r.sha, | |
| 28 | repoId: r.repo_id, | |
| 29 | pullNumber: r.pull_number, | |
| 30 | ok: r.ok === 1, | |
| 31 | line: r.line, | |
| 32 | createdAt: r.created_at, | |
| 33 | }); | |
| 34 | ||
| 35 | export function recordRelease( | |
| 36 | db: Database, | |
| 37 | input: { sha: string; repoId: string; pullNumber: number | null; ok: boolean; line: string }, | |
| 38 | ): Release { | |
| 39 | const row = db | |
| 40 | .query<Row, [string, string, number | null, number, string]>( | |
| 41 | `INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) | |
| 42 | VALUES (?1, ?2, ?3, ?4, ?5, strftime('%Y-%m-%dT%H:%M:%fZ','now')) RETURNING *`, | |
| 43 | ) | |
| 44 | .get(input.sha, input.repoId, input.pullNumber, input.ok ? 1 : 0, input.line)!; | |
| 45 | return toRelease(row); | |
| 46 | } | |
| 47 | ||
| 48 | /** Newest first. */ | |
| 49 | export function listReleases(db: Database, limit = 20): Release[] { | |
| 50 | return db | |
| 51 | .query<Row, [number]>(`SELECT * FROM release ORDER BY id DESC LIMIT ?1`) | |
| 52 | .all(limit) | |
| 53 | .map(toRelease); | |
| 54 | } |
test/release-checkout.test.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** | |
| 2 | * A landing on the forge's own repository (#1662): the landed tree is written | |
| 3 | * to releases/<sha>/, preflighted, and recorded; the oldest is pruned; a red | |
| 4 | * preflight is recorded red and told to the pull. Another repository is left | |
| 5 | * alone. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { existsSync, mkdirSync, readdirSync, readFileSync, utimesSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { releaseOnLand } from "../src/checks/landing.ts"; | |
| 17 | import { listComments } from "../src/core/issues.ts"; | |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { releaseDir } from "../src/release/checkout.ts"; | |
| 22 | import { releaseEnv } from "../src/release/env.ts"; | |
| 23 | import { listReleases } from "../src/release/record.ts"; | |
| 24 | ||
| 25 | const GOOD = `if (process.argv.includes("--preflight")) { console.log("preflight ok: stand-in"); process.exit(0); }\n`; | |
| 26 | const BAD = `if (process.argv.includes("--preflight")) { console.error("preflight failed: migration 9 broke"); process.exit(1); }\n`; | |
| 27 | ||
| 28 | describe("release checkout on landing", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let alice: User; | |
| 32 | let forge: Repo; | |
| 33 | let pull: Pull; | |
| 34 | ||
| 35 | async function land(main: string, extra = "x"): Promise<string> { | |
| 36 | const made = await commitToBranch(app, alice, forge, { | |
| 37 | message: `land ${extra}`, | |
| 38 | files: [ | |
| 39 | { path: "src/main.ts", content: main }, | |
| 40 | { path: "marker.txt", content: extra }, | |
| 41 | ], | |
| 42 | }); | |
| 43 | if (!made.ok) throw new Error("commit"); | |
| 44 | return made.value.commitOid; | |
| 45 | } | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-release-")); | |
| 49 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code", releasesKeep: 2 }); | |
| 50 | const u = await createUser(app.db, { | |
| 51 | trusted: true, | |
| 52 | username: "alice", | |
| 53 | email: "[email protected]", | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: true, | |
| 56 | }); | |
| 57 | if (!u.ok) throw new Error("user"); | |
| 58 | alice = u.value; | |
| 59 | const r = await createRepo(app, alice, { name: "forge-code", visibility: "public" }); | |
| 60 | if (!r.ok) throw new Error("repo"); | |
| 61 | forge = r.value; | |
| 62 | await commitToBranch(app, alice, forge, { message: "seed", files: [{ path: "a.txt", content: "a" }] }); | |
| 63 | await commitToBranch(app, alice, forge, { | |
| 64 | message: "branch", | |
| 65 | branch: "change", | |
| 66 | files: [{ path: "b.txt", content: "b" }], | |
| 67 | }); | |
| 68 | const opened = await openPull(app, alice, forge, { | |
| 69 | title: "a change", | |
| 70 | baseBranch: forge.defaultBranch, | |
| 71 | headBranch: "change", | |
| 72 | }); | |
| 73 | if (!opened.ok) throw new Error("pull"); | |
| 74 | pull = opened.value; | |
| 75 | }, 30_000); | |
| 76 | ||
| 77 | afterAll(async () => { | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("writes that sha's files, preflights green, and records a row", async () => { | |
| 83 | const sha = await land(GOOD, "one"); | |
| 84 | const release = await releaseOnLand(app, alice, forge, pull, sha); | |
| 85 | expect(release).toMatchObject({ sha, ok: true, pullNumber: pull.number }); | |
| 86 | expect(readFileSync(join(releaseDir(dataDir, sha), "marker.txt"), "utf8")).toBe("one"); | |
| 87 | expect(existsSync(join(releaseDir(dataDir, sha), ".git"))).toBe(false); | |
| 88 | expect(listReleases(app.db)[0]).toMatchObject({ sha, ok: true }); | |
| 89 | }, 30_000); | |
| 90 | ||
| 91 | test("is idempotent for a sha already checked out", async () => { | |
| 92 | const [row] = listReleases(app.db); | |
| 93 | const again = await releaseOnLand(app, alice, forge, pull, row!.sha); | |
| 94 | expect(again?.ok).toBe(true); | |
| 95 | expect(readdirSync(join(dataDir, "releases"))).toEqual([row!.sha]); | |
| 96 | }, 30_000); | |
| 97 | ||
| 98 | test("keeps the last N and prunes the oldest", async () => { | |
| 99 | const first = listReleases(app.db)[0]!.sha; | |
| 100 | // Age the first so the order does not hang on clock resolution. | |
| 101 | const old = new Date(Date.now() - 60_000); | |
| 102 | utimesSync(releaseDir(dataDir, first), old, old); | |
| 103 | const second = await land(GOOD, "two"); | |
| 104 | await releaseOnLand(app, alice, forge, pull, second); | |
| 105 | const third = await land(GOOD, "three"); | |
| 106 | await releaseOnLand(app, alice, forge, pull, third); | |
| 107 | const kept = readdirSync(join(dataDir, "releases")).sort(); | |
| 108 | expect(kept).toEqual([second, third].sort()); | |
| 109 | expect(existsSync(releaseDir(dataDir, first))).toBe(false); | |
| 110 | }, 60_000); | |
| 111 | ||
| 112 | test("a failing preflight is recorded red, logged, and told to the pull", async () => { | |
| 113 | const sha = await land(BAD, "red"); | |
| 114 | const release = await releaseOnLand(app, alice, forge, pull, sha); | |
| 115 | expect(release).toMatchObject({ sha, ok: false }); | |
| 116 | expect(release?.line).toContain("migration 9 broke"); | |
| 117 | const told = listComments(app.db, pull.id).map((c) => c.body); | |
| 118 | expect(told.some((b) => b.includes(sha.slice(0, 12)) && b.includes("migration 9 broke"))).toBe(true); | |
| 119 | }, 30_000); | |
| 120 | ||
| 121 | test("a red the second attempt clears is recorded green and not told", async () => { | |
| 122 | // Fails once (a torn copy), then passes: the marker lives in the release dir. | |
| 123 | const flaky = `import { existsSync, writeFileSync } from "node:fs"; | |
| 124 | if (process.argv.includes("--preflight")) { | |
| 125 | if (!existsSync("tried")) { writeFileSync("tried", "1"); console.error("preflight failed: torn copy"); process.exit(1); } | |
| 126 | console.log("preflight ok: second try"); process.exit(0); | |
| 127 | }\n`; | |
| 128 | const sha = await land(flaky, "flaky"); | |
| 129 | const before = listComments(app.db, pull.id).length; | |
| 130 | const release = await releaseOnLand(app, alice, forge, pull, sha); | |
| 131 | expect(release).toMatchObject({ sha, ok: true }); | |
| 132 | expect(release?.line).toContain("second try"); | |
| 133 | expect(listComments(app.db, pull.id)).toHaveLength(before); | |
| 134 | }, 30_000); | |
| 135 | ||
| 136 | test("another repository is untouched", async () => { | |
| 137 | const other = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 138 | if (!other.ok) throw new Error("other"); | |
| 139 | const made = await commitToBranch(app, alice, other.value, { | |
| 140 | message: "n", | |
| 141 | files: [{ path: "src/main.ts", content: GOOD }], | |
| 142 | }); | |
| 143 | if (!made.ok) throw new Error("commit"); | |
| 144 | const before = listReleases(app.db).length; | |
| 145 | const dirs = readdirSync(join(dataDir, "releases")).sort(); | |
| 146 | expect(await releaseOnLand(app, alice, other.value, pull, made.value.commitOid)).toBeUndefined(); | |
| 147 | expect(listReleases(app.db)).toHaveLength(before); | |
| 148 | expect(readdirSync(join(dataDir, "releases")).sort()).toEqual(dirs); | |
| 149 | }); | |
| 150 | }); | |
| 151 | ||
| 152 | describe("overlapping landings", () => { | |
| 153 | test("two landings at once both finish, and a young partial survives a prune", async () => { | |
| 154 | const dataDir = await mkdtemp(join(tmpdir(), "forge-release-race-")); | |
| 155 | const app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code", releasesKeep: 5 }); | |
| 156 | try { | |
| 157 | const u = await createUser(app.db, { | |
| 158 | trusted: true, | |
| 159 | username: "alice", | |
| 160 | email: "[email protected]", | |
| 161 | password: "a-good-passphrase", | |
| 162 | adminIfFirst: true, | |
| 163 | }); | |
| 164 | if (!u.ok) throw new Error("user"); | |
| 165 | const r = await createRepo(app, u.value, { name: "forge-code", visibility: "public" }); | |
| 166 | if (!r.ok) throw new Error("repo"); | |
| 167 | const shas: string[] = []; | |
| 168 | for (const n of ["one", "two"]) { | |
| 169 | const c = await commitToBranch(app, u.value, r.value, { | |
| 170 | message: n, | |
| 171 | files: [{ path: "src/main.ts", content: GOOD }, { path: "n.txt", content: n }], | |
| 172 | }); | |
| 173 | if (!c.ok) throw new Error("commit"); | |
| 174 | shas.push(c.value.commitOid); | |
| 175 | } | |
| 176 | await commitToBranch(app, u.value, r.value, { | |
| 177 | message: "b", | |
| 178 | branch: "change", | |
| 179 | files: [{ path: "b.txt", content: "b" }], | |
| 180 | }); | |
| 181 | const opened = await openPull(app, u.value, r.value, { | |
| 182 | title: "t", | |
| 183 | baseBranch: r.value.defaultBranch, | |
| 184 | headBranch: "change", | |
| 185 | }); | |
| 186 | if (!opened.ok) throw new Error("pull"); | |
| 187 | // A staging directory a live checkout is writing must not be swept. | |
| 188 | const root = join(dataDir, "releases"); | |
| 189 | mkdirSync(join(root, ".live.partial"), { recursive: true }); | |
| 190 | const out = await Promise.all( | |
| 191 | shas.map((sha) => releaseOnLand(app, u.value, r.value, opened.value, sha)), | |
| 192 | ); | |
| 193 | expect(out.map((x) => x?.ok)).toEqual([true, true]); | |
| 194 | expect(existsSync(join(root, ".live.partial"))).toBe(true); | |
| 195 | for (const sha of shas) expect(existsSync(join(releaseDir(dataDir, sha), "n.txt"))).toBe(true); | |
| 196 | expect(listReleases(app.db).map((x) => x.sha).sort()).toEqual([...shas].sort()); | |
| 197 | } finally { | |
| 198 | app.close(); | |
| 199 | await rm(dataDir, { recursive: true, force: true }); | |
| 200 | } | |
| 201 | }, 60_000); | |
| 202 | }); | |
| 203 | ||
| 204 | describe("release environment", () => { | |
| 205 | test("a repository is owner/name; the keep count is an integer of at least one", () => { | |
| 206 | expect(releaseEnv({ FORGE_REPO: "a/b.c", FORGE_RELEASES_KEEP: "3" })).toEqual({ forgeRepo: "a/b.c", releasesKeep: 3 }); | |
| 207 | expect(releaseEnv({})).toEqual({}); | |
| 208 | expect(releaseEnv({ FORGE_REPO: "nope", FORGE_RELEASES_KEEP: "0" })).toEqual({}); | |
| 209 | expect(releaseEnv({ FORGE_REPO: "a/b/c", FORGE_RELEASES_KEEP: "2.5" })).toEqual({}); | |
| 210 | expect(releaseEnv({ FORGE_RELEASES_KEEP: "" })).toEqual({}); | |
| 211 | }); | |
| 212 | }); |
new above
offline