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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6642-the-release-record-leaves-the-tree-relea → main 2 commits fork
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. | ||
| 285 | 285 | an existing migration — an edited one is a new one and runs again; once |
| 286 | 286 | pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run |
| 287 | 287 | 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. | |
| 290 | 292 | - **New git read:** put it in `git/read.ts`; reuse the long-lived `cat-file` |
| 291 | 293 | batch reader rather than spawning per object. Run every rev/path through |
| 292 | 294 | `assertBatchSafe`. |
docs/ARCHITECTURE.md +8 −2
| @@ -174,8 +174,14 @@ that read it (add now, remove next release; a rename is two releases). | ||
| 174 | 174 | `notAdditive` (`kernel/migrate.ts`) names a drop, a rename, a rebuild or a |
| 175 | 175 | NOT NULL column without a default, and `test/schema-additive.test.ts` |
| 176 | 176 | 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. | |
| 179 | 185 | An old process that restarts mid-roll boots on a database ahead of it and |
| 180 | 186 | logs, by name, the migrations it does not know (`schema_migration` records |
| 181 | 187 | 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 | ||
| 43 | 43 | The `setting:<key>` findings filed before that are all closed; none raises an |
| 44 | 44 | ask, and the capacity page's Put back is the one answer. The `sentinel_kept` |
| 45 | 45 | 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. | |
| 47 | 47 | |
| 48 | 48 | **What it never quotes.** Evidence passes through `scrub` (`redactSecrets` and |
| 49 | 49 | 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. | ||
| 142 | 142 | Release as the release workflow does. Nothing in this recipe changes the |
| 143 | 143 | workflow or the chart. |
| 144 | 144 | |
| 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 | ||
| 145 | 155 | ## The gate of record (#2794) |
| 146 | 156 | |
| 147 | 157 | 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 @@ | ||
| 5 | 5 | * once pinned, a migration's text is history, and |
| 6 | 6 | * `test/schema-migrations.test.ts` fails if it changes (#783). |
| 7 | 7 | * |
| 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`). | |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { readFileSync, writeFileSync } from "node:fs"; |
src/core/releases.json → src/core/releases-before-tags.json renamed +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 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).", | |
| 3 | 3 | "releases": [ |
| 4 | 4 | { |
| 5 | 5 | "version": "0.7.271", |
src/main.ts +5 −4
| @@ -544,18 +544,19 @@ void proveRelease({ | ||
| 544 | 544 | url: forgeUrl, |
| 545 | 545 | token: process.env["FORGE_TOKEN"], |
| 546 | 546 | }).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). | |
| 548 | 549 | if (app.config.forgeRepo) { |
| 549 | 550 | const [owner, name] = app.config.forgeRepo.split("/"); |
| 550 | 551 | const watch = watchBehind({ |
| 551 | 552 | live: () => running().version, |
| 552 | 553 | read: async () => { |
| 553 | 554 | 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", | |
| 556 | 557 | ) |
| 557 | 558 | .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; | |
| 559 | 560 | }, |
| 560 | 561 | }); |
| 561 | 562 | setBehind(watch); |
src/release/behind.ts +17 −31
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Is production behind main? Every ten minutes the forge compares the version |
| 3 | 3 | * 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, | |
| 7 | 7 | * vitals carry one `release:behind` cause ("production is 0.7.162; main |
| 8 | 8 | * released 0.7.165 2 h ago"); the incident pager pages it once, as it does |
| 9 | 9 | * any cause, and it clears when the versions match. #2574 proves a release |
| @@ -13,18 +13,15 @@ | ||
| 13 | 13 | * answer is kept in memory (it is derived from git and rebuilt on boot). |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { runGit } from "../git/env.ts"; | |
| 17 | 16 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 17 | import { readTagged, setTagged, type Shipped } from "./shipped.ts"; | |
| 18 | 18 | |
| 19 | 19 | /** How often the forge looks. */ |
| 20 | 20 | export const CHECK_EVERY_MS = 10 * 60_000; |
| 21 | 21 | /** How long a release may sit on main before production not serving it is a cause. */ |
| 22 | 22 | export const GRACE_MS = 60 * 60_000; |
| 23 | 23 | |
| 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). */ | |
| 28 | 25 | export interface Newest { |
| 29 | 26 | readonly version: string; |
| 30 | 27 | readonly at: number; |
| @@ -35,31 +32,20 @@ export interface BehindNeed { | ||
| 35 | 32 | readonly since: string; |
| 36 | 33 | } |
| 37 | 34 | |
| 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 }; | |
| 47 | 39 | } |
| 48 | 40 | |
| 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); | |
| 63 | 49 | } |
| 64 | 50 | |
| 65 | 51 | /** "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"; | ||
| 19 | 19 | import type { App } from "../app.ts"; |
| 20 | 20 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 21 | 21 | import { beatKeyOf, listChildren } from "../actions/spawn.ts"; |
| 22 | import releases from "../core/releases.json" with { type: "json" }; | |
| 22 | import { shippedReleases } from "../release/shipped.ts"; | |
| 23 | 23 | import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts"; |
| 24 | 24 | import { isParentLocal } from "./beat-door.ts"; |
| 25 | 25 | import { upstreamState } from "./upstream.ts"; |
| @@ -356,7 +356,7 @@ export interface ChildStanding { | ||
| 356 | 356 | |
| 357 | 357 | /** How many releases of this forge are newer than `version`; null when it is not one of them. */ |
| 358 | 358 | 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); | |
| 360 | 360 | return i < 0 ? null : i; |
| 361 | 361 | } |
| 362 | 362 |
src/web/standards.ts +7 −6
| @@ -23,7 +23,6 @@ | ||
| 23 | 23 | * FORGE_SECURITY_CONTACT. |
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | import releases from "../core/releases.json" with { type: "json" }; | |
| 27 | 26 | import { markPng, type MarkShape } from "../design/mark.ts"; |
| 28 | 27 | import { metaThemeColor } from "../design/themes.ts"; |
| 29 | 28 | import { BODY_MAX, TITLE_MAX } from "../core/reports.ts"; |
| @@ -38,6 +37,7 @@ import { mcpToolsSection } from "./llms_tools.ts"; | ||
| 38 | 37 | import { mcpCard, mcpPointers } from "../core/mcp_card.ts"; |
| 39 | 38 | import { running } from "../kernel/version.ts"; |
| 40 | 39 | import { timePhase } from "../core/route_timing.ts"; |
| 40 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; | |
| 41 | 41 | |
| 42 | 42 | const DAY = 24 * 60 * 60 * 1000; |
| 43 | 43 | |
| @@ -118,16 +118,17 @@ export function changePassword(): Response { | ||
| 118 | 118 | */ |
| 119 | 119 | export function releasesJson(ctx: Ctx): Response { |
| 120 | 120 | const origin = ctx.url.origin; |
| 121 | const list = shippedReleases(); | |
| 121 | 122 | let kept = releasesBodies.get(origin); |
| 122 | if (kept === undefined) { | |
| 123 | if (kept === undefined || kept.list !== list) { | |
| 123 | 124 | const body = JSON.stringify({ |
| 124 | releases: releases.releases.map((r) => ({ | |
| 125 | releases: list.map((r) => ({ | |
| 125 | 126 | name: r.version, |
| 126 | 127 | url: `${origin}/releases.json#${encodeURIComponent(r.version)}`, |
| 127 | 128 | })), |
| 128 | 129 | }); |
| 129 | 130 | const etag = `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`; |
| 130 | kept = { body, etag }; | |
| 131 | kept = { list, body, etag }; | |
| 131 | 132 | // Origins come from the Host header: keep a handful, not one per guess. |
| 132 | 133 | if (releasesBodies.size >= 16) releasesBodies.clear(); |
| 133 | 134 | releasesBodies.set(origin, kept); |
| @@ -144,8 +145,8 @@ export function releasesJson(ctx: Ctx): Response { | ||
| 144 | 145 | return res; |
| 145 | 146 | } |
| 146 | 147 | |
| 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 }>(); | |
| 149 | 150 | |
| 150 | 151 | /** GET /robots.txt */ |
| 151 | 152 | export function robotsTxt(ctx: Ctx): Response { |
test/release-behind.test.ts +22 −33
| @@ -9,7 +9,8 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | 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"; | |
| 13 | 14 | |
| 14 | 15 | let dataDir: string; |
| 15 | 16 | let app: App; |
| @@ -80,39 +81,27 @@ test("a read that finds nothing clears the cause; an unstamped forge says nothin | ||
| 80 | 81 | expect(h.card()).toBeUndefined(); |
| 81 | 82 | }); |
| 82 | 83 | |
| 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(); | |
| 87 | 89 | }); |
| 88 | 90 | |
| 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 () => { | |
| 90 | 92 | 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(); | |
| 118 | 107 | }); |
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 @@ | ||
| 5 | 5 | * read, and never adds a NOT NULL column they would not fill. |
| 6 | 6 | */ |
| 7 | 7 | 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" }; | |
| 9 | 11 | import { |
| 10 | 12 | migrationFiles, |
| 11 | 13 | migrations, |
| @@ -73,6 +75,14 @@ describe("migrations are additive across a roll", () => { | ||
| 73 | 75 | expect(offenders).toEqual([]); |
| 74 | 76 | }); |
| 75 | 77 | |
| 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 | ||
| 76 | 86 | test("the release record is newest first and never ahead of the schema", () => { |
| 77 | 87 | const counts = record.releases.map((r) => r.migrations); |
| 78 | 88 | expect([...counts].sort((a, b) => b - a)).toEqual(counts); |
test/standards.test.ts +19 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * to unfurl. |
| 6 | 6 | */ |
| 7 | 7 | 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"; | |
| 9 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 11 | import { tmpdir } from "node:os"; |
| @@ -97,7 +97,7 @@ describe("what the open web expects", () => { | ||
| 97 | 97 | expect(res.status).toBe(200); |
| 98 | 98 | expect(res.headers.get("content-type")).toContain("application/json"); |
| 99 | 99 | 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); | |
| 101 | 101 | const read = parseReleases(doc, true); |
| 102 | 102 | // A child reads the newest MAX_RELEASES; the record itself keeps every release, |
| 103 | 103 | // and every entry, past those too, is in the child's shape. |
| @@ -108,7 +108,7 @@ describe("what the open web expects", () => { | ||
| 108 | 108 | const page = doc.releases.slice(at, at + MAX_RELEASES); |
| 109 | 109 | expect(parseReleases({ releases: page }, true)!.length).toBe(page.length); |
| 110 | 110 | } |
| 111 | expect(read![0]!.name).toBe(releasesFile.releases[0]!.version); | |
| 111 | expect(read![0]!.name).toBe(shippedReleases()[0]!.version); | |
| 112 | 112 | expect(read![0]!.url).toStartWith(`${base}/releases.json#`); |
| 113 | 113 | // The verdict is the document's shape, not the machine's speed: a loaded |
| 114 | 114 | // check machine gets a generous bound rather than a red lane (#5966). |
| @@ -134,6 +134,22 @@ describe("what the open web expects", () => { | ||
| 134 | 134 | expect(other.status).toBe(200); |
| 135 | 135 | }); |
| 136 | 136 | |
| 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 | ||
| 137 | 153 | test("robots keeps crawlers out of accounts and points at the sitemap", async () => { |
| 138 | 154 | const body = await fetch(`${base}/robots.txt`).then((r) => r.text()); |
| 139 | 155 | expect(body).toContain("User-agent: *"); |
new above
offline