9a44722de6e7

delete a build from the bucket on its repo's admin page, moving releases/latest to the newest left (v3.0.144)

time
2026-09-30 13:50 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
528bcf043cac
8 files package.json+1 −1src/admin/repos.ts+39 −8src/bucket/reader.ts+34 −13src/mirror/store.ts+5 −0src/releases/releases.ts+37 −1tests/bucket.test.ts+4 −0tests/console.test.ts+54 −0tests/repotext.test.ts+12 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.143",3+	"version": "3.0.144",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+39 −8
@@ -1,4 +1,5 @@11 import type { Context, Hono } from "hono";2+import { BucketWriteError } from "../bucket/reader.ts";23 import { syncAll } from "../bucket/sync.ts";34 import { MAX_CHARS } from "../issues/spam.ts";45 import { operatorIssues } from "../issues/store.ts";@@ -7,6 +8,7 @@78 import { html } from "../pages/html.ts";89 import { formatDate, plural } from "../pages/layout.ts";910 import {11+	deleteRelease,1012 	type OperatorRelease,1113 	operatorReleases,1214 	setRelease,@@ -84,13 +86,9 @@8486 8587 	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.8688 	app.post("/admin/releases/:repo/:version", async (c) => {87-		const repo = findRepo(ctx.kepr, c.req.param("repo"));88-		const release =89-			repo &&90-			operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name).find(91-				(r) => r.version === c.req.param("version"),92-			);93-		if (!repo || !release) return c.notFound();89+		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));90+		if (!found) return c.notFound();91+		const { repo, release } = found;9492 		const fields = await textFields(c);9593 		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");9694 		if (notes.length > MAX_CHARS) {@@ -106,8 +104,35 @@106104 			303,107105 		);108106 	});107+108+	app.post("/admin/releases/:repo/:version/delete", async (c) => {109+		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));110+		if (!found) return c.notFound();111+		const { repo, mirror, release } = found;112+		const refuse = (status: 400 | 503, error: string) =>113+			c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);114+		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");115+		try {116+			await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);117+		} catch (error) {118+			if (error instanceof BucketWriteError) return refuse(503, error.message);119+			throw error;120+		}121+		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);122+	});109123 }110124 125+/** A build of a known repo, as the console lists it, or null. */126+function findRelease(ctx: AdminContext, name: string, version: string) {127+	const repo = findRepo(ctx.kepr, name);128+	const mirror = repo && openMirror(ctx.mirrorDir, repo.name);129+	const release =130+		repo &&131+		mirror &&132+		operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);133+	return repo && mirror && release ? { repo, mirror, release } : null;134+}135+111136 function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {112137 	const repos = allRepos(ctx.kepr);113138 	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];@@ -199,7 +224,7 @@199224 </form>200225 ${201226 	releases.length > 0 &&202-	html`<section class="repo-section"><h2 class="section-label mono">builds</h2>227+	html`<section class="repo-section" id="builds"><h2 class="section-label mono">builds</h2>203228 ${releases.map((release) => releaseForm(c, repo.name, release))}</section>`204229 }205230 </article>`;@@ -232,6 +257,12 @@232257 	""233258 }<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>234259 <button class="issues-submit" type="submit">save ${release.version}</button>260+</form>261+<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">262+${formField(c)}263+<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>264+delete ${release.version} and its files from the bucket; this cannot be undone</label>265+<button class="issues-submit" type="submit">delete ${release.version}</button>235266 </form>236267 </article>`;237268 }

view file

src/bucket/reader.ts+34 −13
@@ -1,4 +1,4 @@1-import { readdir } from "node:fs/promises";1+import { readdir, unlink } from "node:fs/promises";22 import { join, relative } from "node:path";33 import { pathToFileURL } from "node:url";44 import type { BucketSource } from "../server/config.ts";@@ -6,8 +6,10 @@66 /**77  * A Koh 3 remote as Kepr uses it (§9.1): read-only on Koh's data, which needs two of its five88  * operations, plus a short-lived download URL so Kepr never streams release files itself (§22.2).9- * Kepr's own files (uploads) live under `KEPR_PREFIX`, the only keys `put` writes (June,10- * 2026-09-29); no Koh repo can have that name, since Kepr and Koh both need a `head` for one.9+ * Kepr's own files (uploads) live under `KEPR_PREFIX` (June, 2026-09-29); no Koh repo can have10+ * that name, since Kepr and Koh both need a `head` for one. Beyond it Kepr touches only builds,11+ * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files and moves12+ * `releases/latest`.1113  */1214 export type Bucket = {1315 	get(key: string): Promise<Uint8Array | null>;@@ -16,8 +18,10 @@1618 	downloadUrl(key: string, filename: string, type: string): string;1719 	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */1820 	viewUrl(key: string, type: string): string | null;19-	/** Writes one of Kepr's own files; any key outside `KEPR_PREFIX` is refused. */21+	/** Writes one of Kepr's own files or a repo's `releases/latest`; any other key is refused. */2022 	put(key: string, bytes: Uint8Array, type: string): Promise<void>;23+	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */24+	remove(key: string): Promise<void>;2125 };2226 2327 export const KEPR_PREFIX = ".kepr/";@@ -27,9 +31,13 @@2731 2832 const DOWNLOAD_SECONDS = 5 * 60;2933 30-function requireOwnKey(key: string): void {31-	if (!key.startsWith(KEPR_PREFIX) || key.split("/").some((part) => part === ".." || part === "")) {32-		throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX}, not "${key}"`);34+const RELEASES_LATEST = /^[^/]+\/releases\/latest$/;35+const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;36+37+function requireWritable(key: string, releaseKey: RegExp): void {38+	const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);39+	if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {40+		throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);3341 	}3442 }3543 @@ -78,6 +86,12 @@7886 	const client = s3Client(source);7987 	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.8088 	const writer = source.write && s3Client(source, source.write);89+	const requireWriter = () => {90+		if (!writer) {91+			throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");92+		}93+		return writer;94+	};8195 	return {8296 		async get(key) {8397 			try {@@ -110,12 +124,13 @@110124 			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });111125 		},112126 		async put(key, bytes, type) {113-			requireOwnKey(key);114-			if (!writer) {115-				throw new BucketWriteError("Uploads need the bucket's write key: add [bucket.write].");116-			}117-			await writer.file(key).write(bytes, { type });127+			requireWritable(key, RELEASES_LATEST);128+			await requireWriter().file(key).write(bytes, { type });118129 		},130+		async remove(key) {131+			requireWritable(key, RELEASE_FILE);132+			await requireWriter().file(key).delete();133+		},119134 	};120135 }121136 @@ -141,8 +156,14 @@141156 		},142157 		viewUrl: () => null,143158 		async put(key, bytes) {144-			requireOwnKey(key);159+			requireWritable(key, RELEASES_LATEST);145160 			await Bun.write(join(root, key), bytes);161+		},162+		async remove(key) {163+			requireWritable(key, RELEASE_FILE);164+			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {165+				if (error.code !== "ENOENT") throw error;166+			});146167 		},147168 	};148169 }

view file

src/mirror/store.ts+5 −0
@@ -160,6 +160,11 @@160160 	return new Set(rows.map((row) => row.id));161161 }162162 163+/** Drops a version deleted from the bucket; a sync only ever adds releases. */164+export function forgetRelease(db: Database, version: string): void {165+	db.query("DELETE FROM releases WHERE version = ?").run(version);166+}167+163168 export function knownReleases(db: Database): Set<string> {164169 	const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all();165170 	return new Set(rows.map((row) => row.version));

view file

src/releases/releases.ts+37 −1
@@ -1,6 +1,8 @@11 import type { Database } from "bun:sqlite";2-import type { ReleaseFile } from "../bucket/repo.ts";2+import type { Bucket } from "../bucket/reader.ts";3+import { type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";34 import { releaseRecords } from "../mirror/queries.ts";5+import { forgetRelease } from "../mirror/store.ts";46 57 /** A release as the public sees it: its notes are the operator's override when one is set (§24). */68 export type Release = {@@ -162,4 +164,38 @@162164 			ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,163165 		)164166 		.run(repo, version, hidden ? 1 : 0, notes);167+}168+169+/**170+ * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to: its record first,171+ * so the version stops existing (§11.2) even if a later delete fails, then its files. When172+ * `releases/latest` named it, latest moves to the newest version left, or goes with the last one.173+ * Kepr's own settings and counts for it go too.174+ */175+export async function deleteRelease(176+	bucket: Bucket,177+	kepr: Database,178+	mirror: Database,179+	repo: string,180+	version: string,181+): Promise<void> {182+	const prefix = `${repo}/releases/${version}/`;183+	await bucket.remove(`${prefix}release.json`);184+	for (const key of await bucket.list(prefix)) await bucket.remove(key);185+	if ((await readReleasesLatest(bucket, repo)) === version) {186+		const newest = releaseRecords(mirror)187+			.map((record) => record.version)188+			.filter((other) => other !== version)189+			.sort((a, b) => compareVersions(b, a))[0];190+		const latest = `${repo}/releases/latest`;191+		if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");192+		else await bucket.remove(latest);193+	}194+	forgetRelease(mirror, version);195+	kepr.transaction(() => {196+		for (const table of ["release_settings", "downloads"]) {197+			// `table` is one of the two names above, never input.198+			kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);199+		}200+	})();165201 }

view file

tests/bucket.test.ts+4 −0
@@ -149,6 +149,7 @@149149 				downloadUrl: () => "",150150 				viewUrl: () => null,151151 				put: async () => {},152+				remove: async () => {},152153 			},153154 			fx.repo,154155 			id,@@ -200,6 +201,7 @@200201 				downloadUrl: () => "",201202 				viewUrl: () => null,202203 				put: async () => {},204+				remove: async () => {},203205 			},204206 			fx.repo,205207 			id,@@ -271,6 +273,7 @@271273 			downloadUrl: () => "",272274 			viewUrl: () => null,273275 			put: async () => {},276+			remove: async () => {},274277 		};275278 		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");276279 		for (const change of [@@ -290,6 +293,7 @@290293 				downloadUrl: () => "",291294 				viewUrl: () => null,292295 				put: async () => {},296+				remove: async () => {},293297 			};294298 			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");295299 		}

view file

tests/console.test.ts+54 −0
@@ -319,6 +319,60 @@319319 	});320320 });321321 322+describe("deleting a build from the bucket", () => {323+	let inst: Instance;324+	let operator: Browser;325+	let bucketDir: string;326+	const text = async (path: string) => (await inst.app.request(path)).text();327+	const remove = (version: string, fields: Record<string, string> = { confirm: "1" }) =>328+		operator.post(`/admin/releases/app/${version}/delete`, fields);329+330+	beforeAll(async () => {331+		const repos = await makeRepos(["app"]);332+		bucketDir = repos.bucketDir;333+		for (const version of ["1.0.0", "1.1.0"]) {334+			await writeRelease(bucketDir, "app", {335+				version,336+				save: repos.saves.app as string,337+				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],338+			});339+		}340+		inst = await instance(bucketDir);341+		operator = new Browser(inst.app);342+		await operator.signIn();343+		await operator.post("/admin/repos/app", { public: "1" });344+		await inst.app.request("/app/releases/1.1.0/linux-x86_64");345+	});346+347+	test("needs its confirmation, and an unknown version is a 404", async () => {348+		expect((await remove("1.1.0", {})).status).toBe(400);349+		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(true);350+		expect((await remove("9.9.9")).status).toBe(404);351+	});352+353+	test("the newest build goes, latest falls back, and a sync does not bring it back", async () => {354+		const response = await remove("1.1.0");355+		expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#builds");356+		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(false);357+		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/linux-x86_64/app")).exists()).toBe(358+			false,359+		);360+		expect(await Bun.file(join(bucketDir, "app/releases/latest")).text()).toBe("1.0.0");361+		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");362+		expect(inst.kepr.query("SELECT * FROM downloads WHERE version = '1.1.0'").all()).toEqual([]);363+		await operator.post("/admin/sync", {});364+		const page = await (await operator.get("/admin/repos?repo=app")).text();365+		expect(page).not.toContain('id="app-1.1.0"');366+		expect(page).toContain('id="app-1.0.0"');367+	});368+369+	test("the last build goes, and latest with it", async () => {370+		expect((await remove("1.0.0")).status).toBe(303);371+		expect(await Bun.file(join(bucketDir, "app/releases/latest")).exists()).toBe(false);372+		expect((await inst.app.request("/app/releases/latest/version.txt")).status).toBe(404);373+	});374+});375+322376 describe("site and profile settings, and the signature upload", () => {323377 	let inst: Instance;324378 	let operator: Browser;

view file

tests/repotext.test.ts+12 −2
@@ -136,14 +136,23 @@136136 	});137137 });138138 139-test("Kepr writes only inside its own folder, and to S3 only with the write key", async () => {139+test("Kepr writes only its own folder and builds, and to S3 only with the write key", async () => {140140 	const { bucketDir } = await makeRepos(["app"]);141141 	const folder = openBucket({ kind: "folder", path: bucketDir });142-	for (const key of ["app/head", ".kepr/../app/head", ".kepr//x", "x/.kepr/y"]) {142+	const refused = ["app/head", ".kepr/../app/head", ".kepr//x", "x/.kepr/y", "app/releases/.."];143+	for (const key of [...refused, "app/releases/1.0.0/release.json"]) {143144 		await expect(folder.put(key, new Uint8Array([1]), "image/png")).rejects.toBeInstanceOf(144145 			BucketWriteError,145146 		);146147 	}148+	for (const key of [...refused, "app/saves/x", "app/releases/1.0.0/"]) {149+		await expect(folder.remove(key)).rejects.toBeInstanceOf(BucketWriteError);150+	}151+	await folder.put("app/releases/latest", new TextEncoder().encode("1.0.0"), "text/plain");152+	expect(await Bun.file(`${bucketDir}/app/releases/latest`).text()).toBe("1.0.0");153+	await folder.put(".kepr/x", new Uint8Array([1]), "text/plain");154+	await folder.remove(".kepr/x");155+	expect(await Bun.file(`${bucketDir}/.kepr/x`).exists()).toBe(false);147156 	const readOnly = openBucket({148157 		kind: "s3",149158 		endpoint: "http://127.0.0.1:9",@@ -155,4 +164,5 @@155164 	await expect(156165 		readOnly.put(".kepr/media/app/x.png", new Uint8Array([1]), "image/png"),157166 	).rejects.toThrow("[bucket.write]");167+	await expect(readOnly.remove(".kepr/media/app/x.png")).rejects.toThrow("[bucket.write]");158168 });

view file