diff --git a/.github/scripts/cleanup-cnb-releases.mjs b/.github/scripts/cleanup-cnb-releases.mjs new file mode 100644 index 000000000..f4d40d66e --- /dev/null +++ b/.github/scripts/cleanup-cnb-releases.mjs @@ -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-pattern --retain [--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; + }); +} diff --git a/.github/scripts/cleanup-cnb-releases.test.mjs b/.github/scripts/cleanup-cnb-releases.test.mjs new file mode 100644 index 000000000..202527f0f --- /dev/null +++ b/.github/scripts/cleanup-cnb-releases.test.mjs @@ -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"]); +}); diff --git a/.github/scripts/sync-cnb-release.mjs b/.github/scripts/sync-cnb-release.mjs index 4eb2245cf..d4bcc1fbb 100644 --- a/.github/scripts/sync-cnb-release.mjs +++ b/.github/scripts/sync-cnb-release.mjs @@ -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, diff --git a/.github/scripts/sync-cnb-release.test.mjs b/.github/scripts/sync-cnb-release.test.mjs index 1694189a1..2a2609330 100644 --- a/.github/scripts/sync-cnb-release.test.mjs +++ b/.github/scripts/sync-cnb-release.test.mjs @@ -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" }, + ]); +}); diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index 90ed5cf2d..a1cee0a4d 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b9a82d0b..216175056 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: