CI: nightly registry cleanup - prune hash images not release-tagged nor a branch head
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// Prunes the app's container package in the Gitea registry: a commit-hash tag survives only
|
||||
// while its commit is a branch head or carries a vX.Y.Z release tag. Untagged sha256:* package
|
||||
// versions are CHILD manifests (arch image + provenance) of the tagged OCI indexes — deleting
|
||||
// one that a surviving tag still references breaks that image, so only the ones no kept tag
|
||||
// references are removed. Named tags (1.2.3, latest, …) are never touched.
|
||||
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||
|
||||
function env(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") throw new Error(`Missing env var ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
const registryToken = env("REGISTRY_TOKEN");
|
||||
const registryUser = env("REGISTRY_USER");
|
||||
const repoToken = env("REPO_TOKEN");
|
||||
const repository = env("REPOSITORY");
|
||||
const serverUrl = env("SERVER_URL").replace(/\/+$/, "");
|
||||
|
||||
const [owner, name] = repository.split("/");
|
||||
if (owner === undefined || name === undefined || owner === "" || name === "") {
|
||||
throw new Error(`REPOSITORY must be owner/name, got "${repository}"`);
|
||||
}
|
||||
|
||||
async function fetchOk(url: string, init: RequestInit): Promise<Response> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) throw new Error(`${init.method ?? "GET"} ${url} -> ${res.status}: ${await res.text()}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiGetAllPages<T>(path: string, token: string): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
const limit = 50;
|
||||
for (let page = 1; ; page++) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const res = await fetchOk(`${serverUrl}/api/v1${path}${sep}limit=${limit}&page=${page}`, {
|
||||
headers: { authorization: `token ${token}` },
|
||||
});
|
||||
const batch = (await res.json()) as T[];
|
||||
// Stop on an empty page, not a short one — a lowered server page-size cap would otherwise
|
||||
// silently truncate the list, and a truncated branch/tag list deletes protected images.
|
||||
if (batch.length === 0) return all;
|
||||
all.push(...batch);
|
||||
}
|
||||
}
|
||||
|
||||
async function childDigests(tag: string): Promise<string[]> {
|
||||
const res = await fetchOk(`${serverUrl}/v2/${owner}/${name}/manifests/${tag}`, {
|
||||
headers: {
|
||||
accept: [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
].join(", "),
|
||||
authorization: `Basic ${Buffer.from(`${registryUser}:${registryToken}`).toString("base64")}`,
|
||||
},
|
||||
});
|
||||
const manifest = (await res.json()) as { manifests?: { digest: string }[] };
|
||||
return (manifest.manifests ?? []).map((m) => m.digest);
|
||||
}
|
||||
|
||||
async function deleteVersion(version: string): Promise<void> {
|
||||
const url = `${serverUrl}/api/v1/packages/${owner}/container/${name}/${encodeURIComponent(version)}`;
|
||||
const res = await fetch(url, { headers: { authorization: `token ${registryToken}` }, method: "DELETE" });
|
||||
if (res.status === 404) {
|
||||
console.log(`already gone: ${version}`);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`DELETE ${url} -> ${res.status}: ${await res.text()}`);
|
||||
console.log(`deleted: ${version}`);
|
||||
}
|
||||
|
||||
const branches = await apiGetAllPages<{ commit: { id: string } }>(`/repos/${owner}/${name}/branches`, repoToken);
|
||||
if (branches.length === 0) throw new Error("no branches returned — refusing to prune with an empty keep-set");
|
||||
const tags = await apiGetAllPages<{ commit: { sha: string }; name: string }>(`/repos/${owner}/${name}/tags`, repoToken);
|
||||
const packages = await apiGetAllPages<{ name: string; version: string }>(
|
||||
`/packages/${owner}?type=container&q=${encodeURIComponent(name)}`,
|
||||
registryToken,
|
||||
);
|
||||
|
||||
const versions = packages.filter((p) => p.name === name).map((p) => p.version);
|
||||
const plan = planHashTagDeletions({
|
||||
branchHeadShas: branches.map((b) => b.commit.id),
|
||||
releaseTagShas: tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name)).map((t) => t.commit.sha),
|
||||
versions,
|
||||
});
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const tag of plan.keptTags) {
|
||||
for (const digest of await childDigests(tag)) referenced.add(digest);
|
||||
}
|
||||
const orphans = selectOrphanedManifests({ referencedDigests: [...referenced], versions });
|
||||
|
||||
for (const version of [...plan.deletions, ...orphans]) await deleteVersion(version);
|
||||
console.log(
|
||||
`kept ${plan.keptTags.length} tags; deleted ${plan.deletions.length} hash tags and ${orphans.length} orphaned manifests`,
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||
|
||||
const headSha = "0582809000000000000000000000000000000001";
|
||||
const releaseSha = "50006dd000000000000000000000000000000002";
|
||||
const staleSha = "c8981c1000000000000000000000000000000003";
|
||||
|
||||
test("planHashTagDeletions deletes hash tags that are neither a branch head nor release-tagged", () => {
|
||||
const plan = planHashTagDeletions({
|
||||
branchHeadShas: [headSha],
|
||||
releaseTagShas: [releaseSha],
|
||||
versions: [headSha, releaseSha, staleSha],
|
||||
});
|
||||
assert.deepEqual(plan.deletions, [staleSha]);
|
||||
assert.deepEqual(plan.keptTags, [headSha, releaseSha]);
|
||||
});
|
||||
|
||||
test("planHashTagDeletions keeps named tags and excludes sha256 manifest versions from keptTags", () => {
|
||||
const plan = planHashTagDeletions({
|
||||
branchHeadShas: [],
|
||||
releaseTagShas: [],
|
||||
versions: ["0.0.1", "0", "latest", "some-manual-tag", `sha256:${"a".repeat(64)}`, staleSha],
|
||||
});
|
||||
assert.deepEqual(plan.deletions, [staleSha]);
|
||||
assert.deepEqual(plan.keptTags, ["0.0.1", "0", "latest", "some-manual-tag"]);
|
||||
});
|
||||
|
||||
test("planHashTagDeletions with no versions plans nothing", () => {
|
||||
const plan = planHashTagDeletions({ branchHeadShas: [headSha], releaseTagShas: [], versions: [] });
|
||||
assert.deepEqual(plan.deletions, []);
|
||||
assert.deepEqual(plan.keptTags, []);
|
||||
});
|
||||
|
||||
test("selectOrphanedManifests picks only sha256 versions unreferenced by kept tags", () => {
|
||||
const referenced = `sha256:${"b".repeat(64)}`;
|
||||
const orphaned = `sha256:${"c".repeat(64)}`;
|
||||
const orphans = selectOrphanedManifests({
|
||||
referencedDigests: [referenced],
|
||||
versions: ["0.0.1", "latest", headSha, referenced, orphaned],
|
||||
});
|
||||
assert.deepEqual(orphans, [orphaned]);
|
||||
});
|
||||
|
||||
test("selectOrphanedManifests with nothing referenced orphans every sha256 version", () => {
|
||||
const manifest = `sha256:${"d".repeat(64)}`;
|
||||
assert.deepEqual(selectOrphanedManifests({ referencedDigests: [], versions: [manifest, "latest"] }), [manifest]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const hashTag = /^[0-9a-f]{40}$/;
|
||||
const manifestVersion = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
export type HashTagPlan = {
|
||||
deletions: string[];
|
||||
keptTags: string[];
|
||||
};
|
||||
|
||||
export function planHashTagDeletions(input: {
|
||||
branchHeadShas: string[];
|
||||
releaseTagShas: string[];
|
||||
versions: string[];
|
||||
}): HashTagPlan {
|
||||
const keep = new Set([...input.branchHeadShas, ...input.releaseTagShas]);
|
||||
const deletions: string[] = [];
|
||||
const keptTags: string[] = [];
|
||||
for (const version of input.versions) {
|
||||
if (manifestVersion.test(version)) continue;
|
||||
if (hashTag.test(version) && !keep.has(version)) deletions.push(version);
|
||||
else keptTags.push(version);
|
||||
}
|
||||
return { deletions, keptTags };
|
||||
}
|
||||
|
||||
export function selectOrphanedManifests(input: {
|
||||
referencedDigests: string[];
|
||||
versions: string[];
|
||||
}): string[] {
|
||||
const referenced = new Set(input.referencedDigests);
|
||||
return input.versions.filter((v) => manifestVersion.test(v) && !referenced.has(v));
|
||||
}
|
||||
Reference in New Issue
Block a user