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
↓ kepr-9a44722d.tar.gzatlas at this save
files changed8 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 −2package.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": {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 }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 }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));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 }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 }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;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 });