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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/1662-self-deploy-step-2-a-landing-on-the-forg → main 2 commits fork

  • Merged. torque/1662-self-deploy-step-2-a-landing-on-the-forg was merged into main as 948ed10.

Review

Sign in to review this change.
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
3838 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
3939 ```
4040
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
4143 If your shell already has `FORGE_URL` set — a crew workspace sets it to the
4244 forge you work for — this forge takes it as its own canonical address and
4345 refuses every form a browser posts to `localhost`: run it with
scripts/gate.ts +1
@@ -64,6 +64,7 @@ export const GATE: readonly string[] = [
6464 "test/refresh-meta.test.ts",
6565 "test/restore-card.test.ts",
6666 "test/recovery-doc.test.ts",
67 "test/release-checkout.test.ts",
6768 "test/release-preflight.test.ts",
6869 "test/threats-doc.test.ts",
6970 "test/returns.test.ts",
src/app.ts +4
@@ -148,8 +148,12 @@ export interface ForgeConfig {
148148 * (`FORGE_REPO`, e.g. `trevato-apps/forge`). A spawned child forks it,
149149 * so every child starts from the forge's code and can follow its
150150 * 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).
151153 */
152154 forgeRepo?: string;
155 /** How many `releases/<sha>/` checkouts to keep (`FORGE_RELEASES_KEEP`). @default 5 */
156 releasesKeep?: number;
153157 }
154158
155159 export interface App {
src/checks/landing.ts +88
@@ -8,6 +8,18 @@
88 import type { App } from "../app.ts";
99 import { parseCheck } from "../apps/manifest.ts";
1010 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";
1123 import type { Pull } from "../core/pulls.ts";
1224 import type { Repo } from "../core/repos.ts";
1325 import { runGit } from "../git/env.ts";
@@ -58,3 +70,79 @@ export async function gateOnLand(
5870 if (!verdict.lands) addLearned(app.config.dataDir, pull.id, verdict.learn);
5971 return verdict;
6072 }
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[] = [
17051705 );
17061706 CREATE INDEX passkey_user ON passkey(user_id);
17071707 `,
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 `,
17081724 ];
17091725
17101726 /**
src/crew/autonomy.ts +9 −1
@@ -21,7 +21,7 @@ import {
2121 setIssueState,
2222 } from "../actions/issues.ts";
2323 import type { AccessError } from "../actions/access.ts";
24 import { gateOnLand } from "../checks/landing.ts";
24 import { gateOnLand, releaseOnLand } from "../checks/landing.ts";
2525 import { mergeable, resolveOid } from "../git/merge.ts";
2626 import { repoDir } from "../git/repos.ts";
2727 import { mergePull } from "../actions/pulls.ts";
@@ -1420,6 +1420,14 @@ export async function landIfGreen(
14201420 }
14211421 conflicted.delete(pull.id);
14221422 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 );
14231431 // Said once it has landed, so the note wakes no builder: the review was
14241432 // asked for and never came.
14251433 if (review === "lapsed") {
src/main.ts +4
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { releaseEnv } from "./release/env.ts";
78 import { createApp } from "./app.ts";
89 import { reconcile } from "./core/reconcile.ts";
910 import { syncAllPillars } from "./actions/pillars.ts";
@@ -133,6 +134,9 @@ const app = createApp({
133134 ...(hosted && (process.env.FORGE_APP_SSH ?? "").trim() !== ""
134135 ? { appSsh: (process.env.FORGE_APP_SSH ?? "").trim() }
135136 : {}),
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),
136140 // The one knob on the crew's bill: FORGE_CREW_CEILING_USD, dollars a day
137141 // across every repository. Unset is no forge-wide ceiling.
138142 ...(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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs