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

A production deploy's app.serving event carries the release's version and pull titles, read by one tested function #6102

Merged opened this

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

Merged

scout/forge:scout/6098-a-production-deploy-s-app-serving-event → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/6098-a-production-deploy-s-app-serving-event was merged into main as e4b7e3f.

Review

Sign in to review this change.
4 files changed +179 −0
src/apps/runtime.ts +7
@@ -16,6 +16,7 @@
1616 import type { Database } from "bun:sqlite";
1717 import { listEgressGrants } from "../core/egress_grants.ts";
1818 import { recordCarried } from "../core/carried.ts";
19 import { releaseOfCommit } from "../core/release_subject.ts";
1920 import { pullDir, runDir } from "../core/pulls.ts";
2021 import { accountActive } from "../core/accounts.ts";
2122 import { sshCommand } from "../ssh/command.ts";
@@ -1210,6 +1211,11 @@ export class AppsRuntime {
12101211 // subscriber (the looks service) must never be able to take the
12111212 // deploy path down with it.
12121213 try {
1214 const release = await releaseOfCommit(
1215 repoDir(this.app.reposRoot, repo.ownerName, repo.name),
1216 state.env,
1217 live.commitOid,
1218 );
12131219 this.app.bus.append({
12141220 kind: "app.serving",
12151221 repoId: repo.id,
@@ -1220,6 +1226,7 @@ export class AppsRuntime {
12201226 servingAt,
12211227 ...(deployStartedAt === null ? {} : { deployStartedAt }),
12221228 testsRunning: live.manifest.test !== null,
1229 ...(release === undefined ? {} : { version: release.version, pulls: release.pulls }),

No test fails if this spread is removed. Add an apps-suite test that asserts version and pulls on a production app.serving event and their absence on a preview event.

12231230 },
12241231 });
12251232 } catch (error) {
src/core/release_subject.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * What a release commit's subject says: `Release 0.7.251: <title> (#N);<title> (#M)`.
3 * Read for a production deploy only, so the `app.serving` event can carry the
4 * version and the pulls it brought live.
5 */
6
7 import { commitLog } from "../git/read.ts";
8
9 export interface ReleasePull {
10 title: string;
11 number: number;
12 }
13
14 export interface Release {
15 version: string;
16 pulls: ReleasePull[];
17 }
18
19 const HEAD = /^Release (\d+\.\d+\.\d+): (.*)$/s;
20 const PULL = /(.*?) \(#(\d+)\)(?:;|$)/gs;
21
22 /** The release a subject names, or undefined for any other subject. */
23 export function parseReleaseSubject(subject: string): Release | undefined {
24 const head = HEAD.exec(subject.trim());
25 if (head === null) return undefined;
26 const pulls: ReleasePull[] = [];
27 for (const m of head[2]!.matchAll(PULL)) {
28 const title = m[1]!.trim();
29 const number = Number(m[2]);
30 if (title !== "" && Number.isSafeInteger(number)) pulls.push({ title, number });
31 }
32 return { version: head[1]!, pulls };
33 }
34
35 /** The release a production commit is; previews and plain commits are none. */
36 export async function releaseOfCommit(
37 repoDir: string,
38 env: string,
39 oid: string,
40 ): Promise<Release | undefined> {
41 if (env !== "production") return undefined;
42 const log = await commitLog(repoDir, oid, { limit: 1 });
43 if (!log.ok) return undefined;
44 const subject = log.value[0]?.subject;
45 return subject === undefined ? undefined : parseReleaseSubject(subject);
46 }
test/apps-release-serving.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * Apps: a production deploy of a release commit says which release in its
3 * `app.serving` event (#6098); a preview of the same subject says nothing.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch } from "../src/actions/repos.ts";
9 import { listEvents } from "../src/core/events.ts";
10 import {
11 deploySettled,
12 startAppsForge,
13 startHello,
14 stopAppsForge,
15 type AppsForge,
16 } from "./helpers/apps-forge.ts";
17
18 describe("apps: app.serving carries the release", () => {
19 let forge: AppsForge;
20 beforeAll(async () => {
21 forge = await startAppsForge();
22 }, 60_000);
23 afterAll(async () => {
24 await forge.runtime.stop();
25 await stopAppsForge(forge);
26 });
27
28 test("production carries version and pulls; a preview of the same subject carries neither", async () => {
29 const { app, alice } = forge;
30 const { repo } = await startHello(forge);
31 const message = "Release 0.9.0: First thing (#7);Second thing (#8)";
32 const file = (content: string) => [{ path: "notes.txt", content }];
33
34 const prod = await commitToBranch(app, alice, repo, { message, files: file("a\n") });
35 if (!prod.ok) throw new Error(JSON.stringify(prod.error));
36 const prodRun = await deploySettled(
37 app,
38 repo.id,
39 (r) => r.env === "production" && r.commitOid === prod.value.commitOid,
40 );
41 expect(prodRun.state).toBe("ok");
42
43 const preview = await commitToBranch(app, alice, repo, {
44 branch: "rel",
45 base: repo.defaultBranch,
46 message,
47 files: file("b\n"),
48 });
49 if (!preview.ok) throw new Error(JSON.stringify(preview.error));
50 const opened = await openPull(app, alice, repo, {
51 title: "Rel",
52 baseBranch: repo.defaultBranch,
53 headBranch: "rel",
54 });
55 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
56 const env = `pull-${opened.value.number}`;
57 await deploySettled(app, repo.id, (r) => r.env === env && r.commitOid === preview.value.commitOid);
58
59 const events = listEvents(app.db, { repoId: repo.id, limit: 200 }).filter(
60 (e) => e.kind === "app.serving",
61 );
62 const live = events.find((e) => e.payload["commitOid"] === prod.value.commitOid);
63 expect(live?.payload["env"]).toBe("production");
64 expect(live?.payload["version"]).toBe("0.9.0");
65 expect(live?.payload["pulls"]).toEqual([
66 { title: "First thing", number: 7 },
67 { title: "Second thing", number: 8 },
68 ]);
69 const side = events.find((e) => e.payload["commitOid"] === preview.value.commitOid);
70 expect(side?.payload["env"]).toBe(env);
71 expect("version" in side!.payload).toBe(false);
72 expect("pulls" in side!.payload).toBe(false);
73 }, 120_000);
74 });
test/release-subject.test.ts added +52
@@ -0,0 +1,52 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { parseReleaseSubject, releaseOfCommit } from "../src/core/release_subject.ts";
6
7 let dir: string;
8 let release: string;
9 let plain: string;
10
11 function git(...args: string[]): string {
12 const r = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: dir });
13 if (r.exitCode !== 0) throw new Error(r.stderr.toString());
14 return r.stdout.toString().trim();
15 }
16
17 beforeAll(() => {
18 dir = mkdtempSync(join(tmpdir(), "forge-release-subject-"));
19 git("init", "-q");
20 writeFileSync(join(dir, "a"), "1");
21 git("add", "a");
22 git("commit", "-q", "-m", "Release 0.7.251: Hot files; fixed (#12);Second thing (#34)");
23 release = git("rev-parse", "HEAD");
24 writeFileSync(join(dir, "a"), "2");
25 git("commit", "-q", "-am", "Just a change");
26 plain = git("rev-parse", "HEAD");
27 });
28 afterAll(() => rmSync(dir, { recursive: true, force: true }));
29
30 test("a release subject parses into version and pulls", () => {
31 expect(parseReleaseSubject("Release 0.7.251: A (#1);B, c (#2)")).toEqual({
32 version: "0.7.251",
33 pulls: [{ title: "A", number: 1 }, { title: "B, c", number: 2 }],
34 });
35 expect(parseReleaseSubject("Fix things")).toBeUndefined();
36 });
37
38 test("a subject cut short with an ellipsis loses its last pull", () => {
39 expect(parseReleaseSubject("Release 0.7.251: A (#1);B was cut sh…")).toEqual({
40 version: "0.7.251",
41 pulls: [{ title: "A", number: 1 }],
42 });
43 });
44
45 test("a production release commit is read; a preview or a plain commit is not", async () => {
46 expect(await releaseOfCommit(dir, "production", release)).toEqual({
47 version: "0.7.251",
48 pulls: [{ title: "Hot files; fixed", number: 12 }, { title: "Second thing", number: 34 }],
49 });
50 expect(await releaseOfCommit(dir, "preview", release)).toBeUndefined();
51 expect(await releaseOfCommit(dir, "production", plain)).toBeUndefined();
52 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs