1a625426812f
delete one file of a build, rewriting its release record first (v3.0.160)
- time
- 2026-09-30 23:16 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- operator console
- parent
- 0fb42c17ed78
↓ kepr-1a625426.tar.gzatlas at this save
files changed7 files
package.json+1 −1src/admin/repos.ts+45 −0src/bucket/reader.ts+9 −6src/mirror/store.ts+8 −0src/releases/releases.ts+55 −2tests/console.test.ts+53 −0tests/repotext.test.ts+2 −1package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.159",3+ "version": "3.0.160",44 "private": true,55 "type": "module",66 "scripts": {src/admin/repos.ts+45 −0
@@ -10,6 +10,8 @@1010 import { formatDate, plural } from "../pages/layout.ts";1111 import {1212 deleteRelease,13+ deleteReleaseFile,14+ LastReleaseFile,1315 type OperatorRelease,1416 operatorReleases,1517 setRelease,@@ -144,6 +146,37 @@144146 }145147 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);146148 });149+150+ app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {151+ const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));152+ const platform = c.req.param("platform");153+ if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();154+ const { repo, mirror, release } = found;155+ const refuse = (status: 400 | 503, error: string) =>156+ c.html(157+ reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),158+ status,159+ );160+ if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");161+ try {162+ await deleteReleaseFile(163+ ctx.sync.bucket,164+ ctx.kepr,165+ mirror,166+ repo.name,167+ release.version,168+ platform,169+ );170+ } catch (error) {171+ if (error instanceof LastReleaseFile) return refuse(400, error.message);172+ if (error instanceof BucketWriteError) return refuse(503, error.message);173+ throw error;174+ }175+ return c.redirect(176+ `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,177+ 303,178+ );179+ });147180 }148181 149182 /**@@ -309,6 +342,18 @@309342 }<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>310343 <button class="issues-submit" type="submit">save ${release.version}</button>311344 </form>345+${346+ release.downloads.length > 1 &&347+ release.downloads.map(348+ ([platform]) => html`<form class="issues-form" method="post"349+action="/admin/releases/${repo}/${release.version}/${platform}/delete">350+${formField(c)}351+<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>352+delete the ${platform} file from the bucket</label>353+<button class="issues-submit" type="submit">delete ${platform}</button>354+</form>`,355+ )356+}312357 <form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">313358 ${formField(c)}314359 <label class="issues-label"><input type="checkbox" name="confirm" value="1" required>src/bucket/reader.ts+9 −6
@@ -8,8 +8,8 @@88 * operations, plus a short-lived download URL so Kepr never streams release files itself (§22.2).99 * Kepr's own files (uploads) live under `KEPR_PREFIX` (June, 2026-09-29); no Koh repo can have1010 * 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`.11+ * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files, rewrites its12+ * record without a deleted one, and moves `releases/latest`.1313 */1414 export type Bucket = {1515 get(key: string): Promise<Uint8Array | null>;@@ -18,7 +18,10 @@1818 downloadUrl(key: string, filename: string, type: string): string;1919 /** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */2020 viewUrl(key: string, type: string): string | null;21- /** Writes one of Kepr's own files or a repo's `releases/latest`; any other key is refused. */21+ /**22+ * Writes one of Kepr's own files, a repo's `releases/latest`, or a version's `release.json` when23+ * one of its files is deleted; any other key is refused.24+ */2225 put(key: string, bytes: Uint8Array, type: string): Promise<void>;2326 /** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */2427 remove(key: string): Promise<void>;@@ -31,7 +34,7 @@3134 3235 const DOWNLOAD_SECONDS = 5 * 60;3336 34-const RELEASES_LATEST = /^[^/]+\/releases\/latest$/;37+const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;3538 const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;3639 3740 function requireWritable(key: string, releaseKey: RegExp): void {@@ -124,7 +127,7 @@124127 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });125128 },126129 async put(key, bytes, type) {127- requireWritable(key, RELEASES_LATEST);130+ requireWritable(key, RELEASE_WRITE);128131 await requireWriter().file(key).write(bytes, { type });129132 },130133 async remove(key) {@@ -156,7 +159,7 @@156159 },157160 viewUrl: () => null,158161 async put(key, bytes) {159- requireWritable(key, RELEASES_LATEST);162+ requireWritable(key, RELEASE_WRITE);160163 await Bun.write(join(root, key), bytes);161164 },162165 async remove(key) {src/mirror/store.ts+8 −0
@@ -167,6 +167,14 @@167167 db.query("DELETE FROM releases WHERE version = ?").run(version);168168 }169169 170+/** Replaces a version's record after Kepr rewrote it in the bucket. */171+export function replaceRelease(db: Database, record: ReleaseRecord): void {172+ db.query("UPDATE releases SET record = ? WHERE version = ?").run(173+ JSON.stringify(record),174+ record.version,175+ );176+}177+170178 export function knownReleases(db: Database): Set<string> {171179 const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all();172180 return new Set(rows.map((row) => row.version));src/releases/releases.ts+55 −2
@@ -1,8 +1,13 @@11 import type { Database } from "bun:sqlite";22 import type { Bucket } from "../bucket/reader.ts";3-import { listReleaseVersions, type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";3+import {4+ checkRelease,5+ listReleaseVersions,6+ type ReleaseFile,7+ readReleasesLatest,8+} from "../bucket/repo.ts";49 import { releaseRecords } from "../mirror/queries.ts";5-import { forgetRelease } from "../mirror/store.ts";10+import { forgetRelease, replaceRelease } from "../mirror/store.ts";611 712 /** A release as the public sees it: its notes are the operator's override when one is set (§24). */813 export type Release = {@@ -217,4 +222,52 @@217222 kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);218223 }219224 })();225+}226+227+/** Why a single file can't be deleted: the last one goes with its version, never alone. */228+export class LastReleaseFile extends Error {}229+230+/**231+ * Deletes one file of a build (2026-09-30). The record is rewritten without it first, so a failure232+ * part-way leaves at most a file nothing lists; its count goes too. Runs in the same queue as whole233+ * deletes, so neither reads a record the other is rewriting.234+ */235+export function deleteReleaseFile(236+ bucket: Bucket,237+ kepr: Database,238+ mirror: Database,239+ repo: string,240+ version: string,241+ platform: string,242+): Promise<void> {243+ const run = deleting.then(() => removeReleaseFile(bucket, kepr, mirror, repo, version, platform));244+ deleting = run.catch(() => {});245+ return run;246+}247+248+async function removeReleaseFile(249+ bucket: Bucket,250+ kepr: Database,251+ mirror: Database,252+ repo: string,253+ version: string,254+ platform: string,255+): Promise<void> {256+ const key = `${repo}/releases/${version}/release.json`;257+ const bytes = await bucket.get(key);258+ if (bytes === null) throw new Error(`${key} is missing`);259+ // Rewritten from the raw JSON, so fields Kepr doesn't read survive the edit.260+ const raw = JSON.parse(new TextDecoder().decode(bytes)) as { files: ReleaseFile[] };261+ const file = raw.files.find((f) => f.platform === platform);262+ if (file) {263+ if (raw.files.length === 1) throw new LastReleaseFile("delete the version instead");264+ raw.files = raw.files.filter((f) => f !== file);265+ const json = `${JSON.stringify(raw, null, 2)}\n`;266+ await bucket.put(key, new TextEncoder().encode(json), "application/json");267+ replaceRelease(mirror, checkRelease(repo, version, raw));268+ await bucket.remove(`${repo}/releases/${version}/${platform}/${file.name}`);269+ }270+ kepr271+ .query("DELETE FROM downloads WHERE repo = ? AND version = ? AND platform = ?")272+ .run(repo, version, platform);220273 }tests/console.test.ts+53 −0
@@ -376,6 +376,59 @@376376 });377377 });378378 379+describe("deleting one file of a build", () => {380+ let inst: Instance;381+ let operator: Browser;382+ let bucketDir: string;383+ const record = async () =>384+ JSON.parse(await Bun.file(join(bucketDir, "app/releases/1.0.0/release.json")).text());385+ const removeFile = (platform: string, fields: Record<string, string> = { confirm: "1" }) =>386+ operator.post(`/admin/releases/app/1.0.0/${platform}/delete`, fields);387+388+ beforeAll(async () => {389+ const repos = await makeRepos(["app"]);390+ bucketDir = repos.bucketDir;391+ await writeRelease(bucketDir, "app", {392+ version: "1.0.0",393+ save: repos.saves.app as string,394+ files: [395+ { platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) },396+ { platform: "macos-aarch64", name: "app", bytes: new Uint8Array([2]) },397+ ],398+ });399+ inst = await instance(bucketDir);400+ operator = new Browser(inst.app);401+ await operator.signIn();402+ await operator.post("/admin/repos/app", { public: "1" });403+ await inst.app.request("/app/releases/1.0.0/macos-aarch64");404+ });405+406+ test("needs its confirmation, and an unknown platform is a 404", async () => {407+ expect((await removeFile("macos-aarch64", {})).status).toBe(400);408+ expect((await removeFile("windows-x86_64")).status).toBe(404);409+ expect((await record()).files).toHaveLength(2);410+ });411+412+ test("the file leaves the record, the bucket, the counts and the public routes", async () => {413+ const response = await removeFile("macos-aarch64");414+ expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.0.0");415+ const files = (await record()).files.map((file: { platform: string }) => file.platform);416+ expect(files).toEqual(["linux-x86_64"]);417+ const gone = join(bucketDir, "app/releases/1.0.0/macos-aarch64/app");418+ expect(await Bun.file(gone).exists()).toBe(false);419+ expect(420+ inst.kepr.query("SELECT * FROM downloads WHERE platform = 'macos-aarch64'").all(),421+ ).toEqual([]);422+ expect((await inst.app.request("/app/releases/1.0.0/macos-aarch64")).status).toBe(404);423+ expect((await inst.app.request("/app/releases/1.0.0/linux-x86_64")).status).toBe(302);424+ });425+426+ test("the last file can't go alone", async () => {427+ expect((await removeFile("linux-x86_64")).status).toBe(400);428+ expect((await record()).files).toHaveLength(1);429+ });430+});431+379432 describe("deleting builds when a step fails or two run at once", () => {380433 let inst: Instance;381434 let bucketDir: string;tests/repotext.test.ts+2 −1
@@ -140,7 +140,7 @@140140 const { bucketDir } = await makeRepos(["app"]);141141 const folder = openBucket({ kind: "folder", path: bucketDir });142142 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"]) {143+ for (const key of [...refused, "app/releases/1.0.0/app", "app/releases/1.0.0/x/release.json"]) {144144 await expect(folder.put(key, new Uint8Array([1]), "image/png")).rejects.toBeInstanceOf(145145 BucketWriteError,146146 );@@ -150,6 +150,7 @@150150 }151151 await folder.put("app/releases/latest", new TextEncoder().encode("1.0.0"), "text/plain");152152 expect(await Bun.file(`${bucketDir}/app/releases/latest`).text()).toBe("1.0.0");153+ await folder.put("app/releases/1.0.0/release.json", new Uint8Array([1]), "application/json");153154 await folder.put(".kepr/x", new Uint8Array([1]), "text/plain");154155 await folder.remove(".kepr/x");155156 expect(await Bun.file(`${bucketDir}/.kepr/x`).exists()).toBe(false);