diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index efd1afef..7a7b8e4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,7 +56,7 @@ jobs: | sudo tar -xz -C /usr/local/bin gitleaks gitleaks version - - name: Ensure zip (per-platform binary archives) + - name: Ensure zip and tar (per-platform binary archives) run: sudo apt-get update && sudo apt-get install -y zip - run: pnpm install --frozen-lockfile @@ -114,7 +114,7 @@ jobs: | sudo tar -xz -C /usr/local/bin gitleaks gitleaks version - - name: Ensure zip (per-platform binary archives) + - name: Ensure zip and tar (per-platform binary archives) run: sudo apt-get update && sudo apt-get install -y zip - run: pnpm install --frozen-lockfile diff --git a/INSTALL.md b/INSTALL.md index 61e128f6..d159d23f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -70,7 +70,7 @@ bl --version which bl # Windows: where.exe bl ``` -> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node,回退到上方 npm 安装。 +> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node,回退到上方 npm 安装。Unix 二进制安装优先解 `.tar.gz`(不依赖 `unzip`);Windows 仍使用 `.zip`。 --- diff --git a/docs/agents/publish.md b/docs/agents/publish.md index 7e693ace..ffe0ec41 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -71,7 +71,7 @@ workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / ### channel 发布 1. 在 GitHub 触发 Publish workflow,mode 选 `channel`,channel 填 npm dist-tag 名: - - **`bailian-cli`**:npm 发到该 tag;二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release` + - **`bailian-cli`**:npm 发到该 tag;二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release`。**先发二进制(zip+tar.gz 上齐)再发静态仓 `install.sh`**,避免新脚本去拉还不存在的 `.tar.gz`。 - **`knowledge-studio-cli`**:仅 npm(自动跳过 binary,不碰 `sync-release.json`) 2. CI 自动:生成 `0.0.0-beta--`(UTC 到分钟;同 commit 同分钟重跑会覆盖同号)→ 临时 bump → 自检 → **npm 发到 dist-tag** →(bailian-cli)**Bun 编二进制 + GH prerelease + 覆盖 `sync-release.json`** → 还原 package.json 3. 对应脚本:`tools/release/publish-channel.mjs` @@ -81,7 +81,7 @@ workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / 1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli`,`knowledge-studio-cli` 发布会额外包含 `packages/kscli` 2. 在 GitHub 触发 Publish workflow,package 选目标包集合,mode 选 `stable` 3. 需要 production environment 审批人批准 -4. CI 自动:自检 → **npm 发到 latest** → **推送 git tag `v`** → **Bun 编二进制并创建/更新 GitHub Release** →(bailian-cli)维护 CDN **`manifest.json`** → 完成 +4. CI 自动:自检 → **npm 发到 latest** → **推送 git tag `v`** → **Bun 编二进制并创建/更新 GitHub Release**(每平台 `.zip`,darwin/linux 额外 `.tar.gz`)→(bailian-cli)维护 CDN **`manifest.json`**(unix 资产含 `tar` / `tarSha256`,`file` 仍为 zip)→ 完成 5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包 6. 对应脚本:`tools/release/publish-stable.mjs` diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index b23373b9..e58012ed 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -5,7 +5,9 @@ * FC-presigned URLs and holds no OSS credentials itself. * * Layout under the base: - * v/.zip —— immutable per-version binaries + SHA256SUMS + * v/.zip —— per-version zip (Windows installer, `bl update`, old Unix install.sh) + * v/.tar.gz —— unix install.sh default (darwin / linux); same inner binary + * SHA256SUMS —— checksums for zip and tar.gz * manifest.json —— stable install/update pointer (rolling-manifest shape) * latest.json —— stable alias; same body as manifest.json * sync-release.json —— official channel/verify rolling pointer (all bailian-cli @@ -97,6 +99,15 @@ export function binaryAssetFileName( return `bl-${version}-${os}-${arch}.zip`; } +/** + * Unix install.sh archive: `bl---.tar.gz`. + * Windows has no tar.gz (PowerShell Expand-Archive uses zip). + */ +export function binaryTarFileName(version: string, os: string, arch: string): string | undefined { + if (os === "windows") return undefined; + return `bl-${version}-${os}-${arch}.tar.gz`; +} + /** Uncompressed binary name inside the zip. */ export function binaryInnerFileName( version: string, diff --git a/packages/core/src/install/index.ts b/packages/core/src/install/index.ts index db33309a..9e9d8ea8 100644 --- a/packages/core/src/install/index.ts +++ b/packages/core/src/install/index.ts @@ -14,6 +14,7 @@ export { DEFAULT_INSTALL_SCRIPT_URL, GITHUB_RELEASES_BASE, binaryAssetFileName, + binaryTarFileName, binaryInnerFileName, channelManifestUrl, detectBinaryPlatform, diff --git a/packages/core/tests/install-method.test.ts b/packages/core/tests/install-method.test.ts index 559d07b8..731f07fe 100644 --- a/packages/core/tests/install-method.test.ts +++ b/packages/core/tests/install-method.test.ts @@ -9,6 +9,7 @@ import { getUpdateInstallMethod, isCompiledBinary, binaryAssetFileName, + binaryTarFileName, binaryInnerFileName, writeInstallMethodSync, } from "../src/install/index.ts"; @@ -103,6 +104,12 @@ test("binaryAssetFileName uses per-platform zip", () => { expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip"); }); +test("binaryTarFileName is unix-only", () => { + expect(binaryTarFileName("1.2.3", "darwin", "arm64")).toBe("bl-1.2.3-darwin-arm64.tar.gz"); + expect(binaryTarFileName("1.2.3", "linux", "x64")).toBe("bl-1.2.3-linux-x64.tar.gz"); + expect(binaryTarFileName("1.2.3", "windows", "x64")).toBeUndefined(); +}); + 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/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 3a75c3a9..77c632d7 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -32,6 +32,8 @@ curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash Windows PowerShell: `irm https://bailian.aliyun.com/cli/install.ps1 | iex` +Unix `install.sh` prefers the `.tar.gz` asset and does not require `unzip`. Windows keeps `.zip`. + ### Skills - **Supported:** `bl skill init` (installs every `bailian-*`, including `bailian-protocol`) diff --git a/tools/release/lib/binary-archive.test.mjs b/tools/release/lib/binary-archive.test.mjs new file mode 100644 index 00000000..40226d88 --- /dev/null +++ b/tools/release/lib/binary-archive.test.mjs @@ -0,0 +1,59 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "vite-plus/test"; +import { + BINARY_TARGETS, + binaryTarAssetName, + matrixAssetNames, + unixTarTarget, +} from "./binary-build.mjs"; +import { tarOne } from "./binary-tar.mjs"; + +describe("binary archive matrix", () => { + test("unix targets ship zip and tar.gz; windows is zip-only", () => { + const names = matrixAssetNames("1.2.3"); + expect(names).toEqual([ + "bl-1.2.3-darwin-arm64.zip", + "bl-1.2.3-darwin-arm64.tar.gz", + "bl-1.2.3-darwin-x64.zip", + "bl-1.2.3-darwin-x64.tar.gz", + "bl-1.2.3-linux-x64.zip", + "bl-1.2.3-linux-x64.tar.gz", + "bl-1.2.3-windows-x64.zip", + ]); + expect(BINARY_TARGETS.filter((target) => unixTarTarget(target))).toHaveLength(3); + expect(binaryTarAssetName("1.2.3", { os: "linux", arch: "x64" })).toBe( + "bl-1.2.3-linux-x64.tar.gz", + ); + }); +}); + +describe("tarOne", () => { + test("tar.gz stores basename only and round-trips bytes", () => { + const outdir = mkdtempSync(join(tmpdir(), "bl-bin-tar-")); + try { + const innerName = "bl-9.9.9-linux-x64"; + const innerPath = join(outdir, innerName); + writeFileSync(innerPath, "fake-binary-payload\n"); + chmodSync(innerPath, 0o755); + const packed = tarOne( + { innerName, innerPath, os: "linux", arch: "x64" }, + { outdir, tarFileName: "bl-9.9.9-linux-x64.tar.gz" }, + ); + expect(packed.fileName).toBe("bl-9.9.9-linux-x64.tar.gz"); + expect(packed.sha256).toMatch(/^[a-f0-9]{64}$/); + + const extractDir = join(outdir, "out"); + mkdirSync(extractDir); + const extract = spawnSync("tar", ["-C", extractDir, "-xzf", packed.outfile, innerName], { + encoding: "utf-8", + }); + expect(extract.status).toBe(0); + expect(readFileSync(join(extractDir, innerName), "utf-8")).toBe("fake-binary-payload\n"); + } finally { + rmSync(outdir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index ea0108c8..6e9b1247 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -1,6 +1,6 @@ /** * Build standalone `bl` binaries with Bun --compile, then pack each as a - * per-platform `.zip` via binary-zip.mjs (Release / OSS download asset). + * per-platform `.zip` (all OS) plus `.tar.gz` on darwin/linux (Release / OSS). * * Used by lib/binary-release.mjs (and publish-stable / publish-channel orchestrators). * Debug: @@ -21,6 +21,7 @@ import { rollingManifestChannelId, rollingManifestFileName, } from "./binary-options.mjs"; +import { ensureTar, tarOne } from "./binary-tar.mjs"; import { ensureZip, zipOne } from "./binary-zip.mjs"; const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url)); @@ -42,14 +43,27 @@ export function binaryInnerName(version, { os, arch, exe }) { return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; } -/** Release asset basename: `bl---.zip`. */ +/** Release zip basename: `bl---.zip` (install.ps1 / bl update / old install.sh). */ export function binaryAssetName(version, { os, arch }) { return `bl-${version}-${os}-${arch}.zip`; } -/** Full matrix zip basenames for a version (order matches BINARY_TARGETS). */ +/** Unix install.sh default archive: `bl---.tar.gz`. */ +export function binaryTarAssetName(version, { os, arch }) { + return `bl-${version}-${os}-${arch}.tar.gz`; +} + +export function unixTarTarget(target) { + return target.os !== "windows"; +} + +/** Full matrix: zip for every target, plus tar.gz for darwin/linux. */ export function matrixAssetNames(version) { - return BINARY_TARGETS.map((target) => binaryAssetName(version, target)); + return BINARY_TARGETS.flatMap((target) => { + const zipName = binaryAssetName(version, target); + if (!unixTarTarget(target)) return [zipName]; + return [zipName, binaryTarAssetName(version, target)]; + }); } function log(message = "") { @@ -156,22 +170,32 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { } function writeChecksums(outdir, artifacts) { - const lines = artifacts.map((item) => `${item.sha256} ${item.fileName}`); + const lines = []; + for (const item of artifacts) { + lines.push(`${item.sha256} ${item.fileName}`); + if (item.tarFileName && item.tarSha256) { + lines.push(`${item.tarSha256} ${item.tarFileName}`); + } + } writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`); } -/** Write the rolling channel manifest (`latest.json` / `sync-release.json`) with per-platform zip + sha256. */ +/** Write the rolling channel manifest (`latest.json` / `sync-release.json`) with per-platform zip + optional tar.gz. */ function writeChannelManifest(outdir, version, artifacts, mode) { const channel = rollingManifestChannelId(mode); const assets = Object.fromEntries( - artifacts.map((item) => [ - `${item.os}-${item.arch}`, - { + artifacts.map((item) => { + const asset = { file: item.fileName, sha256: item.sha256, inner: item.innerName, - }, - ]), + }; + if (item.tarFileName && item.tarSha256) { + asset.tar = item.tarFileName; + asset.tarSha256 = item.tarSha256; + } + return [`${item.os}-${item.arch}`, asset]; + }), ); const manifest = { name: "bailian-cli", @@ -203,7 +227,28 @@ function smokeTestHostBinary(compiled, outdir) { } } -/** Compile binaries into `outdir`, zip per platform, write checksums (+ channel manifest). */ +function packOne(compiled, version, outdir) { + let tarMeta = null; + if (unixTarTarget(compiled)) { + tarMeta = tarOne(compiled, { + outdir, + tarFileName: binaryTarAssetName(version, compiled), + log, + }); + } + const zipMeta = zipOne(compiled, { + outdir, + zipFileName: binaryAssetName(version, compiled), + log, + }); + return { + ...zipMeta, + tarFileName: tarMeta?.fileName, + tarSha256: tarMeta?.sha256, + }; +} + +/** Compile binaries into `outdir`, zip (+ unix tar.gz) per platform, write checksums (+ channel manifest). */ export function buildBinaryArtifacts(rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); const { outdir, mode, channel } = options; @@ -211,6 +256,7 @@ export function buildBinaryArtifacts(rawOptions = {}) { ensureZip(); const version = cliVersion(); const targets = resolveTargets(options); + if (targets.some((target) => unixTarTarget(target))) ensureTar(); mkdirSync(outdir, { recursive: true }); log(`bun ${bunVersion}`); @@ -220,9 +266,7 @@ export function buildBinaryArtifacts(rawOptions = {}) { 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 }), - ); + const artifacts = compiled.map((item) => packOne(item, version, outdir)); writeChecksums(outdir, artifacts); const extras = ["SHA256SUMS"]; @@ -231,6 +275,9 @@ export function buildBinaryArtifacts(rawOptions = {}) { log(`\nBuilt ${artifacts.length} zip(s):`); for (const item of artifacts) { log(` ${item.fileName} ${item.sha256.slice(0, 12)}… (inner ${item.innerName})`); + if (item.tarFileName) { + log(` ${item.tarFileName} ${item.tarSha256.slice(0, 12)}…`); + } } log(`Also wrote ${extras.join(", ")}`); return { diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 6740684a..84d739bf 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -2,9 +2,9 @@ * Publish bailian-cli binary assets to GitHub Releases. * * stable: release `v` (tag must already be on origin; --verify-tag) - * assets: bl-*.zip, SHA256SUMS (no latest.json on the GH release) + * assets: bl-*.zip, unix bl-*.tar.gz, SHA256SUMS (no latest.json on the GH release) * OSS: rewrite release/manifest.json + latest.json from build latest.json - * channel: versioned prerelease `v` (assets: bl-*.zip, SHA256SUMS) + * channel: versioned prerelease `v` (assets: bl-*.zip, unix bl-*.tar.gz, SHA256SUMS) * + rolling prerelease `channel-sync-release` holding only sync-release.json * OSS: always overwrite prefix-root sync-release.json * diff --git a/tools/release/lib/binary-tar.mjs b/tools/release/lib/binary-tar.mjs new file mode 100644 index 00000000..7acb86c0 --- /dev/null +++ b/tools/release/lib/binary-tar.mjs @@ -0,0 +1,55 @@ +/** + * Pack a Bun-compiled Unix binary into a per-platform `.tar.gz` Release asset. + * Windows stays zip-only (install.ps1 / Expand-Archive). + * + * Called by binary-build.mjs after compile + smoke test, before zipOne removes + * the inner file. Naming (`bl---.tar.gz`) stays in binary-build. + */ +import { createHash } from "node:crypto"; +import { readFileSync } 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 ensureTar() { + const result = spawnSync("tar", ["--help"], { encoding: "utf-8" }); + if (result.error?.code === "ENOENT") { + throw new Error("tar not found on PATH. A POSIX tar is required to pack unix .tar.gz assets."); + } +} + +/** + * Pack compiled binary into `tarFileName` under `outdir`. Does not delete the inner file. + * + * @param {{ innerName: string, innerPath: string, os: string, arch: string }} compiled + * @param {{ outdir: string, tarFileName: string, log?: (message?: string) => void }} options + */ +export function tarOne(compiled, { outdir, tarFileName, log = defaultLog }) { + const tarPath = join(outdir, tarFileName); + log(`tar ${compiled.innerName} → ${tarFileName}`); + + // Store basename only (no directory path). GNU / BSD / BusyBox: tar -czf archive file + const result = spawnSync("tar", ["-czf", tarFileName, compiled.innerName], { + cwd: outdir, + encoding: "utf-8", + }); + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout || ""); + throw new Error(`tar failed for ${compiled.innerName}`); + } + return { + fileName: tarFileName, + outfile: tarPath, + innerName: compiled.innerName, + os: compiled.os, + arch: compiled.arch, + sha256: sha256File(tarPath), + }; +} diff --git a/tools/release/lib/binary-zip.mjs b/tools/release/lib/binary-zip.mjs index d054ba84..4fde68cd 100644 --- a/tools/release/lib/binary-zip.mjs +++ b/tools/release/lib/binary-zip.mjs @@ -28,9 +28,9 @@ export function ensureZip() { * 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 + * @param {{ outdir: string, zipFileName: string, log?: (message?: string) => void, removeInner?: boolean }} options */ -export function zipOne(compiled, { outdir, zipFileName, log = defaultLog }) { +export function zipOne(compiled, { outdir, zipFileName, log = defaultLog, removeInner = true }) { const zipPath = join(outdir, zipFileName); log(`zip ${compiled.innerName} → ${zipFileName}`); @@ -43,7 +43,7 @@ export function zipOne(compiled, { outdir, zipFileName, log = defaultLog }) { process.stderr.write(result.stderr || result.stdout || ""); throw new Error(`zip failed for ${compiled.innerName}`); } - unlinkSync(compiled.innerPath); + if (removeInner) unlinkSync(compiled.innerPath); return { fileName: zipFileName, outfile: zipPath, diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index 33e358fe..a56d8844 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -54,6 +54,7 @@ function fcContext() { function contentTypeFor(name) { if (name.endsWith(".md")) return "text/markdown; charset=utf-8"; + if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) return "application/gzip"; if (name.endsWith(".zip")) return "application/zip"; if (name.endsWith(".json")) return "application/json"; return "application/octet-stream";