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

releases.json misses the tag-based releases 0.7.272 to 0.7.274, so standings and the behind watch can't see new releases #6734

Merged opened this in answer to

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

Merged

mason/forge:mason/6730-releases-json-misses-the-tag-based-relea → main 1 commit fork

  • Merged. mason/6730-releases-json-misses-the-tag-based-relea was merged into main as 8f61dff.

Review

Sign in to review this change.
5 files changed +174 −18
docs/ARCHITECTURE.md +3 −1
@@ -179,7 +179,9 @@ stamped ids). That list is frozen: a release cut since #6642 is an annotated
179179 tag `v<version>` whose message is the same record (`bun scripts/cut-release.ts
180180 <version> --tag`), so a cut never moves main and never voids a gate in flight;
181181 `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
182 `/releases.json`, the children's standings and the behind watch; `release/own_tags.ts`
183 finds that repository (`FORGE_REPO`, any case) on every ten-minute tick and logs,
184 once, why when it cannot read its tags. A check runs
183185 on the tree alone, so a migration landed after the freeze stays held to
184186 additive.
185187 An old process that restarts mid-roll boots on a database ahead of it and
src/main.ts +6 −11
@@ -61,8 +61,8 @@ import { running } from "./kernel/version.ts";
6161 import { record as recordDegraded } from "./kernel/degraded_history.ts";
6262 import { vitals } from "./kernel/vitals.ts";
6363 import { proveRelease } from "./release/prove.ts";
64 import { CHECK_EVERY_MS, readNewest, setBehind, watchBehind } from "./release/behind.ts";
65 import { repoDir } from "./git/repos.ts";
64 import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts";
65 import { ownTagsReader } from "./release/own_tags.ts";
6666 import { snapshotPlace } from "./actions/health.ts";
6767 import { capacityDefault } from "./core/capacity.ts";
6868 import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts";
@@ -547,18 +547,13 @@ void proveRelease({
547547 }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) }));
548548 // The release tags of the forge's own repository are its shipped releases (release/shipped.ts);
549549 // production behind the newest for over an hour is a card (release/behind.ts).
550 // A FORGE_REPO that is not owner/name is dropped by releaseEnv; say so, or the list stops at the frozen one unexplained.
551 if ((process.env.FORGE_REPO ?? "") !== "" && !app.config.forgeRepo)
552 app.log.warn("FORGE_REPO is not owner/name; release tags are not read", { value: process.env.FORGE_REPO });
550553 if (app.config.forgeRepo) {
551 const [owner, name] = app.config.forgeRepo.split("/");
552554 const watch = watchBehind({
553555 live: () => running().version,
554 read: async () => {
555 const row = app.db
556 .query<{ id: string }, [string, string]>(
557 "SELECT repo.id FROM repo JOIN user u ON u.id = repo.owner_id WHERE u.username = ?1 AND repo.name = ?2",
558 )
559 .get(owner ?? "", name ?? "");
560 return row ? readNewest(repoDir(app.reposRoot, owner!, name!)) : undefined;
561 },
556 read: ownTagsReader(app, app.config.forgeRepo),
562557 });
563558 setBehind(watch);
564559 void watch.tick();
src/release/own_tags.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * The forge's own repository (`FORGE_REPO`) as the place its releases are
3 * read from: found by name in this forge's database (any case, as a URL
4 * finds it), and its tags read from that repository's git store on disk.
5 *
6 * When the tags cannot be read — the name is no repository on this forge,
7 * or git refuses — the reason goes to the log once, and again only when it
8 * changes; otherwise `/releases.json` stops at the frozen list and nothing
9 * says why (#6730). A failed read throws, so the behind watch keeps its last
10 * answer and the shipped list keeps the last tags read.
11 */
12
13 import type { Database } from "bun:sqlite";
14 import type { Log } from "../kernel/log.ts";
15 import { err, type Result } from "../kernel/result.ts";
16 import { getRepo } from "../core/repos.ts";
17 import { repoDir } from "../git/repos.ts";
18 import { readTags, setTagged, type Shipped } from "./shipped.ts";
19 import { newestOf, type Newest } from "./behind.ts";
20
21 /** The release records on the tags of the repository `named` (`owner/name`) on this forge; why not when there are none to read. */
22 export async function readOwnTags(
23 app: { readonly db: Database; readonly reposRoot: string },
24 named: string,
25 ): Promise<Result<Shipped[], string>> {
26 const [owner = "", name = ""] = named.split("/");
27 const repo = getRepo(app.db, owner, name);
28 if (repo === undefined) return err(`FORGE_REPO names ${named}, which is no repository on this forge`);
29 const read = await readTags(repoDir(app.reposRoot, repo.ownerName, repo.name));
30 return read.ok ? read : err(`git cannot read the tags of ${repo.ownerName}/${repo.name}: ${read.error}`);
31 }
32
33 /** The behind watch's read: the own tags kept as the shipped releases, and the newest of them; the log told when that stops or starts working. */
34 export function ownTagsReader(
35 app: { readonly db: Database; readonly reposRoot: string; readonly log: Log },
36 named: string,
37 ): () => Promise<Newest | undefined> {
38 let said: string | undefined;
39 let newest: string | undefined;
40 return async () => {
41 const read = await readOwnTags(app, named);
42 if (!read.ok) {
43 if (read.error !== said) app.log.warn("release tags unread; /releases.json lists what was read before", { why: read.error });
44 said = read.error;
45 throw new Error(read.error);
46 }
47 said = undefined;
48 setTagged(read.value);
49 const top = newestOf(read.value);
50 if (top?.version !== newest) app.log.info("release tags read", { repo: named, tags: read.value.length, newest: top?.version ?? "none" });
51 newest = top?.version;
52 return top;
53 };
54 }
src/release/shipped.ts +14 −6
@@ -7,7 +7,8 @@
77 * so it never voids a gate in flight or stales a pull's check (#6640).
88 *
99 * 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`);
10 * ten-minute tick that watches for production behind (`release/behind.ts`,
11 * through `release/own_tags.ts`, which finds the repository and logs why not);
1112 * what was read is kept in memory, so `/releases.json` and the children's
1213 * standings read a list, not git. A forge with no repository of its own
1314 * (`FORGE_REPO` unset, a test) ships the frozen list alone.
@@ -15,6 +16,7 @@
1516
1617 import before from "../core/releases-before-tags.json" with { type: "json" };
1718 import { runGit } from "../git/env.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
1820
1921 export interface Shipped {
2022 readonly version: string;
@@ -74,8 +76,8 @@ export function mergeShipped(frozen: readonly Shipped[], tagged: readonly Shippe
7476 return [...byVersion.values()].sort((a, b) => compareVersions(a.version, b.version));
7577 }
7678
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 /** The release records on the annotated `v*` tags of the repository at `dir`, newest first; why not when git cannot say. */
80 export async function readTags(dir: string): Promise<Result<Shipped[], string>> {
7981 const args = [
8082 "for-each-ref",
8183 `--count=${MAX_TAGS}`,
@@ -84,8 +86,8 @@ export async function readTagged(dir: string): Promise<Shipped[]> {
8486 "refs/tags/v*",
8587 ];
8688 // 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 out = await runGit(args, { cwd: dir }).catch((e: unknown) => err(String(e)));
90 if (!out.ok) return err(typeof out.error === "string" ? out.error : out.error.stderr || `git exited ${out.error.code}`);
8991 const records: Shipped[] = [];
9092 for (const entry of out.value.split("\x01")) {
9193 const [type, secs, message] = entry.replace(/^\n/, "").split("\x00");
@@ -94,7 +96,13 @@ export async function readTagged(dir: string): Promise<Shipped[]> {
9496 const at = Number(secs) * 1000;
9597 if (record !== undefined && Number.isFinite(at) && at > 0) records.push({ ...record, at });
9698 }
97 return mergeShipped([], records);
99 return ok(mergeShipped([], records));
100 }
101
102 /** The release records of the repository at `dir`, newest first; [] when git cannot say. */
103 export async function readTagged(dir: string): Promise<Shipped[]> {
104 const read = await readTags(dir);
105 return read.ok ? read.value : [];
98106 }
99107
100108 const frozen: readonly Shipped[] = before.releases;
test/release-shipped.test.ts +97
@@ -18,6 +18,13 @@ import {
1818 setTagged,
1919 shippedReleases,
2020 } from "../src/release/shipped.ts";
21 import { ownTagsReader, readOwnTags } from "../src/release/own_tags.ts";
22 import { createApp, type App } from "../src/app.ts";
23 import { createRepo } from "../src/actions/repos.ts";
24 import { createUser } from "../src/core/users.ts";
25 import { repoDir } from "../src/git/repos.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27 import type { Log, LogFields } from "../src/kernel/log.ts";
2128
2229 let dir: string;
2330 beforeAll(async () => {
@@ -109,3 +116,93 @@ describe("the shipped releases", () => {
109116 expect(await readTagged(join(dir, "missing"))).toEqual([]);
110117 });
111118 });
119
120 describe("the forge's own tags", () => {
121 let data: string;
122 let app: App;
123 let servers: Servers;
124 const lines: { level: string; message: string; fields?: LogFields | undefined }[] = [];
125 const log: Log = {
126 debug: () => {},
127 info: (message, fields) => lines.push({ level: "info", message, fields }),
128 warn: (message, fields) => lines.push({ level: "warn", message, fields }),
129 error: () => {},
130 };
131 let work: string;
132 const tag = (name: string, date: string, message?: string) =>
133 Bun.spawn(["git", "tag", ...(message === undefined ? [] : ["-a", "-F", "-"]), name], {
134 cwd: work,
135 env: { ...env, GIT_COMMITTER_DATE: date },
136 stdout: "ignore",
137 stderr: "ignore",
138 ...(message === undefined ? {} : { stdin: new Blob([message]) }),
139 }).exited;
140 // As a push lands them, without the forge's receive hooks (they need a running push session).
141 const push = () =>
142 Bun.spawn(["git", "fetch", "-q", work, "main:main", "+refs/tags/*:refs/tags/*"], {
143 cwd: repoDir(app.reposRoot, "trevato", "forge"),
144 env,
145 stdout: "ignore",
146 stderr: "ignore",
147 }).exited;
148 const listed = async () =>
149 ((await (await fetch(`${servers.url}/releases.json`)).json()) as { releases: { name: string }[] }).releases.map((r) => r.name);
150
151 beforeAll(async () => {
152 data = await mkdtemp(join(tmpdir(), "forge-own-tags-"));
153 app = createApp({ dataDir: data, port: 0 });
154 servers = await serve(app);
155 const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
156 if (!owner.ok) throw new Error(owner.error);
157 const repo = await createRepo(app, owner.value, { name: "forge", visibility: "public" });
158 if (!repo.ok) throw new Error(String(repo.error));
159 work = join(data, "work");
160 await Bun.$`mkdir -p ${work}`.quiet();
161 await Bun.spawn(["git", "init", "-q", "-b", "main"], { cwd: work, env }).exited;
162 await Bun.spawn(["git", "commit", "-q", "--allow-empty", "-m", "one"], { cwd: work, env }).exited;
163 }, 60_000);
164 afterAll(async () => {
165 setTagged([]);
166 servers.stop();
167 app.close();
168 await rm(data, { recursive: true, force: true });
169 });
170
171 test("are merged over the frozen list newest first, and /releases.json shows a tag pushed after boot at the next tick", async () => {
172 const frozen = shippedReleases();
173 await tag("v0.7.300", "2026-10-26T10:00:00Z");
174 await tag("not-a-release", "2026-10-26T10:00:00Z", '{"version":"5.0.0","migrations":1}');
175 await tag("v0.7.301", "2026-10-26T11:00:00Z", '{"version":"0.7.301","migrations":151}');
176 await tag("v0.7.302", "2026-10-26T12:00:00Z", '{"version":"0.7.302","migrations":152}');
177 expect(await push()).toBe(0);
178 // The name is found whatever its case, as a URL finds it.
179 const read = ownTagsReader({ db: app.db, reposRoot: app.reposRoot, log }, "Trevato/Forge");
180 expect(await read()).toEqual({ version: "0.7.302", at: Date.parse("2026-10-26T12:00:00Z") });
181 expect(shippedReleases().map((r) => r.version)).toEqual(["0.7.302", "0.7.301", ...frozen.map((r) => r.version)]);
182 expect((await listed()).slice(0, 3)).toEqual(["0.7.302", "0.7.301", frozen[0]!.version]);
183
184 await tag("v0.7.303", "2026-10-26T13:00:00Z", '{"version":"0.7.303","migrations":152}');
185 expect(await push()).toBe(0);
186 expect((await listed())[0]).toBe("0.7.302"); // until the tick
187 await read();
188 expect((await listed()).slice(0, 2)).toEqual(["0.7.303", "0.7.302"]);
189 expect(lines.filter((l) => l.level === "info").map((l) => l.fields?.newest)).toEqual(["0.7.302", "0.7.303"]);
190 });
191
192 test("a FORGE_REPO that is no repository here is said once, and keeps what was read", async () => {
193 const kept = shippedReleases();
194 lines.length = 0;
195 expect((await readOwnTags(app, "trevato-apps/forge")).ok).toBe(false);
196 const read = ownTagsReader({ db: app.db, reposRoot: app.reposRoot, log }, "trevato-apps/forge");
197 await expect(read()).rejects.toThrow("no repository on this forge");
198 await expect(read()).rejects.toThrow();
199 expect(lines).toEqual([
200 {
201 level: "warn",
202 message: "release tags unread; /releases.json lists what was read before",
203 fields: { why: "FORGE_REPO names trevato-apps/forge, which is no repository on this forge" },
204 },
205 ]);
206 expect(shippedReleases()).toBe(kept);
207 });
208 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs