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
↓ kepr-ff5e983f.tar.gzatlas at this save
files changed5 files
package.json+1 −1src/pages/releases.ts+8 −4src/releases/feeds.ts+10 −1tests/admin.test.ts+1 −1tests/kohrelease.test.ts+64 −0package.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": {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>`;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 }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>");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+});