From 1870be4a815a5a35181cac58a22a16fececb3746 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 24 Jul 2026 09:37:42 +0800 Subject: [PATCH] fix(ci): avoid duplicate agent latest assets --- .github/scripts/sync-cnb-release.mjs | 29 +++++++++++- .github/scripts/sync-cnb-release.test.mjs | 58 ++++++++++++++++++++++- .github/workflows/agents-release.yml | 21 ++++---- docs/lib/agentRegistry.test.ts | 37 ++++++++++++--- docs/lib/agentRegistry.ts | 22 ++++----- 5 files changed, 134 insertions(+), 33 deletions(-) diff --git a/.github/scripts/sync-cnb-release.mjs b/.github/scripts/sync-cnb-release.mjs index d110677310..4eb2245cf8 100644 --- a/.github/scripts/sync-cnb-release.mjs +++ b/.github/scripts/sync-cnb-release.mjs @@ -23,8 +23,14 @@ async function main() { return; } + const localAssetPaths = localAssets(args.assetsDir); + const localAssetNames = new Set(localAssetPaths.map((assetPath) => basename(assetPath))); + if (args.pruneAssets) { + await pruneReleaseAssets(client, release, localAssetNames); + } + const existingAssets = new Set((release.assets || []).map((asset) => asset.name)); - const assets = localAssets(args.assetsDir).filter((assetPath) => { + const assets = localAssetPaths.filter((assetPath) => { const name = basename(assetPath); if (existingAssets.has(name) && !args.overwriteExisting) { console.log(`Skipping existing CNB asset: ${name}`); @@ -49,6 +55,7 @@ function parseArgs(argv) { githubReleasePath: "", assetsDir: "", metadataOnly: false, + pruneAssets: false, }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; @@ -56,12 +63,13 @@ function parseArgs(argv) { else if (arg === "--assets-dir") args.assetsDir = argv[++index]; else if (arg === "--metadata-only") args.metadataOnly = true; else if (arg === "--overwrite-existing") args.overwriteExisting = true; + else if (arg === "--prune-assets") args.pruneAssets = true; else throw new Error(`Unknown argument: ${arg}`); } if (!args.token) throw new Error("CNB_TOKEN is required."); if (!args.githubReleasePath || (!args.metadataOnly && !args.assetsDir)) { throw new Error( - "Usage: sync-cnb-release.mjs --github-release (--assets-dir | --metadata-only)", + "Usage: sync-cnb-release.mjs --github-release (--assets-dir | --metadata-only) [--prune-assets]", ); } if (!Number.isInteger(args.concurrency) || args.concurrency < 1) { @@ -70,6 +78,16 @@ function parseArgs(argv) { return args; } +export async function pruneReleaseAssets(client, release, localAssetNames) { + for (const asset of release.assets || []) { + if (localAssetNames.has(asset.name)) continue; + if (!asset.id) throw new Error(`CNB asset ${asset.name} is missing its id.`); + // Mutable release aliases must not retain files removed from the source release. + await client.deleteAsset(release.id, asset.id); + console.log(`Deleted stale CNB asset: ${asset.name}`); + } +} + async function uploadWithRetry(client, releaseId, filePath, overwriteExisting) { const name = basename(filePath); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { @@ -139,6 +157,13 @@ export class CnbClient { } } + async deleteAsset(releaseId, assetId) { + await this.request( + "DELETE", + `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`, + ); + } + 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 892786f495..1694189a1e 100644 --- a/.github/scripts/sync-cnb-release.test.mjs +++ b/.github/scripts/sync-cnb-release.test.mjs @@ -7,7 +7,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { CnbClient } from "./sync-cnb-release.mjs"; +import { CnbClient, pruneReleaseAssets } from "./sync-cnb-release.mjs"; const scriptPath = fileURLToPath(new URL("./sync-cnb-release.mjs", import.meta.url)); @@ -125,3 +125,59 @@ test("metadata-only sync updates release without reading an assets directory", a { method: "PATCH", url: "/dbxio.com/dbx/-/releases/release-2" }, ]); }); + +test("pruneReleaseAssets deletes files missing from the source release", async () => { + const deleted = []; + const client = { + async deleteAsset(releaseId, assetId) { + deleted.push({ releaseId, assetId }); + }, + }; + + await pruneReleaseAssets( + client, + { + id: "release-latest", + assets: [ + { id: "registry", name: "agent-registry.json" }, + { id: "old-jre", name: "dbx-jre-8-linux-x64.tar.gz" }, + { id: "old-driver", name: "dbx-agent-h2-0.1.0.jar" }, + ], + }, + new Set(["agent-registry.json"]), + ); + + assert.deepEqual(deleted, [ + { releaseId: "release-latest", assetId: "old-jre" }, + { releaseId: "release-latest", assetId: "old-driver" }, + ]); +}); + +test("deleteAsset accepts CNB's empty successful response", async (t) => { + const requests = []; + const server = createServer((request, response) => { + requests.push({ method: request.method, url: request.url }); + 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", + }); + + await client.deleteAsset("release/latest", "asset old"); + + assert.deepEqual(requests, [ + { + method: "DELETE", + url: "/dbxio.com/dbx/-/releases/release%2Flatest/assets/asset%20old", + }, + ]); +}); diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index 945d0bcf75..28f31c0b37 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -565,27 +565,24 @@ jobs: git push origin refs/tags/agents-latest --force if gh release view agents-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # Versioned driver assets change names every release, so remove the - # previous set before uploading to keep agents-latest unambiguous. + # agents-latest is only the stable registry entry point. Actual artifacts + # stay on the immutable agents-v* release referenced by the registry. gh release view agents-latest --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | while IFS= read -r asset; do - case "$asset" in - dbx-agent-*.jar|dbx-agent-*.zip|dbx-agent-oracle-*|dbx-agent-xugu-*|dbx-agent-kingbase-*) - gh release delete-asset agents-latest "$asset" --repo "$GITHUB_REPOSITORY" --yes - ;; - esac + [ "$asset" = "agent-registry.json" ] && continue + gh release delete-asset agents-latest "$asset" --repo "$GITHUB_REPOSITORY" --yes done - gh release upload agents-latest release/* --repo "$GITHUB_REPOSITORY" --clobber + gh release upload agents-latest release/agent-registry.json --repo "$GITHUB_REPOSITORY" --clobber gh release edit agents-latest \ --repo "$GITHUB_REPOSITORY" \ --title "Agents latest" \ - --notes "Moving release for the latest DBX agent registry, driver artifacts, managed JRE archives, and offline bundles." \ + --notes "Moving entry point for the latest DBX agent registry. Artifacts are stored on the referenced versioned agents release." \ --latest=false else - gh release create agents-latest release/* \ + gh release create agents-latest release/agent-registry.json \ --repo "$GITHUB_REPOSITORY" \ --title "Agents latest" \ - --notes "Moving release for the latest DBX agent registry, driver artifacts, managed JRE archives, and offline bundles." \ + --notes "Moving entry point for the latest DBX agent registry. Artifacts are stored on the referenced versioned agents release." \ --latest=false fi @@ -690,6 +687,6 @@ jobs: ) # agents-latest is mutable, so its stable asset names must replace the previous release contents. if [[ "$OVERWRITE_EXISTING" == "true" ]]; then - args+=(--overwrite-existing) + args+=(--overwrite-existing --prune-assets) fi node .github/scripts/sync-cnb-release.mjs "${args[@]}" diff --git a/docs/lib/agentRegistry.test.ts b/docs/lib/agentRegistry.test.ts index 87bf0facf1..7f128955e5 100644 --- a/docs/lib/agentRegistry.test.ts +++ b/docs/lib/agentRegistry.test.ts @@ -17,10 +17,10 @@ test("offline download catalog includes the JDBC plugin ZIP", () => { }); }); -test("release assets expose GitHub and CNB download links", () => { - assert.deepEqual(downloadLinksFor("https://github.com/t8y2/dbx/releases/download/agents-latest/dbx-agents-offline-macos-aarch64.zip"), [ - { source: "github", url: "https://github.com/t8y2/dbx/releases/download/agents-latest/dbx-agents-offline-macos-aarch64.zip" }, - { source: "cnb", url: "https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-latest/dbx-agents-offline-macos-aarch64.zip" }, +test("versioned release assets expose GitHub and CNB download links", () => { + assert.deepEqual(downloadLinksFor("https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agents-offline-macos-aarch64.zip"), [ + { source: "github", url: "https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agents-offline-macos-aarch64.zip" }, + { source: "cnb", url: "https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-v0.2.64/dbx-agents-offline-macos-aarch64.zip" }, ]); }); @@ -37,15 +37,34 @@ test("catalog falls back from GitHub to CNB", async () => { vi.fn(async (input: string | URL | Request) => { const url = String(input); requestedUrls.push(url); - if (url.includes("api.github.com")) return new Response("rate limited", { status: 403 }); + if (url.includes("api.github.com")) { + return Response.json({ + assets: [ + { + name: "agent-registry.json", + size: 1, + browser_download_url: "https://github.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json", + }, + ], + }); + } return Response.json({ drivers: { - access: { jar: { url: "https://dl.dbxio.com/agents/dbx-agent-access.jar", size: 1 } }, + access: { + version: "0.1.30", + jar: { + url: "https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agent-access-0.1.30.jar", + size: 1, + }, + }, }, jres: { "21": { platforms: { - "macos-aarch64": { url: "https://dl.dbxio.com/jres/dbx-jre-21-macos-aarch64.tar.gz", size: 1 }, + "macos-aarch64": { + url: "https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-jre-21-macos-aarch64.tar.gz", + size: 1, + }, }, }, }, @@ -62,6 +81,10 @@ test("catalog falls back from GitHub to CNB", async () => { assert.equal(catalog?.drivers[0]?.key, "access"); assert.equal(catalog?.jres[0]?.platformKey, "macos-aarch64"); assert.equal(catalog?.bundles[0]?.platformKey, "macos-aarch64"); + assert.equal( + catalog?.bundles[0]?.url, + "https://github.com/t8y2/dbx/releases/download/agents-v0.2.64/dbx-agents-offline-macos-aarch64.zip", + ); }); test("unknown fallback asset sizes render as unavailable", () => { diff --git a/docs/lib/agentRegistry.ts b/docs/lib/agentRegistry.ts index 9d455c0fa8..7a258f6d95 100644 --- a/docs/lib/agentRegistry.ts +++ b/docs/lib/agentRegistry.ts @@ -175,18 +175,12 @@ function siblingReleaseAssetUrl(url: string, filename: string): string { return separator >= 0 ? `${url.slice(0, separator + 1)}${filename}` : filename; } -function githubReleaseAsset(name: string, size: number, tag = "agents-latest"): GitHubReleaseAsset { - return { - name, - size, - browser_download_url: `${GITHUB_RELEASE_DOWNLOAD_PREFIX}${tag}/${name}`, - }; -} - function registryReleaseAssets(registry: AgentRegistry): GitHubReleaseAsset[] { const assets = new Map(); + let releaseAssetUrl: string | undefined; const addArtifact = (artifact: AgentRegistryArtifact | undefined, fallbackName: string) => { if (!artifact?.url) return; + releaseAssetUrl ??= artifact.url.startsWith(GITHUB_RELEASE_DOWNLOAD_PREFIX) ? artifact.url : undefined; const name = releaseAssetName(artifact.url, fallbackName); assets.set(name, { name, size: artifact.size || 0, browser_download_url: artifact.url }); }; @@ -221,9 +215,15 @@ function registryReleaseAssets(registry: AgentRegistry): GitHubReleaseAsset[] { } } - for (const platformKey of Object.keys(registry.jres?.["21"]?.platforms ?? {})) { - const name = `dbx-agents-offline-${platformKey}.zip`; - assets.set(name, githubReleaseAsset(name, 0)); + if (releaseAssetUrl) { + for (const platformKey of Object.keys(registry.jres?.["21"]?.platforms ?? {})) { + const name = `dbx-agents-offline-${platformKey}.zip`; + assets.set(name, { + name, + size: 0, + browser_download_url: siblingReleaseAssetUrl(releaseAssetUrl, name), + }); + } } return Array.from(assets.values());