fix(agents): reuse effective release artifacts

This commit is contained in:
t8y2 2026-08-03 13:10:18 +08:00
parent 4858fed870
commit 12323e406c
No known key found for this signature in database
10 changed files with 818 additions and 45 deletions

View File

@ -3,6 +3,8 @@ import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
const VERSIONS_PATH = "agents/versions.json";
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
function bumpPatchVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
@ -48,6 +50,7 @@ const nativeDriverDirectories = {
kingbase: "kingbase-go",
rabbitmq: "rabbitmq",
};
const nativeDriverModules = new Set(["duckdb", "oracle", "xugu", "kingbase", "rabbitmq"]);
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
let checkDir = null;
@ -70,10 +73,18 @@ function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists,
return {
checkDir,
modulePath,
javaBuild: hasBuildGradle,
nativeBuild: nativeDriverModules.has(moduleName),
commonDependent: hasBuildGradle && (explicitlyDependsOnCommon || !legacyStandaloneModules.has(moduleName)),
};
}
function classifyModules(versions, options) {
return Object.keys(versions)
.map((moduleName) => ({ moduleName, module: resolveAgentModule(moduleName, options) }))
.filter(({ module }) => module);
}
export function evaluateAgentVersionBump({
versions,
prevVersions = versions,
@ -87,6 +98,11 @@ export function evaluateAgentVersionBump({
const nextVersions = { ...versions };
const logs = [];
let changed = false;
const changedModules = [];
const javaModules = [];
const nativeModules = [];
const reusedModules = [];
const resolvedModules = classifyModules(versions, { legacyStandaloneModules, moduleExists, readModuleFile });
if (manualVersionsChanged && !skipBump) {
logs.push("Manual agents/versions.json changes detected; preserving manually changed module versions and auto-bumping the rest.");
@ -94,7 +110,12 @@ export function evaluateAgentVersionBump({
if (skipBump) {
logs.push("Skipping automatic module version bump for migrated first release; versions.json was carried over from dbx-agents.");
return { changed, versions: nextVersions, prevVersions, logs };
for (const { moduleName, module } of resolvedModules) {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
}
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
}
const commonChanged = changedFiles.some(isCommonRuntimeChange);
@ -102,10 +123,7 @@ export function evaluateAgentVersionBump({
logs.push("Common agent runtime changes detected; common-triggered bumps are limited to modules that package agents/common.");
}
for (const moduleName of Object.keys(versions)) {
const module = resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile });
if (!module) continue;
for (const { moduleName, module } of resolvedModules) {
const moduleChanged = pathChanged(changedFiles, module.modulePath);
// Only modules that package agents/common need installer-visible updates
// for shared Java runtime changes; native and standalone agents do not.
@ -113,16 +131,24 @@ export function evaluateAgentVersionBump({
const oldVersion = nextVersions[moduleName] ?? "0.1.0";
const prevVersion = prevVersions[moduleName] ?? "";
const manuallyVersioned = manualVersionsChanged && (!prevVersion || prevVersion !== oldVersion);
const moduleNeedsBuild = moduleChanged || commonAffectsModule || manuallyVersioned;
if (!moduleChanged && !commonAffectsModule) {
if (!moduleNeedsBuild) {
logs.push(` ${moduleName}: no changes`);
reusedModules.push(moduleName);
} else if (manuallyVersioned) {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
if (!prevVersion) {
logs.push(` ${moduleName}: CHANGED, new module version kept at ${oldVersion}`);
} else {
logs.push(` ${moduleName}: CHANGED, manual version ${prevVersion} -> ${oldVersion}`);
}
} else {
changedModules.push(moduleName);
if (module.javaBuild) javaModules.push(moduleName);
if (module.nativeBuild) nativeModules.push(moduleName);
const newVersion = bumpPatchVersion(oldVersion);
nextVersions[moduleName] = newVersion;
changed = true;
@ -131,7 +157,7 @@ export function evaluateAgentVersionBump({
}
}
return { changed, versions: nextVersions, prevVersions, logs };
return { changed, versions: nextVersions, prevVersions, logs, changedModules, javaModules, nativeModules, reusedModules };
}
export function getAgentVersionChanges(previousVersions, nextVersions) {
@ -148,6 +174,62 @@ function git(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function lines(value) {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
const versionCommits = lines(
gitOutput([
"log",
"--reverse",
"--ancestry-path",
"--format=%H%x09%s",
`${prevTag}..${headRef}`,
"--",
VERSIONS_PATH,
]),
);
let syncCommit = "";
for (const entry of versionCommits) {
const separator = entry.indexOf("\t");
if (separator < 0 || entry.slice(separator + 1) !== VERSION_SYNC_SUBJECT) continue;
const commit = entry.slice(0, separator);
const changedPaths = lines(gitOutput(["diff-tree", "--no-commit-id", "--name-only", "-r", commit]));
if (changedPaths.length !== 1 || changedPaths[0] !== VERSIONS_PATH) continue;
JSON.parse(gitOutput(["show", `${commit}:${VERSIONS_PATH}`]));
syncCommit = commit;
break;
}
const versionsRef = syncCommit || prevTag;
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
const versionsChangedAfterSync = syncCommit
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
: false;
const changedFiles = syncCommit && !versionsChangedAfterSync
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
: allChangedFiles;
return {
prevTag,
versionsRef,
syncCommit,
versions,
changedFiles,
allChangedFiles,
versionsChangedAfterSync,
};
}
export function shouldBuildAgentJre(changedFiles, migratedFirstRelease = false) {
return migratedFirstRelease || changedFiles.some((file) => JRE_BUILD_PATHS.has(file));
}
function parseArgs(argv) {
const options = {
migratedFirstRelease: false,
@ -180,7 +262,7 @@ function parseArgs(argv) {
return options;
}
function outputStepValues(result, prevTag, migratedFirstRelease) {
function outputStepValues(result, baseline, migratedFirstRelease, buildJre) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
@ -189,7 +271,14 @@ function outputStepValues(result, prevTag, migratedFirstRelease) {
[
`versions=${JSON.stringify(result.versions)}`,
`prev_versions=${JSON.stringify(result.prevVersions)}`,
`prev_tag=${prevTag}`,
`prev_tag=${baseline.prevTag}`,
`effective_prev_ref=${baseline.versionsRef}`,
`changed_modules=${JSON.stringify(result.changedModules)}`,
`java_modules=${JSON.stringify(result.javaModules)}`,
`native_modules=${JSON.stringify(result.nativeModules)}`,
`reuse_modules=${JSON.stringify(migratedFirstRelease ? [] : result.reusedModules)}`,
`build_jre=${buildJre}`,
`reuse_jre=${!migratedFirstRelease && !buildJre}`,
`migrated_first_release=${migratedFirstRelease}`,
"",
].join("\n"),
@ -200,13 +289,18 @@ function main() {
const options = parseArgs(process.argv.slice(2));
const versions = JSON.parse(readFileSync(VERSIONS_PATH, "utf8"));
const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8"));
const changedFiles = options.skipBump ? [] : git(["diff", "--name-only", `${options.prevTag}..HEAD`]).split("\n").filter(Boolean);
const baseline = options.prevVersionsFile
? {
prevTag: options.prevTag,
versionsRef: options.prevTag,
syncCommit: "",
versions: JSON.parse(readFileSync(options.prevVersionsFile, "utf8")),
changedFiles: lines(git(["diff", "--name-only", `${options.prevTag}..HEAD`])),
}
: resolveAgentReleaseBaseline({ prevTag: options.prevTag });
const changedFiles = options.skipBump ? [] : baseline.changedFiles;
const manualVersionsChanged = changedFiles.includes(VERSIONS_PATH);
const prevVersions = options.prevVersionsFile
? JSON.parse(readFileSync(options.prevVersionsFile, "utf8"))
: manualVersionsChanged
? JSON.parse(git(["show", `${options.prevTag}:${VERSIONS_PATH}`]))
: versions;
const prevVersions = baseline.versions;
const result = evaluateAgentVersionBump({
versions,
@ -226,7 +320,8 @@ function main() {
writeFileSync(VERSIONS_PATH, versionsJson);
}
console.log(versionsJson);
outputStepValues(result, options.prevTag, options.migratedFirstRelease);
const buildJre = shouldBuildAgentJre(baseline.changedFiles, options.migratedFirstRelease);
outputStepValues(result, baseline, options.migratedFirstRelease, buildJre);
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@ -1,7 +1,11 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { evaluateAgentVersionBump } from "./bump-agent-versions.mjs";
import { evaluateAgentVersionBump, resolveAgentReleaseBaseline, shouldBuildAgentJre } from "./bump-agent-versions.mjs";
const moduleExists = (path) => path === "agents/drivers/duckdb";
@ -40,3 +44,124 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
assert.equal(result.versions.rabbitmq, "0.1.1");
});
test("builds a manually versioned module even without runtime file changes", () => {
const result = evaluateAgentVersionBump({
versions: { duckdb: "0.1.1" },
prevVersions: { duckdb: "0.1.0" },
changedFiles: ["agents/versions.json"],
moduleExists,
readModuleFile: () => "",
});
assert.deepEqual(result.changedModules, ["duckdb"]);
assert.deepEqual(result.nativeModules, ["duckdb"]);
assert.deepEqual(result.reusedModules, []);
assert.equal(result.versions.duckdb, "0.1.1");
});
test("builds only common-dependent Java modules for a shared runtime change", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/mongodb",
"agents/drivers/mongodb/build.gradle",
]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.0", mongodb: "0.1.0" },
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
legacyStandaloneModules: new Set(["mongodb"]),
moduleExists: (path) => existing.has(path),
readModuleFile: () => "",
});
assert.deepEqual(result.changedModules, ["access"]);
assert.deepEqual(result.javaModules, ["access"]);
assert.deepEqual(result.reusedModules, ["mongodb"]);
assert.equal(result.versions.access, "0.1.1");
assert.equal(result.versions.mongodb, "0.1.0");
});
test("rebuilds JREs only for the first migration or release recipe changes", () => {
assert.equal(shouldBuildAgentJre(["agents/drivers/access/src/main/java/Agent.java"]), false);
assert.equal(shouldBuildAgentJre([".github/workflows/agents-release.yml"]), true);
assert.equal(shouldBuildAgentJre([], true), true);
});
test("uses the first post-tag version sync as the effective release baseline", () => {
const repository = createRepository({ kingbase: "0.1.0" });
git(repository, ["tag", "agents-v0.2.72"]);
writeVersions(repository, { kingbase: "0.1.1" });
commitAll(repository, "chore: bump module versions [skip ci]");
const syncCommit = git(repository, ["rev-parse", "HEAD"]);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n\nconst fixed = true\n");
commitAll(repository, "fix(kingbase): export primary key columns");
const baseline = resolveAgentReleaseBaseline({
prevTag: "agents-v0.2.72",
gitOutput: (args) => git(repository, args),
});
assert.equal(baseline.versionsRef, syncCommit);
assert.deepEqual(baseline.versions, { kingbase: "0.1.1" });
assert.deepEqual(baseline.changedFiles, ["agents/drivers/kingbase-go/kingbase_metadata.go"]);
const result = evaluateAgentVersionBump({
versions: { kingbase: "0.1.1" },
prevVersions: baseline.versions,
changedFiles: baseline.changedFiles,
moduleExists: (path) => path === "agents/drivers/kingbase-go",
readModuleFile: () => "",
});
assert.equal(result.versions.kingbase, "0.1.2");
assert.deepEqual(result.nativeModules, ["kingbase"]);
});
test("keeps versions.json publish-relevant when it changes after the sync commit", () => {
const repository = createRepository({ duckdb: "0.1.0" });
git(repository, ["tag", "agents-v0.2.72"]);
writeVersions(repository, { duckdb: "0.1.1" });
commitAll(repository, "chore: bump module versions [skip ci]");
writeVersions(repository, { duckdb: "0.1.2" });
commitAll(repository, "chore: adjust DuckDB agent version");
const baseline = resolveAgentReleaseBaseline({
prevTag: "agents-v0.2.72",
gitOutput: (args) => git(repository, args),
});
assert.equal(baseline.versionsChangedAfterSync, true);
assert.deepEqual(baseline.versions, { duckdb: "0.1.1" });
assert.deepEqual(baseline.changedFiles, ["agents/versions.json"]);
});
function createRepository(versions) {
const repository = mkdtempSync(join(tmpdir(), "dbx-agent-release-"));
git(repository, ["init", "--initial-branch=main"]);
git(repository, ["config", "user.name", "DBX Test"]);
git(repository, ["config", "user.email", "dbx-test@example.com"]);
mkdirSync(join(repository, "agents/drivers/kingbase-go"), { recursive: true });
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
writeVersions(repository, versions);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
commitAll(repository, "feat(agents): initial release state");
return repository;
}
function writeVersions(repository, versions) {
mkdirSync(join(repository, "agents"), { recursive: true });
writeFileSync(join(repository, "agents/versions.json"), `${JSON.stringify(versions, null, 2)}\n`);
}
function commitAll(repository, message) {
git(repository, ["add", "."]);
git(repository, ["commit", "-m", message]);
}
function git(repository, args) {
return execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim();
}

View File

@ -0,0 +1,371 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
const REGISTRY_ASSET = "agent-registry.json";
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "rabbitmq"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
function artifactFilename(url) {
return basename(url.split(/[?#]/, 1)[0]);
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function releaseAssetMap(release) {
return new Map((release.assets ?? []).map((asset) => [asset.name, asset]));
}
function requireReleaseAsset(assets, artifact, context) {
const name = artifactFilename(artifact.url);
const releaseAsset = assets.get(name);
if (!releaseAsset) {
throw new Error(`${context} is missing from the previous GitHub release: ${name}`);
}
if (!artifact.sha256) {
throw new Error(`${context} is missing sha256 in the previous agent registry: ${name}`);
}
if (releaseAsset.digest !== `sha256:${artifact.sha256}`) {
throw new Error(`${context} digest mismatch between the registry and GitHub release: ${name}`);
}
return { name, sha256: artifact.sha256, size: artifact.size, releaseAsset };
}
export function collectReusableAssetPlan({ registry, release, versions, modules, reuseJre }) {
const assets = releaseAssetMap(release);
const driverAssets = [];
const jreAssets = [];
for (const moduleName of modules) {
const driver = registry.drivers?.[moduleName];
if (!driver) {
throw new Error(`Previous agent registry is missing reusable module: ${moduleName}`);
}
if (driver.version !== versions[moduleName]) {
throw new Error(`Previous agent version mismatch for ${moduleName}: registry=${driver.version}, expected=${versions[moduleName]}`);
}
if (driver.jar) {
driverAssets.push({
...requireReleaseAsset(assets, driver.jar, `${moduleName} Java package`),
moduleName,
kind: "jar",
platform: "",
});
}
const nativePlatforms = Object.keys(driver.native ?? {}).sort();
if (NATIVE_MODULES.has(moduleName)) {
const missingPlatforms = PLATFORMS.filter((platform) => !nativePlatforms.includes(platform));
const extraPlatforms = nativePlatforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
}
}
for (const platform of nativePlatforms) {
driverAssets.push({
...requireReleaseAsset(assets, driver.native[platform], `${moduleName}/${platform} native package`),
moduleName,
kind: "native",
platform,
});
}
if (!driver.jar && nativePlatforms.length === 0) {
throw new Error(`Previous agent registry has no reusable artifacts for module: ${moduleName}`);
}
}
if (reuseJre) {
for (const [jreKey, jre] of Object.entries(registry.jres ?? {})) {
const platforms = Object.keys(jre.platforms ?? {}).sort();
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
}
for (const platform of platforms) {
jreAssets.push({
...requireReleaseAsset(assets, jre.platforms[platform], `JRE ${jreKey}/${platform} package`),
jreKey,
platform,
});
}
}
if (jreAssets.length === 0) {
throw new Error("Previous agent registry has no reusable JRE artifacts.");
}
}
return { driverAssets, jreAssets };
}
function verifyDownloadedAsset(path, asset) {
if (!existsSync(path)) {
throw new Error(`Downloaded release asset is missing: ${asset.name}`);
}
const size = statSync(path).size;
if (asset.size != null && size !== asset.size) {
throw new Error(`Downloaded release asset size mismatch for ${asset.name}: got=${size}, expected=${asset.size}`);
}
const digest = sha256(path);
if (digest !== asset.sha256) {
throw new Error(`Downloaded release asset SHA-256 mismatch for ${asset.name}: got=${digest}, expected=${asset.sha256}`);
}
}
function copyWithoutConflict(source, target) {
if (existsSync(target)) {
if (sha256(source) !== sha256(target)) {
throw new Error(`Reused raw artifact conflicts with an existing file: ${basename(target)}`);
}
return;
}
copyFileSync(source, target);
}
function extractRawDriver(packagePath, asset, outputDir = "") {
const extractDir = mkdtempSync(join(tmpdir(), "dbx-agent-package-"));
try {
execFileSync("tar", ["--use-compress-program=unzstd", "-xf", packagePath, "-C", extractDir], { stdio: "inherit" });
const embeddedRegistry = JSON.parse(readFileSync(join(extractDir, REGISTRY_ASSET), "utf8"));
const driver = embeddedRegistry.drivers?.[asset.moduleName];
if (!driver || driver.version !== asset.releaseVersion) {
throw new Error(`Embedded registry mismatch in ${asset.name}`);
}
const embeddedArtifact = asset.kind === "jar" ? driver.jar : driver.native?.[asset.platform];
if (!embeddedArtifact) {
throw new Error(`Embedded registry artifact is missing in ${asset.name}`);
}
const rawName = artifactFilename(embeddedArtifact.url);
const rawPath = join(extractDir, "drivers", rawName);
if (!existsSync(rawPath)) {
throw new Error(`Embedded raw driver is missing in ${asset.name}: ${rawName}`);
}
const rawSize = statSync(rawPath).size;
if (embeddedArtifact.size != null && rawSize !== embeddedArtifact.size) {
throw new Error(`Embedded raw driver size mismatch in ${asset.name}: ${rawName}`);
}
if (embeddedArtifact.sha256 && sha256(rawPath) !== embeddedArtifact.sha256) {
throw new Error(`Embedded raw driver SHA-256 mismatch in ${asset.name}: ${rawName}`);
}
if (outputDir) {
copyWithoutConflict(rawPath, join(outputDir, rawName));
}
} finally {
rmSync(extractDir, { recursive: true, force: true });
}
}
export function extractReusableDriverPackages({ packagesDir, outputDir, versions, modules }) {
mkdirSync(outputDir, { recursive: true });
const filenames = new Set(readdirSync(packagesDir));
let extracted = 0;
for (const moduleName of modules) {
const releaseVersion = versions[moduleName];
if (!releaseVersion) {
throw new Error(`Missing effective previous version for reusable module: ${moduleName}`);
}
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
if (filenames.has(javaName)) {
extractRawDriver(join(packagesDir, javaName), {
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
}, outputDir);
extracted += 1;
}
const nativePlatforms = [];
for (const platform of PLATFORMS) {
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
if (!filenames.has(nativeName)) continue;
nativePlatforms.push(platform);
extractRawDriver(join(packagesDir, nativeName), {
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
}, outputDir);
extracted += 1;
}
if (NATIVE_MODULES.has(moduleName) && nativePlatforms.length !== PLATFORMS.length) {
throw new Error(`Reusable native package set is incomplete for ${moduleName}.`);
}
if (!filenames.has(javaName) && nativePlatforms.length === 0) {
throw new Error(`Reusable package is missing for module: ${moduleName}`);
}
}
return extracted;
}
function gh(args, options = {}) {
const result = execFileSync("gh", args, { encoding: "utf8", ...options });
return typeof result === "string" ? result.trim() : "";
}
function downloadReleaseAssets(assets, downloadDir) {
if (assets.length === 0) return;
const args = [
"--fail",
"--location",
"--silent",
"--show-error",
"--retry",
"5",
"--retry-all-errors",
"--retry-delay",
"2",
"--connect-timeout",
"30",
"--parallel",
"--parallel-immediate",
"--parallel-max",
"6",
];
for (const asset of assets) {
if (!asset.browser_download_url) {
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
}
args.push("--output", join(downloadDir, asset.name), asset.browser_download_url);
}
execFileSync("curl", args, { stdio: "inherit" });
}
function parseArgs(argv) {
const options = {
repo: "",
tag: "",
versions: {},
modules: [],
reuseJre: false,
outputDir: "",
extractPackagesDir: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[++index];
if (value == null) throw new Error(`Missing value for ${arg}`);
if (arg === "--repo") options.repo = value;
else if (arg === "--tag") options.tag = value;
else if (arg === "--versions") options.versions = JSON.parse(value);
else if (arg === "--modules") options.modules = JSON.parse(value);
else if (arg === "--reuse-jre") options.reuseJre = value === "true";
else if (arg === "--output") options.outputDir = value;
else if (arg === "--extract-packages") options.extractPackagesDir = value;
else throw new Error(`Unexpected argument: ${arg}`);
}
const requiredKeys = options.extractPackagesDir ? ["outputDir"] : ["repo", "tag", "outputDir"];
for (const key of requiredKeys) {
if (!options[key]) throw new Error(`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required.`);
}
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.extractPackagesDir) {
const count = extractReusableDriverPackages({
packagesDir: options.extractPackagesDir,
outputDir: options.outputDir,
versions: options.versions,
modules: options.modules,
});
console.log(`Extracted ${count} reusable driver artifacts.`);
return;
}
const workDir = mkdtempSync(join(tmpdir(), "dbx-agent-reuse-"));
const downloadDir = join(workDir, "downloads");
mkdirSync(downloadDir);
mkdirSync(options.outputDir, { recursive: true });
try {
const release = JSON.parse(gh(["api", `repos/${options.repo}/releases/tags/${options.tag}`]));
const assets = releaseAssetMap(release);
const registryReleaseAsset = assets.get(REGISTRY_ASSET);
if (!registryReleaseAsset?.digest?.startsWith("sha256:")) {
throw new Error(`Previous GitHub release ${options.tag} is missing a SHA-256 digest for ${REGISTRY_ASSET}.`);
}
downloadReleaseAssets([registryReleaseAsset], downloadDir);
const registryPath = join(downloadDir, REGISTRY_ASSET);
const registryDigest = registryReleaseAsset.digest.slice("sha256:".length);
verifyDownloadedAsset(registryPath, {
name: REGISTRY_ASSET,
sha256: registryDigest,
size: registryReleaseAsset.size,
});
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
const plan = collectReusableAssetPlan({
registry,
release,
versions: options.versions,
modules: options.modules,
reuseJre: options.reuseJre,
});
const plannedAssets = [...plan.driverAssets, ...plan.jreAssets].map((asset) => ({
...asset,
releaseVersion: options.versions[asset.moduleName],
}));
if (plannedAssets.length > 0) {
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
}
for (const asset of plannedAssets) {
const source = join(downloadDir, asset.name);
verifyDownloadedAsset(source, asset);
copyFileSync(source, join(options.outputDir, asset.name));
if (asset.moduleName) {
extractRawDriver(source, asset);
}
}
const outputNames = readdirSync(options.outputDir).sort();
console.log(`Reused ${plan.driverAssets.length} driver packages and ${plan.jreAssets.length} JRE packages from ${options.tag}.`);
console.log(outputNames.join("\n"));
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
const platforms = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
test("collects complete reusable Java, native, and JRE assets", () => {
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
const kingbase = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const jre = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
);
const registry = {
drivers: {
access: { version: "0.1.34", jar: access },
kingbase: { version: "0.1.40", native: kingbase },
},
jres: { 21: { version: "21.0.12", platforms: jre } },
};
const release = releaseFor([access, ...Object.values(kingbase), ...Object.values(jre)]);
const plan = collectReusableAssetPlan({
registry,
release,
versions: { access: "0.1.34", kingbase: "0.1.40" },
modules: ["access", "kingbase"],
reuseJre: true,
});
assert.equal(plan.driverAssets.length, 7);
assert.equal(plan.jreAssets.length, 6);
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
});
test("rejects an incomplete reusable native platform set", () => {
const native = Object.fromEntries(
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { duckdb: { version: "0.1.2", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { duckdb: "0.1.2" },
modules: ["duckdb"],
reuseJre: false,
}),
/missing=macos-aarch64/,
);
});
test("rejects a registry version that differs from the effective baseline", () => {
const access = artifact("dbx-agent-access-0.1.33.tar.zst", "a");
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
/registry=0\.1\.33, expected=0\.1\.34/,
);
});
function artifact(name, seed) {
return { url: `https://example.invalid/${name}`, size: 100, sha256: seed.repeat(64).slice(0, 64) };
}
function releaseFor(artifacts) {
return {
assets: artifacts.map((entry) => ({
name: entry.url.split("/").at(-1),
size: entry.size,
digest: `sha256:${entry.sha256}`,
})),
};
}

View File

@ -18,6 +18,13 @@ jobs:
versions: ${{ steps.bump.outputs.versions }}
prev_versions: ${{ steps.bump.outputs.prev_versions }}
prev_tag: ${{ steps.bump.outputs.prev_tag }}
effective_prev_ref: ${{ steps.bump.outputs.effective_prev_ref }}
changed_modules: ${{ steps.bump.outputs.changed_modules }}
java_modules: ${{ steps.bump.outputs.java_modules }}
native_modules: ${{ steps.bump.outputs.native_modules }}
reuse_modules: ${{ steps.bump.outputs.reuse_modules }}
build_jre: ${{ steps.bump.outputs.build_jre }}
reuse_jre: ${{ steps.bump.outputs.reuse_jre }}
steps:
- uses: actions/checkout@v4
with:
@ -121,6 +128,7 @@ jobs:
build-agents:
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.java_modules != '[]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -129,7 +137,16 @@ jobs:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew shadowJar --parallel
- name: Build changed Java agents
env:
JAVA_MODULES: ${{ needs.bump-versions.outputs.java_modules }}
run: |
mapfile -t MODULES < <(echo "$JAVA_MODULES" | python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)))')
TASKS=()
for module in "${MODULES[@]}"; do
TASKS+=(":${module}:shadowJar")
done
./gradlew "${TASKS[@]}" --parallel
working-directory: agents
- run: python3 scripts/validate_agent_jars.py
working-directory: agents
@ -141,6 +158,7 @@ jobs:
build-oracle-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'oracle') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -180,6 +198,7 @@ jobs:
build-xugu-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'xugu') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -219,6 +238,7 @@ jobs:
build-rabbitmq-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rabbitmq') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -258,6 +278,7 @@ jobs:
build-kingbase-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'kingbase') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -297,6 +318,7 @@ jobs:
build-duckdb-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'duckdb') }}
strategy:
fail-fast: false
matrix:
@ -401,6 +423,8 @@ jobs:
path: "release-native/dbx-agent-duckdb-*"
build-jre:
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.build_jre == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
@ -474,8 +498,41 @@ jobs:
name: jre-${{ matrix.jre-key }}
path: "dbx-jre-*.tar.zst"
reuse-previous-assets:
name: Reuse unchanged agent artifacts
needs: [bump-versions]
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' || needs.bump-versions.outputs.reuse_jre == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install artifact tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
- name: Download and verify previous release artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREV_TAG: ${{ needs.bump-versions.outputs.prev_tag }}
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
REUSE_JRE: ${{ needs.bump-versions.outputs.reuse_jre }}
run: |
node .github/scripts/reuse-agent-release-assets.mjs \
--repo "$GITHUB_REPOSITORY" \
--tag "$PREV_TAG" \
--versions "$PREV_VERSIONS" \
--modules "$REUSE_MODULES" \
--reuse-jre "$REUSE_JRE" \
--output reused-release
- uses: actions/upload-artifact@v4
with:
name: reused-agent-artifacts
path: reused-release/*
retention-days: 1
release:
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-kingbase-native, build-duckdb-native, build-jre]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-kingbase-native, build-duckdb-native, build-jre, reuse-previous-assets]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
steps:
- name: Create DBX bot release token
@ -499,18 +556,32 @@ jobs:
with:
path: artifacts
- name: Install artifact tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
- name: Flatten artifacts
run: |
mkdir -p release
find artifacts/agent-jars -name '*.jar' -exec cp {} release/ \;
find artifacts/oracle-native -type f -name 'dbx-agent-oracle-*' -exec cp {} release/ \;
find artifacts/xugu-native -type f -name 'dbx-agent-xugu-*' -exec cp {} release/ \;
find artifacts/rabbitmq-native -type f -name 'dbx-agent-rabbitmq-*' -exec cp {} release/ \;
find artifacts/kingbase-native -type f -name 'dbx-agent-kingbase-*' -exec cp {} release/ \;
find artifacts/duckdb-native-* -type f -name 'dbx-agent-duckdb-*' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*.jar' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*' ! -name '*.jar' ! -name '*.tar.zst' -exec cp {} release/ \;
find artifacts -type f -name 'dbx-agent-*.tar.zst' -exec cp {} release/ \;
find artifacts -name 'dbx-jre-*.tar.zst' -exec cp {} release/ \;
ls -lh release/
- name: Extract reused raw agent artifacts
if: ${{ needs.bump-versions.outputs.reuse_modules != '[]' }}
env:
PREV_VERSIONS: ${{ needs.bump-versions.outputs.prev_versions }}
REUSE_MODULES: ${{ needs.bump-versions.outputs.reuse_modules }}
run: |
node .github/scripts/reuse-agent-release-assets.mjs \
--extract-packages release \
--versions "$PREV_VERSIONS" \
--modules "$REUSE_MODULES" \
--output release
- name: Add versions to agent artifact filenames
env:
MODULE_VERSIONS: ${{ needs.bump-versions.outputs.versions }}
@ -681,8 +752,6 @@ jobs:
- name: Build single-driver packages
run: |
sudo apt-get update
sudo apt-get install -y zstd
python3 agents/scripts/build_driver_zips.py release --cleanup-sources
python3 -m json.tool release/agent-registry.json > /dev/null
echo "=== final agent-registry.json ==="

View File

@ -166,9 +166,9 @@ git push origin "$RELEASE_TAG"
The release workflow will:
- Bump changed module versions in `versions.json`.
- Build all agent shadow jars.
- Build/download JRE `.tar.zst` artifacts.
- Resolve the effective previous module versions from the post-release version-sync commit after the previous `agents-v*` tag.
- Bump and build only changed Java or native agent modules.
- Download unchanged single-driver packages and JRE archives from the previous immutable release, then verify filenames, versions, platform coverage, sizes, and SHA-256 digests before reuse.
- Generate `agent-registry.json`.
- Create full offline platform ZIPs from raw staging files.
- Create one `.tar.zst` package per Java or native driver.

View File

@ -81,7 +81,10 @@ def build_driver_zips(release_dir: Path) -> list[Path]:
package_driver["jar"] = packaged_artifact(jar_artifact, source)
package_registry = {"jres": {}, "drivers": {driver_name: package_driver}}
output = release_dir / f"dbx-agent-{driver_name}-{version}.tar.zst"
write_driver_tar_zstd(output, package_registry, source, executable=False)
if not output.exists():
write_driver_tar_zstd(output, package_registry, source, executable=False)
elif not output.is_file():
raise FileExistsError(f"Reusable Java agent package is not a file: {output}")
update_release_artifact(jar_artifact, output)
outputs.append(output)
@ -96,7 +99,10 @@ def build_driver_zips(release_dir: Path) -> list[Path]:
package_driver["native"] = {platform: packaged_artifact(artifact, source)}
package_registry = {"jres": {}, "drivers": {driver_name: package_driver}}
output = release_dir / f"dbx-agent-{driver_name}-{version}-{platform}.tar.zst"
write_driver_tar_zstd(output, package_registry, source, executable=True)
if not output.exists():
write_driver_tar_zstd(output, package_registry, source, executable=True)
elif not output.is_file():
raise FileExistsError(f"Reusable native agent package is not a file: {output}")
update_release_artifact(artifact, output)
outputs.append(output)
@ -121,7 +127,7 @@ def main() -> None:
args = parser.parse_args()
for path in build_driver_zips(args.release_dir):
print(f"Created {path.name} ({path.stat().st_size} bytes)")
print(f"Prepared {path.name} ({path.stat().st_size} bytes)")
if args.cleanup_sources:
for path in remove_raw_driver_artifacts(args.release_dir):
print(f"Removed intermediate {path.name}")

View File

@ -340,7 +340,7 @@ agents/drivers/<驱动模块名>/build/libs/
#### `agents/versions.json` 什么时候修改
<Callout type="warn">
修改已有驱动时,不要手动修改 `agents/versions.json`。Agent 发布工作流会对比上一个 `agents-v*` 标签,自动为发生变化的模块增加 patch 版本。
修改已有驱动时,不要手动修改 `agents/versions.json`。Agent 发布工作流会以上一个 `agents-v*` 标签对比运行时代码,并使用该版本发布后的版本同步提交作为有效版本基线,自动为发生变化的模块增加 patch 版本;未变化模块会复用上一不可变 Release 中经过校验的产物
</Callout>
- 修改 `agents/drivers/<module>/`:发布时自动 bump 该模块版本

View File

@ -340,7 +340,7 @@ Restart DBX or disconnect and reconnect the database so the old agent process ex
#### When to Change `agents/versions.json`
<Callout type="warn">
Do not edit `agents/versions.json` when changing an existing driver. The agent release workflow compares against the previous `agents-v*` tag and automatically increments the patch version of changed modules.
Do not edit `agents/versions.json` when changing an existing driver. The agent release workflow compares runtime files against the previous `agents-v*` tag, uses that release's post-release version-sync commit as the effective version baseline, and automatically increments the patch version of changed modules. Unchanged modules reuse verified artifacts from the previous immutable release.
</Callout>
- A change under `agents/drivers/<module>/` automatically bumps that module during release

View File

@ -2,7 +2,13 @@ import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { evaluateAgentVersionBump, getAgentVersionChanges, isAgentPublishRelevantFile, parseLegacyStandaloneProjects } from "../.github/scripts/bump-agent-versions.mjs";
import {
evaluateAgentVersionBump,
getAgentVersionChanges,
isAgentPublishRelevantFile,
parseLegacyStandaloneProjects,
resolveAgentReleaseBaseline,
} from "../.github/scripts/bump-agent-versions.mjs";
const REPO = "t8y2/dbx";
const PACKAGES_WORKFLOW = "mcp-release.yml";
@ -47,6 +53,7 @@ const AGENT_RELEASE_PATHS = [
"agents/common/build.gradle",
"agents/common/src/main/",
"agents/drivers/",
".github/workflows/agents-release.yml",
];
const args = process.argv.slice(2);
@ -180,7 +187,7 @@ async function releaseAgents(bump) {
console.log(kv("Release target", "Agents", bold));
console.log(kv("Current agent tag", `${latest.tag}${latest.source ? ` (${latest.source})` : ""}`, yellow));
printReleaseStatus(status);
printAgentVersionChanges(latest.tag, status.changedFiles);
printAgentVersionChanges(status);
if (!status.needed && !force) {
console.log(yellow("No agents release needed; publish-relevant agent runtime files have not changed."));
console.log(dim("Use --force to create the tag anyway."));
@ -453,11 +460,18 @@ function getAgentReleaseStatus(latest = getLatestAgentTag()) {
};
}
const changedFiles = getChangedFilesSince(latest.tag, AGENT_RELEASE_PATHS).filter(isAgentPublishRelevantFile);
const effectiveBaseline = resolveAgentReleaseBaseline({ prevTag: latest.tag });
const changedFiles = effectiveBaseline.changedFiles
.filter((file) => AGENT_RELEASE_PATHS.some((path) => file === path || file.startsWith(path)))
.filter(isAgentPublishRelevantFile);
return {
needed: changedFiles.length > 0,
baseline: latest.tag,
changedFiles,
previousVersions: effectiveBaseline.versions,
versionBaseline: effectiveBaseline.syncCommit
? `${effectiveBaseline.syncCommit.slice(0, 10)} (post-release version sync)`
: latest.tag,
};
}
@ -478,6 +492,9 @@ function getChangedFilesSince(ref, paths) {
function printReleaseStatus(status) {
console.log(kv("Release needed", status.needed ? "yes" : "no", status.needed ? yellow : green));
console.log(kv("Compared against", status.baseline, dim));
if (status.versionBaseline && status.versionBaseline !== status.baseline) {
console.log(kv("Version baseline", status.versionBaseline, dim));
}
if (status.reason) {
console.log(kv("Reason", status.reason, dim));
}
@ -492,20 +509,19 @@ function printReleaseStatus(status) {
}
}
function printAgentVersionChanges(baselineTag, changedFiles) {
if (changedFiles.length === 0 || !refExists(`refs/tags/${baselineTag}`)) return;
function printAgentVersionChanges(status) {
if (status.changedFiles.length === 0 || !status.previousVersions) return;
const currentVersions = JSON.parse(readFileSync("agents/versions.json", "utf8"));
const previousVersions = JSON.parse(run("git", ["show", `${baselineTag}:agents/versions.json`]).stdout);
const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8"));
const result = evaluateAgentVersionBump({
versions: currentVersions,
prevVersions: previousVersions,
changedFiles,
prevVersions: status.previousVersions,
changedFiles: status.changedFiles,
legacyStandaloneModules,
manualVersionsChanged: changedFiles.includes("agents/versions.json"),
manualVersionsChanged: status.changedFiles.includes("agents/versions.json"),
});
const changes = getAgentVersionChanges(previousVersions, result.versions)
const changes = getAgentVersionChanges(status.previousVersions, result.versions)
.map(({ moduleName, previousVersion, nextVersion }) => `${moduleName}: ${previousVersion ?? "new"} -> ${nextVersion}`);
if (changes.length === 0) {