fix(ci): avoid duplicate agent latest assets

This commit is contained in:
t8y2
2026-07-24 09:37:44 +08:00
parent 192d874554
commit 1870be4a81
5 changed files with 134 additions and 33 deletions
+27 -2
View File
@@ -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 <release.json> (--assets-dir <dir> | --metadata-only)",
"Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <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,
+57 -1
View File
@@ -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",
},
]);
});
+9 -12
View File
@@ -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[@]}"
+30 -7
View File
@@ -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", () => {
+11 -11
View File
@@ -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<string, GitHubReleaseAsset>();
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());