fix(release): retain latest five CNB releases per family
This commit is contained in:
parent
28476358ff
commit
442b9338b0
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { CnbClient } from "./sync-cnb-release.mjs";
|
||||
|
||||
const DEFAULT_API_BASE = "https://api.cnb.cool";
|
||||
const DEFAULT_REPOSITORY = "dbxio.com/dbx";
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const client = new CnbClient(args);
|
||||
await pruneOldReleases(client, args);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
apiBase: process.env.CNB_API_BASE || DEFAULT_API_BASE,
|
||||
repository: process.env.CNB_REPOSITORY || DEFAULT_REPOSITORY,
|
||||
token: process.env.CNB_TOKEN || "",
|
||||
currentTag: "",
|
||||
tagPattern: "",
|
||||
retain: 0,
|
||||
apply: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--current-tag") args.currentTag = argv[++index];
|
||||
else if (arg === "--tag-pattern") args.tagPattern = argv[++index];
|
||||
else if (arg === "--retain") args.retain = Number.parseInt(argv[++index], 10);
|
||||
else if (arg === "--apply") args.apply = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!args.token) throw new Error("CNB_TOKEN is required.");
|
||||
if (!args.currentTag || !args.tagPattern || !Number.isInteger(args.retain) || args.retain < 1) {
|
||||
throw new Error(
|
||||
"Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]",
|
||||
);
|
||||
}
|
||||
try {
|
||||
new RegExp(args.tagPattern);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid release tag pattern: ${error.message}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function planReleaseRetention(releases, { currentTag, tagPattern, retain }) {
|
||||
const pattern = new RegExp(tagPattern);
|
||||
const family = releases
|
||||
.map(normalizeRelease)
|
||||
.filter((release) => pattern.test(release.tag))
|
||||
.sort(compareReleasesNewestFirst);
|
||||
|
||||
if (!pattern.test(currentTag)) {
|
||||
return { keep: [], remove: [], skipped: `Current tag ${currentTag} is outside the retention family.` };
|
||||
}
|
||||
if (family.length < retain) {
|
||||
return {
|
||||
keep: family,
|
||||
remove: [],
|
||||
skipped: `Found only ${family.length} matching release(s); at least ${retain} are required before cleanup.`,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = family.slice(0, retain);
|
||||
if (!keep.some((release) => release.tag === currentTag)) {
|
||||
return {
|
||||
keep,
|
||||
remove: [],
|
||||
skipped: `Current tag ${currentTag} is not among the latest ${retain} releases.`,
|
||||
};
|
||||
}
|
||||
return { keep, remove: family.slice(retain), skipped: "" };
|
||||
}
|
||||
|
||||
export async function pruneOldReleases(client, { currentTag, tagPattern, retain, apply }) {
|
||||
const plan = planReleaseRetention(await client.listReleases(), { currentTag, tagPattern, retain });
|
||||
console.log(`Keeping CNB releases: ${plan.keep.map((release) => release.tag).join(", ") || "none"}`);
|
||||
if (plan.skipped) {
|
||||
console.warn(`Skipping CNB release cleanup: ${plan.skipped}`);
|
||||
return plan;
|
||||
}
|
||||
if (!plan.remove.length) {
|
||||
console.log("No old CNB releases to remove.");
|
||||
return plan;
|
||||
}
|
||||
|
||||
for (const release of plan.remove) {
|
||||
if (!apply) {
|
||||
console.log(`Would delete old CNB release: ${release.tag} (${release.id})`);
|
||||
continue;
|
||||
}
|
||||
await client.deleteRelease(release.id);
|
||||
console.log(`Deleted old CNB release: ${release.tag} (${release.id})`);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function normalizeRelease(release) {
|
||||
const id = release.id;
|
||||
const tag = release.tag_name || release.tagName;
|
||||
const publishedAt = release.published_at || release.publishedAt || release.created_at || release.createdAt;
|
||||
if (!id || !tag || !publishedAt) throw new Error("CNB release metadata is missing id, tag, or publish time.");
|
||||
const publishedTime = Date.parse(publishedAt);
|
||||
if (!Number.isFinite(publishedTime)) throw new Error(`CNB release ${tag} has an invalid publish time: ${publishedAt}`);
|
||||
return { id, tag, publishedAt, publishedTime };
|
||||
}
|
||||
|
||||
function compareReleasesNewestFirst(left, right) {
|
||||
return right.publishedTime - left.publishedTime || right.tag.localeCompare(left.tag, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { planReleaseRetention, pruneOldReleases } from "./cleanup-cnb-releases.mjs";
|
||||
|
||||
function release(id, tag, publishedAt) {
|
||||
return { id, tag_name: tag, published_at: publishedAt };
|
||||
}
|
||||
|
||||
const appReleases = [
|
||||
release("69", "v0.5.69", "2026-07-29T05:35:19Z"),
|
||||
release("68", "v0.5.68", "2026-07-27T05:35:19Z"),
|
||||
release("67", "v0.5.67", "2026-07-25T05:35:19Z"),
|
||||
release("66", "v0.5.66", "2026-07-23T05:35:19Z"),
|
||||
release("65", "v0.5.65", "2026-07-21T05:35:19Z"),
|
||||
release("64", "v0.5.64", "2026-07-19T05:35:19Z"),
|
||||
];
|
||||
|
||||
test("retention only removes old releases from the selected family", () => {
|
||||
const plan = planReleaseRetention(
|
||||
[
|
||||
release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"),
|
||||
release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"),
|
||||
...appReleases.toReversed(),
|
||||
],
|
||||
{
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(plan.skipped, "");
|
||||
assert.deepEqual(
|
||||
plan.keep.map((item) => item.tag),
|
||||
["v0.5.69", "v0.5.68", "v0.5.67", "v0.5.66", "v0.5.65"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.remove.map((item) => item.tag),
|
||||
["v0.5.64"],
|
||||
);
|
||||
});
|
||||
|
||||
test("retention skips cleanup when syncing an older release", () => {
|
||||
const plan = planReleaseRetention(appReleases, {
|
||||
currentTag: "v0.5.64",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
});
|
||||
|
||||
assert.match(plan.skipped, /not among the latest 5/);
|
||||
assert.deepEqual(plan.remove, []);
|
||||
});
|
||||
|
||||
test("retention rejects incomplete release metadata", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
planReleaseRetention([{ id: "broken", tag_name: "v0.5.69" }], {
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v",
|
||||
retain: 1,
|
||||
}),
|
||||
/missing id, tag, or publish time/,
|
||||
);
|
||||
});
|
||||
|
||||
test("pruneOldReleases is a dry run unless apply is enabled", async () => {
|
||||
const deleted = [];
|
||||
const client = {
|
||||
async listReleases() {
|
||||
return appReleases;
|
||||
},
|
||||
async deleteRelease(id) {
|
||||
deleted.push(id);
|
||||
},
|
||||
};
|
||||
|
||||
await pruneOldReleases(client, {
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
apply: false,
|
||||
});
|
||||
assert.deepEqual(deleted, []);
|
||||
|
||||
await pruneOldReleases(client, {
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
apply: true,
|
||||
});
|
||||
assert.deepEqual(deleted, ["64"]);
|
||||
});
|
||||
|
|
@ -164,6 +164,23 @@ export class CnbClient {
|
|||
);
|
||||
}
|
||||
|
||||
async listReleases(pageSize = 100) {
|
||||
const releases = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await this.request(
|
||||
"GET",
|
||||
`/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
if (!Array.isArray(batch)) throw new Error("CNB release list response must be an array.");
|
||||
releases.push(...batch);
|
||||
if (batch.length < pageSize) return releases;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelease(releaseId) {
|
||||
await this.request("DELETE", `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}`);
|
||||
}
|
||||
|
||||
async request(method, path, body = null, allow404 = false) {
|
||||
const response = await fetch(`${this.apiBase}${path}`, {
|
||||
method,
|
||||
|
|
|
|||
|
|
@ -181,3 +181,42 @@ test("deleteAsset accepts CNB's empty successful response", async (t) => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("listReleases paginates and deleteRelease accepts an empty response", async (t) => {
|
||||
const requests = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ method: request.method, url: request.url });
|
||||
if (request.method === "GET" && request.url?.includes("page=1")) {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify([{ id: "1" }, { id: "2" }]));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url?.includes("page=2")) {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify([{ id: "3" }]));
|
||||
return;
|
||||
}
|
||||
response.statusCode = 204;
|
||||
response.end();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))));
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const client = new CnbClient({
|
||||
apiBase: `http://127.0.0.1:${address.port}`,
|
||||
repository: "dbxio.com/dbx",
|
||||
token: "test-token",
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.listReleases(2), [{ id: "1" }, { id: "2" }, { id: "3" }]);
|
||||
await client.deleteRelease("release/old");
|
||||
|
||||
assert.deepEqual(requests, [
|
||||
{ method: "GET", url: "/dbxio.com/dbx/-/releases?page=1&page_size=2" },
|
||||
{ method: "GET", url: "/dbxio.com/dbx/-/releases?page=2&page_size=2" },
|
||||
{ method: "DELETE", url: "/dbxio.com/dbx/-/releases/release%2Fold" },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -779,3 +779,16 @@ jobs:
|
|||
args+=(--overwrite-existing --prune-assets)
|
||||
fi
|
||||
node .github/scripts/sync-cnb-release.mjs "${args[@]}"
|
||||
|
||||
- name: Keep latest five versioned agent releases in CNB
|
||||
if: ${{ matrix.tag != 'agents-latest' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CURRENT_TAG: ${{ matrix.tag }}
|
||||
run: |
|
||||
node .github/scripts/cleanup-cnb-releases.mjs \
|
||||
--current-tag "$CURRENT_TAG" \
|
||||
--tag-pattern '^agents-v[0-9]+[.][0-9]+[.][0-9]+$' \
|
||||
--retain 5 \
|
||||
--apply
|
||||
|
|
|
|||
|
|
@ -681,6 +681,18 @@ jobs:
|
|||
--github-release "$RUNNER_TEMP/github-release/release.json" \
|
||||
--assets-dir "$RUNNER_TEMP/release-assets"
|
||||
|
||||
- name: Keep latest five app releases in CNB
|
||||
continue-on-error: true
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
node .github/scripts/cleanup-cnb-releases.mjs \
|
||||
--current-tag "$CURRENT_TAG" \
|
||||
--tag-pattern '^v[0-9]+[.][0-9]+[.][0-9]+([.-][0-9A-Za-z.-]+)?$' \
|
||||
--retain 5 \
|
||||
--apply
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
|
|
|||
Loading…
Reference in New Issue