1
0

feat: 跨平台发布打包,支持 7 个目标平台交叉编译和 tar.gz 分发

- 新增 scripts/release.ts,支持 7 个编译目标(linux/darwin/windows + musl 变体)
- 从 build.ts 提取共享构建逻辑到 build-common.ts,现有 build 行为不变
- 使用 tar-stream + node:zlib 创建 tar.gz,精确控制 Unix 权限位
- SHA256 校验和文件格式兼容 sha256sum -c
- 支持 --target 参数选择特定平台编译
- 新增 devDependency: tar-stream、@types/tar-stream
- 更新 README.md 和 DEVELOPMENT.md 文档
- 同步 openspec specs
This commit is contained in:
2026-05-20 23:24:36 +08:00
parent 8eac814cc6
commit ccd16a583e
13 changed files with 902 additions and 139 deletions

View File

@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
ALL_TARGETS,
archiveName,
checksumName,
computeChecksum,
execName,
packageTarget,
parseTargets,
} from "../../scripts/release";
describe("parseTargets", () => {
test("默认返回全部目标", () => {
const targets = parseTargets([]);
expect(targets).toEqual(ALL_TARGETS);
});
test("解析单一目标", () => {
const targets = parseTargets(["--target", "linux-x64"]);
expect(targets).toHaveLength(1);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64");
expect(targets[0]!.os).toBe("linux");
expect(targets[0]!.arch).toBe("x64");
});
test("解析多个逗号分隔目标", () => {
const targets = parseTargets(["--target", "linux-x64,darwin-arm64,windows-x64"]);
expect(targets).toHaveLength(3);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64");
expect(targets[1]!.bunTarget).toBe("bun-darwin-arm64");
expect(targets[2]!.bunTarget).toBe("bun-windows-x64");
});
test("解析 musl 变体", () => {
const targets = parseTargets(["--target", "linux-x64-musl"]);
expect(targets).toHaveLength(1);
expect(targets[0]!.bunTarget).toBe("bun-linux-x64-musl");
expect(targets[0]!.os).toBe("linux");
expect(targets[0]!.arch).toBe("x64-musl");
});
test("无效 target 导致进程退出", () => {
const exitCalls: number[] = [];
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalExit = process.exit;
process.exit = ((code: number) => {
exitCalls.push(code);
}) as never;
try {
parseTargets(["--target", "invalid-target"]);
} finally {
process.exit = originalExit;
}
expect(exitCalls).toEqual([1]);
});
});
describe("execName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
const muslTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64-musl")!;
test("Linux x64 可执行文件命名", () => {
expect(execName(linuxTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64");
});
test("Windows 可执行文件命名带 .exe 后缀", () => {
expect(execName(windowsTarget, "0.1.0")).toBe("dial-server-0.1.0-windows-x64.exe");
});
test("musl 变体命名", () => {
expect(execName(muslTarget, "0.1.0")).toBe("dial-server-0.1.0-linux-x64-musl");
});
test("版本号正确嵌入", () => {
expect(execName(linuxTarget, "1.2.3")).toBe("dial-server-1.2.3-linux-x64");
});
});
describe("archiveName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
test("Linux 压缩包命名", () => {
expect(archiveName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz");
});
test("Windows 压缩包命名", () => {
expect(archiveName(windowsTarget, "0.1.0")).toBe("dial-server_0.1.0_windows_x64.tar.gz");
});
});
describe("checksumName", () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
test("校验和文件命名", () => {
expect(checksumName(linuxTarget, "0.1.0")).toBe("dial-server_0.1.0_linux_x64.tar.gz.sha256");
});
});
describe("computeChecksum", () => {
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
const packagesDir = join(projectRoot, "dist/release/packages");
beforeEach(async () => {
await mkdir(packagesDir, { recursive: true });
});
afterEach(async () => {
await rm(packagesDir, { force: true, recursive: true });
});
test("生成正确格式的 sha256 文件", async () => {
const archivePath = join(packagesDir, "dial-server_0.1.0_linux_x64.tar.gz");
const content = Buffer.from("test archive content");
await Bun.write(archivePath, content);
const checksumPath = await computeChecksum(archivePath);
const checksumContent = await Bun.file(checksumPath).text();
const parts = checksumContent.split(" ");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[0-9a-f]{64}$/);
expect(parts[1]).toBe("dial-server_0.1.0_linux_x64.tar.gz\n");
});
test("校验和与文件内容一致", async () => {
const archivePath = join(packagesDir, "test.tar.gz");
const content = Buffer.from("hello world");
await Bun.write(archivePath, content);
const checksumPath = await computeChecksum(archivePath);
const checksumContent = await Bun.file(checksumPath).text();
const hash = checksumContent.split(" ")[0]!;
const expectedHash = Bun.CryptoHasher.hash("sha256", content, "hex");
expect(hash).toBe(expectedHash);
});
});
describe("packageTarget", () => {
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
const packagesDir = join(projectRoot, "dist/release/packages");
beforeEach(async () => {
await mkdir(packagesDir, { recursive: true });
});
afterEach(async () => {
await rm(packagesDir, { force: true, recursive: true });
});
test("Linux 压缩包包含正确文件、目录前缀和权限位", async () => {
const linuxTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-linux-x64")!;
const binaryPath = join(packagesDir, "test-binary");
await Bun.write(binaryPath, "binary content");
const archivePath = await packageTarget(linuxTarget, "0.1.0", binaryPath);
const archiveContent = await Bun.file(archivePath).arrayBuffer();
const tar = await import("tar-stream");
const zlib = await import("node:zlib");
const extract = tar.extract();
const entries: Array<{ mode: number; name: string }> = [];
await new Promise<void>((resolve, reject) => {
const gunzip = zlib.createGunzip();
gunzip.write(Buffer.from(archiveContent));
gunzip.end();
gunzip.pipe(extract);
extract.on("entry", (header: { mode?: number; name: string }, _stream: unknown, next: () => void) => {
entries.push({ mode: header.mode ?? 0, name: header.name });
next();
});
extract.on("finish", resolve);
extract.on("error", reject);
});
const names = entries.map((e) => e.name);
expect(names).toContain("dial-server_0.1.0_linux_x64/dial-server");
expect(names).toContain("dial-server_0.1.0_linux_x64/probes.example.yaml");
expect(names).toContain("dial-server_0.1.0_linux_x64/LICENSE");
const binaryEntry = entries.find((e) => e.name.endsWith("/dial-server"))!;
expect(binaryEntry.mode).toBe(0o755);
const probesEntry = entries.find((e) => e.name.endsWith("probes.example.yaml"))!;
expect(probesEntry.mode).toBe(0o644);
});
test("Windows 压缩包内可执行文件名为 dial-server.exe", async () => {
const windowsTarget = ALL_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!;
const binaryPath = join(packagesDir, "test-binary.exe");
await Bun.write(binaryPath, "binary content");
const archivePath = await packageTarget(windowsTarget, "0.1.0", binaryPath);
const archiveContent = await Bun.file(archivePath).arrayBuffer();
const tar = await import("tar-stream");
const zlib = await import("node:zlib");
const extract = tar.extract();
const names: string[] = [];
await new Promise<void>((resolve, reject) => {
const gunzip = zlib.createGunzip();
gunzip.write(Buffer.from(archiveContent));
gunzip.end();
gunzip.pipe(extract);
extract.on("entry", (header: { name: string }, _stream: unknown, next: () => void) => {
names.push(header.name);
next();
});
extract.on("finish", resolve);
extract.on("error", reject);
});
expect(names).toContain("dial-server_0.1.0_windows_x64/dial-server.exe");
});
});