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
7 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 −0
package.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": {

view file

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). */

view file

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. */

view 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,

view file

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 });

view file

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). */

view file

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 () => {

view file