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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6730-releases-json-misses-the-tag-based-relea → main 1 commit fork
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 | ||
| 179 | 179 | tag `v<version>` whose message is the same record (`bun scripts/cut-release.ts |
| 180 | 180 | <version> --tag`), so a cut never moves main and never voids a gate in flight; |
| 181 | 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 | |
| 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 | |
| 183 | 185 | on the tree alone, so a migration landed after the freeze stays held to |
| 184 | 186 | additive. |
| 185 | 187 | 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"; | ||
| 61 | 61 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 62 | 62 | import { vitals } from "./kernel/vitals.ts"; |
| 63 | 63 | 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"; | |
| 66 | 66 | import { snapshotPlace } from "./actions/health.ts"; |
| 67 | 67 | import { capacityDefault } from "./core/capacity.ts"; |
| 68 | 68 | import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts"; |
| @@ -547,18 +547,13 @@ void proveRelease({ | ||
| 547 | 547 | }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) })); |
| 548 | 548 | // The release tags of the forge's own repository are its shipped releases (release/shipped.ts); |
| 549 | 549 | // 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 }); | |
| 550 | 553 | if (app.config.forgeRepo) { |
| 551 | const [owner, name] = app.config.forgeRepo.split("/"); | |
| 552 | 554 | const watch = watchBehind({ |
| 553 | 555 | 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), | |
| 562 | 557 | }); |
| 563 | 558 | setBehind(watch); |
| 564 | 559 | 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 @@ | ||
| 7 | 7 | * so it never voids a gate in flight or stales a pull's check (#6640). |
| 8 | 8 | * |
| 9 | 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`); | |
| 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); | |
| 11 | 12 | * what was read is kept in memory, so `/releases.json` and the children's |
| 12 | 13 | * standings read a list, not git. A forge with no repository of its own |
| 13 | 14 | * (`FORGE_REPO` unset, a test) ships the frozen list alone. |
| @@ -15,6 +16,7 @@ | ||
| 15 | 16 | |
| 16 | 17 | import before from "../core/releases-before-tags.json" with { type: "json" }; |
| 17 | 18 | import { runGit } from "../git/env.ts"; |
| 19 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | 20 | |
| 19 | 21 | export interface Shipped { |
| 20 | 22 | readonly version: string; |
| @@ -74,8 +76,8 @@ export function mergeShipped(frozen: readonly Shipped[], tagged: readonly Shippe | ||
| 74 | 76 | return [...byVersion.values()].sort((a, b) => compareVersions(a.version, b.version)); |
| 75 | 77 | } |
| 76 | 78 | |
| 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>> { | |
| 79 | 81 | const args = [ |
| 80 | 82 | "for-each-ref", |
| 81 | 83 | `--count=${MAX_TAGS}`, |
| @@ -84,8 +86,8 @@ export async function readTagged(dir: string): Promise<Shipped[]> { | ||
| 84 | 86 | "refs/tags/v*", |
| 85 | 87 | ]; |
| 86 | 88 | // 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}`); | |
| 89 | 91 | const records: Shipped[] = []; |
| 90 | 92 | for (const entry of out.value.split("\x01")) { |
| 91 | 93 | const [type, secs, message] = entry.replace(/^\n/, "").split("\x00"); |
| @@ -94,7 +96,13 @@ export async function readTagged(dir: string): Promise<Shipped[]> { | ||
| 94 | 96 | const at = Number(secs) * 1000; |
| 95 | 97 | if (record !== undefined && Number.isFinite(at) && at > 0) records.push({ ...record, at }); |
| 96 | 98 | } |
| 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 : []; | |
| 98 | 106 | } |
| 99 | 107 | |
| 100 | 108 | const frozen: readonly Shipped[] = before.releases; |
test/release-shipped.test.ts +97
| @@ -18,6 +18,13 @@ import { | ||
| 18 | 18 | setTagged, |
| 19 | 19 | shippedReleases, |
| 20 | 20 | } 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"; | |
| 21 | 28 | |
| 22 | 29 | let dir: string; |
| 23 | 30 | beforeAll(async () => { |
| @@ -109,3 +116,93 @@ describe("the shipped releases", () => { | ||
| 109 | 116 | expect(await readTagged(join(dir, "missing"))).toEqual([]); |
| 110 | 117 | }); |
| 111 | 118 | }); |
| 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 | }); |
new above
offline