#!/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--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 // 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-` 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: -profile.zip and .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} 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} [agents] * @property {Record} [env] * @property {string} command * @property {string[]} [depends_on] * @property {Record} [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} */ 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} */ 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} */ 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();