Skip to content

Commit d30fb2a

Browse files
committed
feat(release): distribute binaries as per-platform zips
1 parent a1a448c commit d30fb2a

12 files changed

Lines changed: 302 additions & 166 deletions

File tree

.github/workflows/publish.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ on:
1818
- channel
1919
- stable
2020
channel:
21-
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
21+
description: "Required when mode=channel. dist-tag name, e.g. mcp / plugin / release-test (lowercase, digits, dashes)"
2222
required: false
2323
type: string
2424

@@ -55,6 +55,9 @@ jobs:
5555
| sudo tar -xz -C /usr/local/bin gitleaks
5656
gitleaks version
5757
58+
- name: Ensure zip (per-platform binary archives)
59+
run: sudo apt-get update && sudo apt-get install -y zip
60+
5861
- run: pnpm install --frozen-lockfile
5962

6063
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
@@ -78,6 +81,12 @@ jobs:
7881
contents: write # create prerelease GitHub Release with binary assets
7982
id-token: write # OIDC for npm Trusted Publishing + provenance
8083
steps:
84+
- name: Require channel input
85+
if: ${{ inputs.channel == '' }}
86+
run: |
87+
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."
88+
exit 1
89+
8190
- uses: actions/checkout@v6
8291

8392
- uses: pnpm/action-setup@v6
@@ -97,6 +106,9 @@ jobs:
97106
| sudo tar -xz -C /usr/local/bin gitleaks
98107
gitleaks version
99108
109+
- name: Ensure zip (per-platform binary archives)
110+
run: sudo apt-get update && sudo apt-get install -y zip
111+
100112
- run: pnpm install --frozen-lockfile
101113

102114
# Binary compile uses `bun build --compile` CLI (not Bun.build API).

docs/agents/binary-distribution.md

Lines changed: 0 additions & 84 deletions
This file was deleted.

docs/agents/publish.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
108108

109109
- [ ] 验证 npm 上能装:`npm view bailian-cli@<tag> version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@<tag> version`
110110
- [ ] 试装一次:`npm i -g bailian-cli@<tag> && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@<tag> && kscli --version`
111-
- [ ] (若本次含二进制)GitHub Release 页可见资产,`install.sh` / `latest.json` 经 FC 同步到 OSS 后可访问;清单见 [binary-distribution.md](binary-distribution.md)
111+
- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `channels/latest.json` 与 install 脚本由 FC/仓外维护;清单见 [binary-distribution.md](binary-distribution.md)
112112

113113
## 常见漏点(基于历史踩坑)
114114

packages/core/src/install/cdn.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export function getCliCdnBase(): string {
2121

2222
/**
2323
* Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json`
24-
* Formal releases use `latest.json` (default).
24+
* Formal installs still read `channels/latest.json` on OSS; this repo no longer
25+
* attaches `latest.json` to GitHub Releases (FC / ops maintain OSS latest).
2526
*/
2627
export function channelManifestUrl(channel = "latest"): string {
2728
return `${getCliCdnBase()}/channels/${channel}.json`;
@@ -66,7 +67,18 @@ export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix:
6667
return { os, arch: normalizedArch, fileSuffix };
6768
}
6869

70+
/** Release download asset: `bl-<ver>-<os>-<arch>.zip`. */
6971
export function binaryAssetFileName(
72+
version: string,
73+
os: string,
74+
arch: string,
75+
_exe = false,
76+
): string {
77+
return `bl-${version}-${os}-${arch}.zip`;
78+
}
79+
80+
/** Uncompressed binary name inside the zip. */
81+
export function binaryInnerFileName(
7082
version: string,
7183
os: string,
7284
arch: string,

packages/core/src/install/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ export {
1111
DEFAULT_INSTALL_SCRIPT_URL,
1212
GITHUB_RELEASES_BASE,
1313
binaryAssetFileName,
14+
binaryInnerFileName,
1415
channelManifestUrl,
1516
detectBinaryPlatform,
1617
getCliCdnBase,
1718
releaseAssetUrl,
1819
} from "./cdn.ts";
20+
export { extractZipEntryToFile } from "./unzip-asset.ts";
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Extract a single file entry from a ZIP into `destPath` (overwrites).
3+
* Uses yauzl (already a core dependency for dataset ZIP validation).
4+
*/
5+
import { createWriteStream } from "node:fs";
6+
import { mkdir } from "node:fs/promises";
7+
import { dirname } from "node:path";
8+
import { pipeline } from "node:stream/promises";
9+
import * as yauzl from "yauzl";
10+
11+
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
12+
return new Promise((resolve, reject) => {
13+
yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => {
14+
if (error || !zipfile) {
15+
reject(error ?? new Error(`Failed to open zip: ${zipPath}`));
16+
return;
17+
}
18+
resolve(zipfile);
19+
});
20+
});
21+
}
22+
23+
function entryBaseName(fileName: string): string {
24+
const normalized = fileName.replace(/\\/g, "/");
25+
return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
26+
}
27+
28+
/**
29+
* Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`.
30+
* Returns the archive entry basename that was extracted.
31+
*/
32+
export async function extractZipEntryToFile(
33+
zipPath: string,
34+
destPath: string,
35+
entryName?: string,
36+
): Promise<string> {
37+
const zipfile = await openZip(zipPath);
38+
39+
return new Promise((resolve, reject) => {
40+
let settled = false;
41+
42+
const fail = (error: unknown) => {
43+
if (settled) return;
44+
settled = true;
45+
try {
46+
zipfile.close();
47+
} catch {
48+
/* ignore */
49+
}
50+
reject(error instanceof Error ? error : new Error(String(error)));
51+
};
52+
53+
const succeed = (baseName: string) => {
54+
if (settled) return;
55+
settled = true;
56+
try {
57+
zipfile.close();
58+
} catch {
59+
/* ignore */
60+
}
61+
resolve(baseName);
62+
};
63+
64+
zipfile.on("error", fail);
65+
zipfile.on("end", () => {
66+
if (settled) return;
67+
fail(
68+
new Error(
69+
entryName
70+
? `Zip entry not found: ${entryName} in ${zipPath}`
71+
: `Zip has no file entries: ${zipPath}`,
72+
),
73+
);
74+
});
75+
76+
zipfile.on("entry", (current: yauzl.Entry) => {
77+
if (settled) return;
78+
const name = current.fileName.replace(/\\/g, "/");
79+
if (name.endsWith("/")) {
80+
zipfile.readEntry();
81+
return;
82+
}
83+
const base = entryBaseName(name);
84+
const isMatch = entryName ? name === entryName || base === entryName : true;
85+
if (!isMatch) {
86+
zipfile.readEntry();
87+
return;
88+
}
89+
90+
zipfile.openReadStream(current, (streamError, readStream) => {
91+
if (streamError || !readStream) {
92+
fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`));
93+
return;
94+
}
95+
void (async () => {
96+
try {
97+
await mkdir(dirname(destPath), { recursive: true });
98+
await pipeline(readStream, createWriteStream(destPath));
99+
succeed(base);
100+
} catch (error) {
101+
fail(error);
102+
}
103+
})();
104+
});
105+
});
106+
107+
zipfile.readEntry();
108+
});
109+
}

packages/core/tests/install-method.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
detectInstallMethod,
44
isCompiledBinary,
55
binaryAssetFileName,
6+
binaryInnerFileName,
67
} from "../src/install/index.ts";
78

89
test("isCompiledBinary respects BAILIAN_COMPILED", () => {
@@ -23,7 +24,12 @@ test("detectInstallMethod respects BAILIAN_INSTALL_METHOD", () => {
2324
else process.env.BAILIAN_INSTALL_METHOD = previous;
2425
});
2526

26-
test("binaryAssetFileName formats windows exe", () => {
27-
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
28-
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
27+
test("binaryAssetFileName uses per-platform zip", () => {
28+
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.zip");
29+
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip");
30+
});
31+
32+
test("binaryInnerFileName keeps exe suffix inside zip", () => {
33+
expect(binaryInnerFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
34+
expect(binaryInnerFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
2935
});

packages/runtime/src/utils/binary-update.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
import { mkdir, rename, unlink, writeFile, chmod } from "node:fs/promises";
1+
import { mkdir, rename, unlink, writeFile, chmod, readFile } from "node:fs/promises";
22
import { dirname, join } from "node:path";
33
import { homedir } from "node:os";
44
import { createHash } from "node:crypto";
55
import {
66
binaryAssetFileName,
7+
binaryInnerFileName,
78
channelManifestUrl,
89
detectBinaryPlatform,
10+
extractZipEntryToFile,
911
getConfigDir,
1012
releaseAssetUrl,
1113
writeInstallMethodSync,
1214
} from "bailian-cli-core";
1315

1416
export interface ChannelManifest {
1517
version: string;
16-
assets?: Record<string, { file?: string; sha256?: string; url?: string }>;
18+
assets?: Record<string, { file?: string; sha256?: string; url?: string; inner?: string }>;
1719
}
1820

1921
export async function fetchBinaryChannelVersion(
@@ -81,40 +83,46 @@ function sha256(buffer: Buffer): string {
8183

8284
/**
8385
* Download and install a newer standalone binary in place of the current install.
86+
* Assets are per-platform `.zip` files; checksum applies to the zip.
8487
* Returns the installed version string.
8588
*/
8689
export async function performBinaryUpdate(targetVersion: string): Promise<string> {
8790
const { os, arch, fileSuffix } = detectBinaryPlatform();
91+
const exe = fileSuffix === ".exe";
8892
const manifest = await fetchBinaryChannelManifest("latest");
8993
const assetKey = `${os}-${arch}`;
9094
const assetMeta = manifest?.assets?.[assetKey];
91-
const fileName =
92-
assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, fileSuffix === ".exe");
95+
const zipName = assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, exe);
96+
const innerName = assetMeta?.inner ?? binaryInnerFileName(targetVersion, os, arch, exe);
9397
const expectedSha = assetMeta?.sha256;
94-
const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, fileName);
98+
const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, zipName);
9599

96-
const tmpPath = join(shareRoot(), ".tmp", fileName);
97-
const buffer = await downloadToFile(url, tmpPath);
100+
const tmpZip = join(shareRoot(), ".tmp", zipName);
101+
const buffer = await downloadToFile(url, tmpZip);
98102
const actualSha = sha256(buffer);
99103
if (expectedSha && expectedSha !== actualSha) {
100-
await unlink(tmpPath).catch(() => {});
101-
throw new Error(`Checksum mismatch for ${fileName}`);
104+
await unlink(tmpZip).catch(() => {});
105+
throw new Error(`Checksum mismatch for ${zipName}`);
102106
}
103107

104108
const versionDir = join(shareRoot(), "versions", targetVersion);
105109
await mkdir(versionDir, { recursive: true });
106110
const binaryName = process.platform === "win32" ? "bl.exe" : "bl";
107111
const finalPath = join(versionDir, binaryName);
108-
await rename(tmpPath, finalPath);
112+
const tmpBinary = join(shareRoot(), ".tmp", binaryName);
113+
await extractZipEntryToFile(tmpZip, tmpBinary, innerName);
114+
await unlink(tmpZip).catch(() => {});
115+
await rename(tmpBinary, finalPath);
109116
if (process.platform !== "win32") {
110117
await chmod(finalPath, 0o755);
111118
}
112119

113120
const binDir = binRoot();
114121
await mkdir(binDir, { recursive: true });
115122
if (process.platform === "win32") {
116-
await writeFile(join(binDir, "bl.exe"), buffer);
117-
await writeFile(join(binDir, "bailian.exe"), buffer);
123+
const installed = await readFile(finalPath);
124+
await writeFile(join(binDir, "bl.exe"), installed);
125+
await writeFile(join(binDir, "bailian.exe"), installed);
118126
} else {
119127
const { symlink } = await import("node:fs/promises");
120128
for (const name of ["bl", "bailian"] as const) {

0 commit comments

Comments
 (0)