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:
225
tests/scripts/release.test.ts
Normal file
225
tests/scripts/release.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user