fix(release): retain latest five CNB releases per family

This commit is contained in:
t8y2 2026-07-30 09:09:26 +08:00
parent 28476358ff
commit 442b9338b0
No known key found for this signature in database
6 changed files with 293 additions and 0 deletions

119
.github/scripts/cleanup-cnb-releases.mjs vendored Normal file
View File

@ -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;
});
}

View File

@ -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"]);
});

View File

@ -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,

View File

@ -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" },
]);
});

View File

@ -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

View File

@ -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: