Files
2026-08-27 21:09:14 +00:00

1725 lines
62 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Build and test Bun on macOS, Linux, and Windows.
* @link https://buildkite.com/docs/pipelines/defining-steps
*/
import { join } from "node:path";
import {
getBootstrapVersion,
getBuildkiteEmoji,
getBuildMetadata,
getBuildNumber,
getCanaryRevision,
getCommitMessage,
getEmoji,
getEnv,
getLastSuccessfulBuild,
getSecret,
isBuildkite,
isBuildManual,
isFork,
isMainBranch,
isMergeQueue,
parseBoolean,
setBuildMetadata,
spawnSafe,
startGroup,
toYaml,
uploadArtifact,
writeFile,
} from "../scripts/utils.mjs";
/**
* @typedef {"linux" | "darwin" | "windows" | "freebsd"} Os
* @typedef {"aarch64" | "x64"} Arch
* @typedef {"musl" | "android"} Abi
* @typedef {"debian" | "ubuntu" | "alpine" | "amazonlinux"} Distro
* @typedef {"latest" | "previous" | "oldest" | "eol"} Tier
* @typedef {"release" | "assert" | "debug" | "asan"} Profile
*/
/**
* @typedef Target
* @property {Os} os
* @property {Arch} arch
* @property {Abi} [abi]
* @property {boolean} [baseline]
* @property {Profile} [profile]
* @property {boolean} [crossCompile]
* Build on a Linux host for a foreign target OS (currently: darwin and
* windows). Agents/images resolve to the Linux build fleet; keys/labels/
* artifacts are unaffected — these ARE the darwin/windows build lanes,
* there is no native macOS or Windows build. FreeBSD/Android don't set
* this — they already imply a Linux host.
*/
/**
* @param {Target} target
* @returns {string}
*/
function getTargetKey(target) {
const { os, arch, abi, baseline, profile } = target;
let key = `${os}-${arch}`;
if (abi) {
key += `-${abi}`;
}
if (baseline) {
key += "-baseline";
}
if (profile && profile !== "release") {
key += `-${profile}`;
}
return key;
}
/**
* @param {Target} target
* @returns {string}
*/
function getTargetLabel(target) {
const { os, arch, abi, baseline, profile } = target;
let label = `${getBuildkiteEmoji(os)} ${arch}`;
if (abi) {
label += `-${abi}`;
}
if (baseline) {
label += "-baseline";
}
if (profile && profile !== "release") {
label += `-${profile}`;
}
return label;
}
/**
* @typedef Platform
* @property {Os} os
* @property {Arch} arch
* @property {Abi} [abi]
* @property {boolean} [baseline]
* @property {Profile} [profile]
* @property {boolean} [crossCompile]
* @property {Distro} [distro]
* @property {string} release
* @property {Tier} [tier]
* @property {string[]} [features]
*/
// Azure VM sizes for Windows CI runners.
// DDSv6 = x64, DPSv6 = ARM64 (Cobalt 100). Quota: 100 cores per family in eastus2.
const azureVmSizes = {
// Windows builds are cross-compiled on the Linux fleet; these sizes are for
// the steps that still need a real Windows machine (test shards, signing,
// and the baseline-verification emulator phase).
"windows-x64": {
build: "Standard_D16ds_v6", // 16 vCPU, 64 GiB — verify-baseline under Intel SDE
test: "Standard_D4ds_v6", // 4 vCPU, 16 GiB — test shards, signing
},
"windows-aarch64": {
test: "Standard_D4pds_v6", // 4 vCPU, 16 GiB, local NVMe — test shards
},
};
function getAzureVmSize(os, arch, tier = "build") {
return azureVmSizes[`${os}-${arch}`]?.[tier];
}
/**
* The single host image every build lane runs on. All targets below —
* linux x64/aarch64 × gnu/musl, darwin, windows, freebsd, android — are
* cross-compiled from this debian-13 aarch64 box via --target/--sysroot
* (scripts/build/config.ts + flags.ts) so one AMI serves every build.
* @type {Platform}
*/
const buildHostPlatform = { os: "linux", arch: "aarch64", distro: "debian", release: "13" };
/**
* @type {Platform[]}
*/
const buildPlatforms = [
// macOS is cross-compiled from the debian-13 aarch64 host (clang --target +
// the Apple SDK fetched by xmac + ld64.lld — see scripts/build/macos-sdk.ts
// and scripts/build/flags.ts). There is no native macOS build lane: the mac
// fleet only runs tests, against these artifacts (see testPlatforms), and
// these are the darwin artifacts the release ships.
{ os: "darwin", arch: "aarch64", crossCompile: true, distro: "debian", release: "13" },
{ os: "darwin", arch: "x64", crossCompile: true, distro: "debian", release: "13" },
{ os: "linux", arch: "aarch64", distro: "debian", release: "13" },
{ os: "linux", arch: "x64", distro: "debian", release: "13" },
// asan x64 cross-builds from the arm64 host too; if install_cross_compiler_rt()
// can't fetch amd64 libclang-rt on arm64, this lane may need an x64 host as
// the one exception — see scripts/bootstrap.sh.
{ os: "linux", arch: "x64", profile: "asan", distro: "debian", release: "13" },
{ os: "linux", arch: "aarch64", abi: "musl", distro: "debian", release: "13" },
{ os: "linux", arch: "x64", abi: "musl", distro: "debian", release: "13" },
// Android: cross-compiled from the debian-13 aarch64 host via NDK sysroot.
{ os: "linux", arch: "aarch64", abi: "android", distro: "debian", release: "13" },
{ os: "linux", arch: "x64", abi: "android", distro: "debian", release: "13" },
// FreeBSD: cross-compiled from the debian-13 aarch64 host via base.txz
// sysroot, same model as Android. Target os/arch are explicit.
{ os: "freebsd", arch: "x64", distro: "debian", release: "13" },
{ os: "freebsd", arch: "aarch64", distro: "debian", release: "13" },
// Windows is cross-compiled from the debian-13 aarch64 host (clang-cl
// --target + the xwin MSVC/SDK sysroot + lld-link — see
// scripts/build/winsysroot.ts and scripts/build/flags.ts), the same model
// as macOS above. There is no native Windows build lane: the Windows fleet
// only runs tests, signing, and baseline verification, against these
// artifacts (see testPlatforms), and these are the Windows artifacts the
// release ships. x64 uses ThinLTO + cross-language LTO by default; arm64
// stays non-LTO (no windows-arm64-lto WebKit prebuilt, see config.ts).
{ os: "windows", arch: "x64", crossCompile: true, distro: "debian", release: "13" },
{ os: "windows", arch: "aarch64", crossCompile: true, distro: "debian", release: "13" },
];
/**
* @type {Platform[]}
*/
const testPlatforms = [
// Darwin arm64 is targeted by `release-tier` (see getTestAgent): one job on
// `latest` (current macOS, 26 today) and one on `previous` (anything older
// — currently 13/14/15). x64 is NOT tier-targeted: a single entry runs on
// whichever Intel box is free. Intel Macs can't run latest macOS and the
// tier split bottlenecked the smaller pool, so x64 trades guaranteed
// version coverage for throughput. The `release` field only labels the step.
// The darwin test suite runs on real macOS agents against the Linux-built
// artifacts from the `darwin-<arch>-build-bun` steps (the only darwin build
// lanes — see buildPlatforms).
// These three version-specific lanes run on main and on opt-in (see
// darwinTestsEnabled). PR builds instead get one aarch64 lane that any mac
// agent can take (prDarwinTestPlatforms), so the whole arm64 pool serves PRs.
{ os: "darwin", arch: "aarch64", release: "26", tier: "latest" },
{ os: "darwin", arch: "aarch64", release: "14", tier: "previous" },
{ os: "darwin", arch: "x64", release: "14", tier: "latest" },
{ os: "linux", arch: "aarch64", distro: "debian", release: "13", tier: "latest" },
{ os: "linux", arch: "x64", distro: "debian", release: "13", tier: "latest" },
{ os: "linux", arch: "x64", profile: "asan", distro: "debian", release: "13", tier: "latest" },
{ os: "linux", arch: "aarch64", distro: "ubuntu", release: "25.04", tier: "latest" },
{ os: "linux", arch: "x64", distro: "ubuntu", release: "25.04", tier: "latest" },
{ os: "linux", arch: "aarch64", abi: "musl", distro: "alpine", release: "3.23", tier: "latest" },
{ os: "linux", arch: "x64", abi: "musl", distro: "alpine", release: "3.23", tier: "latest" },
{ os: "windows", arch: "x64", release: "2019", tier: "oldest" },
{ os: "windows", arch: "aarch64", release: "11", tier: "latest" },
];
/**
* @param {Platform} platform
* @returns {string}
*/
function getPlatformKey(platform) {
const { distro, release } = platform;
const target = getTargetKey(platform);
const version = release.replace(/\./g, "");
if (distro) {
return `${target}-${distro}-${version}`;
}
return `${target}-${version}`;
}
/**
* @param {Platform} platform
* @returns {string}
*/
function getPlatformLabel(platform) {
const { os, arch, baseline, profile, distro, release } = platform;
let label = `${getBuildkiteEmoji(distro || os)} ${release} ${arch}`;
if (baseline) {
label += "-baseline";
}
if (profile && profile !== "release") {
label += `-${profile}`;
}
return label;
}
/**
* @param {Platform} platform
* @returns {string}
*/
function getImageKey(platform) {
const { os, arch, distro, release, features, abi, crossCompile } = platform;
// Cross-compiled targets (Android, FreeBSD, macOS-cross) build from a Linux
// host image — bootstrap.sh installs the NDK / base.txz sysroot on it (the
// macOS SDK is fetched by the build itself). No separate image is baked.
const hostOs = os === "freebsd" || crossCompile ? "linux" : os;
const version = release.replace(/\./g, "");
let key = `${hostOs}-${arch}-${version}`;
if (distro) {
key += `-${distro}`;
}
if (features?.length) {
key += `-with-${features.join("-")}`;
}
if (abi && abi !== "android") {
key += `-${abi}`;
}
return key;
}
/**
* @param {Platform} platform
* @returns {string}
*/
function getImageLabel(platform) {
const { os, arch, distro, release } = platform;
return `${getBuildkiteEmoji(distro || os)} ${release} ${arch}`;
}
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {string}
*/
function getImageName(platform, options) {
const { os, distro, crossCompile } = platform;
const { buildImages, publishImages, imageFilter } = options;
const name = getImageKey(platform);
// Cross-compiled targets (and FreeBSD) build on a Linux host image (see
// getImageKey) — both the [build images] filter below and the published
// image tag should be judged by the host, not the target. Windows-cross
// would otherwise miss the freshly-baked linux image on a
// "[build linux images]" run, and pick up bootstrap.ps1's version for a
// linux image tag that doesn't exist.
const hostOs = os === "freebsd" || crossCompile ? "linux" : os;
if (buildImages && !publishImages && (!imageFilter || hostOs === imageFilter || distro === imageFilter)) {
return `${name}-build-${getBuildNumber()}`;
}
return `${name}-v${getBootstrapVersion(hostOs)}`;
}
/**
* @link https://buildkite.com/docs/pipelines/configure/retry#retry-attributes-automatic-retry-attributes
*/
function getRetry() {
return {
manual: {
permit_on_passed: true,
},
// Self-heal agent/infra loss, and only that. Conditions within one rule
// are ANDed, so `signal_reason` scopes each rule to the failure mode it
// names: `none` is an agent that dropped its connection mid-job,
// `agent_stop` is a graceful agent restart mid-job, `process_run_error`
// is the bootstrap failing before the command ever ran. A blanket
// `exit_status: -1` / `255` also matches `cancel`, which is what a
// `timeout_in_minutes` kill records, so a timed-out shard would be
// re-queued just to time out again on the next agent. User-canceled
// builds are state=canceled and never auto-retry regardless of these
// rules.
automatic: [
{ exit_status: -1, signal_reason: "none", limit: 1 },
{ signal_reason: "agent_stop", limit: 2 },
{ signal_reason: "process_run_error", limit: 1 },
],
};
}
/**
* @returns {number}
* @link https://buildkite.com/docs/pipelines/managing-priorities
*/
function getPriority() {
if (isFork()) {
return -1;
}
if (isMainBranch()) {
return 2;
}
if (isMergeQueue()) {
return 1;
}
return 0;
}
/**
* Agents
*/
/**
* @typedef {Object} Ec2Options
* @property {string} instanceType
* @property {boolean} dryRun
*/
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @param {Ec2Options} ec2Options
* @returns {Agent}
*/
function getEc2Agent(platform, options, ec2Options) {
const { os, arch, abi, distro, release, crossCompile } = platform;
const { instanceType } = ec2Options;
// Cross-compiled targets run on a Linux EC2 box; the agent tag must match
// the host (`linux`), not the target.
const hostOs = os === "freebsd" || crossCompile ? "linux" : os;
return {
os: hostOs,
arch,
abi,
distro,
release,
robobun: true,
robobun2: true,
"image-name": getImageName(platform, options),
"instance-type": instanceType,
"preemptible": false,
};
}
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {string}
*/
function getBuildAgent(platform, options) {
// Every build lane runs on the single debian-13 aarch64 host image
// (buildHostPlatform) and cross-compiles to its target; the target's
// os/arch only affect build args, not agent tags or image-name.
const { os, arch, abi, profile } = platform;
// Lanes without LTO (see ltoDefault in scripts/build/config.ts): rustc does its own fat LTO + codegen inside cargo, so the C++ compile overlapping it costs ~20s on 16 vCPUs; give them 32.
const nonLto =
profile === "asan" || abi === "android" || os === "freebsd" || (os === "windows" && arch === "aarch64");
return getEc2Agent(buildHostPlatform, options, {
// Replaces the c8g.4xlarge (C++) + r8g.2xlarge (cargo + ThinLTO link; r8g.4xlarge for asan) pair.
instanceType: nonLto ? "r8g.8xlarge" : "r8g.4xlarge",
});
}
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {Agent}
*/
function getTestAgent(platform, options) {
const { os, arch, profile, tier } = platform;
if (os === "darwin") {
// `release-tier` is emitted by scripts/agent.mjs based on the box's macOS
// major version. arm64 splits into `latest` (current macOS) + `previous`
// (anything older). x64 is NOT tier-targeted — single entry, any Intel
// box — because the tier split bottlenecked the smaller pool and Intel
// can't run latest anyway.
return {
queue: `test-${os}`,
os,
arch,
...(arch === "aarch64" && tier ? { "release-tier": tier } : {}),
};
}
// TODO: delete this block when we upgrade to mimalloc v3
if (os === "windows") {
return getEc2Agent(platform, options, {
instanceType: getAzureVmSize(os, arch, "test"),
});
}
// musl: same vCPU as glibc but 2× RAM (m-family). The alpine images now bake
// ~14 GB of build prefetch + ~6 GB of pre-pulled docker test images, and
// the docker test containers (mysql/postgres on tmpfs) run alongside the
// tests — c-family's 8 GB was the wrong side of tight.
const musl = platform.abi === "musl";
if (arch === "aarch64") {
if (profile === "asan") {
// ASAN needs ~1:8 shadow memory plus a 256 MB quarantine per process
// plus LSan loading the binary's DWARF; the c-family's 16 GB OOMs the
// agent. r-family has 4× the RAM at the same vCPU.
return getEc2Agent(platform, options, {
instanceType: "r8g.2xlarge",
});
}
return getEc2Agent(platform, options, {
instanceType: musl ? "m8g.xlarge" : "c8g.xlarge",
});
}
if (profile === "asan") {
// Same rationale as the aarch64 asan branch above.
return getEc2Agent(platform, options, {
instanceType: "r7i.2xlarge",
});
}
return getEc2Agent(platform, options, {
instanceType: musl ? "m7i.xlarge" : "c7i.xlarge",
});
}
/**
* Steps
*/
/**
* Build the scripts/build.ts argument list from a target's properties.
* Replaces the old getBuildEnv (cmake -D env vars) + getBuildCommand
* (--target passthrough) with direct build.ts flags.
*
* @param {Target} target
* @param {PipelineOptions} options
* @param {"build" | "cpp-only" | "rust-only" | "link-only" | "rust-and-link"} mode
* @returns {string}
*/
function getBuildArgs(target, options, mode) {
const { os, arch, abi, baseline, profile } = target;
const { canary } = options;
const args = [`--profile=ci-${mode}`];
// All build lanes share a debian-13 arm64 host, so host detection cannot
// infer the target triple — always pass os/arch (and abi on linux).
args.push(`--os=${os}`, `--arch=${arch}`);
if (os === "linux") args.push(`--abi=${abi ?? "gnu"}`);
if (baseline) args.push("--baseline=on");
if (profile === "asan") args.push("--asan=on");
// canary: options.canary can be number (revision count) or undefined
// (default on). Old system used CANARY_REVISION as a counter; build.ts
// has only on/off — disabled only when explicitly 0.
const canaryRev = typeof canary === "number" ? canary : 1;
if (canaryRev === 0) args.push("--canary=off");
return args.join(" ");
}
/**
* @param {Target} target
* @param {PipelineOptions} options
* @param {"build" | "cpp-only" | "rust-only" | "link-only" | "rust-and-link"} mode
* @returns {string}
*/
function getBuildCommand(target, options, mode) {
// Windows code signing is handled by a dedicated 'windows-sign' step after
// all Windows builds complete — see getWindowsSignStep(). smctl is x64-only,
// so signing on the build agent wouldn't work for ARM64 anyway.
//
// Literal `node` — ci.mjs generates pipeline YAML that runs on a
// different agent later, so process.execPath (the generator's path)
// is wrong. PATH on the agent has node via bootstrap.sh.
// --experimental-strip-types for Node 24's .ts support (unflagged in
// 25+; drop once CI bumps past the ABI-141 blocker).
return `node --experimental-strip-types scripts/build.ts ${getBuildArgs(target, options, mode)}`;
}
/**
* deps + C++ + cargo + link on one agent; also uploads libbun-*.a, libbun_rust.a and the dep libs.
*
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {Step}
*/
function getBuildBunStep(platform, options) {
const { os, arch } = platform;
// BoringSSL's win-x64 assembly is NASM syntax. The agent images bake nasm
// (.buildkite/Dockerfile); best-effort install covers older images, and
// `|| true` keeps a missing package manager from failing the step — the
// build's own "nasm not found" error is clearer.
const nasmSetup =
os === "windows" && arch === "x64"
? [
"which nasm || (apt-get update -qq && apt-get install -y -qq nasm) || dnf install -y -q nasm || yum install -y -q nasm || true",
]
: [];
return {
key: `${getTargetKey(platform)}-build-bun`,
label: `${getTargetLabel(platform)} - build-bun`,
agents: getBuildAgent(platform, options),
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
timeout_in_minutes: 60,
env: {
// ASAN runtime settings — unrelated to build config, affects the
// linked binary's startup during the smoke test.
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=0",
},
command: [...nasmSetup, getBuildCommand(platform, options, "build")],
};
}
/**
* Returns the artifact triplet for a platform, e.g. "bun-linux-aarch64" or "bun-linux-x64-musl-baseline".
* Matches the naming convention in cmake/targets/BuildBun.cmake.
* @param {Platform} platform
* @returns {string}
*/
function getTargetTriplet(platform) {
const { os, arch, abi, baseline } = platform;
let triplet = `bun-${os}-${arch}`;
if (abi === "musl") {
triplet += "-musl";
}
if (abi === "android") {
triplet += "-android";
}
if (baseline) {
triplet += "-baseline";
}
return triplet;
}
/**
* Returns true if a platform needs QEMU-based baseline CPU verification.
* x64 baseline builds verify no AVX/AVX2 instructions snuck in.
* aarch64 builds verify no LSE/SVE instructions snuck in.
* @param {Platform} platform
* @returns {boolean}
*/
function needsBaselineVerification(platform) {
const { os, arch, abi, profile } = platform;
// asan never ships. x64-android is emulator-only; aarch64-android keeps its
// static LSE/SVE scan via --skip-emulation in getVerifyBaselineStep().
if (profile === "asan") return false;
if (os === "linux") return (arch === "x64" && abi !== "android") || arch === "aarch64";
if (os === "windows") return arch === "x64";
return false;
}
// Ubuntu 20.04's qemu 4.2 mis-emulates concurrent atomics in same-arch user mode; after #34009
// (mimalloc per-thread heaps) the SIMD baseline test segfaults/deadlocks in `_mi_theap_init`
// ~10-20% of x64 runs and ~5% of aarch64 runs. qemu 9.1 is 40/40 green. Static-pie binaries.
const PINNED_QEMU = {
x64: {
url: "https://github.com/ziglang/qemu-static/releases/download/9.1.0/qemu-linux-x86_64-9.1.0.tar.xz",
sha256: "1ac92f632417d981810fda891e4a1b20f2d71f50f9ec705532afa8162b449c70",
binary: "qemu-linux-x86_64-9.1.0/bin/qemu-x86_64",
},
aarch64: {
url: "https://github.com/ziglang/qemu-static/releases/download/9.1.0/qemu-linux-aarch64-9.1.0.tar.xz",
sha256: "5a82a96ac74932a802fb5753673beff27359faea8736286477b0bf2c268fd06d",
binary: "qemu-linux-aarch64-9.1.0/bin/qemu-aarch64",
},
};
/**
* Returns the emulator binary name for the given platform.
* Linux uses QEMU user-mode; Windows uses Intel SDE.
* @param {Platform} platform
* @returns {string}
*/
function getEmulatorBinary(platform) {
const { os, arch } = platform;
// Intel SDE is baked into the Windows image by scripts/bootstrap.ps1
// (Install-IntelSde): downloadmirror.intel.com sits behind a bot challenge
// that blocks non-browser clients, so it cannot be downloaded at job time.
if (os === "windows") return "C:\\intel-sde\\sde.exe";
// Fetched into the checkout root by the setup command below (see PINNED_QEMU).
return `./${PINNED_QEMU[arch].binary}`;
}
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {Step}
*/
function hasWebKitChanges(options) {
const { changedFiles = [] } = options;
// Kept pointing at the removed SetupWebKit.cmake (always false) until
// verify-baseline.ts's --jit-stress path is fixed: it runs wasm fixtures
// without BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING / parsed //@ flags, so
// fixtures using wasm-GC types (bbq-osr-with-exceptions,
// omg-tail-call-clobber-scratch-register) fail to parse under it.
return changedFiles.some(file => file.includes("SetupWebKit.cmake"));
}
/**
* Host platform the verify-baseline step runs on — per-TARGET-arch, not the
* shared arm64 build host. Reuses test-fleet images (debian-13 / win-2019) so
* no extra bake is needed; getPipeline() keys its build-image depends_on on this.
* @param {Platform} platform
* @returns {Platform}
*/
function getVerifyBaselineHost(platform) {
const { os, arch, abi } = platform;
if (os === "windows") return { os: "windows", arch, release: "2019" };
if (abi === "musl") return { os: "linux", arch, abi: "musl", distro: "alpine", release: "3.23" };
return { os: "linux", arch, distro: "debian", release: "13" };
}
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {Step}
*/
function getVerifyBaselineStep(platform, options) {
const { os, abi } = platform;
const targetKey = getTargetKey(platform);
const triplet = getTargetTriplet(platform);
const emulator = getEmulatorBinary(platform);
const jitStressFlag = hasWebKitChanges(options) ? " --jit-stress" : "";
// Android binaries need /system/bin/linker64 + a bionic sysroot, neither of which exist on the
// build host, so qemu-user cannot load them; only the static instruction scan is meaningful.
const skipEmulationFlag = abi === "android" ? " --skip-emulation" : "";
// Scan bun-profile, not bun. The stripped binary has no .symtab (ELF) and
// no companion .pdb (PE) — the static scanner would emit <no-symbol@addr>
// for everything and none of the allowlist entries would match. bun-profile
// has identical .text so violation results are the same, just attributable.
const profileDir = `${triplet}-profile`;
const profileExe = os === "windows" ? "bun-profile.exe" : "bun-profile";
const setupCommands =
os === "windows"
? [
// cmd.exe batch does not stop on error: without `|| exit /b 1` a
// failed line is ignored and only the last command's exit code
// becomes the step result.
`echo Downloading build artifacts...`,
`buildkite-agent artifact download ${profileDir}.zip . --step ${targetKey}-build-bun || exit /b 1`,
`echo Extracting ${profileDir}.zip...`,
`tar -xf ${profileDir}.zip || exit /b 1`,
]
: [
`buildkite-agent artifact download '${profileDir}.zip' . --step ${targetKey}-build-bun`,
`unzip -o '${profileDir}.zip'`,
`chmod +x ${profileDir}/${profileExe}`,
// Linux lanes pin a known-good qemu (see PINNED_QEMU). sha256 check makes a
// truncated/hijacked download a hard failure before anything runs under it.
...(abi === "android"
? [] // --skip-emulation: no emulator needed
: [
`curl -fsSL --retry 5 --connect-timeout 15 --max-time 120 -o ./qemu.tar.xz '${PINNED_QEMU[platform.arch].url}'`,
`echo '${PINNED_QEMU[platform.arch].sha256} ./qemu.tar.xz' | sha256sum -c -`,
`tar -xJf ./qemu.tar.xz '${PINNED_QEMU[platform.arch].binary}'`,
]),
];
// verify-baseline is not a build lane: it stays on a host whose arch matches
// the TARGET so PINNED_QEMU's host-arch-specific static binaries keep working
// (the link agent is now always arm64 and can't run the x86_64-host qemu).
const host = getVerifyBaselineHost(platform);
const agents =
os === "windows"
? getEc2Agent(host, options, { instanceType: getAzureVmSize("windows", platform.arch) })
: getEc2Agent(host, options, {
instanceType: platform.arch === "aarch64" ? "r8g.2xlarge" : "r7i.2xlarge",
});
return {
key: `${targetKey}-verify-baseline`,
label: `${getTargetLabel(platform)} - verify-baseline`,
depends_on: [`${targetKey}-build-bun`],
agents,
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
timeout_in_minutes: hasWebKitChanges(options) ? 30 : 10,
command: [
...setupCommands,
`cargo build --release --manifest-path scripts/verify-baseline-static/Cargo.toml${os === "windows" ? " || exit /b 1" : ""}`,
`bun scripts/verify-baseline.ts --binary ${profileDir}/${profileExe} --arch ${platform.arch} --emulator ${emulator}${skipEmulationFlag}${jitStressFlag}`,
],
};
}
/**
* Targets whose build lane cross-compiles (so `canTraceOrderFile()` is false)
* but whose test fleet is native. A `-trace-order` step runs there, downloads
* the cross-built `bun-profile`, traces it, and uploads the `.order` artifact
* that the next build's `inheritOrderFile()` picks up. One build of lag.
*
* linux-aarch64 is absent because its build lane runs on the aarch64 host and
* traces itself; `packageAndUpload()` is its sole publisher.
*/
const traceOrderTargets = [
{ os: "darwin", arch: "aarch64", on: { os: "darwin", arch: "aarch64", release: "26", tier: "latest" } },
{ os: "linux", arch: "x64", on: { os: "linux", arch: "x64", distro: "debian", release: "13" } },
];
/**
* Trace the symbol order file for a cross-compiled target on a native-arch
* host, so the next build's `inheritOrderFile()` has something to download.
*
* The build lane cross-compiles from the aarch64 `buildHostPlatform` and cannot
* run the binary it linked. This step runs on the target-arch test fleet,
* downloads that lane's unstripped `bun-profile`, runs it under `scripts/
* orderfile/generate.ts` (the traced binary doubles as the interpreter), and
* uploads the result.
*
* Non-PR only — `orderFileEligible()` ignores PR builds, so a trace there has
* no consumer. Soft-fail: the order file is an optimization, and a broken
* tracer must not fail a build.
* @param {Target} target
* @param {Platform} tracePlatform
* @param {PipelineOptions} options
* @returns {CommandStep}
*/
function getTraceOrderStep(target, tracePlatform, options) {
const targetKey = getTargetKey(target);
const triplet = getTargetTriplet(target);
const profileDir = `${triplet}-profile`;
return {
key: `${targetKey}-trace-order`,
label: `${getTargetLabel(target)} - trace-order`,
depends_on: [`${targetKey}-build-bun`],
agents: getTestAgent(tracePlatform, options),
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
soft_fail: true,
timeout_in_minutes: 15,
command: [
`buildkite-agent artifact download '${profileDir}.zip' . --step ${targetKey}-build-bun`,
`unzip -o '${profileDir}.zip'`,
`chmod +x ${profileDir}/bun-profile`,
`./${profileDir}/bun-profile scripts/orderfile/generate.ts --build-dir=${profileDir} --out=${triplet}.order`,
`buildkite-agent artifact upload '${triplet}.order'`,
],
};
}
/**
* @typedef {Object} TestOptions
* @property {string} [buildId]
* @property {string[]} [testFiles]
* @property {boolean} [dryRun]
*/
/**
* @param {Platform} platform
* @param {PipelineOptions} options
* @param {TestOptions} [testOptions]
* @returns {Step}
*/
function getTestBunStep(platform, options, testOptions = {}) {
const { os, profile } = platform;
const { buildId, testFiles } = testOptions;
const args = [`--step=${getTargetKey(platform)}-build-bun`];
if (buildId) {
args.push(`--build-id=${buildId}`);
}
if (testFiles?.length) {
args.push(...testFiles.map(testFile => `--include=${testFile}`));
} else {
// platform-independent tsc check; runs in .github/workflows/bun-types.yml instead
args.push("--exclude=integration/bun-types");
// source-tree lints and build-script unit tests that never touch the built
// binary; run in .github/workflows/source-lints.yml instead
args.push("--exclude=internal/source-lints");
}
// The untiered darwin lane PR builds get (see prDarwinTestPlatforms) skips
// the ~1% of files that take 10s or more; they are ~60% of a shard's wall
// time and still run on every other PR lane and on main's darwin lanes.
if (os === "darwin" && !platform.tier) {
args.push("--skip-slower-than=10000");
}
const depends = [];
if (!buildId) {
depends.push(`${getTargetKey(platform)}-build-bun`);
}
return {
key: `${getPlatformKey(platform)}-test-bun`,
label: `${getPlatformLabel(platform)} - test-bun`,
depends_on: depends,
agents: getTestAgent(platform, options),
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
parallelism: os === "darwin" ? 2 : os === "windows" ? 8 : 20,
timeout_in_minutes: profile === "asan" || os === "windows" || os === "darwin" ? 45 : 30,
env: {
ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=0",
// Platform smoke check: runner.node.mjs asserts the agent matches what
// this step targets before running any test (see assertExpectedPlatform).
// `release` is only asserted where the lane pins an exact version:
// darwin aarch64 "previous" and darwin x64 intentionally float across
// macOS versions, and the windows "2019" label doesn't match the
// kernel-style version the agent reports.
EXPECTED_PLATFORM_OS: platform.os,
EXPECTED_PLATFORM_ARCH: platform.arch,
...(platform.abi ? { EXPECTED_PLATFORM_ABI: platform.abi } : {}),
...(platform.os === "linux" && platform.distro ? { EXPECTED_PLATFORM_DISTRO: platform.distro } : {}),
...(platform.os === "linux" ||
(platform.os === "darwin" && platform.arch === "aarch64" && platform.tier === "latest")
? { EXPECTED_PLATFORM_RELEASE: platform.release }
: {}),
},
command:
os === "windows"
? `pwsh -NoProfile -File .\\scripts\\vs-shell.ps1 node .\\scripts\\runner.node.mjs ${args.join(" ")}`
: `./scripts/runner.node.mjs ${args.join(" ")}`,
};
}
/**
* CI image lifecycle
* ------------------
* Build/test agents boot from pre-baked cloud images (AWS AMIs for Linux,
* Azure Shared Image Gallery for Windows). The image a job requests is
* `${getImageKey(platform)}-v${N}`, where N is the `# Version:` comment at the
* top of scripts/bootstrap.sh (Linux) or scripts/bootstrap.ps1 (Windows).
*
* To change what's installed on a CI machine:
*
* 1. Edit bootstrap.sh / bootstrap.ps1 and bump its `# Version:` line.
* 2. Open a PR whose **commit subject** contains `[build images]` (or
* `[build linux images]` / `[build windows images]` to scope it). This
* bakes throwaway `…-build-<buildNumber>` images and runs the full
* build+test pipeline against them so you can verify the change.
* 3. Once green, amend/force-push the subject to `[publish images]` (or the
* scoped variant). This bakes the real `…-vN` images that normal CI will
* pick up. Publishing replaces the live tag in place — for Windows it
* deletes the existing gallery version before the new one finishes — so
* don't cancel a publish run mid-bake.
* 4. Merge the PR **after** the publish run is green. By then the `…-vN`
* images already exist, so the post-merge `main` build runs immediately
* instead of everyone waiting 2-3 h on a bake.
*
* These tags are ignored on `main` — image bakes happen on the PR only.
*
* @param {Platform} platform
* @param {PipelineOptions} options
* @returns {Step}
*/
function getBuildImageStep(platform, options) {
const { os, arch, distro, release, features } = platform;
const { publishImages } = options;
const action = publishImages ? "publish-image" : "create-image";
const cloud = os === "windows" ? "azure" : "aws";
const command = [
"node",
"./scripts/machine.mjs",
action,
`--os=${os}`,
`--arch=${arch}`,
distro && `--distro=${distro}`,
`--release=${release}`,
`--cloud=${cloud}`,
"--ci",
"--authorized-org=oven-sh",
];
for (const feature of features || []) {
command.push(`--feature=${feature}`);
}
return {
key: `${getImageKey(platform)}-build-image`,
label: `${getImageLabel(platform)} - build-image`,
agents: {
queue: "build-image",
},
env: {
DEBUG: "1",
// Packer needs several minutes to delete its temp Azure resources after a cancel;
// the agent's default 10s grace SIGKILLs it mid-cleanup and leaks a full
// VM/NIC/IP stack per retry. The agent reads this from job env — there's no
// step-level property for it.
BUILDKITE_SIGNAL_GRACE_PERIOD_SECONDS: `${10 * 60}`,
},
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
command: command.filter(Boolean).join(" "),
timeout_in_minutes: 3 * 60,
};
}
/**
* Batch-signs all Windows artifacts on an x64 agent. DigiCert smctl is x64-only
* and silently fails under ARM64 emulation, so signing must happen here instead
* of inline during each build. Re-uploads signed zips with the same names so
* the release step picks them up transparently.
* @param {Platform[]} windowsPlatforms
* @param {PipelineOptions} options
* @returns {Step}
*/
function getWindowsSignStep(windowsPlatforms, options) {
// Each build-bun step produces two zips: <triplet>-profile.zip and <triplet>.zip
const artifacts = [];
const buildSteps = [];
for (const platform of windowsPlatforms) {
const triplet = getTargetTriplet(platform);
const stepKey = `${getTargetKey(platform)}-build-bun`;
artifacts.push(`${triplet}-profile.zip`, `${triplet}.zip`);
buildSteps.push(stepKey, stepKey);
}
// Signing runs on a real Windows x64 machine (smctl; doesn't work on
// ARM64) — the build platforms themselves are cross-compiled on Linux, so
// the agent descriptor here is explicitly a native Windows box.
return {
key: "windows-sign",
label: `${getBuildkiteEmoji("windows")} sign`,
depends_on: windowsPlatforms.map(p => `${getTargetKey(p)}-build-bun`),
agents: getEc2Agent({ os: "windows", arch: "x64", release: "2019" }, options, {
instanceType: getAzureVmSize("windows", "x64", "test"),
}),
retry: getRetry(),
cancel_on_build_failing: isMergeQueue(),
command: [
`powershell -NoProfile -ExecutionPolicy Bypass -File .buildkite/scripts/sign-windows-artifacts.ps1 ` +
`-Artifacts ${artifacts.join(",")} ` +
`-BuildSteps ${buildSteps.join(",")}`,
],
};
}
/**
* Aggregates stripped-binary sizes from every release build, compares them
* against the latest main build's binary-sizes.json, and fails if any grew
* past the threshold. Runs on PR builds (comparison) and main (record-only,
* to produce the baseline artifact).
*
* @param {Platform[]} releasePlatforms
* @param {PipelineOptions} options
* @param {{ recordOnly: boolean }} [extra]
* @returns {Step}
*/
function getBinarySizeStep(releasePlatforms, options, { recordOnly = false } = {}) {
const targets = releasePlatforms.map(p => ({ triplet: getTargetTriplet(p) }));
const args = [`--targets '${JSON.stringify(targets)}'`, `--threshold-mb ${BINARY_SIZE_THRESHOLD_MB}`];
if (recordOnly) args.push("--no-fail");
if (!options.canary) args.push("--release");
return {
key: "binary-size",
label: `${getBuildkiteEmoji("package")} binary-size`,
agents: getEc2Agent(buildHostPlatform, options, { instanceType: "c8g.large" }),
depends_on: releasePlatforms.map(p => `${getTargetKey(p)}-build-bun`),
allow_dependency_failure: true,
soft_fail: !!options.skipSizeCheck,
retry: {
manual: { permit_on_passed: true },
automatic: [{ exit_status: "*", limit: 2 }],
},
cancel_on_build_failing: isMergeQueue(),
command: `bun scripts/binary-size.ts ${args.join(" ")}`,
};
}
const BINARY_SIZE_THRESHOLD_MB = 0.5;
/**
* @param {Platform[]} releasePlatforms
* @param {PipelineOptions} options
* @param {{ signed?: boolean, testStepKeys?: string[] }} [extra]
* @returns {Step}
*/
function getReleaseStep(releasePlatforms, options, { signed = false, testStepKeys = [] } = {}) {
const { canary } = options;
const revision = typeof canary === "number" ? canary : 1;
// When signing ran, depend on windows-sign instead of the raw Windows builds
// so we wait for signed artifacts before releasing.
const depends_on = signed
? [...releasePlatforms.filter(p => p.os !== "windows").map(p => `${getTargetKey(p)}-build-bun`), "windows-sign"]
: releasePlatforms.map(platform => `${getTargetKey(platform)}-build-bun`);
// Gate canary upload on green tests. A red test lane leaves the artifacts in
// Buildkite but skips the GitHub/S3 upload; the next green main push ships.
// [skip tests] on main still lets the release run (testStepKeys is empty).
depends_on.push(...testStepKeys);
return {
key: "release",
label: getBuildkiteEmoji("rocket"),
agents: getEc2Agent(buildHostPlatform, options, { instanceType: "c8g.large" }),
depends_on,
env: {
CANARY: revision,
// Tells upload-release.sh to fetch Windows zips from the sign step
// (same filenames, but the signed re-uploads are the ones we want).
WINDOWS_ARTIFACT_STEP: signed ? "windows-sign" : "",
},
command: ".buildkite/scripts/upload-release.sh",
};
}
/**
* @typedef {Object} Pipeline
* @property {Step[]} [steps]
* @property {number} [priority]
*/
/**
* @typedef {Record<string, string | undefined>} Agent
*/
/**
* @typedef {GroupStep | CommandStep | BlockStep} Step
*/
/**
* @typedef {Object} GroupStep
* @property {string} key
* @property {string} group
* @property {Step[]} steps
* @property {string[]} [depends_on]
*/
/**
* @typedef {Object} CommandStep
* @property {string} key
* @property {string} [label]
* @property {Record<string, string | undefined>} [agents]
* @property {Record<string, string | undefined>} [env]
* @property {string} command
* @property {string[]} [depends_on]
* @property {Record<string, string | undefined>} [retry]
* @property {boolean} [cancel_on_build_failing]
* @property {boolean} [soft_fail]
* @property {number} [parallelism]
* @property {number} [concurrency]
* @property {string} [concurrency_group]
* @property {number} [priority]
* @property {number} [timeout_in_minutes]
* @link https://buildkite.com/docs/pipelines/command-step
*/
/**
* @typedef {Object} BlockStep
* @property {string} key
* @property {string} block
* @property {string} [prompt]
* @property {"passed" | "failed" | "running"} [blocked_state]
* @property {(SelectInput | TextInput)[]} [fields]
*/
/**
* @typedef {Object} TextInput
* @property {string} key
* @property {string} text
* @property {string} [default]
* @property {boolean} [required]
* @property {string} [hint]
*/
/**
* @typedef {Object} SelectInput
* @property {string} key
* @property {string} select
* @property {string | string[]} [default]
* @property {boolean} [required]
* @property {boolean} [multiple]
* @property {string} [hint]
* @property {SelectOption[]} [options]
*/
/**
* @typedef {Object} SelectOption
* @property {string} label
* @property {string} value
*/
/**
* @typedef {Object} PipelineOptions
* @property {string | boolean} [skipEverything]
* @property {string | boolean} [skipBuilds]
* @property {string | boolean} [skipTests]
* @property {string | boolean} [skipSizeCheck]
* @property {string | boolean} [forceBuilds]
* @property {string | boolean} [forceTests]
* @property {string | boolean} [buildImages]
* @property {string | boolean} [signWindows]
* @property {string | boolean} [publishImages]
* @property {number} [canary]
* @property {Platform[]} [buildPlatforms]
* @property {Platform[]} [testPlatforms]
* @property {string[]} [testFiles]
* @property {string[]} [changedFiles]
*/
/**
* @param {Step} step
* @param {(string | undefined)[]} dependsOn
* @returns {Step}
*/
function getStepWithDependsOn(step, ...dependsOn) {
const { depends_on: existingDependsOn = [] } = step;
return {
...step,
depends_on: [...existingDependsOn, ...dependsOn.filter(Boolean)],
};
}
/**
* @returns {BlockStep}
*/
function getOptionsStep() {
const booleanOptions = [
{
label: `${getEmoji("true")} Yes`,
value: "true",
},
{
label: `${getEmoji("false")} No`,
value: "false",
},
];
return {
key: "options",
block: getBuildkiteEmoji("clipboard"),
blocked_state: "running",
fields: [
{
key: "canary",
select: "If building, is this a canary build?",
hint: "If you are building for a release, this should be false",
required: false,
default: "true",
options: booleanOptions,
},
{
key: "skip-builds",
select: "Do you want to skip the build?",
hint: "If true, artifacts will be downloaded from the last successful build",
required: false,
default: "false",
options: booleanOptions,
},
{
key: "skip-tests",
select: "Do you want to skip the tests?",
required: false,
default: "false",
options: booleanOptions,
},
{
key: "force-builds",
select: "Do you want to force run the build?",
hint: "If true, the build will run even if no source files have changed",
required: false,
default: "false",
options: booleanOptions,
},
{
key: "force-tests",
select: "Do you want to force run the tests?",
hint: "If true, the tests will run even if no test files have changed",
required: false,
default: "false",
options: booleanOptions,
},
{
key: "build-profiles",
select: "If building, which profiles do you want to build?",
required: false,
multiple: true,
default: ["release"],
options: [
{
label: `${getEmoji("release")} Release`,
value: "release",
},
{
label: `${getEmoji("assert")} Release with Assertions`,
value: "assert",
},
{
label: `${getEmoji("asan")} Release with ASAN`,
value: "asan",
},
{
label: `${getEmoji("debug")} Debug`,
value: "debug",
},
],
},
{
key: "build-platforms",
select: "If building, which platforms do you want to build?",
hint: "If this is left blank, all platforms are built",
required: false,
multiple: true,
default: [],
options: buildPlatforms.map(platform => {
const { os, arch, abi, baseline } = platform;
let label = `${getEmoji(os)} ${arch}`;
if (abi) {
label += `-${abi}`;
}
if (baseline) {
label += `-baseline`;
}
return {
label,
value: getTargetKey(platform),
};
}),
},
{
key: "test-platforms",
select: "If testing, which platforms do you want to test?",
hint: "If this is left blank, all platforms are tested",
required: false,
multiple: true,
default: [],
// One option per distinct image — the baseline/profile variants collapse
// into the first (plain) entry since profiles come from `build-profiles`.
// The option value must be that entry's *platform* key: it's what
// getPipelineOptions() resolves through testPlatformsMap, and the image
// key isn't a platform key.
options: testPlatforms
.filter((platform, index, array) => index === array.findIndex(p => getImageKey(p) === getImageKey(platform)))
.map(platform => {
const { os, arch, abi, distro, release } = platform;
let label = `${getEmoji(os)} ${arch}`;
if (abi) {
label += `-${abi}`;
}
if (distro) {
label += ` ${distro}`;
}
if (release) {
label += ` ${release}`;
}
return {
label,
value: getPlatformKey(platform),
};
}),
},
{
key: "test-files",
text: "If testing, which files do you want to test?",
hint: "If specified, only run test paths that include the list of strings (e.g. 'test/js', 'test/cli/hot/watch.ts')",
required: false,
},
{
key: "build-images",
select: "Do you want to re-build the base images?",
hint: "This can take 2-3 hours to complete, only do so if you've tested locally",
required: false,
default: "false",
options: booleanOptions,
},
{
key: "publish-images",
select: "Do you want to re-build and publish the base images?",
hint: "This can take 2-3 hours to complete, only do so if you've tested locally",
required: false,
default: "false",
options: booleanOptions,
},
],
};
}
/**
* @returns {Step}
*/
function getOptionsApplyStep() {
const command = getEnv("BUILDKITE_COMMAND");
return {
key: "options-apply",
label: getBuildkiteEmoji("gear"),
command: `${command} --apply`,
depends_on: ["options"],
agents: {
queue: getEnv("BUILDKITE_AGENT_META_DATA_QUEUE", false),
},
};
}
/**
* @returns {Promise<PipelineOptions | undefined>}
*/
async function getPipelineOptions() {
const isManual = isBuildManual();
if (isManual && !process.argv.includes("--apply")) {
return;
}
let filteredBuildPlatforms = buildPlatforms;
if (isMainBranch()) {
filteredBuildPlatforms = buildPlatforms.filter(({ profile }) => profile !== "asan");
}
const canary = await getCanaryRevision();
const buildPlatformsMap = new Map(filteredBuildPlatforms.map(platform => [getTargetKey(platform), platform]));
const testPlatformsMap = new Map(testPlatforms.map(platform => [getPlatformKey(platform), platform]));
if (isManual) {
const { fields } = getOptionsStep();
const keys = fields?.map(({ key }) => key) ?? [];
const values = await Promise.all(keys.map(getBuildMetadata));
const options = Object.fromEntries(keys.map((key, index) => [key, values[index]]));
/**
* @param {string} value
* @returns {string[] | undefined}
*/
const parseArray = value =>
value
?.split("\n")
?.map(item => item.trim())
?.filter(Boolean);
const buildProfiles = parseArray(options["build-profiles"]);
const buildPlatformKeys = parseArray(options["build-platforms"]);
const testPlatformKeys = parseArray(options["test-platforms"]);
return {
canary: parseBoolean(options["canary"]) ? canary : 0,
skipBuilds: parseBoolean(options["skip-builds"]),
forceBuilds: parseBoolean(options["force-builds"]),
skipTests: parseBoolean(options["skip-tests"]),
buildImages: parseBoolean(options["build-images"]),
publishImages: parseBoolean(options["publish-images"]),
testFiles: parseArray(options["test-files"]),
buildPlatforms: buildPlatformKeys?.length
? buildPlatformKeys.flatMap(key => buildProfiles.map(profile => ({ ...buildPlatformsMap.get(key), profile })))
: Array.from(buildPlatformsMap.values()),
testPlatforms: testPlatformKeys?.length
? testPlatformKeys.flatMap(key => buildProfiles.map(profile => ({ ...testPlatformsMap.get(key), profile })))
: Array.from(testPlatformsMap.values()),
dryRun: parseBoolean(options["dry-run"]),
};
}
// BUILDKITE_MESSAGE is the commit subject line only — option tags like
// [publish images] must appear in the subject, not the commit body.
const commitMessage = getCommitMessage();
/**
* @param {RegExp} pattern
* @returns {string | boolean}
*/
const parseOption = pattern => {
const match = pattern.exec(commitMessage);
if (match) {
const [, value] = match;
return value;
}
return false;
};
const isCanary =
!parseBoolean(getEnv("RELEASE", false) || "false") &&
!/\[(release|build release|release build)\]/i.test(commitMessage);
let buildImages = parseOption(/\[(build (?:(?:windows|linux) )?images?)\]/i);
let publishImages = parseOption(/\[(publish (?:(?:windows|linux) )?images?)\]/i);
let imageFilter = (commitMessage.match(/\[(?:build|publish) (windows|linux) images?\]/i) || [])[1]?.toLowerCase();
// Image bake/publish is meant to happen on the PR; the squash-merge commit
// subject often still carries the [publish images] tag, which would re-run
// the multi-hour bake on main and (because publish replaces the live image
// tag) briefly delete the images CI runs on. Ignore the tag on main and run
// a normal build instead.
if (isMainBranch() && (buildImages || publishImages)) {
console.log(`Ignoring [${publishImages || buildImages}] on main branch — images are built and published from PRs.`);
buildImages = false;
publishImages = false;
imageFilter = undefined;
}
return {
canary: isCanary ? canary : 0,
skipEverything: parseOption(/\[(skip ci|no ci)\]/i),
skipBuilds: parseOption(/\[(skip builds?|no builds?|only tests?)\]/i),
forceBuilds: parseOption(/\[(force builds?)\]/i),
skipTests: parseOption(/\[(skip tests?|no tests?|only builds?)\]/i),
skipSizeCheck: parseOption(/\[(skip size( check)?|allow size)\]/i),
signWindows: parseOption(/\[(sign windows)\]/i),
buildImages,
dryRun: parseOption(/\[(dry run)\]/i),
publishImages,
imageFilter,
buildPlatforms: Array.from(buildPlatformsMap.values()),
testPlatforms: Array.from(testPlatformsMap.values()),
};
}
/**
* @param {PipelineOptions} [options]
* @returns {Promise<Pipeline | undefined>}
*/
async function getPipeline(options = {}) {
const priority = getPriority();
if (isBuildManual() && !Object.keys(options).length) {
return {
priority,
steps: [getOptionsStep(), getOptionsApplyStep()],
};
}
const { skipEverything } = options;
if (skipEverything) {
return;
}
const { buildPlatforms = [], testPlatforms = [], buildImages, publishImages, imageFilter } = options;
// Every build lane runs on buildHostPlatform (see getBuildAgent),
// so the build-image set is exactly {buildHostPlatform} testPlatforms' native
// images — buildPlatforms entries encode TARGET os/arch/abi, not a host image.
const imagePlatforms = new Map(
buildImages || publishImages
? [buildHostPlatform, ...testPlatforms]
// darwin: no cloud images (bare-metal test fleet only).
.filter(({ os }) => os !== "darwin")
.filter(({ os, distro }) => !imageFilter || os === imageFilter || distro === imageFilter)
.map(platform => [getImageKey(platform), platform])
: [],
);
/** @type {Step[]} */
const steps = [];
if (imagePlatforms.size) {
steps.push({
key: "build-images",
group: getBuildkiteEmoji("aws"),
steps: [...imagePlatforms.values()].map(platform => getBuildImageStep(platform, options)),
});
}
let { skipBuilds, forceBuilds, dryRun } = options;
dryRun = dryRun || !!buildImages;
/** @type {string | undefined} */
let buildId;
if (skipBuilds && !forceBuilds) {
const lastBuild = await getLastSuccessfulBuild();
if (lastBuild) {
const { id } = lastBuild;
buildId = id;
} else {
console.warn("No last successful build found, must force builds...");
}
}
const includeASAN = !isMainBranch();
if (!buildId) {
let relevantBuildPlatforms = includeASAN
? buildPlatforms
: buildPlatforms.filter(({ profile }) => profile !== "asan");
steps.push(
...relevantBuildPlatforms.map(target => {
// build-bun always runs on buildHostPlatform regardless of
// target, so the only build-image dependency is the host's.
const imageKey = getImageKey(buildHostPlatform);
const dependsOn = [];
if (imagePlatforms.has(imageKey)) {
dependsOn.push(`${imageKey}-build-image`);
}
const steps = [getBuildBunStep(target, options)];
if (needsBaselineVerification(target)) {
// verify-baseline runs on a per-target-arch native host (see
// getVerifyBaselineHost), not buildHostPlatform; its image dep goes
// on the step itself so build-bun doesn't wait for it.
const verifyImageKey = getImageKey(getVerifyBaselineHost(target));
const verifyDeps =
verifyImageKey !== imageKey && imagePlatforms.has(verifyImageKey) ? [`${verifyImageKey}-build-image`] : [];
steps.push(getStepWithDependsOn(getVerifyBaselineStep(target, options), ...verifyDeps));
}
// Seed the symbol order file for a cross-compiled target on its native
// test fleet (see getTraceOrderStep). Always on main so the inheritance
// chain stays fed, and anywhere else on commit-message opt-in so a PR
// that changes the tracer can prove the step works before merge — the
// same `[generate symbol order]` tag ci.ts already honours. Release
// profile only — usesOrderFile() is false under a sanitizer anyway.
const traceOn = traceOrderTargets.find(
t =>
t.os === target.os && t.arch === target.arch && !target.abi && (target.profile ?? "release") === "release",
);
if (traceOn && (isMainBranch() || /\[generate symbol order\]/i.test(getCommitMessage()))) {
// The trace host's image, same as verify-baseline: on the step, so
// build-bun doesn't wait for it. Darwin has no cloud image.
const traceImageKey = getImageKey(traceOn.on);
const traceDeps =
traceImageKey !== imageKey && imagePlatforms.has(traceImageKey) ? [`${traceImageKey}-build-image`] : [];
steps.push(getStepWithDependsOn(getTraceOrderStep(target, traceOn.on, options), ...traceDeps));
}
return getStepWithDependsOn(
{
key: getTargetKey(target),
group: getTargetLabel(target),
steps,
},
...dependsOn,
);
}),
);
}
// Tests run on main too so the canary release step below can gate on them.
// ASAN is PR-only (see includeASAN above), so the asan test lane is dropped
// on main along with its build.
// Untiered: any arm64 mac agent, whatever macOS it runs, can take it.
/** @type {Platform[]} */
const prDarwinTestPlatforms = [{ os: "darwin", arch: "aarch64", release: "any" }];
const darwinTestsEnabled = isMainBranch() || isBuildManual() || /\[(macos|darwin) tests?\]/i.test(getCommitMessage());
const relevantTestPlatforms = (
includeASAN ? testPlatforms : testPlatforms.filter(({ profile }) => profile !== "asan")
)
.filter(({ os }) => os !== "darwin" || darwinTestsEnabled)
.concat(darwinTestsEnabled ? [] : prDarwinTestPlatforms);
/** @type {string[]} */
const testStepKeys = [];
{
const { skipTests, forceTests, testFiles } = options;
if (!skipTests || forceTests) {
steps.push(
...relevantTestPlatforms.map(target => {
const step = getTestBunStep(target, options, { testFiles, buildId });
testStepKeys.push(step.key);
// Test shards run on their native platform image; on [build images]
// runs they must wait for that freshly-baked image before starting.
const imageKey = getImageKey(target);
const dependsOn = imagePlatforms.has(imageKey) ? [`${imageKey}-build-image`] : [];
return getStepWithDependsOn(
{
key: getPlatformKey(target),
group: getPlatformLabel(target),
steps: [step],
},
...dependsOn,
);
}),
);
}
}
// Binary-size tracking: main records the baseline, PRs enforce the threshold.
const strippedPlatforms = buildPlatforms.filter(p => (p.profile ?? "release") === "release");
if (!buildId && strippedPlatforms.length) {
steps.push(getBinarySizeStep(strippedPlatforms, options, { recordOnly: isMainBranch() }));
}
// Sign Windows builds on release (non-canary main) or when [sign windows]
// is in the commit message (for testing the sign step on a branch).
// DigiCert charges per signature, so canary builds are never signed.
const shouldSignWindows = (isMainBranch() && !options.canary) || options.signWindows;
if (shouldSignWindows) {
const windowsPlatforms = buildPlatforms.filter(p => p.os === "windows");
if (windowsPlatforms.length > 0) {
// Signing runs on a native Windows x64 box — on [build images] runs it
// requests the freshly baked native Windows image, so wait for it.
steps.push(
getStepWithDependsOn(
getWindowsSignStep(windowsPlatforms, options),
imagePlatforms.has("windows-x64-2019") ? "windows-x64-2019-build-image" : undefined,
),
);
}
}
if (isMainBranch()) {
steps.push(getReleaseStep(buildPlatforms, options, { signed: shouldSignWindows, testStepKeys }));
}
/** @type {Map<string, GroupStep>} */
const stepsByGroup = new Map();
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (!("group" in step)) {
continue;
}
const { group, steps: groupSteps } = step;
if (stepsByGroup.has(group)) {
stepsByGroup.get(group).steps.push(...groupSteps);
} else {
stepsByGroup.set(group, step);
}
steps[i] = undefined;
}
return {
priority,
steps: [...steps.filter(step => typeof step !== "undefined"), ...Array.from(stepsByGroup.values())],
};
}
async function main() {
startGroup("Generating options...");
const options = await getPipelineOptions();
if (options) {
console.log("Generated options:", options);
}
startGroup("Querying GitHub for files...");
if (options && isBuildkite && !isMainBranch()) {
/** @type {string[]} */
let allFiles = [];
/** @type {string[]} */
let newFiles = [];
let prFileCount = 0;
try {
console.log("on buildkite: collecting new files from PR");
const per_page = 50;
const { BUILDKITE_PULL_REQUEST } = process.env;
for (let i = 1; i <= 10; i++) {
const res = await fetch(
`https://api.github.com/repos/oven-sh/bun/pulls/${BUILDKITE_PULL_REQUEST}/files?per_page=${per_page}&page=${i}`,
{ headers: { Authorization: `Bearer ${getSecret("GITHUB_TOKEN")}` } },
);
const doc = await res.json();
if (!Array.isArray(doc)) {
console.error(`-> page ${i}, unexpected response:`, JSON.stringify(doc));
break;
}
console.log(`-> page ${i}, found ${doc.length} items`);
if (doc.length === 0) break;
for (const { filename, status } of doc) {
prFileCount += 1;
allFiles.push(filename);
if (status !== "added") continue;
newFiles.push(filename);
}
if (doc.length < per_page) break;
}
console.log(`- PR ${BUILDKITE_PULL_REQUEST}, ${prFileCount} files, ${newFiles.length} new files`);
} catch (e) {
console.error(e);
}
if (allFiles.length > 0 && allFiles.every(filename => filename.startsWith("docs/"))) {
console.log(`- PR is only docs, skipping tests!`);
return;
}
options.changedFiles = allFiles;
// Publish the file lists as build meta-data so each test shard can read
// them instead of re-querying GitHub. With ~150 shards per build, this
// is the difference between 1 API call and 150, and the per-shard calls
// were exhausting the token's hourly rate limit under load.
if (allFiles.length > 0) {
await setBuildMetadata("pr-all-files", JSON.stringify(allFiles));
await setBuildMetadata("pr-new-files", JSON.stringify(newFiles));
}
}
startGroup("Generating pipeline...");
const pipeline = await getPipeline(options);
if (!pipeline) {
console.log("Generated pipeline is empty, skipping...");
return;
}
const content = toYaml(pipeline);
const contentPath = join(process.cwd(), ".buildkite", "ci.yml");
writeFile(contentPath, content);
console.log("Generated pipeline:");
console.log(" - Path:", contentPath);
console.log(" - Size:", (content.length / 1024).toFixed(), "KB");
if (isBuildkite) {
startGroup("Uploading pipeline...");
try {
await spawnSafe(["buildkite-agent", "pipeline", "upload", contentPath], { stdio: "inherit" });
} finally {
await uploadArtifact(contentPath);
}
}
}
await main();