feat(release): distribute binaries as per-platform zips

This commit is contained in:
rendianmeng
2026-07-24 15:33:15 +08:00
parent a1a448c5d2
commit d30fb2ae68
12 changed files with 303 additions and 167 deletions
+13 -1
View File
@@ -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).
-84
View File
@@ -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 为 `<name>.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-<name>` 只挂 `<name>.json`。
+1 -1
View File
@@ -108,7 +108,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
- [ ] 验证 npm 上能装:`npm view bailian-cli@<tag> version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@<tag> version`
- [ ] 试装一次:`npm i -g bailian-cli@<tag> && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@<tag> && 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)
## 常见漏点(基于历史踩坑)
+13 -1
View File
@@ -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-<ver>-<os>-<arch>.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,
+2
View File
@@ -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";
+109
View File
@@ -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<yauzl.ZipFile> {
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<string> {
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();
});
}
+9 -3
View File
@@ -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");
});
+20 -12
View File
@@ -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<string, { file?: string; sha256?: string; url?: string }>;
assets?: Record<string, { file?: string; sha256?: string; url?: string; inner?: string }>;
}
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<string> {
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<string
const binDir = binRoot();
await mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
await writeFile(join(binDir, "bl.exe"), buffer);
await writeFile(join(binDir, "bailian.exe"), buffer);
const installed = await readFile(finalPath);
await writeFile(join(binDir, "bl.exe"), installed);
await writeFile(join(binDir, "bailian.exe"), installed);
} else {
const { symlink } = await import("node:fs/promises");
for (const name of ["bl", "bailian"] as const) {
+58 -44
View File
@@ -1,22 +1,23 @@
/**
* Build standalone `bl` binaries with Bun --compile.
* Build standalone `bl` binaries with Bun --compile, then pack each as a
* per-platform `.zip` via binary-zip.mjs (Release / OSS download asset).
*
* Used by lib/binary-release.mjs (and publish-stable / publish-channel orchestrators).
* Debug:
* node tools/release/lib/binary-build.mjs --mode stable --host
*
* Manifests:
* --mode stable → writes latest.json only
* --mode channel → writes <channel>.json only (does not touch latest.json)
* --mode stable → no release manifest (latest.json removed)
* --mode channel → writes <channel>.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-<ver>-<os>-<arch>[.exe]`. */
export function binaryAssetName(version, { os, arch, exe }) {
/** Uncompressed binary basename inside the zip: `bl-<ver>-<os>-<arch>[.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-<ver>-<os>-<arch>.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 <channel>.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 `<channel>.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)) {
+8 -3
View File
@@ -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 `<channel>.json`
* onto the rolling `channel-<name>` 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`;
}
+15 -18
View File
@@ -2,8 +2,8 @@
* Publish bailian-cli binary assets to GitHub Releases.
*
* stable: release `v<version>` (tag must already be on origin; --verify-tag)
* assets: bl-*, SHA256SUMS, latest.json
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*, SHA256SUMS)
* assets: bl-*.zip, SHA256SUMS (no latest.json)
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*.zip, SHA256SUMS)
* + rolling prerelease tag `channel-<name>` holding only `<name>.json`
*
* Same commit/day channel publishes share one `v<betaVersion>` 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`);
+55
View File
@@ -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-<ver>-<os>-<arch>.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),
};
}