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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6098-a-production-deploy-s-app-serving-event → main 2 commits fork 1 thread · 1 unresolved
4 files
changed +179
−0
src/apps/runtime.ts +7
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | import { listEgressGrants } from "../core/egress_grants.ts"; |
| 18 | 18 | import { recordCarried } from "../core/carried.ts"; |
| 19 | import { releaseOfCommit } from "../core/release_subject.ts"; | |
| 19 | 20 | import { pullDir, runDir } from "../core/pulls.ts"; |
| 20 | 21 | import { accountActive } from "../core/accounts.ts"; |
| 21 | 22 | import { sshCommand } from "../ssh/command.ts"; |
| @@ -1210,6 +1211,11 @@ export class AppsRuntime { | ||
| 1210 | 1211 | // subscriber (the looks service) must never be able to take the |
| 1211 | 1212 | // deploy path down with it. |
| 1212 | 1213 | try { |
| 1214 | const release = await releaseOfCommit( | |
| 1215 | repoDir(this.app.reposRoot, repo.ownerName, repo.name), | |
| 1216 | state.env, | |
| 1217 | live.commitOid, | |
| 1218 | ); | |
| 1213 | 1219 | this.app.bus.append({ |
| 1214 | 1220 | kind: "app.serving", |
| 1215 | 1221 | repoId: repo.id, |
| @@ -1220,6 +1226,7 @@ export class AppsRuntime { | ||
| 1220 | 1226 | servingAt, |
| 1221 | 1227 | ...(deployStartedAt === null ? {} : { deployStartedAt }), |
| 1222 | 1228 | testsRunning: live.manifest.test !== null, |
| 1229 | ...(release === undefined ? {} : { version: release.version, pulls: release.pulls }), | |
| 1223 | 1230 | }, |
| 1224 | 1231 | }); |
| 1225 | 1232 | } 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 | }); |
new above
offline
No test fails if this spread is removed. Add an apps-suite test that asserts
versionandpullson a productionapp.servingevent and their absence on a preview event.