diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md index 6a7f025..27159fb 100644 --- a/docs/agents/binary-distribution.md +++ b/docs/agents/binary-distribution.md @@ -31,8 +31,9 @@ Publish workflow ### A. 本仓库构建 / Release - [ ] `node tools/release/lib/binary-build.mjs --mode stable --host` -- [ ] `dist-bin/` 含矩阵二进制、`SHA256SUMS`、`latest.json`(channel 为 `.json`) -- [ ] dry-run:`node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run` +- [ ] `dist-bin/` 含**完整**矩阵二进制、`SHA256SUMS`、`latest.json`(channel 为 `.json`) +- [ ] manifest asset 只有 `file` + `sha256`(无硬编码 `url`;客户端按 OSS `{base}/releases/{version}/{file}` 拼) +- [ ] dry-run:`node tools/release/lib/binary-release.mjs --mode stable --dry-run`(不编译) - [ ] Release **不含** 生产 install 脚本 ### B. 仓外(联调时确认) @@ -53,19 +54,31 @@ node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run vp check ``` +实现分层(均在 `tools/release/lib/`): + +- `binary-build.mjs` / `binary-compile.mjs` — 编译 + manifest +- `binary-options.mjs` — 共享 `--mode` / `--channel` 校验 +- `binary-release.mjs` — 编排(stable/channel 上传哪些资产) +- `gh-release.mjs` — `gh release` create / clobber / verify +- `oss-sync-webhook.mjs` — 可选 FC 通知 + ## 常见漏点 -| 漏点 | 后果 | -| ---------------------- | ------------------------- | -| 只发 npm、未建 Release | FC 无源可同步 | -| FC 未跑完用户就 curl | OSS 404 / 半包 | -| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | -| webhook 配错当发版失败 | 不应;webhook 失败只 warn | -| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | -| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | +| 漏点 | 后果 | +| ------------------------------------ | ----------------------------------------------------------------- | +| 只发 npm、未建 Release | FC 无源可同步 | +| FC 未跑完用户就 curl | OSS 404 / 半包 | +| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | +| webhook 配错当发版失败 | 不应;webhook 失败只 warn | +| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | +| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | +| manifest 写死 GitHub `url` | FC 同步后 `bl update` 仍打 GitHub,绕开 OSS | +| `--dry-run` 仍全量 compile | 本地验证极慢;dry-run 应只规划 gh / webhook | +| 用 `--host` 产物去 upload | 半包上架;release 路径会校验完整矩阵 | ## 编译实现注意 - `binary-compile.mjs` 必须走 **`bun build --compile --outfile …`**,不要用 `Bun.build({ compile })`(CI 钉 `1.2.19` 时 API 会假成功)。 - 编译后校验 outfile 存在再算 SHA256。 - 每个产物在哈希前 `chmod 0755`(规避 Bun 1.2.19 windows cross-compile 无权限,见 oven-sh/bun#21308)。 +- channel 同日同 commit 共用一个 `v0.0.0-beta-…` Release;滚动 tag `channel-` 只挂 `.json`。 diff --git a/docs/agents/publish.md b/docs/agents/publish.md index ab8bba6..f6b85d7 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -16,10 +16,13 @@ ```text publish-stable.mjs / publish-channel.mjs ← 唯一发版入口 ├─ npm(pnpm publish) - └─ binary(lib/binary-release → lib/binary-build + gh release) + └─ binary(lib/binary-release + → binary-build + → gh-release + → oss-sync-webhook) ``` -`tools/release/lib/binary-release.mjs` / `binary-build.mjs` 是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution 方案](../proposals/binary-distribution.md)。 +`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution](binary-distribution.md)。 两种模式: diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 030b118..e5dee42 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -16,12 +16,11 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { parseArgs } from "node:util"; import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; -import { assertChannel } from "./validate.mjs"; +import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url)); const CLI_ENTRY = join(ROOT, "packages/cli/src/main.ts"); const DEFAULT_OUTDIR = join(ROOT, "dist-bin"); -const DEFAULT_CDN = "https://github.com/modelstudioai/cli/releases"; const USAGE = "Usage: node tools/release/lib/binary-build.mjs [--mode stable|channel] [--channel ] [--host] [--target ] [--outdir ]\n"; @@ -33,6 +32,16 @@ export const BINARY_TARGETS = [ { bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true }, ]; +/** Asset basename for a matrix row: `bl---[.exe]`. */ +export function binaryAssetName(version, { os, arch, exe }) { + return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; +} + +/** Full matrix basenames for a version (order matches BINARY_TARGETS). */ +export function matrixAssetNames(version) { + return BINARY_TARGETS.map((target) => binaryAssetName(version, target)); +} + function log(message = "") { process.stdout.write(`${message}\n`); } @@ -41,10 +50,6 @@ function writeJson(path, value) { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } -function cdnBase() { - return (process.env.BAILIAN_CLI_CDN || DEFAULT_CDN).replace(/\/$/, ""); -} - function parseCliArgs(argv) { const { values } = parseArgs({ args: argv, @@ -78,22 +83,12 @@ function normalizeBuildOptions({ mode = "stable", channel = null, }) { - if (mode !== "stable" && mode !== "channel") { - throw new Error(`--mode must be stable or channel, got: ${mode}`); - } - if (mode === "channel") { - if (!channel) throw new Error("--mode channel requires --channel "); - assertChannel(channel); - if (channel === "stable") { - throw new Error(`--channel cannot be "stable"; use --mode stable`); - } - } + const modeChannel = normalizeModeChannel(mode, channel); return { outdir: outdir ?? DEFAULT_OUTDIR, onlyTarget, hostOnly: Boolean(hostOnly), - mode, - channel: mode === "channel" ? channel : null, + ...modeChannel, }; } @@ -133,16 +128,12 @@ function sha256File(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } -function assetFileName(version, os, arch, exe) { - return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; -} - function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { - const fileName = assetFileName(version, os, arch, exe); + const fileName = binaryAssetName(version, { os, arch, exe }); const outfile = join(outdir, fileName); log(`compile ${bunTarget} → ${fileName}`); - // Bun.build() lives in binary-compile.mjs (must run under Bun); this file stays Node. + // binary-compile.mjs shells out to `bun build --compile` (CLI); this file stays Node. const result = spawnSync( "bun", [BINARY_COMPILE, "--entry", entry, "--outfile", outfile, "--target", bunTarget], @@ -165,15 +156,19 @@ function writeChecksums(outdir, artifacts) { writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`); } -function writeChannelManifest(outdir, version, artifacts, mode, channel) { - const base = cdnBase(); +/** + * Write latest.json (stable) or .json (channel). + * Asset entries carry file + sha256 only — no baked download URL. + * Consumers (bl update / install scripts) resolve via BAILIAN_CLI_CDN + + * `{base}/releases/{version}/{file}` (see packages/core releaseAssetUrl). + */ +function writeManifest(outdir, version, artifacts, mode, channel) { const assets = Object.fromEntries( artifacts.map((item) => [ `${item.os}-${item.arch}`, { file: item.fileName, sha256: item.sha256, - url: `${base}/download/v${version}/${item.fileName}`, }, ]), ); @@ -184,9 +179,9 @@ function writeChannelManifest(outdir, version, artifacts, mode, channel) { releasedAt: new Date().toISOString(), assets, }; - const names = mode === "stable" ? ["latest.json"] : [`${channel}.json`]; - for (const name of names) writeJson(join(outdir, name), manifest); - return names; + const name = manifestFileName(mode, channel); + writeJson(join(outdir, name), manifest); + return [name]; } function cliVersion() { @@ -207,7 +202,7 @@ function smokeTestHostBinary(artifacts, outdir) { } } -/** Compile binaries into `outdir` and write checksums + channel manifest. */ +/** Compile binaries into `outdir` and write checksums + manifest. */ export function buildBinaryArtifacts(rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); const { outdir, mode, channel } = options; @@ -223,7 +218,7 @@ export function buildBinaryArtifacts(rawOptions = {}) { const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY)); writeChecksums(outdir, artifacts); - const manifests = writeChannelManifest(outdir, version, artifacts, mode, channel); + const manifests = writeManifest(outdir, version, artifacts, mode, channel); smokeTestHostBinary(artifacts, outdir); log(`\nBuilt ${artifacts.length} binary(ies):`); diff --git a/tools/release/lib/binary-compile.mjs b/tools/release/lib/binary-compile.mjs index 8cd288c..778f4a1 100644 --- a/tools/release/lib/binary-compile.mjs +++ b/tools/release/lib/binary-compile.mjs @@ -2,9 +2,8 @@ * Single-target Bun compile helper. Must be run with Bun on PATH: * bun tools/release/lib/binary-compile.mjs --entry --outfile --target * - * Uses `bun build --compile` (CLI), not `Bun.build({ compile })`. - * Bun ≤1.2.19's Build API can report success without writing `compile.outfile` - * (API support landed properly around 1.2.21). CLI works on the pinned CI version. + * Uses `bun build --compile` (CLI). The Bun.build({ compile }) API on ≤1.2.19 + * can exit 0 without writing outfile; CI pins 1.2.19 so we stay on the CLI. * * Called by binary-build.mjs (Node orchestration stays on Node). */ @@ -61,9 +60,6 @@ if (result.status !== 0) { } if (!existsSync(outfile)) { - console.error( - `bun build --compile exited 0 but outfile missing: ${outfile}\n` + - `(Bun Build API compile.outfile is unreliable on some versions; this helper uses the CLI.)`, - ); + console.error(`bun build --compile exited 0 but outfile missing: ${outfile}`); process.exit(1); } diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs new file mode 100644 index 0000000..59457e0 --- /dev/null +++ b/tools/release/lib/binary-options.mjs @@ -0,0 +1,29 @@ +/** + * Shared mode / channel / manifest naming for binary-build and binary-release. + */ +import { assertChannel } from "./validate.mjs"; + +/** + * @param {string} mode + * @param {string | null | undefined} channel + * @returns {{ mode: "stable" | "channel", channel: string | null }} + */ +export function normalizeModeChannel(mode = "stable", channel = null) { + if (mode !== "stable" && mode !== "channel") { + throw new Error(`--mode must be stable or channel, got: ${mode}`); + } + if (mode === "channel") { + if (!channel) throw new Error("--mode channel requires --channel "); + assertChannel(channel); + if (channel === "stable") { + throw new Error(`--channel cannot be "stable"; use --mode stable`); + } + return { mode, channel }; + } + return { mode: "stable", channel: null }; +} + +/** Manifest basename written to dist-bin / uploaded to Releases. */ +export function manifestFileName(mode, channel) { + return mode === "stable" ? "latest.json" : `${channel}.json`; +} diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 098fcc5..624523a 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -1,13 +1,16 @@ /** - * Publish bailian-cli binary assets to GitHub Releases via the `gh` CLI. + * Publish bailian-cli binary assets to GitHub Releases. * * stable: release `v` (tag must already be on origin; --verify-tag) * assets: bl-*, SHA256SUMS, latest.json * channel: versioned prerelease `v` (assets: bl-*, SHA256SUMS) * + rolling prerelease tag `channel-` holding only `.json` * - * Re-runs are idempotent: existing releases get `gh release upload --clobber`. - * Optionally POSTs BAILIAN_OSS_SYNC_WEBHOOK so an external FC can mirror to OSS. + * Same commit/day channel publishes share one `v` Release (identical + * binaries); only the rolling `channel-` manifest differs per channel. + * + * Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs). + * Optionally notifies BAILIAN_OSS_SYNC_WEBHOOK (see oss-sync-webhook.mjs). * * Called by publish-stable.mjs / publish-channel.mjs. * Debug: @@ -15,90 +18,16 @@ * node tools/release/lib/binary-release.mjs --mode channel --channel beta --dry-run */ import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseArgs as parseCliArgs } from "node:util"; import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; -import { BINARY_TARGETS, buildBinaryArtifacts } from "./binary-build.mjs"; -import { run, runCapture, tryRun } from "./proc.mjs"; -import { assertChannel } from "./validate.mjs"; +import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs"; +import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; +import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs"; -const REPO = process.env.GITHUB_REPOSITORY || "modelstudioai/cli"; - -function parseArgs(argv) { - let dir = join(ROOT, "dist-bin"); - let dryRun = false; - let mode = "stable"; - let channel = null; - let skipBuild = false; - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]; - if (arg === "--dir") dir = resolve(argv[++index]); - else if (arg === "--dry-run") dryRun = true; - else if (arg === "--mode") mode = argv[++index]; - else if (arg === "--channel") channel = argv[++index]; - else if (arg === "--skip-build") skipBuild = true; - else if (arg === "--help" || arg === "-h") { - process.stdout.write( - "Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel ] [--dir dist-bin] [--skip-build] [--dry-run]\n", - ); - process.exit(0); - } else throw new Error(`Unknown argument: ${arg}`); - } - return normalizeOptions({ dir, dryRun, mode, channel, skipBuild }); -} - -function normalizeOptions({ dir, dryRun, mode, channel, skipBuild = false }) { - if (mode !== "stable" && mode !== "channel") { - throw new Error(`--mode must be stable or channel, got: ${mode}`); - } - if (mode === "channel") { - if (!channel) throw new Error("--mode channel requires --channel "); - assertChannel(channel); - if (channel === "stable") { - throw new Error(`--channel cannot be "stable"; use --mode stable`); - } - } - return { - dir: dir ?? join(ROOT, "dist-bin"), - dryRun: Boolean(dryRun), - mode, - channel: mode === "channel" ? channel : null, - skipBuild: Boolean(skipBuild), - }; -} - -function requiredManifestName(mode, channel) { - return mode === "stable" ? "latest.json" : `${channel}.json`; -} - -function ensureGh() { - if (tryRun("gh", ["--version"]).status !== 0) { - throw new Error("gh CLI not found on PATH. Install from https://cli.github.com"); - } -} - -function releaseExists(tag) { - return tryRun("gh", ["release", "view", tag, "--repo", REPO]).status === 0; -} - -function verifyReleaseAssets(tag, assetPaths) { - const output = runCapture("gh", [ - "release", - "view", - tag, - "--repo", - REPO, - "--json", - "assets", - "--jq", - ".assets[].name", - ]); - const uploaded = new Set(output.split("\n").filter(Boolean)); - const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name)); - if (missing.length > 0) { - throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`); - } -} +const DEFAULT_DIR = join(ROOT, "dist-bin"); /** Extract the `## []` section from CHANGELOG.md, or null when absent. */ function extractChangelogSection(version) { @@ -111,62 +40,34 @@ function extractChangelogSection(version) { return section ? `${section}\n` : null; } -function printPlanned(tag, assets, extraArgs) { - process.stdout.write(`[dry-run] gh release view ${tag} --repo ${REPO}\n`); - process.stdout.write( - `[dry-run] exists → gh release upload ${tag} --repo ${REPO} --clobber \n`, - ); - process.stdout.write( - `[dry-run] missing → gh release create ${tag} --repo ${REPO} ${extraArgs.join(" ")} \n`, - ); - for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`); +function assertFullMatrix(files, version) { + const missing = matrixAssetNames(version).filter((name) => !files.includes(name)); + if (missing.length > 0) { + throw new Error( + `Incomplete binary matrix in dist-bin (missing: ${missing.join(", ")}). ` + + `Rebuild the full matrix before upload (do not use --host / partial --target for release).`, + ); + } } -/** - * Create a release with assets, or clobber-upload onto an existing one. - * options: { tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun } - */ -function upsertRelease({ tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun }) { - const createArgs = ["--title", title]; - if (prerelease) createArgs.push("--prerelease", "--target", "main"); - if (verifyTag) createArgs.push("--verify-tag"); - if (notesFile) createArgs.push("--notes-file", notesFile); - else if (notes) createArgs.push("--notes", notes); - else createArgs.push("--generate-notes"); - - if (dryRun) { - printPlanned(tag, assets, createArgs); - return; - } - - if (releaseExists(tag)) { - process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`); - run("gh", ["release", "upload", tag, "--repo", REPO, "--clobber", ...assets]); - } else { - run("gh", ["release", "create", tag, "--repo", REPO, ...createArgs, ...assets]); - } - verifyReleaseAssets(tag, assets); +function versionBinaryAssets(dir, version, files) { + assertFullMatrix(files, version); + const matrixNames = new Set(matrixAssetNames(version)); + return files + .filter((name) => matrixNames.has(name) || name === "SHA256SUMS") + .map((name) => join(dir, name)); } function uploadStable({ dir, version, files, dryRun }) { - const tag = `v${version}`; - const matrixNames = new Set( - BINARY_TARGETS.map( - (target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`, - ), - ); - // Binaries + checksums + latest.json only. Production install.sh/ps1 are maintained - // outside this repo and served from OSS after an external FC sync. - const wanted = files.filter( - (name) => matrixNames.has(name) || name === "SHA256SUMS" || name === "latest.json", - ); - const assets = wanted.map((name) => join(dir, name)); - + const assets = [ + ...versionBinaryAssets(dir, version, files), + join(dir, manifestFileName("stable", null)), + ]; const section = extractChangelogSection(version); upsertRelease({ - tag, - title: tag, + tag: `v${version}`, + title: `v${version}`, verifyTag: true, notes: section || undefined, assets, @@ -175,20 +76,13 @@ function uploadStable({ dir, version, files, dryRun }) { } function uploadChannel({ dir, version, channel, files, dryRun }) { - const matrixNames = new Set( - BINARY_TARGETS.map( - (target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`, - ), - ); - const binaries = files - .filter((name) => matrixNames.has(name) || name === "SHA256SUMS") - .map((name) => join(dir, name)); + // Versioned tag is shared across channels built from the same beta version string. upsertRelease({ tag: `v${version}`, title: `v${version}`, prerelease: true, notes: `Beta build for the \`${channel}\` channel.`, - assets: binaries, + assets: versionBinaryAssets(dir, version, files), dryRun, }); @@ -202,67 +96,76 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { }); } -/** - * Optional hook for an external FC that mirrors GitHub Releases → OSS. - * Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op. - */ -function notifyOssSyncWebhook({ version, mode, channel, dryRun }) { - const webhook = process.env.BAILIAN_OSS_SYNC_WEBHOOK?.trim(); - if (!webhook) { - process.stdout.write( - "\n[info] BAILIAN_OSS_SYNC_WEBHOOK unset; skip notifying external OSS sync FC\n", - ); +/** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */ +function planDryRunWithoutArtifacts({ version, mode, channel }) { + const matrix = matrixAssetNames(version); + const manifestName = manifestFileName(mode, channel); + if (mode === "stable") { + upsertRelease({ + tag: `v${version}`, + title: `v${version}`, + verifyTag: true, + notes: extractChangelogSection(version) || undefined, + assets: [...matrix, "SHA256SUMS", manifestName], + dryRun: true, + }); return; } - const tag = mode === "stable" ? `v${version}` : `v${version}`; - const body = { - repo: REPO, - mode, - channel, - version, - tag, - rollingChannelTag: mode === "channel" ? `channel-${channel}` : null, - }; - if (dryRun) { - process.stdout.write(`[dry-run] POST ${webhook}\n${JSON.stringify(body, null, 2)}\n`); - return; - } - process.stdout.write(`\n==> notify OSS sync FC: ${webhook}\n`); - const result = tryRun("curl", [ - "-fsS", - "-X", - "POST", - "-H", - "Content-Type: application/json", - "-d", - JSON.stringify(body), - webhook, - ]); - if (result.status !== 0) { - process.stdout.write( - `[warn] OSS sync webhook failed (release already published): ${result.stderr || result.stdout}\n`, - ); - return; - } - if (result.stdout) process.stdout.write(`${result.stdout}\n`); + upsertRelease({ + tag: `v${version}`, + title: `v${version}`, + prerelease: true, + notes: `Beta build for the \`${channel}\` channel.`, + assets: [...matrix, "SHA256SUMS"], + dryRun: true, + }); + upsertRelease({ + tag: `channel-${channel}`, + title: `channel: ${channel}`, + prerelease: true, + notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, + assets: [manifestName], + dryRun: true, + }); } /** - * Build (unless skipped) and upload binary artifacts to GitHub Releases. + * Build (unless skipped / dry-run) and upload binary artifacts to GitHub Releases. * Called by publish-stable / publish-channel orchestrators. + * + * `--dry-run` never compiles; it plans gh release steps. Prebuilt `dist-bin` is + * optional (used only to list real paths when present). */ -export function releaseBinaryArtifacts(rawOptions) { - const { dir, dryRun, mode, channel, skipBuild } = normalizeOptions(rawOptions); +export function releaseBinaryArtifacts(rawOptions = {}) { + const { mode, channel } = normalizeModeChannel(rawOptions.mode, rawOptions.channel); + const dir = rawOptions.dir ? resolve(rawOptions.dir) : DEFAULT_DIR; + const dryRun = Boolean(rawOptions.dryRun); + const skipBuild = Boolean(rawOptions.skipBuild); const cliPkg = readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli")); const version = cliPkg.version; - if (!skipBuild) { + if (dryRun) { + process.stdout.write( + `\n[dry-run] skipping binary build (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`, + ); + } else if (!skipBuild) { process.stdout.write( `\n==> build binary (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`, ); buildBinaryArtifacts({ mode, channel, outdir: dir }); } + process.stdout.write(`repo ${GITHUB_REPOSITORY}\n`); + process.stdout.write(`version ${version}\n`); + process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`); + + if (dryRun && !existsSync(dir)) { + process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`); + planDryRunWithoutArtifacts({ version, mode, channel }); + notifyOssSyncWebhook({ version, mode, channel, dryRun }); + return { version, mode, channel, dryRun }; + } + if (!existsSync(dir)) { throw new Error( `Missing ${dir}. Run binary-build or omit --skip-build (mode=${mode}${channel ? ` channel=${channel}` : ""}).`, @@ -270,7 +173,7 @@ export function releaseBinaryArtifacts(rawOptions) { } const files = readdirSync(dir).filter((name) => !name.startsWith(".")); - const manifestName = requiredManifestName(mode, channel); + const manifestName = manifestFileName(mode, channel); if (!files.includes(manifestName)) { throw new Error( `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, @@ -280,12 +183,12 @@ export function releaseBinaryArtifacts(rawOptions) { throw new Error(`Missing SHA256SUMS in ${dir}`); } - process.stdout.write(`repo ${REPO}\n`); - process.stdout.write(`version ${version}\n`); - process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`); process.stdout.write(`artifacts in ${dir}:\n`); for (const name of files) process.stdout.write(` ${name}\n`); + // Validate matrix before touching gh, so --skip-build mistakes fail without network/CLI. + assertFullMatrix(files, version); + if (dryRun) { process.stdout.write("\n[dry-run] skipping GitHub Release upload\n"); } else { @@ -304,8 +207,32 @@ export function releaseBinaryArtifacts(rawOptions) { } if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + const USAGE = + "Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel ] [--dir dist-bin] [--skip-build] [--dry-run]\n"; try { - releaseBinaryArtifacts(parseArgs(process.argv.slice(2))); + const { values } = parseCliArgs({ + args: process.argv.slice(2), + options: { + dir: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + mode: { type: "string", default: "stable" }, + channel: { type: "string" }, + "skip-build": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: false, + }); + if (values.help) { + process.stdout.write(USAGE); + process.exit(0); + } + releaseBinaryArtifacts({ + dir: values.dir ? resolve(values.dir) : undefined, + dryRun: values["dry-run"], + mode: values.mode, + channel: values.channel ?? null, + skipBuild: values["skip-build"], + }); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); diff --git a/tools/release/lib/gh-release.mjs b/tools/release/lib/gh-release.mjs new file mode 100644 index 0000000..bd65553 --- /dev/null +++ b/tools/release/lib/gh-release.mjs @@ -0,0 +1,95 @@ +/** + * Thin wrappers around `gh release` for create / clobber-upload / verify. + * Shared by binary-release (and any future publish path that needs GitHub Releases). + */ +import { basename } from "node:path"; +import { run, runCapture, tryRun } from "./proc.mjs"; + +export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "modelstudioai/cli"; + +export function ensureGh() { + if (tryRun("gh", ["--version"]).status !== 0) { + throw new Error("gh CLI not found on PATH. Install from https://cli.github.com"); + } +} + +export function releaseExists(tag, repo = GITHUB_REPOSITORY) { + return tryRun("gh", ["release", "view", tag, "--repo", repo]).status === 0; +} + +export function verifyReleaseAssets(tag, assetPaths, repo = GITHUB_REPOSITORY) { + const output = runCapture("gh", [ + "release", + "view", + tag, + "--repo", + repo, + "--json", + "assets", + "--jq", + ".assets[].name", + ]); + const uploaded = new Set(output.split("\n").filter(Boolean)); + const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name)); + if (missing.length > 0) { + throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`); + } +} + +function printPlanned(tag, assets, extraArgs, repo) { + process.stdout.write(`[dry-run] gh release view ${tag} --repo ${repo}\n`); + process.stdout.write( + `[dry-run] exists → gh release upload ${tag} --repo ${repo} --clobber \n`, + ); + process.stdout.write( + `[dry-run] missing → gh release create ${tag} --repo ${repo} ${extraArgs.join(" ")} \n`, + ); + for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`); +} + +/** + * Create a release with assets, or clobber-upload onto an existing one. + * + * @param {{ + * tag: string, + * title: string, + * prerelease?: boolean, + * verifyTag?: boolean, + * notes?: string, + * notesFile?: string, + * assets: string[], + * dryRun?: boolean, + * repo?: string, + * }} options + */ +export function upsertRelease({ + tag, + title, + prerelease, + verifyTag, + notes, + notesFile, + assets, + dryRun, + repo = GITHUB_REPOSITORY, +}) { + const createArgs = ["--title", title]; + if (prerelease) createArgs.push("--prerelease", "--target", "main"); + if (verifyTag) createArgs.push("--verify-tag"); + if (notesFile) createArgs.push("--notes-file", notesFile); + else if (notes) createArgs.push("--notes", notes); + else createArgs.push("--generate-notes"); + + if (dryRun) { + printPlanned(tag, assets, createArgs, repo); + return; + } + + if (releaseExists(tag, repo)) { + process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`); + run("gh", ["release", "upload", tag, "--repo", repo, "--clobber", ...assets]); + } else { + run("gh", ["release", "create", tag, "--repo", repo, ...createArgs, ...assets]); + } + verifyReleaseAssets(tag, assets, repo); +} diff --git a/tools/release/lib/oss-sync-webhook.mjs b/tools/release/lib/oss-sync-webhook.mjs new file mode 100644 index 0000000..ba9f814 --- /dev/null +++ b/tools/release/lib/oss-sync-webhook.mjs @@ -0,0 +1,63 @@ +/** + * Optional hook for an external FC that mirrors GitHub Releases → OSS. + * Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op. + * Failure is warn-only — Release publish already succeeded. + */ +import { tryRun } from "./proc.mjs"; +import { GITHUB_REPOSITORY } from "./gh-release.mjs"; + +/** + * @param {{ + * version: string, + * mode: "stable" | "channel", + * channel: string | null, + * dryRun?: boolean, + * repo?: string, + * }} options + */ +export function notifyOssSyncWebhook({ + version, + mode, + channel, + dryRun = false, + repo = GITHUB_REPOSITORY, +}) { + const webhook = process.env.BAILIAN_OSS_SYNC_WEBHOOK?.trim(); + if (!webhook) { + process.stdout.write( + "\n[info] BAILIAN_OSS_SYNC_WEBHOOK unset; skip notifying external OSS sync FC\n", + ); + return; + } + const tag = `v${version}`; + const body = { + repo, + mode, + channel, + version, + tag, + rollingChannelTag: mode === "channel" ? `channel-${channel}` : null, + }; + if (dryRun) { + process.stdout.write(`[dry-run] POST ${webhook}\n${JSON.stringify(body, null, 2)}\n`); + return; + } + process.stdout.write(`\n==> notify OSS sync FC: ${webhook}\n`); + const result = tryRun("curl", [ + "-fsS", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + JSON.stringify(body), + webhook, + ]); + if (result.status !== 0) { + process.stdout.write( + `[warn] OSS sync webhook failed (release already published): ${result.stderr || result.stdout}\n`, + ); + return; + } + if (result.stdout) process.stdout.write(`${result.stdout}\n`); +} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 4f7d498..420271e 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -91,7 +91,7 @@ try { // 2) binary GitHub Release — must run before finally restores package.json versions if (skipBinary) { - log("\n[skip-binary] skipping Bun binary build/upload"); + log("\n[skip-binary] skipping binary GitHub Release"); } else { step( `publish binary GitHub Release (mode=channel, channel=${channel}, version=${betaVersion})`, @@ -99,9 +99,12 @@ try { releaseBinaryArtifacts({ mode: "channel", channel, dryRun }); } - log(`\nchannel release complete: ${channel}@${betaVersion} (npm + binary)`); + const parts = ["npm"]; + if (!skipBinary) parts.push("binary"); + log(`\nchannel release complete: ${channel}@${betaVersion} (${parts.join(" + ")})`); } catch (error) { process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`); + // Use exitCode (not process.exit) so `finally` can restore package.json bumps. process.exitCode = 1; } finally { restoreOriginals(); diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 986ecef..40fa2cb 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -83,13 +83,15 @@ try { // 3) binary GitHub Release (same version; orchestrated here, not a separate release entry) if (skipBinary) { - log("\n[skip-binary] skipping Bun binary build/upload"); + log("\n[skip-binary] skipping binary GitHub Release"); } else { step(`publish binary GitHub Release (mode=stable, version=${version})`); releaseBinaryArtifacts({ mode: "stable", dryRun }); } - log("\nstable release complete (npm + binary)."); + const parts = ["npm"]; + if (!skipBinary) parts.push("binary"); + log(`\nstable release complete (${parts.join(" + ")}).`); } catch (error) { process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`); process.exit(1);