From d30fb2ae6866a0acc63f424748a1eea8f886b1ae Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 24 Jul 2026 15:33:15 +0800 Subject: [PATCH] feat(release): distribute binaries as per-platform zips --- .github/workflows/publish.yml | 14 ++- docs/agents/binary-distribution.md | 84 --------------- docs/agents/publish.md | 2 +- packages/core/src/install/cdn.ts | 14 ++- packages/core/src/install/index.ts | 2 + packages/core/src/install/unzip-asset.ts | 109 ++++++++++++++++++++ packages/core/tests/install-method.test.ts | 12 ++- packages/runtime/src/utils/binary-update.ts | 32 +++--- tools/release/lib/binary-build.mjs | 102 ++++++++++-------- tools/release/lib/binary-options.mjs | 11 +- tools/release/lib/binary-release.mjs | 33 +++--- tools/release/lib/binary-zip.mjs | 55 ++++++++++ 12 files changed, 303 insertions(+), 167 deletions(-) delete mode 100644 docs/agents/binary-distribution.md create mode 100644 packages/core/src/install/unzip-asset.ts create mode 100644 tools/release/lib/binary-zip.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0346059..875a35e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: - channel - stable channel: - description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)" + description: "Required when mode=channel. dist-tag name, e.g. mcp / plugin / release-test (lowercase, digits, dashes)" required: false type: string @@ -55,6 +55,9 @@ jobs: | sudo tar -xz -C /usr/local/bin gitleaks gitleaks version + - name: Ensure zip (per-platform binary archives) + run: sudo apt-get update && sudo apt-get install -y zip + - run: pnpm install --frozen-lockfile # Binary compile uses `bun build --compile` CLI (not Bun.build API). @@ -78,6 +81,12 @@ jobs: contents: write # create prerelease GitHub Release with binary assets id-token: write # OIDC for npm Trusted Publishing + provenance steps: + - name: Require channel input + if: ${{ inputs.channel == '' }} + run: | + echo "::error::mode=channel requires the workflow input \"channel\" (e.g. mcp, plugin, release-test). Leave mode=stable if you do not need a dist-tag." + exit 1 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -97,6 +106,9 @@ jobs: | sudo tar -xz -C /usr/local/bin gitleaks gitleaks version + - name: Ensure zip (per-platform binary archives) + run: sudo apt-get update && sudo apt-get install -y zip + - run: pnpm install --frozen-lockfile # Binary compile uses `bun build --compile` CLI (not Bun.build API). diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md deleted file mode 100644 index 27159fb..0000000 --- a/docs/agents/binary-distribution.md +++ /dev/null @@ -1,84 +0,0 @@ -# 二进制分发(GitHub Release → 外部 FC → OSS 安装) - -> 完整技术方案:[docs/proposals/binary-distribution.md](../proposals/binary-distribution.md) - -## 触发条件 - -- 修改 `packages/cli/src/main.ts` 或 `tools/release/lib/binary-*` -- 调整 Release 资产 / manifest / 可选 `BAILIAN_OSS_SYNC_WEBHOOK` -- 发版需要把独立二进制推到 **GitHub Releases**(本仓职责到此) - -## 概念 - -```text -Publish workflow - ├─ npm - └─ GitHub Release ← 本仓库 - ↓ - 外部 FC → 同步 OSS ← 仓外 - ↓ - 外置 install.sh / ps1 ← 仓外,只拉 OSS -``` - -- **Source of truth**:GitHub Release -- **国内安装面**:OSS + 外置脚本 -- 本仓 `packaging/install.*`:契约**参考**,不挂 Release、不作为生产入口 - -可选 Secret:`BAILIAN_OSS_SYNC_WEBHOOK`(发版后 POST 通知 FC;失败仅 warn)。 - -## 必查清单 - -### A. 本仓库构建 / Release - -- [ ] `node tools/release/lib/binary-build.mjs --mode stable --host` -- [ ] `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. 仓外(联调时确认) - -- [ ] FC 已同步本次 Release 到 OSS(路径与参考脚本一致) -- [ ] 外置 `install.sh` / `install.ps1` 可从 OSS 安装 - -### C. 运行时 - -- [ ] `bl update` 二进制路径读 OSS manifest(`BAILIAN_CLI_CDN`) -- [ ] 无 npm 时 plugin hint 明确 - -## 完成后自查 - -```sh -node tools/release/lib/binary-build.mjs --mode stable --host -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` | -| 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 f6b85d7..6532b9d 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -108,7 +108,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run - [ ] 验证 npm 上能装:`npm view bailian-cli@ version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@ version` - [ ] 试装一次:`npm i -g bailian-cli@ && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@ && kscli --version` -- [ ] (若本次含二进制)GitHub Release 页可见资产,`install.sh` / `latest.json` 经 FC 同步到 OSS 后可访问;清单见 [binary-distribution.md](binary-distribution.md) +- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `channels/latest.json` 与 install 脚本由 FC/仓外维护;清单见 [binary-distribution.md](binary-distribution.md) ## 常见漏点(基于历史踩坑) diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index 4ec9585..44d23d1 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -21,7 +21,8 @@ export function getCliCdnBase(): string { /** * Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json` - * Formal releases use `latest.json` (default). + * Formal installs still read `channels/latest.json` on OSS; this repo no longer + * attaches `latest.json` to GitHub Releases (FC / ops maintain OSS latest). */ export function channelManifestUrl(channel = "latest"): string { return `${getCliCdnBase()}/channels/${channel}.json`; @@ -66,7 +67,18 @@ export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: return { os, arch: normalizedArch, fileSuffix }; } +/** Release download asset: `bl---.zip`. */ export function binaryAssetFileName( + version: string, + os: string, + arch: string, + _exe = false, +): string { + return `bl-${version}-${os}-${arch}.zip`; +} + +/** Uncompressed binary name inside the zip. */ +export function binaryInnerFileName( version: string, os: string, arch: string, diff --git a/packages/core/src/install/index.ts b/packages/core/src/install/index.ts index 8765902..78e0535 100644 --- a/packages/core/src/install/index.ts +++ b/packages/core/src/install/index.ts @@ -11,8 +11,10 @@ export { DEFAULT_INSTALL_SCRIPT_URL, GITHUB_RELEASES_BASE, binaryAssetFileName, + binaryInnerFileName, channelManifestUrl, detectBinaryPlatform, getCliCdnBase, releaseAssetUrl, } from "./cdn.ts"; +export { extractZipEntryToFile } from "./unzip-asset.ts"; diff --git a/packages/core/src/install/unzip-asset.ts b/packages/core/src/install/unzip-asset.ts new file mode 100644 index 0000000..3ccea4f --- /dev/null +++ b/packages/core/src/install/unzip-asset.ts @@ -0,0 +1,109 @@ +/** + * Extract a single file entry from a ZIP into `destPath` (overwrites). + * Uses yauzl (already a core dependency for dataset ZIP validation). + */ +import { createWriteStream } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import * as yauzl from "yauzl"; + +function openZip(zipPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => { + if (error || !zipfile) { + reject(error ?? new Error(`Failed to open zip: ${zipPath}`)); + return; + } + resolve(zipfile); + }); + }); +} + +function entryBaseName(fileName: string): string { + const normalized = fileName.replace(/\\/g, "/"); + return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized; +} + +/** + * Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`. + * Returns the archive entry basename that was extracted. + */ +export async function extractZipEntryToFile( + zipPath: string, + destPath: string, + entryName?: string, +): Promise { + const zipfile = await openZip(zipPath); + + return new Promise((resolve, reject) => { + let settled = false; + + const fail = (error: unknown) => { + if (settled) return; + settled = true; + try { + zipfile.close(); + } catch { + /* ignore */ + } + reject(error instanceof Error ? error : new Error(String(error))); + }; + + const succeed = (baseName: string) => { + if (settled) return; + settled = true; + try { + zipfile.close(); + } catch { + /* ignore */ + } + resolve(baseName); + }; + + zipfile.on("error", fail); + zipfile.on("end", () => { + if (settled) return; + fail( + new Error( + entryName + ? `Zip entry not found: ${entryName} in ${zipPath}` + : `Zip has no file entries: ${zipPath}`, + ), + ); + }); + + zipfile.on("entry", (current: yauzl.Entry) => { + if (settled) return; + const name = current.fileName.replace(/\\/g, "/"); + if (name.endsWith("/")) { + zipfile.readEntry(); + return; + } + const base = entryBaseName(name); + const isMatch = entryName ? name === entryName || base === entryName : true; + if (!isMatch) { + zipfile.readEntry(); + return; + } + + zipfile.openReadStream(current, (streamError, readStream) => { + if (streamError || !readStream) { + fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`)); + return; + } + void (async () => { + try { + await mkdir(dirname(destPath), { recursive: true }); + await pipeline(readStream, createWriteStream(destPath)); + succeed(base); + } catch (error) { + fail(error); + } + })(); + }); + }); + + zipfile.readEntry(); + }); +} diff --git a/packages/core/tests/install-method.test.ts b/packages/core/tests/install-method.test.ts index 57f866b..e4aa557 100644 --- a/packages/core/tests/install-method.test.ts +++ b/packages/core/tests/install-method.test.ts @@ -3,6 +3,7 @@ import { detectInstallMethod, isCompiledBinary, binaryAssetFileName, + binaryInnerFileName, } from "../src/install/index.ts"; test("isCompiledBinary respects BAILIAN_COMPILED", () => { @@ -23,7 +24,12 @@ test("detectInstallMethod respects BAILIAN_INSTALL_METHOD", () => { else process.env.BAILIAN_INSTALL_METHOD = previous; }); -test("binaryAssetFileName formats windows exe", () => { - expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe"); - expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64"); +test("binaryAssetFileName uses per-platform zip", () => { + expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.zip"); + expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip"); +}); + +test("binaryInnerFileName keeps exe suffix inside zip", () => { + expect(binaryInnerFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe"); + expect(binaryInnerFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64"); }); diff --git a/packages/runtime/src/utils/binary-update.ts b/packages/runtime/src/utils/binary-update.ts index e107e4d..e7f61b8 100644 --- a/packages/runtime/src/utils/binary-update.ts +++ b/packages/runtime/src/utils/binary-update.ts @@ -1,11 +1,13 @@ -import { mkdir, rename, unlink, writeFile, chmod } from "node:fs/promises"; +import { mkdir, rename, unlink, writeFile, chmod, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { binaryAssetFileName, + binaryInnerFileName, channelManifestUrl, detectBinaryPlatform, + extractZipEntryToFile, getConfigDir, releaseAssetUrl, writeInstallMethodSync, @@ -13,7 +15,7 @@ import { export interface ChannelManifest { version: string; - assets?: Record; + assets?: Record; } export async function fetchBinaryChannelVersion( @@ -81,31 +83,36 @@ function sha256(buffer: Buffer): string { /** * Download and install a newer standalone binary in place of the current install. + * Assets are per-platform `.zip` files; checksum applies to the zip. * Returns the installed version string. */ export async function performBinaryUpdate(targetVersion: string): Promise { const { os, arch, fileSuffix } = detectBinaryPlatform(); + const exe = fileSuffix === ".exe"; const manifest = await fetchBinaryChannelManifest("latest"); const assetKey = `${os}-${arch}`; const assetMeta = manifest?.assets?.[assetKey]; - const fileName = - assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, fileSuffix === ".exe"); + const zipName = assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, exe); + const innerName = assetMeta?.inner ?? binaryInnerFileName(targetVersion, os, arch, exe); const expectedSha = assetMeta?.sha256; - const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, fileName); + const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, zipName); - const tmpPath = join(shareRoot(), ".tmp", fileName); - const buffer = await downloadToFile(url, tmpPath); + const tmpZip = join(shareRoot(), ".tmp", zipName); + const buffer = await downloadToFile(url, tmpZip); const actualSha = sha256(buffer); if (expectedSha && expectedSha !== actualSha) { - await unlink(tmpPath).catch(() => {}); - throw new Error(`Checksum mismatch for ${fileName}`); + await unlink(tmpZip).catch(() => {}); + throw new Error(`Checksum mismatch for ${zipName}`); } const versionDir = join(shareRoot(), "versions", targetVersion); await mkdir(versionDir, { recursive: true }); const binaryName = process.platform === "win32" ? "bl.exe" : "bl"; const finalPath = join(versionDir, binaryName); - await rename(tmpPath, finalPath); + const tmpBinary = join(shareRoot(), ".tmp", binaryName); + await extractZipEntryToFile(tmpZip, tmpBinary, innerName); + await unlink(tmpZip).catch(() => {}); + await rename(tmpBinary, finalPath); if (process.platform !== "win32") { await chmod(finalPath, 0o755); } @@ -113,8 +120,9 @@ export async function performBinaryUpdate(targetVersion: string): Promise.json only (does not touch latest.json) + * --mode stable → no release manifest (latest.json removed) + * --mode channel → writes .json only */ -import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { parseArgs } from "node:util"; import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; -import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { channelManifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { ensureZip, zipOne } from "./binary-zip.mjs"; const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url)); const CLI_ENTRY = join(ROOT, "packages/cli/src/main.ts"); @@ -32,12 +33,17 @@ 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 }) { +/** Uncompressed binary basename inside the zip: `bl---[.exe]`. */ +export function binaryInnerName(version, { os, arch, exe }) { return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; } -/** Full matrix basenames for a version (order matches BINARY_TARGETS). */ +/** Release asset basename: `bl---.zip`. */ +export function binaryAssetName(version, { os, arch }) { + return `bl-${version}-${os}-${arch}.zip`; +} + +/** Full matrix zip basenames for a version (order matches BINARY_TARGETS). */ export function matrixAssetNames(version) { return BINARY_TARGETS.map((target) => binaryAssetName(version, target)); } @@ -124,19 +130,14 @@ function ensureBun() { return result.stdout.trim(); } -function sha256File(path) { - return createHash("sha256").update(readFileSync(path)).digest("hex"); -} - function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { - const fileName = binaryAssetName(version, { os, arch, exe }); - const outfile = join(outdir, fileName); - log(`compile ${bunTarget} → ${fileName}`); + const innerName = binaryInnerName(version, { os, arch, exe }); + const innerPath = join(outdir, innerName); + log(`compile ${bunTarget} → ${innerName}`); - // 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], + [BINARY_COMPILE, "--entry", entry, "--outfile", innerPath, "--target", bunTarget], { cwd: ROOT, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, ); if (result.status !== 0) { @@ -146,9 +147,8 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); // Bun 1.2.19 writes windows-x64 .exe with mode 000 on Unix hosts (oven-sh/bun#21308). - // chmod so sha256 / gh upload can open the file; harmless for other targets. - chmodSync(outfile, 0o755); - return { fileName, outfile, os, arch, sha256: sha256File(outfile) }; + chmodSync(innerPath, 0o755); + return { innerName, innerPath, os, arch, exe }; } function writeChecksums(outdir, artifacts) { @@ -157,56 +157,56 @@ function writeChecksums(outdir, artifacts) { } /** - * 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). + * Channel-only: write `.json` with per-platform zip file + sha256. + * Stable no longer emits latest.json. */ -function writeManifest(outdir, version, artifacts, mode, channel) { +function writeChannelManifest(outdir, version, artifacts, channel) { const assets = Object.fromEntries( artifacts.map((item) => [ `${item.os}-${item.arch}`, { file: item.fileName, sha256: item.sha256, + inner: item.innerName, }, ]), ); const manifest = { name: "bailian-cli", - channel: mode === "stable" ? "latest" : channel, + channel, version, releasedAt: new Date().toISOString(), assets, }; - const name = manifestFileName(mode, channel); + const name = channelManifestFileName(channel); writeJson(join(outdir, name), manifest); - return [name]; + return name; } function cliVersion() { return readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli")).version; } -/** Run `--version` on the artifact matching the host platform, if any was built. */ -function smokeTestHostBinary(artifacts, outdir) { +/** Run `--version` on the host platform's uncompressed binary, if present. */ +function smokeTestHostBinary(compiled, outdir) { const hostOs = process.platform === "win32" ? "windows" : process.platform; - const host = artifacts.find((item) => item.os === hostOs && item.arch === process.arch); + const host = compiled.find((item) => item.os === hostOs && item.arch === process.arch); if (!host) return; - const binary = join(outdir, host.fileName); - log(`smoke test ${host.fileName} --version`); + const binary = join(outdir, host.innerName); + log(`smoke test ${host.innerName} --version`); const result = spawnSync(binary, ["--version"], { encoding: "utf-8" }); if (result.status !== 0) { process.stderr.write(result.stderr || result.stdout || ""); - throw new Error(`smoke test failed: ${host.fileName} --version`); + throw new Error(`smoke test failed: ${host.innerName} --version`); } } -/** Compile binaries into `outdir` and write checksums + manifest. */ +/** Compile binaries into `outdir`, zip per platform, write checksums (+ channel manifest). */ export function buildBinaryArtifacts(rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); const { outdir, mode, channel } = options; const bunVersion = ensureBun(); + ensureZip(); const version = cliVersion(); const targets = resolveTargets(options); @@ -216,17 +216,31 @@ export function buildBinaryArtifacts(rawOptions = {}) { log(`mode ${mode}${channel ? ` channel=${channel}` : ""}`); log(`outdir ${outdir}`); - const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY)); + const compiled = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY)); + smokeTestHostBinary(compiled, outdir); + const artifacts = compiled.map((item) => + zipOne(item, { outdir, zipFileName: binaryAssetName(version, item), log }), + ); writeChecksums(outdir, artifacts); - const manifests = writeManifest(outdir, version, artifacts, mode, channel); - smokeTestHostBinary(artifacts, outdir); - log(`\nBuilt ${artifacts.length} binary(ies):`); - for (const item of artifacts) { - log(` ${item.fileName} ${item.sha256.slice(0, 12)}…`); + const extras = ["SHA256SUMS"]; + if (mode === "channel") { + extras.push(writeChannelManifest(outdir, version, artifacts, channel)); } - log(`Also wrote SHA256SUMS, ${manifests.join(", ")}`); - return { version, mode, channel, outdir, artifacts, manifests }; + + log(`\nBuilt ${artifacts.length} zip(s):`); + for (const item of artifacts) { + log(` ${item.fileName} ${item.sha256.slice(0, 12)}… (inner ${item.innerName})`); + } + log(`Also wrote ${extras.join(", ")}`); + return { + version, + mode, + channel, + outdir, + artifacts, + manifests: extras.filter((name) => name.endsWith(".json")), + }; } if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs index 59457e0..f91b4f2 100644 --- a/tools/release/lib/binary-options.mjs +++ b/tools/release/lib/binary-options.mjs @@ -1,5 +1,9 @@ /** * Shared mode / channel / manifest naming for binary-build and binary-release. + * + * Stable releases no longer write `latest.json` (OSS `channels/latest.json` is + * maintained outside this repo / by FC). Channel mode still writes `.json` + * onto the rolling `channel-` GitHub Release. */ import { assertChannel } from "./validate.mjs"; @@ -23,7 +27,8 @@ export function normalizeModeChannel(mode = "stable", channel = null) { 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`; +/** Channel rolling-manifest basename (`mcp.json`, …). Stable has none. */ +export function channelManifestFileName(channel) { + if (!channel) throw new Error("channelManifestFileName requires a channel name"); + return `${channel}.json`; } diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 84304ef..de646a1 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -2,8 +2,8 @@ * 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) + * assets: bl-*.zip, SHA256SUMS (no latest.json) + * channel: versioned prerelease `v` (assets: bl-*.zip, SHA256SUMS) * + rolling prerelease tag `channel-` holding only `.json` * * Same commit/day channel publishes share one `v` Release (identical @@ -23,7 +23,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs as parseCliArgs } from "node:util"; import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs"; -import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { channelManifestFileName, normalizeModeChannel } from "./binary-options.mjs"; import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs"; @@ -59,10 +59,6 @@ function versionBinaryAssets(dir, version, files) { } function uploadStable({ dir, version, files, dryRun }) { - const assets = [ - ...versionBinaryAssets(dir, version, files), - join(dir, manifestFileName("stable", null)), - ]; const section = extractChangelogSection(version); upsertRelease({ @@ -70,7 +66,7 @@ function uploadStable({ dir, version, files, dryRun }) { title: `v${version}`, verifyTag: true, notes: section || undefined, - assets, + assets: versionBinaryAssets(dir, version, files), dryRun, }); } @@ -91,7 +87,7 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { title: `channel: ${channel}`, prerelease: true, notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, - assets: [join(dir, manifestFileName("channel", channel))], + assets: [join(dir, channelManifestFileName(channel))], dryRun, }); } @@ -99,14 +95,13 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { /** 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], + assets: [...matrix, "SHA256SUMS"], dryRun: true, }); return; @@ -124,7 +119,7 @@ function planDryRunWithoutArtifacts({ version, mode, channel }) { title: `channel: ${channel}`, prerelease: true, notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, - assets: [manifestName], + assets: [channelManifestFileName(channel)], dryRun: true, }); } @@ -173,15 +168,17 @@ export function releaseBinaryArtifacts(rawOptions = {}) { } const files = readdirSync(dir).filter((name) => !name.startsWith(".")); - 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)"}).`, - ); - } if (!files.includes("SHA256SUMS")) { throw new Error(`Missing SHA256SUMS in ${dir}`); } + if (mode === "channel") { + const manifestName = channelManifestFileName(channel); + if (!files.includes(manifestName)) { + throw new Error( + `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, + ); + } + } process.stdout.write(`artifacts in ${dir}:\n`); for (const name of files) process.stdout.write(` ${name}\n`); diff --git a/tools/release/lib/binary-zip.mjs b/tools/release/lib/binary-zip.mjs new file mode 100644 index 0000000..d054ba8 --- /dev/null +++ b/tools/release/lib/binary-zip.mjs @@ -0,0 +1,55 @@ +/** + * Pack a Bun-compiled binary into a per-platform `.zip` Release asset. + * + * Called by binary-build.mjs after compile + smoke test. + * Naming (`bl---.zip`) stays in binary-build (contract source). + */ +import { createHash } from "node:crypto"; +import { readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +function defaultLog(message = "") { + process.stdout.write(`${message}\n`); +} + +function sha256File(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function ensureZip() { + const result = spawnSync("zip", ["-h"], { encoding: "utf-8" }); + if (result.error?.code === "ENOENT") { + throw new Error("zip not found on PATH. Install zip (e.g. apt-get install zip)."); + } +} + +/** + * Pack compiled binary into `zipFileName` under `outdir` and remove the raw file. + * + * @param {{ innerName: string, innerPath: string, os: string, arch: string }} compiled + * @param {{ outdir: string, zipFileName: string, log?: (message?: string) => void }} options + */ +export function zipOne(compiled, { outdir, zipFileName, log = defaultLog }) { + const zipPath = join(outdir, zipFileName); + log(`zip ${compiled.innerName} → ${zipFileName}`); + + // -j: store basename only (no directory path inside the archive) + const result = spawnSync("zip", ["-j", "-q", zipFileName, compiled.innerName], { + cwd: outdir, + encoding: "utf-8", + }); + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout || ""); + throw new Error(`zip failed for ${compiled.innerName}`); + } + unlinkSync(compiled.innerPath); + return { + fileName: zipFileName, + outfile: zipPath, + innerName: compiled.innerName, + os: compiled.os, + arch: compiled.arch, + sha256: sha256File(zipPath), + }; +}