mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(release): distribute binaries as per-platform zips
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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`。
|
||||
@@ -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)
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user