ff5e983fb907

link a release file whose name other platforms share as <repo>-<platform>, so each platform downloads its own build (v3.1.0)

time
2026-10-05 04:08 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
unique release download links
parent
109d0d64844e
5 files package.json+1 −1src/pages/releases.ts+8 −4src/releases/feeds.ts+10 −1tests/admin.test.ts+1 −1tests/kohrelease.test.ts+64 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.224",3+	"version": "3.1.0",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/pages/releases.ts+8 −4
@@ -1,6 +1,7 @@11 import type { Hono } from "hono";22 import type { ReleaseFile } from "../bucket/repo.ts";33 import { saveByPrefix } from "../mirror/queries.ts";4+import { downloadName } from "../releases/feeds.ts";45 import { downloadCounts, type Release, visibleReleases } from "../releases/releases.ts";56 import { type Html, html } from "./html.ts";67 import {@@ -86,19 +87,22 @@8687 <div class="release-notes markdown">${notes}</div>8788 <div class="release-files">8889 ${release.files.map((file) =>89-	fileRow(name, release.version, file, counts.get(`${release.version}/${file.platform}`) ?? 0),90+	fileRow(91+		`/${name}/releases/${release.version}/${encodeURIComponent(downloadName(name, release, file))}`,92+		file,93+		counts.get(`${release.version}/${file.platform}`) ?? 0,94+	),9095 )}9196 </div>9297 </article>`;9398 }9499 95-function fileRow(repo: string, version: string, file: ReleaseFile, downloads: number): Html {100+function fileRow(href: string, file: ReleaseFile, downloads: number): Html {96101 	const hint = installHint(file.name);97-	// The `<repo>-<platform>` form, as Kepr 2 linked it, so `curl -O` saves a self-describing name.98102 	return html`<div class="release-file">99103 <p class="release-file-head"><span class="release-platform">${100104 		PLATFORM_LABELS[file.platform]101-	}</span>${html`<a class="release-download mono" href="${`/${repo}/releases/${version}/${encodeURIComponent(file.name)}`}">↓ ${file.name}</a>`}${html`<span class="release-meta mono">${formatSize(file.size)} · ${plural(downloads, "download")}</span>`}</p>105+	}</span>${html`<a class="release-download mono" href="${href}">↓ ${file.name}</a>`}${html`<span class="release-meta mono">${formatSize(file.size)} · ${plural(downloads, "download")}</span>`}</p>102106 <p class="release-sha mono"><span class="muted">sha256</span> ${file.sha256}</p>103107 ${hint && html`<p class="release-hint muted">${hint}</p>`}104108 </div>`;

view file

src/releases/feeds.ts+10 −1
@@ -44,11 +44,20 @@4444 	return `{"version":${str(release.version)}${notes}`;4545 }4646 47+/**48+ * A file's name in its download link: the stored name, unless another platform's file in the49+ * release has it too, as when every platform builds `zig-out/bin/nina` (§30, 2026-10-05).50+ */51+export function downloadName(repo: string, release: Release, file: ReleaseFile): string {52+	const shared = release.files.some((other) => other !== file && other.name === file.name);53+	return shared ? `${repo}-${file.platform}` : file.name;54+}55+4756 /** §22.4's release JSON shape, field for field and in Kepr 2's order. */4857 export function releaseJson(domain: string, repo: string, release: Release): string {4958 	const platforms = release.files.map(5059 		(file) =>51-			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${release.version}/${encodeURIComponent(file.name)}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`,60+			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${release.version}/${encodeURIComponent(downloadName(repo, release, file))}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`,5261 	);5362 	return `${head(release)},"platforms":{${platforms.join(",")}}}`;5463 }

view file

tests/admin.test.ts+1 −1
@@ -80,7 +80,7 @@8080 			"Bucket unreachable at the last sync: timed out",8181 		);8282 		inst.kepr.query("DELETE FROM settings WHERE key = 'bucket_error'").run();83-		expect(text).toMatch(/Running kepr 3\.0\.\d+: [\d,]+ lines of TypeScript/);83+		expect(text).toMatch(/Running kepr 3\.\d+\.\d+: [\d,]+ lines of TypeScript/);8484 		expect(text).toContain('href="/admin?section=backups"');8585 		const backupPage = await (await browser.get("/admin?section=backups")).text();8686 		expect(backupPage).toContain("<h1>backups</h1>");

view file

tests/kohrelease.test.ts+64 −0
@@ -144,3 +144,67 @@144144 		expect(await Bun.file(path).text()).toBe(ARTIFACT);145145 	});146146 });147+148+// nina's shape: every platform builds a file of the same name, so a link by name alone would serve149+// one platform's file to all of them (§30 2026-10-05).150+describe("a release whose platforms share a file name", () => {151+	let app: Hono;152+	const platforms = ["linux-x86_64", "linux-aarch64"];153+154+	beforeAll(async () => {155+		const space = await kohWorkspace("twins");156+		await writeFile(join(space.repoDir, "VERSION"), "1.0.0\n");157+		await writeFile(158+			join(space.repoDir, "koh.build"),159+			[160+				'command = mkdir -p "out/$KOH_TARGET" && printf "$KOH_TARGET" > "out/$KOH_TARGET/demo"',161+				"output = out/{platform}/demo",162+				`platforms = ${platforms.join(", ")}`,163+				"",164+			].join("\n"),165+		);166+		space.run("init");167+		space.run("remote", "folder", space.bucketDir);168+		space.run("save", "first");169+		space.run("offer", "--builds");170+171+		const dataDir = await mkdtemp(join(tmpdir(), "kepr-kohrelease-"));172+		const kepr = openKeprDb(dataDir);173+		await syncAll({174+			bucket: openBucket({ kind: "folder", path: space.bucketDir }),175+			kepr,176+			mirrorDir: join(dataDir, "mirror"),177+		});178+		kepr.run("UPDATE repos SET public = 1 WHERE name = 'twins'");179+		app = createApp(180+			{181+				domain: "kepr.test",182+				dataDir,183+				adminPasswordHash: "$argon2id$x",184+				bucket: { kind: "folder", path: space.bucketDir },185+			},186+			kepr,187+		);188+	});189+190+	const served = async (path: string) => {191+		const res = await app.request(path, { redirect: "manual" });192+		return Bun.file((res.headers.get("location") ?? "").replace(/^file:\/\//, "")).text();193+	};194+195+	test("each platform's url serves that platform's file", async () => {196+		const latest = (await (await app.request("/api/twins/releases/latest")).json()) as Latest;197+		for (const platform of platforms) {198+			const url = new URL(latest.platforms[platform]?.url ?? "");199+			expect(url.pathname).toBe(`/twins/releases/1.0.0/twins-${platform}`);200+			expect(await served(url.pathname)).toBe(platform);201+		}202+	});203+204+	test("the releases page links each platform to its own file", async () => {205+		const page = await (await app.request("/twins/releases")).text();206+		for (const platform of platforms) {207+			expect(page).toContain(`href="/twins/releases/1.0.0/twins-${platform}"`);208+		}209+	});210+});

view file