diff --git a/agents/scripts/release.sh b/agents/scripts/release.sh index 90ce756b30..2c261bac21 100755 --- a/agents/scripts/release.sh +++ b/agents/scripts/release.sh @@ -1,86 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# 自动打 tag 并推送触发发布 -# 用法: -# ./scripts/release.sh # 自动递增 patch 版本 -# ./scripts/release.sh minor # 递增 minor 版本 -# ./scripts/release.sh major # 递增 major 版本 -# ./scripts/release.sh agents-v0.3.0 # 手动指定版本号 -# ./scripts/release.sh v0.3.0 # 手动指定旧 dbx-agents 版本号,会转换为 agents-v0.3.0 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" -BUMP="${1:-patch}" - -to_agents_tag() { - case "$1" in - agents-v*) echo "$1" ;; - v*) echo "agents-$1" ;; - *) echo "$1" ;; - esac -} - -bump_tag() { - local tag="$1" - local bump="$2" - local version major minor patch - - version="${tag#agents-v}" - IFS='.' read -r major minor patch <<< "$version" - - case "$bump" in - major) - echo "agents-v$((major + 1)).0.0" - ;; - minor) - echo "agents-v${major}.$((minor + 1)).0" - ;; - patch) - echo "agents-v${major}.${minor}.$((patch + 1))" - ;; - agents-v*|v*) - to_agents_tag "$bump" - ;; - *) - echo "错误: 未知的版本递增方式 '$bump',请使用 patch/minor/major 或 agents-vX.Y.Z" >&2 - return 1 - ;; - esac -} - -# 获取当前仓库最新的 agents-v 开头的 tag -latest_tag=$(git tag --sort=-v:refname | grep '^agents-v' | head -1 || true) - -if [ -z "$latest_tag" ]; then - # 迁移后首次发布时,沿用旁边旧 dbx-agents 仓库的 vX.Y.Z tag。 - legacy_tag="" - if git -C ../dbx-agents rev-parse --is-inside-work-tree >/dev/null 2>&1; then - legacy_tag=$(git -C ../dbx-agents tag --sort=-v:refname | grep '^v' | head -1 || true) - fi - - if [ -n "$legacy_tag" ]; then - latest_tag=$(to_agents_tag "$legacy_tag") - echo "当前仓库未找到 Agent 版本 tag,沿用旧 dbx-agents 最新 tag: $legacy_tag" - else - latest_tag="agents-v0.0.0" - echo "未找到任何 Agent 版本 tag,从 agents-v0.1.0 开始" - fi -else - echo "当前最新 tag: $latest_tag" -fi - -new_tag=$(bump_tag "$latest_tag" "$BUMP") - -echo "新版本: $new_tag" - -# 确认 -read -r -p "确认创建并推送 tag ${new_tag}? [y/N] " confirm -if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then - echo "已取消" - exit 0 -fi - -# 创建并推送 tag -git tag "$new_tag" -git push origin "$new_tag" - -echo "完成! tag $new_tag 已推送,CI 将自动触发发布流程。" +cd "${ROOT_DIR}" +exec node scripts/release.mjs agents "$@" diff --git a/package.json b/package.json index 22a9c16c9d..150e438cbd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test", "pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check", "publish:dry-run": "pnpm build:packages && pnpm pack:packages", + "release": "node scripts/release.mjs", + "release:agents": "node scripts/release.mjs agents", + "release:app": "node scripts/release.mjs app", + "release:packages": "node scripts/release.mjs packages", "typecheck": "vue-tsc --noEmit --project apps/desktop/tsconfig.json", "build": "vite build --config apps/desktop/vite.config.ts", "build:checked": "pnpm typecheck && pnpm build", diff --git a/scripts/release-node-packages.mjs b/scripts/release-node-packages.mjs new file mode 100644 index 0000000000..6af92e5fb5 --- /dev/null +++ b/scripts/release-node-packages.mjs @@ -0,0 +1,9 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const releaseScript = fileURLToPath(new URL("./release.mjs", import.meta.url)); +const result = spawnSync(process.execPath, [releaseScript, "packages", ...process.argv.slice(2)], { + stdio: "inherit", +}); + +process.exit(result.status ?? 1); diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000000..724e7421ea --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,401 @@ +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 args = process.argv.slice(2); +let target = null; +let requestedBump = null; +let dryRun = false; +let yes = false; +let skipFetch = false; + +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 "-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) { + run("git", ["fetch", "--tags", "--quiet", "origin"]); +} + +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 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(`Release target: Node packages / MCP`); + console.log(`Current package version: ${latestVersion ?? "none"}`); + console.log(`New package version: ${releaseVersion}`); + console.log(`Release tag: ${releaseTag}`); + console.log(`Workflow: Node Packages Release (${PACKAGES_WORKFLOW})`); + console.log(`Command: gh ${workflowArgs.join(" ")}`); + + if (dryRun) { + console.log("Dry run only; workflow was not triggered."); + return; + } + + ensureGhReady(PACKAGES_WORKFLOW); + await confirmOrExit(`Confirm triggering Node Packages Release for ${releaseVersion}? [y/N] `); + + run("gh", workflowArgs, { stdio: "inherit" }); + console.log(`Triggered Node Packages Release for ${releaseVersion}.`); +} + +async function releaseAgents(bump) { + const latest = getLatestAgentTag(); + const releaseTag = resolveAgentTag(bump, latest.tag); + + if (tagExists(releaseTag)) { + fail(`Tag ${releaseTag} already exists.`); + } + + console.log(`Release target: Agents`); + console.log(`Current agent tag: ${latest.tag}${latest.source ? ` (${latest.source})` : ""}`); + console.log(`New agent tag: ${releaseTag}`); + console.log(`Workflow: Agents Release (.github/workflows/agents-release.yml)`); + console.log(`Commands: git tag ${releaseTag} && git push origin ${releaseTag}`); + + if (dryRun) { + console.log("Dry run only; tag was not created or pushed."); + return; + } + + await confirmOrExit(`Confirm creating and pushing tag ${releaseTag}? [y/N] `); + + run("git", ["tag", releaseTag], { stdio: "inherit" }); + run("git", ["push", "origin", releaseTag], { stdio: "inherit" }); + console.log(`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(`Release target: App distribution`); + console.log(`Latest app tag: ${latest.tag}`); + console.log(`Publish tag: ${releaseTag}`); + console.log(`Workflow: Publish Packages (${APP_PUBLISH_WORKFLOW})`); + console.log(`Command: gh ${workflowArgs.join(" ")}`); + + if (dryRun) { + console.log("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 ${releaseTag}? [y/N] `); + + run("gh", workflowArgs, { stdio: "inherit" }); + console.log(`Triggered Publish Packages for ${releaseTag}.`); +} + +async function promptTarget() { + const answer = await ask(`Select release target: + 1. Node packages / MCP + 2. Agents + 3. App distribution +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("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 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 getLatestAppTag() { + const tag = getLatestSemverTag(APP_TAG_PREFIX); + if (!tag) { + fail("No v* app release tag was found."); + } + return { tag: tag.tag }; +} + +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 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 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(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 + -h, --help Show this help +`); +}