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

The release record leaves the tree: releases live in the database or on an annotated tag, not in src/core/releases.json #6645

Merged opened this

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

Merged

mason/forge:mason/6642-the-release-record-leaves-the-tree-relea → main 2 commits fork

  • Merged. mason/6642-the-release-record-leaves-the-tree-relea was merged into main as acb5410.

Review

Sign in to review this change.
16 files changed +380 −89
CLAUDE.md +4 −2
@@ -285,8 +285,10 @@ flag exists only in the stdio dev runner and is never reachable over SSH.
285285 an existing migration — an edited one is a new one and runs again; once
286286 pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run
287287 by the release agent, never by a pull) the schema test fails on an edit. It must only add
288 (`test/schema-additive.test.ts`); the release agent lists its id in
289 `core/releases.json`.
288 (`test/schema-additive.test.ts`). A release is an annotated tag whose
289 message is its record (`bun scripts/cut-release.ts <version> --tag`,
290 read by `release/shipped.ts`); no pull and no release touches
291 `core/releases-before-tags.json`, which is frozen.
290292 - **New git read:** put it in `git/read.ts`; reuse the long-lived `cat-file`
291293 batch reader rather than spawning per object. Run every rev/path through
292294 `assertBatchSafe`.
docs/ARCHITECTURE.md +8 −2
@@ -174,8 +174,14 @@ that read it (add now, remove next release; a rename is two releases).
174174 `notAdditive` (`kernel/migrate.ts`) names a drop, a rename, a rebuild or a
175175 NOT NULL column without a default, and `test/schema-additive.test.ts`
176176 refuses one in any migration not shipped by both of the two newest releases
177 recorded in `src/core/releases.json` (by id: a count, and the stamped ids),
178 which the release agent bumps when it cuts one.
177 recorded in `src/core/releases-before-tags.json` (by id: a count, and the
178 stamped ids). That list is frozen: a release cut since #6642 is an annotated
179 tag `v<version>` whose message is the same record (`bun scripts/cut-release.ts
180 <version> --tag`), so a cut never moves main and never voids a gate in flight;
181 `release/shipped.ts` reads the tags from the forge's own repository for
182 `/releases.json`, the children's standings and the behind watch. A check runs
183 on the tree alone, so a migration landed after the freeze stays held to
184 additive.
179185 An old process that restarts mid-roll boots on a database ahead of it and
180186 logs, by name, the migrations it does not know (`schema_migration` records
181187 each name, #783) — they only add, so it serves as it did.
docs/SENTINEL.md +1 −1
@@ -43,7 +43,7 @@ JSON API and MCP carry the same fact: a setting away from its default has a
4343 The `setting:<key>` findings filed before that are all closed; none raises an
4444 ask, and the capacity page's Put back is the one answer. The `sentinel_kept`
4545 table they once used is unread; a migration drops it once the additive rule
46 (`src/core/releases.json`) allows a drop.
46 (`test/schema-additive.test.ts`) allows a drop.
4747
4848 **What it never quotes.** Evidence passes through `scrub` (`redactSecrets` and
4949 any 32-character unbroken token) before it is filed, since the repository may
docs/ops/ROLL.md +10
@@ -142,6 +142,16 @@ request routed to it in that beat is refused.
142142 Release as the release workflow does. Nothing in this recipe changes the
143143 workflow or the chart.
144144
145 **Cutting a release moves no branch (#6642).** On the commit of main being
146 released, `bun scripts/cut-release.ts <version> --tag` makes the annotated
147 tag `v<version>` whose message is the release record (`{"version",
148 "migrations", "ids"}`); push that tag to the forge's own repository, where
149 `release/shipped.ts` reads it for `/releases.json`, the children's standings
150 and the behind watch, and to the mirror, where it starts `release.yml`.
151 Nothing is committed: a commit to main would void every gate in flight
152 (#6640). `src/core/releases-before-tags.json` keeps the releases cut before
153 this, frozen.
154
145155 ## The gate of record (#2794)
146156
147157 A release ships on two proofs, and nothing else holds it:
scripts/cut-release.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * `bun scripts/cut-release.ts <version> [--tag]`: the release record for
3 * the commit checked out — its version, how many migrations it applies and
4 * the stamped ones by name — printed as the JSON `release/shipped.ts`
5 * reads. With `--tag` it also makes the annotated tag `v<version>` on HEAD
6 * with that record as its message; pushing the tag (`git push origin
7 * v<version>`) is the release. Nothing is committed: a release cut never
8 * moves main, so it voids no gate in flight (#6640).
9 */
10
11 import { migrationFiles, NUMBERED } from "../src/core/schema.ts";
12 import { compareVersions, mergeShipped, readTagged, recordFor, shippedReleases } from "../src/release/shipped.ts";
13
14 const VERSION = /^\d+\.\d+\.\d+$/;
15 const [version, flag] = process.argv.slice(2);
16 if (version === undefined || !VERSION.test(version) || (flag !== undefined && flag !== "--tag")) {
17 console.error("usage: bun scripts/cut-release.ts <major.minor.patch> [--tag]");
18 process.exit(2);
19 }
20 const root = `${import.meta.dir}/..`;
21 const newest = mergeShipped(shippedReleases(), await readTagged(root))[0];
22 if (newest !== undefined && compareVersions(version, newest.version) >= 0) {
23 console.error(`cut-release: ${version} is not newer than ${newest.version}, the newest release`);
24 process.exit(1);
25 }
26 const record = JSON.stringify(recordFor(version, migrationFiles, NUMBERED));
27 if (flag === "--tag") {
28 const made = Bun.spawnSync(["git", "tag", "--annotate", "--file=-", `v${version}`, "HEAD"], {
29 cwd: root,
30 stdin: new Blob([`${record}\n`]),
31 stderr: "pipe",
32 });
33 if (made.exitCode !== 0) {
34 console.error(`cut-release: ${made.stderr.toString().trim()}`);
35 process.exit(1);
36 }
37 }
38 // CLI answer for the person at the terminal.
39 console.log(record);
scripts/pin-migrations.ts +3 −3
@@ -5,9 +5,9 @@
55 * once pinned, a migration's text is history, and
66 * `test/schema-migrations.test.ts` fails if it changes (#783).
77 *
8 * The release agent runs it when it cuts a release, beside bumping
9 * `src/core/releases.json`, so pulls never touch the file and two of them
10 * adding a migration each never meet in it.
8 * The release agent runs it, never a pull, so two pulls adding a
9 * migration each never meet in the file. The release record itself is a
10 * tag and moves no branch (`scripts/cut-release.ts`).
1111 */
1212
1313 import { readFileSync, writeFileSync } from "node:fs";
src/core/releases.json → src/core/releases-before-tags.json renamed +1 −1
@@ -1,5 +1,5 @@
11 {
2 "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`) — and runs `bun scripts/pin-migrations.ts`, which pins each new migration's text in src/core/migration-pins.json. A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.",
2 "note": "The releases cut before #6642, newest first, and frozen: nothing adds to this file. A release since is an annotated tag `v<version>` on the forge's own repository whose message is its record, `{ \"version\", \"migrations\", \"ids\" }` (`bun scripts/cut-release.ts <version>` writes it; src/release/shipped.ts reads both). `migrations` counts the migration ids a release applied, and `ids` names the stamped ones among them, every file past 0090 by its name without .ts; a release from before #1837 has a count only: the first that many numbered files. test/schema-additive.test.ts holds every migration not in both of the two newest releases here to additive (notAdditive in src/kernel/migrate.ts).",
33 "releases": [
44 {
55 "version": "0.7.271",
src/main.ts +5 −4
@@ -544,18 +544,19 @@ void proveRelease({
544544 url: forgeUrl,
545545 token: process.env["FORGE_TOKEN"],
546546 }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) }));
547 // Production behind main's newest release for over an hour is a card (release/behind.ts).
547 // The release tags of the forge's own repository are its shipped releases (release/shipped.ts);
548 // production behind the newest for over an hour is a card (release/behind.ts).
548549 if (app.config.forgeRepo) {
549550 const [owner, name] = app.config.forgeRepo.split("/");
550551 const watch = watchBehind({
551552 live: () => running().version,
552553 read: async () => {
553554 const row = app.db
554 .query<{ default_branch: string }, [string, string]>(
555 "SELECT repo.default_branch FROM repo JOIN user u ON u.id = repo.owner_id WHERE u.username = ?1 AND repo.name = ?2",
555 .query<{ id: string }, [string, string]>(
556 "SELECT repo.id FROM repo JOIN user u ON u.id = repo.owner_id WHERE u.username = ?1 AND repo.name = ?2",
556557 )
557558 .get(owner ?? "", name ?? "");
558 return row ? readNewest(repoDir(app.reposRoot, owner!, name!), row.default_branch) : undefined;
559 return row ? readNewest(repoDir(app.reposRoot, owner!, name!)) : undefined;
559560 },
560561 });
561562 setBehind(watch);
src/release/behind.ts +17 −31
@@ -1,9 +1,9 @@
11 /**
22 * Is production behind main? Every ten minutes the forge compares the version
33 * it is running (the one `/healthz` sends as `X-Forge-Version`) with the newest
4 * release in `src/core/releases.json` on the default branch of its own
5 * repository, read from the git store and never the network. When that
6 * release has been on main for over an hour and is not the version serving,
4 * release tagged in its own repository (`release/shipped.ts`), read from the
5 * git store and never the network. When that release was cut over an hour
6 * ago and is not the version serving,
77 * vitals carry one `release:behind` cause ("production is 0.7.162; main
88 * released 0.7.165 2 h ago"); the incident pager pages it once, as it does
99 * any cause, and it clears when the versions match. #2574 proves a release
@@ -13,18 +13,15 @@
1313 * answer is kept in memory (it is derived from git and rebuilt on boot).
1414 */
1515
16 import { runGit } from "../git/env.ts";
1716 import { UNSTAMPED } from "../kernel/version.ts";
17 import { readTagged, setTagged, type Shipped } from "./shipped.ts";
1818
1919 /** How often the forge looks. */
2020 export const CHECK_EVERY_MS = 10 * 60_000;
2121 /** How long a release may sit on main before production not serving it is a cause. */
2222 export const GRACE_MS = 60 * 60_000;
2323
24 const FILE = "src/core/releases.json";
25 const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
26
27 /** The newest release on main and when it landed there (epoch ms). */
24 /** The newest release and when it was cut (epoch ms). */
2825 export interface Newest {
2926 readonly version: string;
3027 readonly at: number;
@@ -35,31 +32,20 @@ export interface BehindNeed {
3532 readonly since: string;
3633 }
3734
38 /** The newest version in a releases document (newest first), or undefined when it names none. */
39 export function newestVersion(text: string): string | undefined {
40 try {
41 const list = (JSON.parse(text) as { releases?: unknown }).releases;
42 const v = Array.isArray(list) ? (list[0] as { version?: unknown } | undefined)?.version : undefined;
43 return typeof v === "string" && VERSION.test(v) ? v : undefined;
44 } catch {
45 return undefined;
46 }
35 /** The newest of the tagged releases (newest first), or undefined while none is tagged. */
36 export function newestOf(tagged: readonly Shipped[]): Newest | undefined {
37 const first = tagged[0];
38 return first?.at === undefined ? undefined : { version: first.version, at: first.at };
4739 }
4840
49 /** The newest release of the bare repository at `dir`, on `branch`; undefined when git cannot say. */
50 export async function readNewest(dir: string, branch: string): Promise<Newest | undefined> {
51 if (!/^[\w][\w./-]{0,200}$/.test(branch) || branch.includes("..")) return undefined;
52 const doc = await runGit(["show", `refs/heads/${branch}:${FILE}`], { cwd: dir });
53 if (!doc.ok) return undefined;
54 const version = newestVersion(doc.value);
55 if (version === undefined) return undefined;
56 // The commit that put this version in the list is when main released it.
57 const when = await runGit(
58 ["log", "-1", "--format=%ct", `-S"version": "${version}"`, `refs/heads/${branch}`, "--", FILE],
59 { cwd: dir },
60 );
61 const secs = when.ok ? Number(when.value) : NaN;
62 return Number.isFinite(secs) && secs > 0 ? { version, at: secs * 1000 } : undefined;
41 /**
42 * Read the release tags of the bare repository at `dir`, keep them as the
43 * shipped releases, and answer the newest; undefined when none is tagged.
44 */
45 export async function readNewest(dir: string): Promise<Newest | undefined> {
46 const tagged = await readTagged(dir);
47 setTagged(tagged);
48 return newestOf(tagged);
6349 }
6450
6551 /** "40 min ago", "2 h ago", "3 d ago". */
src/release/shipped.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The releases this forge has shipped, newest first: the ones cut before
3 * #6642 from `core/releases-before-tags.json`, frozen, and every one since
4 * from its annotated tag `v<version>` in the forge's own repository, whose
5 * message is the record `{ "version", "migrations", "ids" }`
6 * (`scripts/cut-release.ts` writes it). Cutting a release moves no branch,
7 * so it never voids a gate in flight or stales a pull's check (#6640).
8 *
9 * The tags are read from the git store, never the network, by the same
10 * ten-minute tick that watches for production behind (`release/behind.ts`);
11 * what was read is kept in memory, so `/releases.json` and the children's
12 * standings read a list, not git. A forge with no repository of its own
13 * (`FORGE_REPO` unset, a test) ships the frozen list alone.
14 */
15
16 import before from "../core/releases-before-tags.json" with { type: "json" };
17 import { runGit } from "../git/env.ts";
18
19 export interface Shipped {
20 readonly version: string;
21 /** How many migration ids the release applied. */
22 readonly migrations: number;
23 /** The stamped ones among them, by name; absent on a release from before #1837. */
24 readonly ids?: readonly string[];
25 /** When the tag was cut (epoch ms); absent on a frozen release. */
26 readonly at?: number;
27 }
28
29 const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
30 const ID = /^[0-9A-Za-z][0-9A-Za-z_-]{0,127}$/;
31 /** More tags than this are older than anything a reader asks about. */
32 const MAX_TAGS = 1000;
33 const MAX_IDS = 10_000;
34
35 /** A tag message as a release record, or undefined when it is not one. */
36 export function parseRecord(message: string): Omit<Shipped, "at"> | undefined {
37 // A signed tag carries its signature after the message.
38 const text = message.split(/\n-----BEGIN [A-Z ]*SIGNATURE-----/)[0]!.trim();
39 if (!text.startsWith("{")) return undefined;
40 let doc: unknown;
41 try {
42 doc = JSON.parse(text);
43 } catch {
44 return undefined;
45 }
46 if (typeof doc !== "object" || doc === null) return undefined;
47 const { version, migrations, ids } = doc as Record<string, unknown>;
48 if (typeof version !== "string" || !VERSION.test(version)) return undefined;
49 if (!Number.isSafeInteger(migrations) || (migrations as number) < 0) return undefined;
50 if (ids === undefined) return { version, migrations: migrations as number };
51 if (!Array.isArray(ids) || ids.length > MAX_IDS || !ids.every((i) => typeof i === "string" && ID.test(i)))
52 return undefined;
53 return { version, migrations: migrations as number, ids: ids as string[] };
54 }
55
56 /** Newer first: dotted numbers compare as numbers, anything else as text. */
57 export function compareVersions(a: string, b: string): number {
58 const x = a.split("."), y = b.split(".");
59 for (let i = 0; i < Math.max(x.length, y.length); i++) {
60 const p = x[i] ?? "", q = y[i] ?? "";
61 if (p === q) continue;
62 const m = Number(p), n = Number(q);
63 if (p !== "" && q !== "" && Number.isInteger(m) && Number.isInteger(n)) return n - m;
64 return p < q ? 1 : -1;
65 }
66 return 0;
67 }
68
69 /** The frozen releases and the tagged ones as one list, newest first; a tag wins over a frozen entry of its version. */
70 export function mergeShipped(frozen: readonly Shipped[], tagged: readonly Shipped[]): Shipped[] {
71 const byVersion = new Map<string, Shipped>();
72 for (const r of frozen) byVersion.set(r.version, r);
73 for (const r of tagged) byVersion.set(r.version, r);
74 return [...byVersion.values()].sort((a, b) => compareVersions(a.version, b.version));
75 }
76
77 /** The release records on the annotated `v*` tags of the repository at `dir`, newest first; [] when git cannot say. */
78 export async function readTagged(dir: string): Promise<Shipped[]> {
79 const args = [
80 "for-each-ref",
81 `--count=${MAX_TAGS}`,
82 "--sort=-creatordate",
83 "--format=%(objecttype)%00%(creatordate:unix)%00%(contents)%01",
84 "refs/tags/v*",
85 ];
86 // A repository not yet on disk cannot even be a working directory.
87 const out = await runGit(args, { cwd: dir }).catch(() => undefined);
88 if (!out?.ok) return [];
89 const records: Shipped[] = [];
90 for (const entry of out.value.split("\x01")) {
91 const [type, secs, message] = entry.replace(/^\n/, "").split("\x00");
92 if (type !== "tag" || message === undefined) continue;
93 const record = parseRecord(message);
94 const at = Number(secs) * 1000;
95 if (record !== undefined && Number.isFinite(at) && at > 0) records.push({ ...record, at });
96 }
97 return mergeShipped([], records);
98 }
99
100 const frozen: readonly Shipped[] = before.releases;
101 let current: readonly Shipped[] = frozen;
102
103 /** Every release this forge has shipped, newest first. The same array until the tags read differently. */
104 export function shippedReleases(): readonly Shipped[] {
105 return current;
106 }
107
108 /** Keep what the tags said; main calls this from its tick. */
109 export function setTagged(tagged: readonly Shipped[]): void {
110 current = tagged.length === 0 ? frozen : mergeShipped(frozen, tagged);
111 }
112
113 /** The record a release cut from these migration files carries: every one counted, the stamped ones named. */
114 export function recordFor(
115 version: string,
116 files: readonly { readonly name: string }[],
117 numbered: number,
118 ): Omit<Shipped, "at"> {
119 return { version, migrations: files.length, ids: files.slice(numbered).map((m) => m.name) };
120 }
src/spawn/heartbeat.ts +2 −2
@@ -19,7 +19,7 @@ import { join } from "node:path";
1919 import type { App } from "../app.ts";
2020 import { spentTodayEverywhere } from "../crew/autonomy.ts";
2121 import { beatKeyOf, listChildren } from "../actions/spawn.ts";
22 import releases from "../core/releases.json" with { type: "json" };
22 import { shippedReleases } from "../release/shipped.ts";
2323 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
2424 import { isParentLocal } from "./beat-door.ts";
2525 import { upstreamState } from "./upstream.ts";
@@ -356,7 +356,7 @@ export interface ChildStanding {
356356
357357 /** How many releases of this forge are newer than `version`; null when it is not one of them. */
358358 export function releasesBehind(version: string): number | null {
359 const i = releases.releases.findIndex((r) => r.version === version);
359 const i = shippedReleases().findIndex((r) => r.version === version);
360360 return i < 0 ? null : i;
361361 }
362362
src/web/standards.ts +7 −6
@@ -23,7 +23,6 @@
2323 * FORGE_SECURITY_CONTACT.
2424 */
2525
26 import releases from "../core/releases.json" with { type: "json" };
2726 import { markPng, type MarkShape } from "../design/mark.ts";
2827 import { metaThemeColor } from "../design/themes.ts";
2928 import { BODY_MAX, TITLE_MAX } from "../core/reports.ts";
@@ -38,6 +37,7 @@ import { mcpToolsSection } from "./llms_tools.ts";
3837 import { mcpCard, mcpPointers } from "../core/mcp_card.ts";
3938 import { running } from "../kernel/version.ts";
4039 import { timePhase } from "../core/route_timing.ts";
40 import { shippedReleases, type Shipped } from "../release/shipped.ts";
4141
4242 const DAY = 24 * 60 * 60 * 1000;
4343
@@ -118,16 +118,17 @@ export function changePassword(): Response {
118118 */
119119 export function releasesJson(ctx: Ctx): Response {
120120 const origin = ctx.url.origin;
121 const list = shippedReleases();
121122 let kept = releasesBodies.get(origin);
122 if (kept === undefined) {
123 if (kept === undefined || kept.list !== list) {
123124 const body = JSON.stringify({
124 releases: releases.releases.map((r) => ({
125 releases: list.map((r) => ({
125126 name: r.version,
126127 url: `${origin}/releases.json#${encodeURIComponent(r.version)}`,
127128 })),
128129 });
129130 const etag = `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`;
130 kept = { body, etag };
131 kept = { list, body, etag };
131132 // Origins come from the Host header: keep a handful, not one per guess.
132133 if (releasesBodies.size >= 16) releasesBodies.clear();
133134 releasesBodies.set(origin, kept);
@@ -144,8 +145,8 @@ export function releasesJson(ctx: Ctx): Response {
144145 return res;
145146 }
146147
147 /** The releases document and its ETag, per origin; the file is read once at boot. */
148 const releasesBodies = new Map<string, { body: string; etag: string }>();
148 /** The releases document and its ETag, per origin, made again when the tags read differently. */
149 const releasesBodies = new Map<string, { list: readonly Shipped[]; body: string; etag: string }>();
149150
150151 /** GET /robots.txt */
151152 export function robotsTxt(ctx: Ctx): Response {
test/release-behind.test.ts +22 −33
@@ -9,7 +9,8 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { vitals } from "../src/kernel/vitals.ts";
12 import { newestVersion, readNewest, setBehind, watchBehind, type Newest } from "../src/release/behind.ts";
12 import { newestOf, readNewest, setBehind, watchBehind, type Newest } from "../src/release/behind.ts";
13 import { setTagged, shippedReleases } from "../src/release/shipped.ts";
1314
1415 let dataDir: string;
1516 let app: App;
@@ -80,39 +81,27 @@ test("a read that finds nothing clears the cause; an unstamped forge says nothin
8081 expect(h.card()).toBeUndefined();
8182 });
8283
83 test("the newest version is the first of the document, or nothing", () => {
84 expect(newestVersion(JSON.stringify({ releases: [{ version: "1.2.3" }, { version: "1.2.2" }] }))).toBe("1.2.3");
85 expect(newestVersion("{")).toBeUndefined();
86 expect(newestVersion(JSON.stringify({ releases: [{ version: "a b; rm" }] }))).toBeUndefined();
84 test("the newest is the first tagged release, or nothing while none is tagged", () => {
85 expect(newestOf([{ version: "1.2.3", migrations: 1, at: T0 }, { version: "1.2.2", migrations: 1, at: T0 - MIN }])).toEqual({ version: "1.2.3", at: T0 });
86 expect(newestOf([])).toBeUndefined();
87 // A frozen release carries no time it was cut: no cause can be dated from it.
88 expect(newestOf([{ version: "1.2.3", migrations: 1 }])).toBeUndefined();
8789 });
8890
89 test("readNewest reads the list and its landing time from the git store", async () => {
91 test("readNewest reads the release tags and when each was cut from the git store, and keeps them as shipped", async () => {
9092 const dir = join(dataDir, "r.git");
91 const run = async (...a: string[]) => {
92 const p = Bun.spawn(["git", ...a], {
93 cwd: dir,
94 stdout: "ignore",
95 stderr: "ignore",
96 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
97 });
98 await p.exited;
99 };
100 await Bun.spawn(["git", "init", "-q", "-b", "main", dir]).exited;
101 const commit = async (v: string[], date: string) => {
102 await Bun.$`mkdir -p ${dir}/src/core`.quiet();
103 await Bun.write(
104 join(dir, "src/core/releases.json"),
105 JSON.stringify({ releases: v.map((version) => ({ version })) }, null, 2),
106 );
107 await run("add", ".");
108 await Bun.spawn(["git", "commit", "-q", "-m", v[0]!, "--date", date], {
109 cwd: dir,
110 env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", GIT_COMMITTER_DATE: date },
111 }).exited;
112 };
113 await commit(["0.1.0"], "2026-10-16T10:00:00Z");
114 await commit(["0.1.1", "0.1.0"], "2026-10-16T12:00:00Z");
115 expect(await readNewest(dir, "main")).toEqual({ version: "0.1.1", at: Date.parse("2026-10-16T12:00:00Z") });
116 expect(await readNewest(dir, "nope")).toBeUndefined();
117 expect(await readNewest(dir, "--bad")).toBeUndefined();
93 const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
94 const git = (args: string[], date = "2026-10-16T10:00:00Z", stdin?: string) =>
95 Bun.spawn(["git", ...args], { cwd: dir, env: { ...env, GIT_COMMITTER_DATE: date }, stdout: "ignore", stderr: "ignore", ...(stdin ? { stdin: new Blob([stdin]) } : {}) }).exited;
96 await Bun.$`mkdir -p ${dir}`.quiet();
97 await git(["init", "-q", "-b", "main"]);
98 await git(["commit", "-q", "--allow-empty", "-m", "one"]);
99 expect(await readNewest(dir)).toBeUndefined();
100 await git(["tag", "-a", "-F", "-", "v9.1.0"], "2026-10-16T10:00:00Z", JSON.stringify({ version: "9.1.0", migrations: 91, ids: [] }));
101 await git(["tag", "-a", "-F", "-", "v9.1.1"], "2026-10-16T12:00:00Z", JSON.stringify({ version: "9.1.1", migrations: 91, ids: [] }));
102 // Main never moved: the release is the tag.
103 expect(await readNewest(dir)).toEqual({ version: "9.1.1", at: Date.parse("2026-10-16T12:00:00Z") });
104 expect(shippedReleases()[0]!.version).toBe("9.1.1");
105 setTagged([]);
106 expect(await readNewest(join(dataDir, "nope.git"))).toBeUndefined();
118107 });
test/release-shipped.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * The releases a forge has shipped are the frozen ones and its annotated
3 * `v*` tags, newest first; a cut is a tag, so main never moves (#6642).
4 */
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 { migrationFiles, NUMBERED } from "../src/core/schema.ts";
11 import { releasesBehind } from "../src/spawn/heartbeat.ts";
12 import {
13 compareVersions,
14 mergeShipped,
15 parseRecord,
16 readTagged,
17 recordFor,
18 setTagged,
19 shippedReleases,
20 } from "../src/release/shipped.ts";
21
22 let dir: string;
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-shipped-"));
25 });
26 afterAll(async () => {
27 setTagged([]);
28 await rm(dir, { recursive: true, force: true });
29 });
30
31 const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
32 const git = (args: string[], date: string, stdin?: string) =>
33 Bun.spawn(["git", ...args], {
34 cwd: dir,
35 env: { ...env, GIT_COMMITTER_DATE: date },
36 stdout: "ignore",
37 stderr: "ignore",
38 ...(stdin === undefined ? {} : { stdin: new Blob([stdin]) }),
39 }).exited;
40
41 describe("a release record", () => {
42 test("is read from a tag message, and anything else is not one", () => {
43 expect(parseRecord('{"version":"0.7.272","migrations":151,"ids":["20261016130000-usage-count"]}\n')).toEqual({
44 version: "0.7.272",
45 migrations: 151,
46 ids: ["20261016130000-usage-count"],
47 });
48 expect(parseRecord('{"version":"0.7.40","migrations":40}')).toEqual({ version: "0.7.40", migrations: 40 });
49 const signed = '{"version":"1.0.0","migrations":1}\n-----BEGIN PGP SIGNATURE-----\nxyz\n-----END PGP SIGNATURE-----\n';
50 expect(parseRecord(signed)).toEqual({ version: "1.0.0", migrations: 1 });
51 for (const bad of [
52 "forge 0.3.9 — git trusts the runtime's extra certificate authority",
53 "{",
54 '{"version":"a b; rm","migrations":1}',
55 '{"version":"1.0.0","migrations":-1}',
56 '{"version":"1.0.0","migrations":1.5}',
57 '{"version":"1.0.0","migrations":1,"ids":["../x"]}',
58 '{"version":"1.0.0","migrations":1,"ids":"x"}',
59 "null",
60 ])
61 expect(parseRecord(bad)).toBeUndefined();
62 });
63
64 test("of a cut counts every migration and names the stamped ones", () => {
65 const record = recordFor("9.0.0", migrationFiles, NUMBERED);
66 expect(record.migrations).toBe(migrationFiles.length);
67 expect(record.ids).toEqual(migrationFiles.slice(NUMBERED).map((m) => m.name));
68 expect(parseRecord(JSON.stringify(record))).toEqual(record);
69 });
70
71 test("versions sort newest first, by number", () => {
72 expect(["0.7.9", "0.7.271", "0.10.0", "0.7.10"].sort(compareVersions)).toEqual(["0.10.0", "0.7.271", "0.7.10", "0.7.9"]);
73 expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
74 });
75 });
76
77 describe("the shipped releases", () => {
78 test("are the frozen ones until a tag is read, then the tags ahead of them", () => {
79 const frozen = shippedReleases();
80 expect(frozen.length).toBeGreaterThan(0);
81 expect(shippedReleases()).toBe(frozen);
82 expect(releasesBehind(frozen[0]!.version)).toBe(0);
83 setTagged([{ version: "99.0.1", migrations: 1, at: 2 }, { version: "99.0.0", migrations: 1, at: 1 }]);
84 expect(shippedReleases().slice(0, 3).map((r) => r.version)).toEqual(["99.0.1", "99.0.0", frozen[0]!.version]);
85 expect(shippedReleases().length).toBe(frozen.length + 2);
86 // A child on the newest frozen release is now two behind.
87 expect(releasesBehind(frozen[0]!.version)).toBe(2);
88 setTagged([]);
89 expect(shippedReleases()).toBe(frozen);
90 });
91
92 test("a tag wins over the frozen entry of its version", () => {
93 const merged = mergeShipped([{ version: "1.0.0", migrations: 1 }], [{ version: "1.0.0", migrations: 2, at: 5 }]);
94 expect(merged).toEqual([{ version: "1.0.0", migrations: 2, at: 5 }]);
95 });
96
97 test("are read from the annotated v* tags; a lightweight or wordy tag is no record", async () => {
98 await git(["init", "-q", "-b", "main"], "2026-10-26T10:00:00Z");
99 await git(["commit", "-q", "--allow-empty", "-m", "one"], "2026-10-26T10:00:00Z");
100 await git(["tag", "-a", "-F", "-", "v0.3.9"], "2026-09-09T19:54:19Z", "forge 0.3.9 — words, not a record");
101 await git(["tag", "v0.7.300"], "2026-10-26T10:00:00Z");
102 await git(["tag", "-a", "-F", "-", "not-a-release"], "2026-10-26T10:00:00Z", '{"version":"5.0.0","migrations":1}');
103 await git(["tag", "-a", "-F", "-", "v0.7.272"], "2026-10-26T11:00:00Z", '{"version":"0.7.272","migrations":151,"ids":["a-b"]}');
104 await git(["tag", "-a", "-F", "-", "v0.7.273"], "2026-10-26T12:00:00Z", '{\n "version": "0.7.273",\n "migrations": 152\n}\n');
105 expect(await readTagged(dir)).toEqual([
106 { version: "0.7.273", migrations: 152, at: Date.parse("2026-10-26T12:00:00Z") },
107 { version: "0.7.272", migrations: 151, ids: ["a-b"], at: Date.parse("2026-10-26T11:00:00Z") },
108 ]);
109 expect(await readTagged(join(dir, "missing"))).toEqual([]);
110 });
111 });
test/schema-additive.test.ts +11 −1
@@ -5,7 +5,9 @@
55 * read, and never adds a NOT NULL column they would not fill.
66 */
77 import { describe, expect, test } from "bun:test";
8 import releases from "../src/core/releases.json" with { type: "json" };
8 import { existsSync } from "node:fs";
9 import { join } from "node:path";
10 import releases from "../src/core/releases-before-tags.json" with { type: "json" };
911 import {
1012 migrationFiles,
1113 migrations,
@@ -73,6 +75,14 @@ describe("migrations are additive across a roll", () => {
7375 expect(offenders).toEqual([]);
7476 });
7577
78 test("a release cut moves no branch: its record is a tag, not a file in the tree", () => {
79 // The releases before #6642 stay frozen beside the code; one cut since
80 // lives on its tag (release/shipped.ts). A check runs on the tree
81 // alone, so it reads the frozen list: a migration landed since is held
82 // to additive, as it was when it landed.
83 expect(existsSync(join(import.meta.dir, "../src/core/releases.json"))).toBe(false);
84 });
85
7686 test("the release record is newest first and never ahead of the schema", () => {
7787 const counts = record.releases.map((r) => r.migrations);
7888 expect([...counts].sort((a, b) => b - a)).toEqual(counts);
test/standards.test.ts +19 −3
@@ -5,7 +5,7 @@
55 * to unfurl.
66 */
77 import { MAX_RELEASES, parseReleases } from "../src/core/parent_releases.ts";
8 import releasesFile from "../src/core/releases.json" with { type: "json" };
8 import { setTagged, shippedReleases } from "../src/release/shipped.ts";
99 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
@@ -97,7 +97,7 @@ describe("what the open web expects", () => {
9797 expect(res.status).toBe(200);
9898 expect(res.headers.get("content-type")).toContain("application/json");
9999 const doc = (await res.json()) as { releases: unknown[] };
100 expect(doc.releases.length).toBe(releasesFile.releases.length);
100 expect(doc.releases.length).toBe(shippedReleases().length);
101101 const read = parseReleases(doc, true);
102102 // A child reads the newest MAX_RELEASES; the record itself keeps every release,
103103 // and every entry, past those too, is in the child's shape.
@@ -108,7 +108,7 @@ describe("what the open web expects", () => {
108108 const page = doc.releases.slice(at, at + MAX_RELEASES);
109109 expect(parseReleases({ releases: page }, true)!.length).toBe(page.length);
110110 }
111 expect(read![0]!.name).toBe(releasesFile.releases[0]!.version);
111 expect(read![0]!.name).toBe(shippedReleases()[0]!.version);
112112 expect(read![0]!.url).toStartWith(`${base}/releases.json#`);
113113 // The verdict is the document's shape, not the machine's speed: a loaded
114114 // check machine gets a generous bound rather than a red lane (#5966).
@@ -134,6 +134,22 @@ describe("what the open web expects", () => {
134134 expect(other.status).toBe(200);
135135 });
136136
137 test("a release tag read since is in releases.json at once, with a new ETag", async () => {
138 const before = await fetch(`${base}/releases.json`);
139 const etag = before.headers.get("etag")!;
140 await before.text();
141 setTagged([{ version: "9.9.9", migrations: 1, at: Date.now() }]);
142 try {
143 const after = await fetch(`${base}/releases.json`, { headers: { "if-none-match": etag } });
144 expect(after.status).toBe(200);
145 expect(after.headers.get("etag")).not.toBe(etag);
146 const doc = (await after.json()) as { releases: { name: string }[] };
147 expect(doc.releases[0]!.name).toBe("9.9.9");
148 } finally {
149 setTagged([]);
150 }
151 });
152
137153 test("robots keeps crawlers out of accounts and points at the sitemap", async () => {
138154 const body = await fetch(`${base}/robots.txt`).then((r) => r.text());
139155 expect(body).toContain("User-agent: *");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs