feat(build): migrated native pipeline to bazel with remote caching

- Replaced the napi-cli/cargo-zigbuild/cargo-xwin/sccache build path with
  Bazel: rules_rust + crate_universe over Cargo.lock, hermetic zig cc
  toolchains (linux-gnu pinned to glibc 2.17, linux-musl), host Xcode for
  darwin, and a repo-local hermetic clang-cl + llvm-ml + xwin toolchain for
  windows-msvc (bazel/toolchains/msvc).
- All eight shipped addons build as //:natives-<target> via the release
  transition in bazel/defs.bzl (opt, thin LTO, cgu=16, stripped, canonical
  .node naming); scripts/bazel-natives.ts is the single driver for local
  dev and CI.
- Rust validation moved to bazel test + clippy aspects (strict workspace
  policy for opted-in crates, default lints elsewhere, mirroring cargo
  semantics) and the rustfmt aspect; cargo stays as the dev-iteration
  surface, with brush-core/brush-builtins promoted to workspace members
  and excluded from cargo dev tasks to keep their historical scope.
- CI caches through an in-cluster bazel-remote action cache (TLS + basic
  auth, cluster-internal only); GitHub-hosted runners never touch the
  infrastructure and use an actions/cache-backed disk cache instead.
- Deleted the hand-rolled caching machinery: ci-target-cache,
  ci-native-artifact-cache, ci-build-native, native-source-hash,
  find-native-artifacts, restore-linux-native, native-prewarm workflow,
  ensure-* toolchain actions, and all sccache/Swatinem wiring.
- Warm native rebuilds drop from ~20 minutes to seconds; a cold client
  with a warm remote cache rebuilds the linux x64 pair in ~2.5 minutes.
This commit is contained in:
can1357
2026-07-27 12:22:19 +02:00
parent 5f988a8270
commit 8facd237d5
121 changed files with 66794 additions and 2630 deletions
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import {
conventionOutputPaths,
type HostInfo,
hostTargetName,
parseBazelFilesOutput,
parseCliArgs,
resolveTargetLabels,
} from "./bazel-natives";
const linuxModern: HostInfo = { platform: "linux", arch: "x64", avx2: true };
const linuxBaseline: HostInfo = { platform: "linux", arch: "x64", avx2: false };
const macArm: HostInfo = { platform: "darwin", arch: "arm64", avx2: false };
describe("hostTargetName", () => {
test("picks the x64 variant from AVX2 support", () => {
expect(hostTargetName(linuxModern)).toBe("linux-x64-modern");
expect(hostTargetName(linuxBaseline)).toBe("linux-x64-baseline");
// darwin x64 ships baseline only; AVX2 must not invent a modern target.
expect(hostTargetName({ platform: "darwin", arch: "x64", avx2: true })).toBe("darwin-x64-baseline");
});
test("maps non-x64 and windows hosts", () => {
expect(hostTargetName(macArm)).toBe("darwin-arm64");
expect(hostTargetName({ platform: "linux", arch: "arm64", avx2: false })).toBe("linux-arm64");
expect(hostTargetName({ platform: "win32", arch: "x64", avx2: true })).toBe("win32-x64-baseline");
});
test("rejects hosts without an addon target", () => {
expect(() => hostTargetName({ platform: "freebsd", arch: "x64", avx2: false })).toThrow(
/No pi_natives addon target/,
);
expect(() => hostTargetName({ platform: "win32", arch: "arm64", avx2: false })).toThrow(
/No pi_natives addon target/,
);
});
});
describe("resolveTargetLabels", () => {
test("maps explicit names, pseudo-targets, and aggregates to labels", () => {
expect(resolveTargetLabels(["linux-x64-baseline", "linux-x64-modern"], macArm)).toEqual([
"//:natives-linux-x64-baseline",
"//:natives-linux-x64-modern",
]);
expect(resolveTargetLabels(["host"], linuxModern)).toEqual(["//:natives-linux-x64-modern"]);
expect(resolveTargetLabels(["linux-all", "darwin-all"], macArm)).toEqual([
"//:natives-linux-all",
"//:natives-darwin-all",
]);
});
test("deduplicates and rejects unknown targets", () => {
expect(resolveTargetLabels(["host", "darwin-arm64"], macArm)).toEqual(["//:natives-darwin-arm64"]);
expect(() => resolveTargetLabels(["linux-x64"], macArm)).toThrow(/Unknown native target "linux-x64"/);
});
});
describe("conventionOutputPaths", () => {
test("builds bazel-bin paths with canonical filenames (musl reuses linux names)", () => {
expect(conventionOutputPaths(["linux-musl-x64-baseline", "win32-x64-baseline"], macArm)).toEqual([
"bazel-bin/natives-linux-musl-x64-baseline/pi_natives.linux-x64-baseline.node",
"bazel-bin/natives-win32-x64-baseline/pi_natives.win32-x64-baseline.node",
]);
});
test("expands aggregates and the host pseudo-target", () => {
expect(conventionOutputPaths(["darwin-all"], macArm)).toEqual([
"bazel-bin/natives-darwin-arm64/pi_natives.darwin-arm64.node",
"bazel-bin/natives-darwin-x64-baseline/pi_natives.darwin-x64-baseline.node",
]);
expect(conventionOutputPaths(["host"], linuxBaseline)).toEqual([
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
]);
});
});
describe("parseBazelFilesOutput", () => {
test("keeps only .node paths, trimmed and deduplicated", () => {
const output = [
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
" bazel-bin/natives-linux-x64-modern/pi_natives.linux-x64-modern.node ",
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
"INFO: Analyzed 2 targets (0 packages loaded, 0 targets configured).",
"",
].join("\n");
expect(parseBazelFilesOutput(output)).toEqual([
"bazel-bin/natives-linux-x64-baseline/pi_natives.linux-x64-baseline.node",
"bazel-bin/natives-linux-x64-modern/pi_natives.linux-x64-modern.node",
]);
});
test("returns empty for output without addon files", () => {
expect(parseBazelFilesOutput("INFO: Build completed successfully\n")).toEqual([]);
});
});
describe("parseCliArgs", () => {
test("splits targets, --dest, and passthrough bazel args", () => {
expect(
parseCliArgs(["linux-x64-baseline", "linux-x64-modern", "--dest", "out", "--", "--config=ci", "--dest"]),
).toEqual({
targets: ["linux-x64-baseline", "linux-x64-modern"],
dest: "out",
bazelArgs: ["--config=ci", "--dest"],
});
expect(parseCliArgs(["host"])).toEqual({ targets: ["host"], dest: null, bazelArgs: [] });
});
test("rejects missing targets, stray flags, and a valueless --dest", () => {
expect(() => parseCliArgs([])).toThrow(/Usage:/);
expect(() => parseCliArgs(["--", "--config=ci"])).toThrow(/Usage:/);
expect(() => parseCliArgs(["host", "--config=ci"])).toThrow(/Unknown flag --config=ci/);
expect(() => parseCliArgs(["host", "--dest"])).toThrow(/--dest requires/);
});
});
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env bun
/**
* Canonical Bazel driver for the shipping pi_natives addons.
*
* Usage: bun scripts/bazel-natives.ts <target>... [--dest <dir>] [-- <extra bazel args>]
*
* Targets are the //:natives-* names from BUILD.bazel (e.g. linux-x64-baseline,
* darwin-arm64) plus three pseudo-targets:
* - host the single addon matching this machine (x64 hosts pick
* modern vs baseline via AVX2 detection)
* - linux-all every addon buildable from a linux-x64 host (incl. win32)
* - darwin-all both darwin addons (mac hosts only)
*
* One `bazel build` covers all requested targets; outputs are located via
* `bazel cquery --output=files` (falling back to the bazel-bin path convention)
* and copied dereferenced into --dest (default packages/natives/native).
*
* Extra args after `--` are passed to bazel verbatim (cache configs, endpoints,
* headers — see .bazelrc for the cache-rw/cache-ro policy configs).
*
* Note: musl addons intentionally reuse the plain linux-<arch> filenames, so a
* `linux-all` copy overwrites the gnu addon with the musl one (and vice versa);
* CI jobs that ship files always request an explicit disjoint target set.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { detectHostAvx2Support } from "./host-detect";
const repoRoot = path.join(import.meta.dir, "..");
/** //:natives-<name> → canonical addon filename (mirrors _ADDONS in BUILD.bazel). */
export const ADDON_OUTPUTS: Record<string, string> = {
"linux-x64-baseline": "pi_natives.linux-x64-baseline.node",
"linux-x64-modern": "pi_natives.linux-x64-modern.node",
"linux-arm64": "pi_natives.linux-arm64.node",
"linux-musl-x64-baseline": "pi_natives.linux-x64-baseline.node",
"linux-musl-arm64": "pi_natives.linux-arm64.node",
"darwin-x64-baseline": "pi_natives.darwin-x64-baseline.node",
"darwin-arm64": "pi_natives.darwin-arm64.node",
"win32-x64-baseline": "pi_natives.win32-x64-baseline.node",
};
/** Aggregate filegroups → their member addon targets (mirrors BUILD.bazel). */
export const AGGREGATE_TARGETS: Record<string, string[]> = {
"linux-all": [
"linux-arm64",
"linux-musl-arm64",
"linux-musl-x64-baseline",
"linux-x64-baseline",
"linux-x64-modern",
"win32-x64-baseline",
],
"darwin-all": ["darwin-arm64", "darwin-x64-baseline"],
};
export interface HostInfo {
platform: string;
arch: string;
avx2: boolean;
}
/** The single addon target matching the host CPU (modern iff x64 + AVX2). */
export function hostTargetName(host: HostInfo): string {
if (host.platform === "darwin") {
if (host.arch === "arm64") return "darwin-arm64";
if (host.arch === "x64") return "darwin-x64-baseline";
}
if (host.platform === "linux") {
if (host.arch === "arm64") return "linux-arm64";
if (host.arch === "x64") return host.avx2 ? "linux-x64-modern" : "linux-x64-baseline";
}
if (host.platform === "win32" && host.arch === "x64") return "win32-x64-baseline";
throw new Error(`No pi_natives addon target for host ${host.platform}-${host.arch}`);
}
/** Expand pseudo-targets and map names to //:natives-* labels (deduplicated). */
export function resolveTargetLabels(names: string[], host: HostInfo): string[] {
const labels: string[] = [];
for (const name of names) {
const resolved = name === "host" ? hostTargetName(host) : name;
if (!(resolved in AGGREGATE_TARGETS) && !(resolved in ADDON_OUTPUTS)) {
const known = [...Object.keys(ADDON_OUTPUTS), ...Object.keys(AGGREGATE_TARGETS), "host"].join(", ");
throw new Error(`Unknown native target "${name}". Known targets: ${known}`);
}
const label = `//:natives-${resolved}`;
if (!labels.includes(label)) labels.push(label);
}
return labels;
}
/**
* Workspace-relative output paths by bazel-bin convention:
* bazel-bin/natives-<t>/<canonical>.node. Fallback when cquery is unavailable.
*/
export function conventionOutputPaths(names: string[], host: HostInfo): string[] {
const paths: string[] = [];
for (const name of names) {
const resolved = name === "host" ? hostTargetName(host) : name;
const members = AGGREGATE_TARGETS[resolved] ?? [resolved];
for (const member of members) {
const out = ADDON_OUTPUTS[member];
if (!out) throw new Error(`Unknown native target "${name}"`);
const p = `bazel-bin/natives-${member}/${out}`;
if (!paths.includes(p)) paths.push(p);
}
}
return paths;
}
/** Parse `bazel cquery --output=files` stdout into deduplicated .node paths. */
export function parseBazelFilesOutput(output: string): string[] {
const files: string[] = [];
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed.endsWith(".node")) continue;
if (!files.includes(trimmed)) files.push(trimmed);
}
return files;
}
export interface CliOptions {
targets: string[];
dest: string | null;
bazelArgs: string[];
}
export function parseCliArgs(argv: string[]): CliOptions {
const targets: string[] = [];
let dest: string | null = null;
const bazelArgs: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--") {
bazelArgs.push(...argv.slice(i + 1));
break;
}
if (arg === "--dest") {
const value = argv[++i];
if (!value) throw new Error("--dest requires a directory argument");
dest = value;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown flag ${arg} (extra bazel args go after \`--\`)`);
}
targets.push(arg);
}
if (targets.length === 0) {
throw new Error("Usage: bun scripts/bazel-natives.ts <target>... [--dest <dir>] [-- <extra bazel args>]");
}
return { targets, dest, bazelArgs };
}
function resolveBazelBinary(): string {
const bin = Bun.which("bazelisk") ?? Bun.which("bazel");
if (!bin) {
throw new Error(
"Neither `bazelisk` nor `bazel` found on PATH. Install bazelisk: https://github.com/bazelbuild/bazelisk",
);
}
return bin;
}
const STDERR_TAIL_LINES = 40;
/** Run a bazel command streaming stderr live while keeping a tail for the failure report. */
async function runBazel(
bin: string,
args: string[],
stdout: "inherit" | "pipe",
): Promise<{ exitCode: number; stdout: string; stderrTail: string }> {
const proc = Bun.spawn([bin, ...args], { cwd: repoRoot, stdout, stderr: "pipe" });
const decoder = new TextDecoder();
let tail = "";
const pumpStderr = (async () => {
for await (const chunk of proc.stderr) {
const text = decoder.decode(chunk, { stream: true });
process.stderr.write(text);
tail = (tail + text)
.split("\n")
.slice(-STDERR_TAIL_LINES - 1)
.join("\n");
}
})();
const stdoutText = stdout === "pipe" ? await new Response(proc.stdout as ReadableStream).text() : "";
const exitCode = await proc.exited;
await pumpStderr;
return { exitCode, stdout: stdoutText, stderrTail: tail };
}
async function installAddon(sourcePath: string, destPath: string): Promise<void> {
const realSource = await fs.realpath(sourcePath); // bazel-bin outputs are symlink-reachable; copy the real bytes
const tempPath = `${destPath}.tmp.${process.pid}`;
await fs.copyFile(realSource, tempPath);
await fs.chmod(tempPath, 0o644);
try {
await fs.rename(tempPath, destPath); // atomic even if dest is a loaded addon
} catch (err) {
await fs.unlink(tempPath).catch(() => {});
throw err;
}
}
async function main(): Promise<void> {
const options = parseCliArgs(process.argv.slice(2));
const host: HostInfo = { platform: process.platform, arch: process.arch, avx2: detectHostAvx2Support() };
const labels = resolveTargetLabels(options.targets, host);
const destDir = options.dest ? path.resolve(options.dest) : path.join(repoRoot, "packages/natives/native");
const bazel = resolveBazelBinary();
// CI hands cache wiring (remote or disk) through a bazelrc fragment so
// endpoint composition stays in .github/actions/bazel-cache.
const rcPath = Bun.env.OMP_BAZEL_RC?.trim();
const startupArgs = rcPath ? [`--bazelrc=${rcPath}`] : [];
const buildArgs = [...startupArgs, "build", ...options.bazelArgs, "--", ...labels];
console.log(`$ ${path.basename(bazel)} ${buildArgs.join(" ")}`);
const build = await runBazel(bazel, buildArgs, "inherit");
if (build.exitCode !== 0) {
console.error(`\nbazel build failed (exit ${build.exitCode}). stderr tail:\n${build.stderrTail}`);
process.exit(build.exitCode || 1);
}
// Same flags as the build so cquery resolves the identical configuration.
// cquery takes exactly one query expression, so multiple targets join
// into a single union rather than positional args.
const cquery = await runBazel(
bazel,
[...startupArgs, "cquery", ...options.bazelArgs, "--output=files", labels.join(" + ")],
"pipe",
);
let outputs: string[];
if (cquery.exitCode === 0) {
outputs = parseBazelFilesOutput(cquery.stdout);
} else {
console.warn(`bazel cquery failed (exit ${cquery.exitCode}); falling back to bazel-bin path convention`);
outputs = conventionOutputPaths(options.targets, host);
}
if (outputs.length === 0) {
console.error("bazel build succeeded but no .node outputs were located");
process.exit(1);
}
const seen = new Map<string, string>();
for (const output of outputs) {
const base = path.basename(output);
const prior = seen.get(base);
if (prior) {
// gnu and musl x64/arm64 addons share canonical basenames by design;
// installing both into one dest would silently clobber.
console.error(
`refusing to install ${output}: ${base} already provided by ${prior}. ` +
"Build gnu and musl targets in separate invocations with separate --dest dirs.",
);
process.exit(1);
}
seen.set(base, output);
}
await fs.mkdir(destDir, { recursive: true });
for (const output of outputs) {
const absolute = path.isAbsolute(output) ? output : path.join(repoRoot, output);
const destPath = path.join(destDir, path.basename(output));
await installAddon(absolute, destPath);
console.log(`installed ${path.basename(output)} → ${destPath}`);
}
}
if (import.meta.main) {
try {
await main();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
-36
View File
@@ -1,36 +0,0 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { $ } from "bun";
const repoRoot = path.join(import.meta.dir, "..");
async function runCiNativeDryRun(env: Record<string, string | undefined> = {}): Promise<string> {
const result = await $`bun scripts/ci-build-native.ts --dry-run`
.cwd(repoRoot)
.quiet()
.env({
...process.env,
PCRE2_SYS_STATIC: "0",
RUSTFLAGS: "",
TARGET_VARIANT: "",
TARGET_VARIANTS: "",
...env,
})
.nothrow();
expect(result.exitCode).toBe(0);
return result.text();
}
describe("ci native build environment", () => {
it("prints static PCRE2 env for the default native build dry run", async () => {
await expect(runCiNativeDryRun()).resolves.toBe(
"DRY RUN bun --cwd=packages/natives run build [default] PCRE2_SYS_STATIC=1\n",
);
});
it("prints static PCRE2 env without dropping x64 variant settings", async () => {
await expect(runCiNativeDryRun({ TARGET_VARIANTS: "baseline" })).resolves.toBe(
'DRY RUN bun --cwd=packages/natives run build [baseline] PCRE2_SYS_STATIC=1 TARGET_VARIANT=baseline RUSTFLAGS="-C target-cpu=x86-64-v2"\n',
);
});
});
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bun
import * as path from "node:path";
import { $ } from "bun";
interface NativeBuildVariant {
name: "baseline" | "modern";
rustflags: string;
}
const repoRoot = path.join(import.meta.dir, "..");
const isDryRun = process.argv.includes("--dry-run");
const variantConfigs: Record<NativeBuildVariant["name"], NativeBuildVariant> = {
baseline: {
name: "baseline",
rustflags: "-C target-cpu=x86-64-v2",
},
modern: {
name: "modern",
rustflags: "-C target-cpu=x86-64-v3",
},
};
/** Adds release-portability env required by native addon builds. */
export function withPortableNativeBuildEnv(
env: Record<string, string | undefined>,
): Record<string, string | undefined> {
return { ...env, PCRE2_SYS_STATIC: "1" };
}
function parseTargetVariants(): NativeBuildVariant[] {
const rawVariants = (Bun.env.TARGET_VARIANTS ?? "").trim();
if (!rawVariants) return [];
return rawVariants.split(/\s+/).map(rawVariant => {
const variant = variantConfigs[rawVariant as keyof typeof variantConfigs];
if (!variant) {
throw new Error(`Unsupported TARGET_VARIANTS entry: ${rawVariant}. Expected baseline or modern.`);
}
return variant;
});
}
async function runNativeBuild(env: Record<string, string | undefined>, label: string): Promise<void> {
const buildEnv = withPortableNativeBuildEnv(env);
if (isDryRun) {
const staticPcre = ` PCRE2_SYS_STATIC=${buildEnv.PCRE2_SYS_STATIC}`;
const variant = buildEnv.TARGET_VARIANT ? ` TARGET_VARIANT=${buildEnv.TARGET_VARIANT}` : "";
const rustflags = buildEnv.RUSTFLAGS ? ` RUSTFLAGS=${JSON.stringify(buildEnv.RUSTFLAGS)}` : "";
console.log(`DRY RUN bun --cwd=packages/natives run build [${label}]${staticPcre}${variant}${rustflags}`);
return;
}
console.log(`Building natives [${label}]...`);
await $`bun --cwd=packages/natives run build`.cwd(repoRoot).env(buildEnv);
}
async function main(): Promise<void> {
const variants = parseTargetVariants();
if (variants.length === 0) {
await runNativeBuild(Bun.env, "default");
return;
}
for (const variant of variants) {
await runNativeBuild(
{
...Bun.env,
RUSTFLAGS: variant.rustflags,
TARGET_VARIANT: variant.name,
},
variant.name,
);
}
}
if (import.meta.main) await main();
-83
View File
@@ -1,83 +0,0 @@
import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
const script = path.join(import.meta.dir, "ci-native-artifact-cache.ts");
const tempRoots: string[] = [];
async function tempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-native-cache-test-"));
tempRoots.push(dir);
return dir;
}
async function run(args: string[], cacheDir: string, outputPath?: string): Promise<string> {
const proc = Bun.spawn([process.execPath, script, ...args], {
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
OMP_NATIVE_CACHE_DIR: cacheDir,
GITHUB_OUTPUT: outputPath,
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) throw new Error(`cache command failed (${exitCode}): ${stderr}`);
return stdout;
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
describe("CI native artifact cache", () => {
it("restores a complete artifact set without unrelated build output", async () => {
const root = await tempDir();
const source = path.join(root, "source");
const destination = path.join(root, "destination");
const output = path.join(root, "github-output");
await fs.mkdir(source);
await Promise.all([
Bun.write(path.join(source, "pi_natives.linux-x64.node"), "baseline"),
Bun.write(path.join(source, "build.log"), "not an artifact"),
]);
await run(["save", "abcdef12", "pi-natives-linux-x64-baseline-habcdef12", source], root);
await run(["restore", "abcdef12", destination, "pi-natives-linux-x64-baseline-habcdef12"], root, output);
expect(await Bun.file(output).text()).toBe("hit=true\n");
expect(await Bun.file(path.join(destination, "pi_natives.linux-x64.node")).text()).toBe("baseline");
expect(await Bun.file(path.join(destination, "build.log")).exists()).toBe(false);
});
it("reports a miss and copies nothing unless every requested artifact is complete", async () => {
const root = await tempDir();
const source = path.join(root, "source");
const destination = path.join(root, "destination");
const output = path.join(root, "github-output");
await fs.mkdir(source);
await Bun.write(path.join(source, "pi_natives.linux-x64.node"), "baseline");
await run(["save", "abcdef12", "pi-natives-linux-x64-baseline-habcdef12", source], root);
await run(
[
"restore",
"abcdef12",
destination,
"pi-natives-linux-x64-baseline-habcdef12",
"pi-natives-linux-x64-modern-habcdef12",
],
root,
output,
);
expect(await Bun.file(output).text()).toBe("hit=false\n");
expect(await Bun.file(destination).exists()).toBe(false);
});
});
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env bun
import * as fs from "node:fs/promises";
import * as path from "node:path";
const CACHE_ENV = "OMP_NATIVE_CACHE_DIR";
const COMPLETE_FILE = ".complete";
function validateSegment(value: string, label: string): void {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
throw new Error(`Invalid ${label} ${JSON.stringify(value)}`);
}
}
async function writeOutput(name: string, value: string): Promise<void> {
const outputPath = Bun.env.GITHUB_OUTPUT;
if (outputPath) {
await fs.appendFile(outputPath, `${name}=${value}\n`);
}
}
async function completedFiles(artifactDir: string): Promise<string[] | null> {
try {
const text = await Bun.file(path.join(artifactDir, COMPLETE_FILE)).text();
const files = text.split("\n").filter(Boolean);
if (files.length === 0 || files.some(file => path.basename(file) !== file || !file.endsWith(".node"))) {
return null;
}
for (const file of files) {
if (!(await Bun.file(path.join(artifactDir, file)).exists())) return null;
}
return files;
} catch {
return null;
}
}
async function save(cacheRoot: string, hash: string, artifactName: string, sourceDir: string): Promise<void> {
validateSegment(hash, "source hash");
validateSegment(artifactName, "artifact name");
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile() && entry.name.endsWith(".node"))
.map(entry => entry.name)
.sort();
if (files.length === 0) throw new Error(`No native addons found in ${sourceDir}`);
const hashDir = path.join(cacheRoot, hash);
const artifactDir = path.join(hashDir, artifactName);
if (await completedFiles(artifactDir)) {
console.log(`Native artifact cache already populated: ${artifactName}`);
return;
}
await fs.mkdir(hashDir, { recursive: true });
const stagingDir = path.join(hashDir, `${artifactName}.tmp-${process.pid}-${crypto.randomUUID()}`);
await fs.mkdir(stagingDir);
try {
for (const file of files) {
await fs.copyFile(path.join(sourceDir, file), path.join(stagingDir, file));
}
await Bun.write(path.join(stagingDir, COMPLETE_FILE), `${files.join("\n")}\n`);
try {
await fs.rename(stagingDir, artifactDir);
} catch (error) {
if (!(await completedFiles(artifactDir))) throw error;
await fs.rm(stagingDir, { recursive: true, force: true });
}
} catch (error) {
await fs.rm(stagingDir, { recursive: true, force: true });
throw error;
}
console.log(`Saved native artifact cache: ${artifactName} (${files.join(", ")})`);
}
async function restore(
cacheRoot: string,
hash: string,
destination: string,
artifactNames: string[],
): Promise<boolean> {
validateSegment(hash, "source hash");
if (artifactNames.length === 0) throw new Error("At least one artifact name is required");
const sources: Array<{ dir: string; files: string[] }> = [];
for (const artifactName of artifactNames) {
validateSegment(artifactName, "artifact name");
const dir = path.join(cacheRoot, hash, artifactName);
const files = await completedFiles(dir);
if (!files) return false;
sources.push({ dir, files });
}
await fs.mkdir(destination, { recursive: true });
for (const source of sources) {
for (const file of source.files) {
await fs.copyFile(path.join(source.dir, file), path.join(destination, file));
}
}
console.log(`Restored native artifacts from local cache: ${artifactNames.join(", ")}`);
return true;
}
async function main(): Promise<void> {
const [mode, hash, first, ...rest] = process.argv.slice(2);
if ((mode !== "save" && mode !== "restore") || !hash || !first) {
throw new Error(
"Usage: ci-native-artifact-cache.ts save <hash> <artifact-name> [source-dir] | restore <hash> <destination> <artifact-name>...",
);
}
const cacheRoot = Bun.env[CACHE_ENV]?.trim();
if (!cacheRoot) {
if (mode === "restore") await writeOutput("hit", "false");
console.log(`Native artifact cache disabled: ${CACHE_ENV} is unset`);
return;
}
if (mode === "save") {
await save(cacheRoot, hash, first, rest[0] ?? "packages/natives/native");
return;
}
const hit = await restore(cacheRoot, hash, first, rest);
await writeOutput("hit", String(hit));
if (!hit) console.log(`Native artifact cache miss: ${rest.join(", ")}`);
}
if (import.meta.main) await main();
-40
View File
@@ -1,40 +0,0 @@
import { describe, expect, test } from "bun:test";
import { objectKeyFor, resolveEndpoint } from "./ci-target-cache";
describe("resolveEndpoint", () => {
test("selects scheme from SCCACHE_S3_USE_SSL, defaulting to http for in-cluster RustFS", () => {
expect(resolveEndpoint({ SCCACHE_ENDPOINT: "rustfs.sccache.svc.cluster.local:9000" })).toBe(
"http://rustfs.sccache.svc.cluster.local:9000",
);
expect(resolveEndpoint({ SCCACHE_ENDPOINT: "rustfs:9000", SCCACHE_S3_USE_SSL: "true" })).toBe(
"https://rustfs:9000",
);
expect(resolveEndpoint({ SCCACHE_ENDPOINT: "rustfs:9000", SCCACHE_S3_USE_SSL: "false" })).toBe(
"http://rustfs:9000",
);
});
test("passes through endpoints that already carry a scheme and rejects missing config", () => {
expect(resolveEndpoint({ SCCACHE_ENDPOINT: "https://s3.example.com" })).toBe("https://s3.example.com");
expect(resolveEndpoint({})).toBeNull();
expect(resolveEndpoint({ SCCACHE_ENDPOINT: " " })).toBeNull();
});
});
describe("objectKeyFor", () => {
test("namespaces snapshots under target-cache/ and separates toolchains", () => {
const stable = objectKeyFor("native-linux-default-x64-baseline", "rustc 1.91.0-nightly (abc 2026-04-29)");
expect(stable).toMatch(/^target-cache\/native-linux-default-x64-baseline-[0-9a-f]{12}\.tar\.zst$/);
// Same inputs must be deterministic; a toolchain bump must be a clean miss.
expect(objectKeyFor("native-linux-default-x64-baseline", "rustc 1.91.0-nightly (abc 2026-04-29)")).toBe(stable);
expect(objectKeyFor("native-linux-default-x64-baseline", "rustc 1.92.0-nightly (def 2026-06-01)")).not.toBe(
stable,
);
});
test("rejects keys that could escape the target-cache/ prefix", () => {
expect(() => objectKeyFor("../sccache-poison", "rustc 1.91.0")).toThrow(/Invalid cache key/);
expect(() => objectKeyFor("a/b", "rustc 1.91.0")).toThrow(/Invalid cache key/);
expect(() => objectKeyFor("", "rustc 1.91.0")).toThrow(/Invalid cache key/);
});
});
-214
View File
@@ -1,214 +0,0 @@
#!/usr/bin/env bun
/**
* Persist the cargo `target/` directory to the in-cluster RustFS S3 bucket
* between omp-kata CI runs.
*
* sccache only caches rustc invocations; build-script outputs (57 tree-sitter
* grammar C compiles, audiopus_sys' bundled opus via CMake, ring's asm) and
* cargo's fingerprint/link work bypass it entirely. Restoring `target/` reuses
* all of that, so a warm native job only recompiles workspace crates.
*
* Storage model: one object per cache key (`target-cache/<key>-<toolchain>.tar.zst`),
* overwritten on every save — storage stays bounded at one snapshot per
* platform/libc/arch/variant/toolchain. Staleness is safe: cargo fingerprints
* invalidate anything that no longer matches, exactly like Swatinem/rust-cache
* on the GitHub-hosted runners.
*
* Credentials/config come from the pod-wide sccache env (`SCCACHE_BUCKET`,
* `SCCACHE_ENDPOINT`, `SCCACHE_S3_USE_SSL`, `AWS_*`); Bun's S3Client reads the
* AWS credentials from the environment. Off-infra (no `SCCACHE_BUCKET`) and
* every failure path degrade to a logged no-op — this script must never fail
* a CI job.
*
* Usage: `bun scripts/ci-target-cache.ts <restore|save> <cache-key>`
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $, S3Client } from "bun";
const repoRoot = path.join(import.meta.dir, "..");
/** Compressed snapshots above this size are not uploaded; the next full miss rebuilds a compact one. */
const MAX_SNAPSHOT_BYTES = 4 * 1024 ** 3;
const EXISTS_TIMEOUT_MS = 30_000;
const DOWNLOAD_TIMEOUT_MS = 180_000;
const UPLOAD_TIMEOUT_MS = 300_000;
const configuredCompressionThreads = Number(Bun.env.OMP_CI_CPU_COUNT);
const COMPRESSION_THREADS =
Number.isInteger(configuredCompressionThreads) && configuredCompressionThreads > 0
? configuredCompressionThreads
: 2;
/**
* Resolve the S3 endpoint URL from the sccache pod env. `SCCACHE_ENDPOINT` is
* host:port without a scheme; `SCCACHE_S3_USE_SSL=true` selects https,
* anything else http (in-cluster RustFS serves plain HTTP). A value that
* already carries a scheme is passed through untouched.
*/
export function resolveEndpoint(env: Record<string, string | undefined>): string | null {
const endpoint = env.SCCACHE_ENDPOINT?.trim();
if (!endpoint) return null;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint)) return endpoint;
const scheme = env.SCCACHE_S3_USE_SSL === "true" ? "https" : "http";
return `${scheme}://${endpoint}`;
}
/**
* Object key for one snapshot. The toolchain fingerprint (`rustc -V`) is
* hashed in so a nightly bump becomes a clean miss instead of a useless
* multi-GB restore that cargo immediately invalidates.
*/
export function objectKeyFor(cacheKey: string, rustcVersion: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(cacheKey)) {
throw new Error(`Invalid cache key ${JSON.stringify(cacheKey)}; expected [A-Za-z0-9._-]+`);
}
const toolchain = new Bun.CryptoHasher("sha256").update(rustcVersion).digest("hex").slice(0, 12);
return `target-cache/${cacheKey}-${toolchain}.tar.zst`;
}
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
const { promise: timeout, reject } = Promise.withResolvers<never>();
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/** `target_directory` from cargo metadata (honors CARGO_TARGET_DIR/config), falling back to `<repo>/target`. */
async function resolveTargetDir(): Promise<string> {
const meta = await $`cargo metadata --no-deps --format-version 1`.cwd(repoRoot).quiet().nothrow();
if (meta.exitCode === 0) {
try {
const parsed = meta.json() as { target_directory?: string };
if (parsed.target_directory) return parsed.target_directory;
} catch {
// fall through to default
}
}
return path.join(repoRoot, "target");
}
async function restore(s3: S3Client, objectKey: string, targetDir: string): Promise<void> {
const object = s3.file(objectKey);
if (!(await withTimeout(object.exists(), EXISTS_TIMEOUT_MS, "cache lookup"))) {
console.log(`target cache miss: ${objectKey}`);
return;
}
const tmpTar = path.join(Bun.env.RUNNER_TEMP ?? os.tmpdir(), `target-cache-${process.pid}.tar.zst`);
const started = Bun.nanoseconds();
try {
// NB: `Bun.write(dest, new Response(s3file.stream()))` never resolves in
// Bun 1.3.x; iterating the stream into a FileSink works.
const download = async () => {
const sink = Bun.file(tmpTar).writer();
for await (const chunk of object.stream()) sink.write(chunk);
await sink.end();
};
await withTimeout(download(), DOWNLOAD_TIMEOUT_MS, "cache download");
const sizeMb = (Bun.file(tmpTar).size / 1024 ** 2).toFixed(0);
// Explicit decompress pipe: GNU tar passes -d to a --use-compress-program
// filter on extract but bsdtar does not, so filter flags are a trap.
// The pipe reports only tar's exit code, which reads a truncated zstd
// stream as a short-but-valid archive — so verify the zstd layer first.
const verify = await $`zstd -tq ${tmpTar}`.quiet().nothrow();
const extract =
verify.exitCode === 0
? await $`zstd -dcq ${tmpTar} | tar -xf - -C ${path.dirname(targetDir)}`.quiet().nothrow()
: verify;
if (extract.exitCode !== 0) {
// A torn/corrupt snapshot must not leave a half-extracted target/
// behind: cargo would trust whatever fingerprints survived.
await fs.rm(targetDir, { recursive: true, force: true });
console.warn(
`target cache extract failed (exit ${extract.exitCode}); removed ${targetDir} and continuing cold`,
);
return;
}
const secs = ((Bun.nanoseconds() - started) / 1e9).toFixed(1);
console.log(`target cache restored: ${objectKey} (${sizeMb} MiB in ${secs}s)`);
} finally {
await fs.rm(tmpTar, { force: true });
}
}
async function save(s3: S3Client, objectKey: string, targetDir: string): Promise<void> {
try {
await fs.stat(targetDir);
} catch {
console.log(`target cache save skipped: ${targetDir} does not exist`);
return;
}
const tmpTar = path.join(Bun.env.RUNNER_TEMP ?? os.tmpdir(), `target-cache-${process.pid}.tar.zst`);
const started = Bun.nanoseconds();
try {
// CARGO_INCREMENTAL=0 in CI, so incremental/ only exists from stray
// local state; exclude it regardless — it is the one cargo dir that is
// pure dead weight for a cold consumer. Explicit compress pipe for the
// same tar-flavor reason as in restore(). Compression is capped to the
// runner's admitted CPU allocation; `-T0` multiplied host contention when
// several native matrix jobs saved snapshots together.
const create =
await $`tar -cf - --exclude=${"*/incremental"} -C ${path.dirname(targetDir)} ${path.basename(targetDir)} | zstd -q -T${COMPRESSION_THREADS} -3 -f -o ${tmpTar}`
.quiet()
.nothrow();
if (create.exitCode !== 0) {
console.warn(`target cache save skipped: tar failed (exit ${create.exitCode})`);
return;
}
const size = Bun.file(tmpTar).size;
if (size > MAX_SNAPSHOT_BYTES) {
// Orphaned artifacts accumulate across restore→build→save cycles;
// refusing oversized uploads bounds the object. The stale snapshot
// keeps serving restores until a full-miss rebuild saves a compact one.
console.warn(
`target cache save skipped: snapshot ${(size / 1024 ** 3).toFixed(1)} GiB exceeds ${MAX_SNAPSHOT_BYTES / 1024 ** 3} GiB cap`,
);
return;
}
await withTimeout(s3.write(objectKey, Bun.file(tmpTar)), UPLOAD_TIMEOUT_MS, "cache upload");
const secs = ((Bun.nanoseconds() - started) / 1e9).toFixed(1);
console.log(`target cache saved: ${objectKey} (${(size / 1024 ** 2).toFixed(0)} MiB in ${secs}s)`);
} finally {
await fs.rm(tmpTar, { force: true });
}
}
async function main(): Promise<void> {
const [mode, cacheKey] = [process.argv[2], process.argv[3]];
if ((mode !== "restore" && mode !== "save") || !cacheKey) {
console.error("Usage: bun scripts/ci-target-cache.ts <restore|save> <cache-key>");
process.exit(1);
}
const bucket = Bun.env.SCCACHE_BUCKET;
const endpoint = resolveEndpoint(Bun.env);
if (!bucket || !endpoint) {
console.log("target cache skipped: no SCCACHE_BUCKET/SCCACHE_ENDPOINT in env (not on omp-kata infra)");
return;
}
if (!Bun.which("zstd")) {
console.warn("target cache skipped: zstd not on PATH");
return;
}
const rustc = await $`rustc -V`.quiet().nothrow();
if (rustc.exitCode !== 0) {
console.warn("target cache skipped: rustc not on PATH");
return;
}
const objectKey = objectKeyFor(cacheKey, rustc.text().trim());
const s3 = new S3Client({ bucket, endpoint, region: Bun.env.SCCACHE_REGION ?? Bun.env.AWS_REGION ?? "us-east-1" });
const targetDir = await resolveTargetDir();
if (mode === "restore") await restore(s3, objectKey, targetDir);
else await save(s3, objectKey, targetDir);
}
if (import.meta.main) {
try {
await main();
} catch (err) {
// Cache trouble must never fail a build; cold compile is the fallback.
console.warn(`target cache ${process.argv[2] ?? ""} failed non-fatally:`, err);
}
}
+5 -7
View File
@@ -119,8 +119,7 @@ const localOnlyWorkspacePackages = ["packages/mnemopi", "python/robomp/web"];
// silently ignores unmatched filters when at least one other filter matches.)
const repoScriptTests = [
"scripts/ci-concurrency.test.ts",
"scripts/ci-build-native.test.ts",
"scripts/ci-native-artifact-cache.test.ts",
"scripts/bazel-natives.test.ts",
"scripts/ci-release-notes.test.ts",
"scripts/ci-release-publish.test.ts",
"scripts/fix-dts-extensions.test.ts",
@@ -356,7 +355,7 @@ async function commandsForMode(mode: Mode): Promise<TestCommand[]> {
"--parallel=4",
...onlyFailuresArgs,
"scripts/ci-concurrency.test.ts",
"scripts/ci-build-native.test.ts",
"scripts/bazel-natives.test.ts",
"scripts/ci-release-publish.test.ts",
"scripts/fix-dts-extensions.test.ts",
],
@@ -410,16 +409,15 @@ async function commandsForMode(mode: Mode): Promise<TestCommand[]> {
}
}
// The omp-kata runner pods inject sccache S3 credentials (`AWS_*`) and config
// (`SCCACHE_*`) pod-wide via `envFrom`, GitHub Actions injects `GITHUB_TOKEN`,
// The omp-kata runner pods may inject cloud credentials (`AWS_*`) pod-wide via
// `envFrom`, GitHub Actions injects `GITHUB_TOKEN`,
// and a host may carry provider API keys. Any of these make env-sensitive code
// non-deterministic in tests — e.g. leaked AWS creds make `amazon-bedrock` look
// authenticated and win the provider startup fallback over `anthropic`. Run the
// suites in a hermetic environment with all credential / cloud-config variables
// stripped so resolution depends only on the test's own fixtures.
const SCRUBBED_ENV_PREFIXES = ["AWS_", "SCCACHE_", "GOOGLE_CLOUD_"];
const SCRUBBED_ENV_PREFIXES = ["AWS_", "GOOGLE_CLOUD_"];
const SCRUBBED_ENV_NAMES = new Set([
"RUSTC_WRAPPER",
"GITHUB_TOKEN",
"GH_TOKEN",
"COPILOT_GITHUB_TOKEN",
+33 -4
View File
@@ -14,18 +14,47 @@ const RUST_AFFECTING_FILE_NAMES = [
"rustfmt.toml",
".rustfmt.toml",
] as const satisfies readonly string[];
// brush-core/brush-builtins became workspace members for Bazel hermeticity
// (path-patch rendering is machine-local), but the cargo dev tasks keep their
// historical scope: the vendored fork is not held to workspace lint/test gates.
const VENDORED_FORK_EXCLUDES = [
"--exclude",
"brush-core",
"--exclude",
"brush-builtins",
] as const satisfies readonly string[];
const TASK_COMMANDS = {
"check:rs": [
["cargo", "fmt", "--all", "--", "--check"],
["cargo", "clippy", "--workspace", "--", "-D", "warnings"],
["cargo", "clippy", "--workspace", ...VENDORED_FORK_EXCLUDES, "--", "-D", "warnings"],
],
"fix:rs": [
["cargo", "fmt", "--all"],
["cargo", "clippy", "--workspace", "--fix", "--allow-dirty", "--no-deps", "--allow-staged", "--allow-no-vcs"],
[
"cargo",
"clippy",
"--workspace",
...VENDORED_FORK_EXCLUDES,
"--fix",
"--allow-dirty",
"--no-deps",
"--allow-staged",
"--allow-no-vcs",
],
],
"fmt:rs": [["cargo", "fmt", "--all"]],
"lint:rs": [["cargo", "clippy", "--workspace", "--", "-D", "warnings"]],
"test:rs": [["cargo", "nextest", "run", "--workspace", "--status-level=fail", "--final-status-level=fail"]],
"lint:rs": [["cargo", "clippy", "--workspace", ...VENDORED_FORK_EXCLUDES, "--", "-D", "warnings"]],
"test:rs": [
[
"cargo",
"nextest",
"run",
"--workspace",
...VENDORED_FORK_EXCLUDES,
"--status-level=fail",
"--final-status-level=fail",
],
],
} as const satisfies Record<string, readonly (readonly string[])[]>;
type RustTaskName = keyof typeof TASK_COMMANDS;