Files
t8y2--dbx/scripts/release.mjs
T
2026-07-13 13:09:14 +08:00

554 lines
18 KiB
JavaScript

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";
const REPO = "t8y2/dbx";
const PACKAGES_WORKFLOW = "mcp-release.yml";
const APP_PUBLISH_WORKFLOW = "publish-packages.yml";
const PACKAGE_TAG_PREFIX = "packages-v";
const AGENT_TAG_PREFIX = "agents-v";
const APP_TAG_PREFIX = "v";
const PACKAGE_RELEASE_PATHS = [
"packages/node-core/src/",
"packages/node-core/README.md",
"packages/node-core/package.json",
"packages/node-core/tsconfig.json",
"packages/cli/src/",
"packages/cli/README.md",
"packages/cli/package.json",
"packages/cli/tsconfig.json",
"packages/mcp-server/src/",
"packages/mcp-server/README.md",
"packages/mcp-server/package.json",
"packages/mcp-server/server.json",
"packages/mcp-server/tsconfig.json",
];
const AGENT_RELEASE_PATHS = [
"agents/build.gradle",
"agents/settings.gradle",
"agents/versions.json",
"agents/common/build.gradle",
"agents/common/src/main/",
"agents/drivers/",
"agents/opengauss/",
];
const args = process.argv.slice(2);
let target = null;
let requestedBump = null;
let dryRun = false;
let yes = false;
let skipFetch = false;
let force = false;
// NO_COLOR (https://no-color.org) wins, then FORCE_COLOR, then autodetect TTY.
const USE_COLOR = !process.env.NO_COLOR && (process.stdout.isTTY || (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0"));
function ansi(code, text) {
return USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
}
const bold = (s) => ansi(1, s);
const dim = (s) => ansi(2, s);
const cyan = (s) => ansi(36, s);
const green = (s) => ansi(32, s);
const yellow = (s) => ansi(33, s);
const red = (s) => ansi(31, s);
const magenta = (s) => ansi(35, s);
// `Label: value` with a dimmed label and a colored value.
function kv(label, value, color = cyan) {
return `${dim(label)}: ${color(value)}`;
}
for (const arg of args) {
switch (arg) {
case "--":
break;
case "--dry-run":
dryRun = true;
break;
case "-y":
case "--yes":
yes = true;
break;
case "--skip-fetch":
skipFetch = true;
break;
case "--force":
force = true;
break;
case "-h":
case "--help":
printHelp();
process.exit(0);
break;
default: {
const normalizedTarget = normalizeTarget(arg);
if (!target && normalizedTarget) {
target = normalizedTarget;
} else if (!requestedBump) {
requestedBump = arg;
} else {
fail(`Unexpected argument: ${arg}`);
}
break;
}
}
}
const repoRoot = run("git", ["rev-parse", "--show-toplevel"]).stdout.trim();
process.chdir(repoRoot);
if (!skipFetch) {
fetchReleaseTags();
}
if (!target) {
if (!process.stdin.isTTY) {
fail("Release target is required in a non-interactive shell. Use packages, agents, or app.");
}
target = await promptTarget();
}
if (target === "packages") {
await releasePackages(requestedBump ?? "patch");
} else if (target === "agents") {
await releaseAgents(requestedBump ?? "patch");
} else if (target === "app") {
await publishApp(requestedBump);
} else {
fail(`Unknown release target: ${target}`);
}
async function releasePackages(bump) {
const status = getPackageReleaseStatus();
const latestVersion = status.latestVersion ?? getLatestPackageVersion();
const releaseVersion = resolveReleaseVersion(bump, latestVersion, PACKAGE_TAG_PREFIX);
const releaseTag = `${PACKAGE_TAG_PREFIX}${releaseVersion}`;
const workflowArgs = ["workflow", "run", PACKAGES_WORKFLOW, "--repo", REPO, "-f", `version=${releaseVersion}`];
console.log(kv("Release target", "Node packages / MCP", bold));
console.log(kv("Current package version", latestVersion ?? "none", green));
printReleaseStatus(status);
if (!status.needed && !force) {
console.log(yellow("No Node package release needed; publish-relevant package files have not changed."));
console.log(dim("Use --force to trigger the workflow anyway."));
return;
}
console.log(kv("New package version", releaseVersion, green));
console.log(kv("Release tag", releaseTag, yellow));
console.log(kv("Workflow", `Node Packages Release (${PACKAGES_WORKFLOW})`, magenta));
console.log(kv("Command", `gh ${workflowArgs.join(" ")}`, dim));
if (dryRun) {
console.log(dim("Dry run only; workflow was not triggered."));
return;
}
ensureGhReady(PACKAGES_WORKFLOW);
await confirmOrExit(`Confirm triggering Node Packages Release for ${bold(releaseVersion)}? [y/N] `);
run("gh", workflowArgs, { stdio: "inherit" });
console.log(green(`Triggered Node Packages Release for ${releaseVersion}.`));
}
async function releaseAgents(bump) {
const latest = getLatestAgentTag();
const status = getAgentReleaseStatus(latest);
const releaseTag = resolveAgentTag(bump, latest.tag);
if (tagExists(releaseTag)) {
fail(`Tag ${releaseTag} already exists.`);
}
console.log(kv("Release target", "Agents", bold));
console.log(kv("Current agent tag", `${latest.tag}${latest.source ? ` (${latest.source})` : ""}`, yellow));
printReleaseStatus(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."));
return;
}
console.log(kv("New agent tag", releaseTag, yellow));
console.log(kv("Workflow", "Agents Release (.github/workflows/agents-release.yml)", magenta));
console.log(kv("Commands", `git tag ${releaseTag} && git push origin ${releaseTag}`, dim));
if (dryRun) {
console.log(dim("Dry run only; tag was not created or pushed."));
return;
}
await confirmOrExit(`Confirm creating and pushing tag ${bold(releaseTag)}? [y/N] `);
run("git", ["tag", releaseTag], { stdio: "inherit" });
run("git", ["push", "origin", releaseTag], { stdio: "inherit" });
console.log(green(`Pushed ${releaseTag}; Agents Release will run from the tag push.`));
}
async function publishApp(tagInput) {
const latest = getLatestAppTag();
const releaseTag = tagInput ? resolveAppTag(tagInput) : latest.tag;
const workflowArgs = ["workflow", "run", APP_PUBLISH_WORKFLOW, "--repo", REPO, "-f", `tag=${releaseTag}`];
console.log(kv("Release target", "App distribution", bold));
console.log(kv("Latest app tag", latest.tag, yellow));
console.log(kv("Publish tag", releaseTag, yellow));
console.log(kv("Workflow", `Publish Packages (${APP_PUBLISH_WORKFLOW})`, magenta));
console.log(kv("Command", `gh ${workflowArgs.join(" ")}`, dim));
if (dryRun) {
console.log(dim("Dry run only; workflow was not triggered."));
return;
}
ensureGhReady(APP_PUBLISH_WORKFLOW);
run("gh", ["release", "view", releaseTag, "--repo", REPO], { stdio: "inherit" });
await confirmOrExit(`Confirm publishing app distribution for ${bold(releaseTag)}? [y/N] `);
run("gh", workflowArgs, { stdio: "inherit" });
console.log(green(`Triggered Publish Packages for ${releaseTag}.`));
}
async function promptTarget() {
const packageStatus = getPackageReleaseStatus();
const agentStatus = getAgentReleaseStatus(getLatestAgentTag());
const answer = await ask(`${bold("Select release target:")}
${cyan("1")}. Node packages / MCP (${formatStatusSummary(packageStatus)})
${cyan("2")}. Agents (${formatStatusSummary(agentStatus)})
${cyan("3")}. App distribution
${dim("Choice")} [1]: `);
const normalized = answer.trim().toLowerCase();
if (!normalized || normalized === "1" || normalized === "packages" || normalized === "mcp") return "packages";
if (normalized === "2" || normalized === "agents" || normalized === "agent") return "agents";
if (normalized === "3" || normalized === "app" || normalized === "desktop" || normalized === "publish") return "app";
fail(`Unknown release target: ${answer}`);
}
async function confirmOrExit(message) {
if (yes) return;
if (!process.stdin.isTTY) {
fail("Refusing to trigger release without confirmation in a non-interactive shell. Re-run with --yes if this is intentional.");
}
const answer = await ask(message);
if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
console.log(dim("Cancelled."));
process.exit(0);
}
}
async function ask(question) {
const rl = createInterface({ input, output });
const answer = await rl.question(question);
rl.close();
return answer;
}
function normalizeTarget(value) {
if (["package", "packages", "node-packages", "node", "mcp"].includes(value)) return "packages";
if (["agent", "agents"].includes(value)) return "agents";
if (["app", "desktop", "publish", "publish-packages", "distribution"].includes(value)) return "app";
return null;
}
function getLatestPackageVersion() {
const tag = getLatestSemverTag(PACKAGE_TAG_PREFIX);
if (tag) return tag.versionText;
const packageVersions = [
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/mcp-server/package.json",
].map((path) => JSON.parse(readFileSync(path, "utf8")).version);
const uniqueVersions = [...new Set(packageVersions)];
if (uniqueVersions.length !== 1) {
fail(`Package versions differ and no ${PACKAGE_TAG_PREFIX} tag was found: ${uniqueVersions.join(", ")}`);
}
return uniqueVersions[0];
}
function getPackageReleaseStatus() {
const latest = getLatestSemverTag(PACKAGE_TAG_PREFIX);
if (!latest) {
return {
needed: true,
baseline: "no packages-v* tag",
latestVersion: getLatestPackageVersion(),
changedFiles: [],
reason: "No previous package release tag was found.",
};
}
const changedFiles = getChangedFilesSince(latest.tag, PACKAGE_RELEASE_PATHS);
return {
needed: changedFiles.length > 0,
baseline: latest.tag,
latestVersion: latest.versionText,
changedFiles,
};
}
function getLatestAgentTag() {
const tag = getLatestSemverTag(AGENT_TAG_PREFIX);
if (tag) return { tag: tag.tag, source: "current repo" };
const legacyRepo = "../dbx-agents";
const legacyRepoCheck = spawnSync("git", ["-C", legacyRepo, "rev-parse", "--is-inside-work-tree"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
if (legacyRepoCheck.status === 0) {
const legacyTags = run("git", ["-C", legacyRepo, "tag", "--list", "v*"]).stdout
.split(/\r?\n/)
.map((legacyTag) => legacyTag.trim())
.filter(Boolean)
.map((legacyTag) => ({ legacyTag, version: parseVersion(legacyTag.replace(/^v/, "")) }))
.filter((entry) => entry.version)
.sort((a, b) => compareVersions(b.version, a.version));
if (legacyTags.length > 0) {
return {
tag: `${AGENT_TAG_PREFIX}${formatVersion(legacyTags[0].version)}`,
source: `${legacyRepo} ${legacyTags[0].legacyTag}`,
};
}
}
return { tag: `${AGENT_TAG_PREFIX}0.0.0`, source: "initial baseline" };
}
function getAgentReleaseStatus(latest = getLatestAgentTag()) {
if (!refExists(`refs/tags/${latest.tag}`)) {
return {
needed: true,
baseline: `${latest.tag} (${latest.source ?? "missing local tag"})`,
changedFiles: [],
reason: "No comparable local agents release tag was found.",
};
}
const changedFiles = getChangedFilesSince(latest.tag, AGENT_RELEASE_PATHS);
return {
needed: changedFiles.length > 0,
baseline: latest.tag,
changedFiles,
};
}
function getLatestAppTag() {
const tag = getLatestSemverTag(APP_TAG_PREFIX);
if (!tag) {
fail("No v* app release tag was found.");
}
return { tag: tag.tag };
}
function getChangedFilesSince(ref, paths) {
const output = run("git", ["diff", "--name-only", `${ref}..HEAD`, "--", ...paths]).stdout.trim();
if (!output) return [];
return output.split(/\r?\n/).filter(Boolean);
}
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.reason) {
console.log(kv("Reason", status.reason, dim));
}
if (status.changedFiles.length > 0) {
console.log(dim("Changed publish-relevant files:"));
for (const file of status.changedFiles.slice(0, 20)) {
console.log(` ${dim("-")} ${file}`);
}
if (status.changedFiles.length > 20) {
console.log(dim(` ... and ${status.changedFiles.length - 20} more`));
}
}
}
function formatStatusSummary(status) {
if (status.needed) {
const detail = status.changedFiles.length > 0 ? `${status.changedFiles.length} changed file${status.changedFiles.length === 1 ? "" : "s"}` : "release baseline missing";
return `${yellow("needs release")}, ${detail}`;
}
return `${green("no release needed")} since ${status.baseline}`;
}
function getLatestSemverTag(prefix) {
const tags = run("git", ["tag", "--list", `${prefix}*`]).stdout
.split(/\r?\n/)
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => ({ tag, version: parseVersion(tag.replace(prefix, "")) }))
.filter((entry) => entry.version)
.sort((a, b) => compareVersions(b.version, a.version));
if (tags.length === 0) return null;
return { ...tags[0], versionText: formatVersion(tags[0].version) };
}
function refExists(ref) {
const result = spawnSync("git", ["rev-parse", "-q", "--verify", ref], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
return result.status === 0;
}
function resolveAgentTag(bump, latestTag) {
const explicitVersion = normalizeExplicitVersion(bump, AGENT_TAG_PREFIX);
if (explicitVersion) return `${AGENT_TAG_PREFIX}${explicitVersion}`;
const latestVersion = latestTag.replace(AGENT_TAG_PREFIX, "");
return `${AGENT_TAG_PREFIX}${resolveReleaseVersion(bump, latestVersion, AGENT_TAG_PREFIX)}`;
}
function resolveAppTag(value) {
if (["patch", "minor", "major"].includes(value)) {
fail("App distribution publishing requires an existing vX.Y.Z release tag, not a version bump.");
}
const explicitVersion = normalizeExplicitVersion(value, APP_TAG_PREFIX);
if (!explicitVersion) {
fail(`Invalid app release tag '${value}'. Use vX.Y.Z or X.Y.Z.`);
}
return `${APP_TAG_PREFIX}${explicitVersion}`;
}
function resolveReleaseVersion(bump, latestVersion, explicitTagPrefix) {
const normalizedVersion = normalizeExplicitVersion(bump, explicitTagPrefix);
if (normalizedVersion) return normalizedVersion;
if (!["patch", "minor", "major"].includes(bump)) {
fail(`Unknown version bump '${bump}'. Use patch, minor, major, or an explicit semver version.`);
}
const latest = parseVersion(latestVersion);
if (!latest || latest.prerelease) {
fail(`Cannot ${bump} bump from non-standard version '${latestVersion}'. Pass an explicit version instead.`);
}
if (bump === "major") return `${latest.major + 1}.0.0`;
if (bump === "minor") return `${latest.major}.${latest.minor + 1}.0`;
return `${latest.major}.${latest.minor}.${latest.patch + 1}`;
}
function normalizeExplicitVersion(value, explicitTagPrefix) {
const trimmed = value.trim().replace(new RegExp(`^${escapeRegExp(explicitTagPrefix)}`), "").replace(/^v/, "");
const version = parseVersion(trimmed);
return version ? formatVersion(version) : null;
}
function parseVersion(value) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? "",
};
}
function formatVersion(version) {
return `${version.major}.${version.minor}.${version.patch}${version.prerelease ? `-${version.prerelease}` : ""}`;
}
function compareVersions(a, b) {
for (const key of ["major", "minor", "patch"]) {
if (a[key] !== b[key]) return a[key] - b[key];
}
if (a.prerelease === b.prerelease) return 0;
if (!a.prerelease) return 1;
if (!b.prerelease) return -1;
return a.prerelease.localeCompare(b.prerelease);
}
function tagExists(tag) {
const result = spawnSync("git", ["rev-parse", "-q", "--verify", `refs/tags/${tag}`], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "pipe",
});
return result.status === 0;
}
function ensureGhReady(workflow) {
run("gh", ["auth", "status", "--hostname", "github.com"], { stdio: "inherit" });
run("gh", ["workflow", "view", workflow, "--repo", REPO], { stdio: "inherit" });
}
function fetchReleaseTags() {
run("git", [
"fetch",
"--quiet",
"origin",
"+refs/tags/v*:refs/tags/v*",
"+refs/tags/packages-v*:refs/tags/packages-v*",
"+refs/tags/agents-v*:refs/tags/agents-v*",
]);
}
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, {
cwd: process.cwd(),
env: process.env,
encoding: "utf8",
stdio: options.stdio ?? "pipe",
});
if (result.error) {
fail(`${command} ${commandArgs.join(" ")} failed: ${result.error.message}`);
}
if (result.status !== 0) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
fail(`${command} ${commandArgs.join(" ")} failed${stderr ? `:\n${stderr}` : ""}`);
}
return result;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fail(message) {
console.error(red(message));
process.exit(1);
}
function printHelp() {
console.log(`Usage: node scripts/release.mjs [packages|agents|app] [patch|minor|major|version] [options]
Unified release trigger for DBX packages, agents, and app distribution.
Targets:
packages Trigger Node Packages Release via gh workflow run
agents Create and push an agents-v* tag
app Trigger Publish Packages for an existing v* app release tag
Arguments:
patch Bump the latest target tag by one patch version (default)
minor Bump the latest target tag by one minor version
major Bump the latest target tag by one major version
0.4.14 Trigger an explicit version
packages-v0.4.14 Explicit package tag style, for packages
agents-v0.4.14 Explicit agent tag style, for agents
v0.5.38 Existing app release tag, for app
Options:
--dry-run Print the release command without triggering it
-y, --yes Skip the confirmation prompt
--skip-fetch Do not run git fetch --tags before reading release tags
--force Allow a package/agent release even when no relevant files changed
-h, --help Show this help
`);
}