3835b4d58a83
take update-info's head from the actual head save, send cors on the api feeds' 404s, compare version components exactly, and refuse a releases/latest that is not a version (v3.0.26)
- time
- 2026-09-26 19:02 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 1fc904a2b237
↓ kepr-3835b4d5.tar.gzatlas at this save
files changed7 files
package.json+1 −1src/bucket/repo.ts+5 −1src/releases/releases.ts+11 −4src/releases/routes.ts+11 −9tests/contract.test.ts+4 −1tests/fixture.ts+11 −4tests/releases.test.ts+42 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.25",3+ "version": "3.0.26",44 "private": true,55 "type": "module",66 "scripts": {src/bucket/repo.ts+5 −1
@@ -190,7 +190,11 @@190190 ): Promise<string | null> {191191 requireRepo(repo);192192 const bytes = await bucket.get(`${repo}/releases/latest`);193- return bytes === null ? null : decoder.decode(bytes).trim();193+ if (bytes === null) return null;194+ const version = decoder.decode(bytes).trim();195+ if (!isVersion(version))196+ throw new BucketError(`${repo}/releases/latest is not a version: "${version}"`);197+ return version;194198 }195199 196200 /** Every version with a `release.json`: a version without one doesn't exist (§11.2). */src/releases/releases.ts+11 −4
@@ -39,14 +39,21 @@3939 }4040 4141 export function compareVersions(a: string, b: string): number {42- const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);43- const right = b.split(".").map((part) => Number.parseInt(part, 10) || 0);42+ const left = a.split(".").map(digits);43+ const right = b.split(".").map(digits);4444 for (let i = 0; i < Math.max(left.length, right.length); i++) {45- const diff = (left[i] ?? 0) - (right[i] ?? 0);46- if (diff !== 0) return diff;45+ const [x, y] = [left[i] ?? "0", right[i] ?? "0"];46+ // Compared as digit strings, never as floats, so long components keep their precision.47+ if (x.length !== y.length) return x.length - y.length;48+ if (x !== y) return x < y ? -1 : 1;4749 }4850 // Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total.4951 return a < b ? -1 : a > b ? 1 : 0;52+}53+54+// A component's leading digits without leading zeros ("0" when it has none), as parseInt reads it.55+function digits(part: string): string {56+ return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");5057 }5158 5259 /** §22.2's content types for a release file. */src/releases/routes.ts+11 −9
@@ -3,7 +3,7 @@33 import type { Context, Hono } from "hono";44 import type { BucketReader } from "../bucket/reader.ts";55 import { isPlatform, isVersion, type ReleaseFile } from "../bucket/repo.ts";6-import { history, repoStats } from "../mirror/queries.ts";6+import { headId, repoStats, saveByPrefix } from "../mirror/queries.ts";77 import { notFound, openPublicRepo, type PageContext, type PublicRepo } from "../pages/layout.ts";88 import { readmeAtHead, readmeIntro } from "../pages/repo.ts";99 import {@@ -36,10 +36,8 @@3636 type Feed = { repo: PublicRepo; releases: Release[] };3737 3838 export function registerReleaseRoutes(app: Hono, ctx: PageContext, bucket: BucketReader): void {39- const send = (c: Context, status: 200 | 404, body: string, type = JSON_TYPE, cors = false) => {40- if (cors) c.header("Access-Control-Allow-Origin", "*");41- return c.body(body, status, { "Content-Type": type });42- };39+ const send = (c: Context, status: 200 | 404, body: string, type = JSON_TYPE) =>40+ c.body(body, status, { "Content-Type": type });4341 const feed = (name: string): Feed | null => {4442 const repo = openPublicRepo(ctx, name);4543 return repo && { repo, releases: visibleReleases(ctx.kepr, repo.mirror, name) };@@ -83,9 +81,11 @@8381 ["/:repo/releases/latest.json", false],8482 ] as const) {8583 app.get(path, (c) => {84+ // §22.4 puts CORS on the route, so its 404s carry it too (Kepr 2 sent it only on success).85+ if (cors) c.header("Access-Control-Allow-Origin", "*");8686 const release = newest(c, c.req.param("repo"));8787 if (release instanceof Response) return release;88- return send(c, 200, releaseJson(ctx.domain, c.req.param("repo"), release), JSON_TYPE, cors);88+ return send(c, 200, releaseJson(ctx.domain, c.req.param("repo"), release));8989 });9090 }9191 for (const [path, cors] of [@@ -93,10 +93,11 @@9393 ["/:repo/releases/history.json", false],9494 ] as const) {9595 app.get(path, (c) => {96+ if (cors) c.header("Access-Control-Allow-Origin", "*");9697 const f = feed(c.req.param("repo"));9798 if (!f) return send(c, 404, REPO_NOT_FOUND);9899 if (f.releases.length === 0) return send(c, 404, NO_RELEASES);99- return send(c, 200, historyJson(f.releases), JSON_TYPE, cors);100+ return send(c, 200, historyJson(f.releases));100101 });101102 }102103 app.get("/:repo/releases/latest", (c) => {@@ -149,9 +150,10 @@149150 const name = c.req.param("repo");150151 const f = feed(name);151152 if (!f) return send(c, 404, REPO_NOT_FOUND);152- // Head is the newest save on the line; checkpoints are never shown (§21.4). Its version is the153+ // §22.4: head is the newest save on the line, a checkpoint included. Its version is the153154 // release built from it, else its ID, as in Kepr 2.154- const save = history(f.repo.mirror, 1, 0)[0];155+ const id = headId(f.repo.mirror);156+ const save = id ? saveByPrefix(f.repo.mirror, id) : null;155157 const saveHead = save && {156158 id: save.id,157159 version: f.releases.find((r) => r.save === save.id)?.version ?? save.id,tests/contract.test.ts+4 −1
@@ -255,7 +255,10 @@255255 ]) {256256 const [res, rec] = [await get(path), await recorded(path)];257257 expect([path, res.status, await res.text()]).toEqual([path, rec.status, rec.body]);258- expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);258+ expectHeaders(res, rec, ["content-type"]);259+ // Kepr 2 sent CORS only on success; §22.4 puts it on the /api feed routes, so it wins.260+ const cors = path.startsWith("/api/koh/") ? null : "*";261+ expect([path, res.headers.get("access-control-allow-origin")]).toEqual([path, cors]);259262 }260263 });261264 });tests/fixture.ts+11 −4
@@ -118,9 +118,12 @@118118 }119119 120120 /** Repos with one koh3 save each, all offered to one folder remote: enough history for releases. */121-export async function makeRepos(122- names: string[],123-): Promise<{ bucketDir: string; saves: Record<string, string> }> {121+export async function makeRepos(names: string[]): Promise<{122+ bucketDir: string;123+ saves: Record<string, string>;124+ /** Runs koh3 in one of the repos, under the fixture's HOME. */125+ koh(repo: string, ...args: string[]): string;126+}> {124127 const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));125128 const home = join(root, "home");126129 const bucketDir = join(root, "bucket");@@ -135,7 +138,11 @@135138 run(repoDir, home, ["save", `release ${name}`]);136139 saves[name] = JSON.parse(run(repoDir, home, ["log", "--json"])).head_id;137140 }138- return { bucketDir, saves };141+ return {142+ bucketDir,143+ saves,144+ koh: (repo, ...args) => run(join(root, "work", repo), home, args),145+ };139146 }140147 141148 /** A release file with its bytes, or only as a record declares it (the bytes are then a stand-in). */tests/releases.test.ts+42 −0
@@ -84,6 +84,20 @@8484 target.kepr.run("DELETE FROM release_settings");8585 });8686 87+ test("a releases/latest that is not a version is a sync error and is not kept", async () => {88+ const mirror = openMirror(target.mirrorDir, "app");89+ const before = mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get();90+ await writeFile(join(bucketDir, "app/releases/latest"), "../../1.0");91+ await syncAll(target);92+ expect(lastError()).toContain('app/releases/latest is not a version: "../../1.0"');93+ expect(mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get()).toEqual(94+ before,95+ );96+ await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");97+ await syncAll(target);98+ expect(lastError()).toBeNull();99+ });100+87101 test("a release record that breaks §11.2 is the repo's last sync error", async () => {88102 await writeRelease(bucketDir, "app", {89103 version: "1.12.0",@@ -105,6 +119,11 @@105119 test("versions compare as numbers, segment by segment", () => {106120 const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);107121 expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);122+ // Beyond a float's precision, and with leading zeros.123+ expect(compareVersions("10000000000000000", "9999999999999999")).toBeGreaterThan(0);124+ expect(compareVersions("1.10000000000000001", "1.10000000000000000")).toBeGreaterThan(0);125+ expect(compareVersions("1.010", "1.9")).toBeGreaterThan(0);126+ expect(compareVersions("1.007", "1.7")).not.toBe(0);108127 });109128 110129 describe("the builds panel", () => {@@ -198,6 +217,7 @@198217 let app: Hono;199218 let kepr: Database;200219 let bucketDir: string;220+ let checkpoint: string;201221 const get = (path: string, headers?: Record<string, string>) => app.request(path, { headers });202222 const downloads = () =>203223 kepr.query("SELECT version, platform, count FROM downloads ORDER BY version, platform").all();@@ -228,6 +248,9 @@228248 save: repos.saves.hush as string,229249 files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }],230250 });251+ // cli's head is a checkpoint made after its releases (koh3 offers it on its own).252+ repos.koh("cli", "checkpoint", "after the releases");253+ checkpoint = JSON.parse(repos.koh("cli", "log", "--json")).head_id;231254 app = await releaseApp(bucketDir, (db) => {232255 db.run("UPDATE repos SET public = 1 WHERE name = 'cli'");233256 });@@ -337,6 +360,25 @@337360 "/1.0.0/linux-x86_64/cli",338361 );339362 kepr.run("DELETE FROM release_settings");363+ });364+365+ test("update-info's head is the head save even when it is a checkpoint", async () => {366+ const info = (await (await get("/cli/update-info")).json()) as {367+ head: { save_id: string; version: string };368+ latest_build: { version: string };369+ };370+ expect([info.head.save_id, info.head.version]).toEqual([checkpoint, checkpoint]);371+ expect(info.latest_build.version).toBe("1.1.0");372+ });373+374+ test("the /api feeds send CORS on their 404s too", async () => {375+ for (const path of ["/api/hush/releases/latest", "/api/nowhere/releases/history"]) {376+ const res = await get(path);377+ expect([res.status, res.headers.get("Access-Control-Allow-Origin")]).toEqual([404, "*"]);378+ }379+ expect(380+ (await get("/hush/releases/latest.json")).headers.get("Access-Control-Allow-Origin"),381+ ).toBeNull();340382 });341383 342384 test("releases/latest sends old links to the builds panel", async () => {