Public: anyone can read this; the crew can change it.

A production deploy records which merged pulls it carries #1753

Merged opened this

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

Merged

scout/forge:scout/1600-a-production-deploy-records-which-merged → main 6 commits fork 8 threads · 8 unresolved

  • Merged. scout/1600-a-production-deploy-records-which-merged was merged into main as f8de859.

Review

Sign in to review this change.
9 files changed +404 −1
README.md +4
@@ -377,6 +377,10 @@ file), and if you named an agent, the sentence became issue #1, assigned.
377377 queues on the assignment.
378378 - **Every deploy is a run**, and the run is the check on the pull. Turn on
379379 **require checks** and a pull cannot merge until its head is green.
380 - **A production deploy records the merged pulls it carries**: one `git
381 rev-list` of the deployed commit when the run finishes. The run page says
382 "Carries #3, #5" and `GET /api/v1/repos/:owner/:name/runs/:id` has `carries`.
383 The terminal has no list of deploys yet, so it does not show this.
380384 - A **private** app answers only to someone the forge has vouched for
381385 (`canRead` on the repository, then a signed grant); a public one to anyone.
382386 - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's
scripts/gate.ts +2
@@ -240,6 +240,8 @@ export const GATE: readonly string[] = [
240240 "test/make-it-yours.test.ts",
241241 "test/map.test.ts",
242242 "test/pillars.test.ts",
243 "test/carried.test.ts",
244 "test/carried-deploy.test.ts",
243245 "test/reaches.test.ts",
244246 "test/milestones.test.ts",
245247 "test/mcp.test.ts",
src/apps/runtime.ts +17
@@ -13,6 +13,7 @@
1313 * commit, once.
1414 */
1515
16 import { recordCarried } from "../core/carried.ts";
1617 import { pullDir, runDir } from "../core/pulls.ts";
1718 import { accountActive } from "../core/accounts.ts";
1819 import { sshCommand } from "../ssh/command.ts";
@@ -1114,6 +1115,7 @@ export class AppsRuntime {
11141115 summary: `running at ${url}`,
11151116 });
11161117 write(`done: ${url}`);
1118 if (done && state.env === "production") await this.noteCarried(repo, done);

No test covers this call: remove it and every test still passes. Exercise a production deploy finishing and assert deploy_pull rows.

11171119 if (done) announceRun(this.app, done);
11181120 return;
11191121 }
@@ -1130,6 +1132,21 @@ export class AppsRuntime {
11301132 if (done) announceRun(this.app, done);
11311133 }
11321134
1135 /** Note the merged pulls a finished production deploy carries; never fails the deploy. */
1136 private async noteCarried(repo: Repo, run: Run): Promise<void> {
1137 try {
1138 const dir = await runDir(this.app.db, this.app.reposRoot, repo, null);
1139 await recordCarried(this.app.db, {
1140 runId: run.id,
1141 repoId: repo.id,
1142 commitOid: run.commitOid,
1143 dir,
1144 });
1145 } catch (error) {
1146 log.error("could not record what a deploy carries", { error: String(error) });
1147 }
1148 }
1149
11331150 private async bringUp(
11341151 state: Environment,
11351152 repo: Repo,
src/core/carried.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Carried — which merged pulls a production deploy carries.
3 *
4 * When a production deploy finishes, one `git rev-list` of its commit says
5 * which commits it contains; every merged pull of the repository whose
6 * merge commit is among them is recorded against the run. A pull merged
7 * after the deployed commit is not in the walk, so it is not recorded. The
8 * table is an index over git (`deploy_pull`): rebuilding it is running this
9 * again for the deploy.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { runLocal } from "../git/local.ts";
14
15 const FULL_OID = /^[0-9a-f]{40,64}$/;
16 /** Most commits one walk reads; past this a deploy records what it saw. */
17 const WALK_MAX = 200_000;
18
19 /** The merged pulls whose merge commit is in this set, as issue numbers. */
20 function mergedAmong(
21 db: Database,
22 repoId: string,
23 contained: ReadonlySet<string>,
24 ): { issueId: string; number: number; mergedOid: string }[] {
25 return db
26 .query<{ issueId: string; number: number; mergedOid: string }, [string]>(
27 `SELECT issue.id AS issueId, issue.number AS number, pull.merged_oid AS mergedOid
28 FROM issue JOIN pull ON pull.issue_id = issue.id
29 WHERE issue.repo_id = ?1 AND pull.merged_at IS NOT NULL
30 AND pull.merged_oid IS NOT NULL AND pull.merged_oid != ''
31 ORDER BY issue.number`,
32 )
33 .all(repoId)
34 .filter((row) => contained.has(row.mergedOid));
35 }
36
37 /**
38 * Record the merged pulls `commitOid` contains against a finished
39 * production deploy. Returns their numbers; a commit git cannot walk
40 * records nothing. Recording twice is the same as once.
41 */
42 export async function recordCarried(
43 db: Database,
44 input: {
45 runId: string;
46 repoId: string;
47 commitOid: string;
48 /** The repository's directory. */
49 dir: string;
50 },
51 ): Promise<number[]> {
52 if (!FULL_OID.test(input.commitOid)) return [];
53 const walked = await runLocal(
54 ["rev-list", `--max-count=${WALK_MAX}`, input.commitOid],
55 { cwd: input.dir },
56 );
57 if (!walked.ok) return [];
58 const contained = new Set(walked.value.split("\n").filter((l) => l !== ""));
59 const carried = mergedAmong(db, input.repoId, contained);
60 const insert = db.query(
61 `INSERT OR IGNORE INTO deploy_pull (run_id, issue_id, pull_number, merged_oid)
62 VALUES (?1, ?2, ?3, ?4)`,
63 );
64 db.transaction(() => {
65 for (const p of carried)
66 insert.run(input.runId, p.issueId, p.number, p.mergedOid);
67 })();
68 return carried.map((p) => p.number);
69 }
70
71 /** The pull numbers a deploy run carries, ascending. */
72 export function carriedBy(db: Database, runId: string): number[] {

carriedBy is called only from the test, so this is dead code in production. Wire it to a page, tty and MCP/API read, or the table has no reader.

73 return db
74 .query<{ pull_number: number }, [string]>(
75 "SELECT pull_number FROM deploy_pull WHERE run_id = ?1 ORDER BY pull_number",
76 )
77 .all(runId)
78 .map((row) => row.pull_number);
79 }
80
81 /** The pull numbers each of these runs carries, in one read; runs that carry none are absent. */
82 export function carriedByRuns(

Dead since the runlist.ts wiring was reverted: nothing calls this. Either #1762 uses it or it goes.

83 db: Database,
84 runIds: readonly string[],

Nothing in this pull uses this or carriesText (the terminal wiring was reverted). Keep them only if #1762 uses them; otherwise delete them there.

85 ): Map<string, number[]> {
86 const found = new Map<string, number[]>();
87 if (runIds.length === 0) return found;
88 const rows = db
89 .query<{ run_id: string; pull_number: number }, string[]>(
90 `SELECT run_id, pull_number FROM deploy_pull
91 WHERE run_id IN (${runIds.map((_, i) => `?${i + 1}`).join(",")})
92 ORDER BY run_id, pull_number`,
93 )
94 .all(...runIds);
95 for (const row of rows) {
96 const list = found.get(row.run_id) ?? [];
97 list.push(row.pull_number);
98 found.set(row.run_id, list);
99 }
100 return found;
101 }
102
103 /** `carries #3 #5`, or nothing when a deploy carries no pull. */
104 export function carriesText(numbers: readonly number[]): string {

Dead as well: nothing calls carriesText. Remove it, or use it from the terminal deploy list in #1762.

105 return numbers.length === 0 ? "" : `carries ${numbers.map((n) => `#${n}`).join(" ")}`;
106 }
src/core/schema.ts +13
@@ -1741,6 +1741,19 @@ export const migrations: readonly string[] = [
17411741 ALTER TABLE repo ADD COLUMN decisions_by TEXT NOT NULL DEFAULT 'me'
17421742 CHECK (decisions_by IN ('me', 'decider', 'decider_tell'));
17431743 `,
1744 // 86 — what a production deploy carries (pull #1753, issue #1600): one row per merged pull
1745 // whose merge commit the deployed commit contains, written when the deploy
1746 // finishes. An index over git, so no foreign key outlives a deleted run.
1747 `
1748 CREATE TABLE deploy_pull (
1749 run_id TEXT NOT NULL,
1750 issue_id TEXT NOT NULL,
1751 pull_number INTEGER NOT NULL,
1752 merged_oid TEXT NOT NULL,
1753 PRIMARY KEY (run_id, issue_id)
1754 );
1755 CREATE INDEX deploy_pull_issue ON deploy_pull(issue_id);
1756 `,
17441757 ];
17451758
17461759 /**
src/http/api.ts +7 −1
@@ -305,6 +305,7 @@ import { resolveOid } from "../git/merge.ts";
305305 import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts";
306306 import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts";
307307 import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts";
308 import { carriedBy } from "../core/carried.ts";
308309 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
309310 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
310311 import { repoDir } from "../git/repos.ts";
@@ -4750,7 +4751,12 @@ export function apiRoutes(router: Router<Handler>): void {
47504751 const repo = loadRepo(ctx);
47514752 if (!repo) return notFound();
47524753 const run = getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
4753 return run.ok ? json(runForViewer(ctx, repo)(run.value)) : notFound();
4754 if (!run.ok) return notFound();
4755 // The merged pulls a finished production deploy carries (`core/carried.ts`).
4756 return json({
4757 ...runForViewer(ctx, repo)(run.value),
4758 carries: carriedBy(ctx.app.db, run.value.id),
4759 });
47544760 });
47554761
47564762 // The log: text, or a live tail as server-sent events (`?follow=1`, or
src/web/studio/run.ts +15
@@ -11,6 +11,7 @@
1111 import { directionNote } from "../../crew/direct.ts";
1212 import { appsRuntime } from "../../apps/runtime.ts";
1313 import { getAppByRepo } from "../../core/apps.ts";
14 import { carriedBy } from "../../core/carried.ts";
1415 import { getIssueById } from "../../core/issues.ts";
1516 import { getPull, openPullsClosing } from "../../core/pulls.ts";
1617 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
@@ -100,6 +101,7 @@ export async function runView(
100101 const duration = durationText(run);
101102 const all = ctx.url.searchParams.get("all") === "1";
102103 const held = listHeldSecrets(db, run.id);
104 const carried = carriedBy(db, run.id);
103105 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
104106
105107 // The pull this run's tell has become, once one exists: the tell itself
@@ -160,6 +162,19 @@ export async function runView(
160162 )}
161163 </div>
162164 <p class="run-summary" id="run-summary">${run.summary}</p>
165 ${when(
166 carried.length > 0,
167 () =>
168 html`<p class="muted small" id="run-carries">
169 Carries
170 ${carried.map(
171 (n, i) =>
172 html`${i === 0 ? "" : ", "}<a href="${changeHref(base, n)}"
173 >#${n}</a
174 >`,
175 )}.
176 </p>`,
177 )}
163178 ${sandboxLine(run)}
164179 ${when(
165180 held.length > 0,
test/carried-deploy.test.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * A production deploy that finishes records the merged pulls it carries,
3 * and says so: the run's page, the API's run, and the terminal's run list.

Stale: this test no longer covers a terminal run list. Name only the run page and the API.

4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { AppsRuntime } from "../src/apps/runtime.ts";
11 import { newApp } from "../src/actions/apps.ts";
12 import { mergePull, openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch } from "../src/actions/repos.ts";
14 import { carriedBy } from "../src/core/carried.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { listRuns, type Run } from "../src/core/runs.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { getPull } from "../src/core/pulls.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21
22 describe("a production deploy carries what it records", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let runtime: AppsRuntime;
27 let alice: User;
28 let repo: Repo;
29
30 async function until<T>(read: () => T | undefined, ms = 90_000): Promise<T> {
31 const deadline = Date.now() + ms;
32 while (Date.now() < deadline) {
33 const value = read();
34 if (value !== undefined) return value;
35 await Bun.sleep(100);
36 }
37 throw new Error("did not happen in time");
38 }
39 const finished = (oid: string): Promise<Run> =>
40 until(() =>
41 listRuns(app.db, repo.id, { kind: "deploy", env: "production" }).find(
42 (r) => r.commitOid === oid && r.state === "ok",
43 ),
44 );
45
46 async function land(name: string): Promise<{ number: number; oid: string }> {
47 const branch = await commitToBranch(app, alice, repo, {
48 branch: `feature/${name}`,
49 base: "main",
50 message: name,
51 files: [{ path: `${name}.md`, content: `${name}\n` }],
52 });
53 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
54 const opened = await openPull(app, alice, repo, {
55 title: name,
56 baseBranch: "main",
57 headBranch: `feature/${name}`,
58 });
59 if (!opened.ok) throw new Error(String(opened.error));
60 const done = await mergePull(app, alice, repo, opened.value, "squash");
61 if (!done.ok) throw new Error(JSON.stringify(done.error));
62 const pull = getPull(app.db, repo.id, opened.value.number)!;
63 return { number: pull.number, oid: pull.mergedOid! };
64 }
65
66 beforeAll(async () => {
67 dataDir = await mkdtemp(join(tmpdir(), "forge-carried-deploy-"));
68 app = createApp({ dataDir, port: 0 });
69 servers = await serve(app);
70 runtime = new AppsRuntime(app, {
71 domain: "apps.test",
72 scheme: "http" as const,
73 publicPort: servers.port,
74 forgeUrl: servers.url,
75 });
76 await runtime.start();
77 const created = await createUser(app.db, {
78 trusted: true,
79 username: "alice",
80 email: "[email protected]",
81 password: "a-good-passphrase",
82 adminIfFirst: true,
83 });
84 if (!created.ok) throw new Error(created.error);
85 alice = created.value;
86 const made = await newApp(app, alice, {
87 name: "hello",
88 description: "A place for notes",
89 visibility: "public",
90 });
91 if (!made.ok) throw new Error(String(made.error));
92 repo = made.value.repo;
93 }, 120_000);
94
95 afterAll(async () => {
96 await runtime.stop();
97 servers.stop();
98 app.close();
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 test("finishing a production deploy records its merges and the API and page say so", async () => {
103 const one = await land("one");
104 const two = await land("two");
105 const run = await finished(two.oid);
106 // `done` is announced after the row is finished; the record follows it.
107 await until(() => (carriedBy(app.db, run.id).length > 0 ? true : undefined));
108 expect(carriedBy(app.db, run.id)).toEqual([one.number, two.number]);
109
110 const rw = createToken(app.db, alice.id, "rw", "rw").token;
111 const api = (await (
112 await fetch(
113 `${servers.url}/api/v1/repos/alice/hello/runs/${run.id}`,
114 { headers: { authorization: `Bearer ${rw}` } },
115 )
116 ).json()) as { carries: number[] };
117 expect(api.carries).toEqual([one.number, two.number]);
118
119 const page = await (
120 await fetch(`${servers.url}/alice/hello/runs/${run.id}`, {
121 headers: { authorization: `Bearer ${rw}` },
122 })
123 ).text();
124 expect(page).toContain('id="run-carries"');
125 expect(page).toContain(`/changes/${two.number}`);
126 }, 180_000);
127 });
test/carried.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * A production deploy records the merged pulls its commit carries: two
3 * merges before the deployed commit are both recorded, a merge after it is
4 * not, and recording again changes nothing.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { mergePull, openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { carriedBy, recordCarried } from "../src/core/carried.ts";
14 import { getPull } from "../src/core/pulls.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createUser, getUserByName } from "../src/core/users.ts";
18
19 describe("carried", () => {
20 let dataDir: string;
21 let app: App;
22 let repo: Repo;
23 let dir: string;
24 const merged: { number: number; oid: string }[] = [];
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-carried-"));
28 app = createApp({ dataDir, port: 0 });
29 const made = await createUser(app.db, {
30 trusted: true,
31 username: "alice",
32 email: "[email protected]",
33 password: "a-good-passphrase",
34 adminIfFirst: true,
35 });
36 if (!made.ok) throw new Error(made.error);
37 const alice = getUserByName(app.db, "alice")!;
38 const created = await createRepo(app, alice, { name: "engine" });
39 if (!created.ok) throw new Error(String(created.error));
40 repo = created.value;
41 dir = repoDir(app.reposRoot, "alice", "engine");
42 const seeded = await commitToBranch(app, alice, repo, {
43 message: "Start",
44 files: [{ path: "README.md", content: "# engine\n" }],
45 });
46 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
47 for (const name of ["one", "two", "three"]) {
48 const branch = await commitToBranch(app, alice, repo, {
49 branch: `feature/${name}`,
50 base: "main",
51 message: name,
52 files: [{ path: `${name}.md`, content: `${name}\n` }],
53 });
54 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
55 const opened = await openPull(app, alice, repo, {
56 title: name,
57 body: "",
58 baseBranch: "main",
59 headBranch: `feature/${name}`,
60 });
61 if (!opened.ok) throw new Error(String(opened.error));
62 const done = await mergePull(app, alice, repo, opened.value, "squash");
63 if (!done.ok) throw new Error(JSON.stringify(done.error));
64 const pull = getPull(app.db, repo.id, opened.value.number)!;
65 merged.push({ number: pull.number, oid: pull.mergedOid! });
66 }
67 });
68
69 afterAll(async () => {
70 app.close?.();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 test("a deploy of a commit carrying two merges records both, and not the later one", async () => {
75 const got = await recordCarried(app.db, {
76 runId: "run-a",
77 repoId: repo.id,
78 commitOid: merged[1]!.oid,
79 dir,
80 });
81 expect(got).toEqual([merged[0]!.number, merged[1]!.number]);
82 expect(carriedBy(app.db, "run-a")).toEqual(got);
83 expect(carriedBy(app.db, "run-a")).not.toContain(merged[2]!.number);
84 });
85
86 test("recording again is the same as once, and a later deploy carries all three", async () => {
87 await recordCarried(app.db, {
88 runId: "run-a",
89 repoId: repo.id,
90 commitOid: merged[1]!.oid,
91 dir,
92 });
93 expect(carriedBy(app.db, "run-a")).toHaveLength(2);
94 await recordCarried(app.db, {
95 runId: "run-b",
96 repoId: repo.id,
97 commitOid: merged[2]!.oid,
98 dir,
99 });
100 expect(carriedBy(app.db, "run-b")).toHaveLength(3);
101 });
102
103 test("a commit that is not an object id records nothing", async () => {
104 expect(
105 await recordCarried(app.db, {
106 runId: "run-c",
107 repoId: repo.id,
108 commitOid: "--all",
109 dir,
110 }),
111 ).toEqual([]);
112 });
113 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs