Bun Builds and Tests on GitHub Actions

Bun jobs on GitHub Actions stall on a cold install cache. Cache ~/.bun/install/cache with WarpBuilds/cache keyed on bun.lock and size warp- runners for tests.

Last verified:

Bun builds on GitHub Actions are fast once the install cache is warm and slow every time it is cold, because each job starts on a fresh virtual machine with an empty ~/.bun/install/cache. Restore that directory with WarpBuilds/cache@v1 keyed on bun.lock, run bun install --frozen-lockfile, and put the job on a warp- runner sized for what bun test and bun build actually use.

This page covers the workflow configuration for both installation paths, the sizing call between 2, 4, and 8 vCPU runners with the list-price arithmetic, and the three bottlenecks that dominate Bun pipelines.

Overview

Bun keeps every downloaded package in a global install cache, at ~/.bun/install/cache by default. bun install resolves the lockfile against that cache and links packages into node_modules rather than refetching them, which is why a second local install finishes almost immediately and a first install on a clean machine does not.

A GitHub Actions job is always a clean machine. Nothing from the previous run survives, so the global cache is empty at the start of every job and bun install fetches the full dependency set from the registry before the first test runs. Restoring that one directory between jobs is the single highest-value change to a Bun workflow.

There is no WarpBuild fork of a Bun setup action, so the install cache is handled explicitly. Two paths work:

  • Install Bun with the upstream oven-sh/setup-bun action and cache ~/.bun/install/cache with WarpBuilds/cache@v1, which is a drop-in replacement for actions/cache@v4 and stores entries in WarpBuild's cache backend.
  • Install Bun through WarpBuilds/mise-action@v2 when the repository already pins its runtimes in mise.toml. That action caches the tools mise installs, so the Bun binary itself comes back warm.

The two paths combine. mise brings back the runtime, WarpBuilds/cache brings back the packages, and neither one covers the other.

Bun work belongs on the Linux sizes, and moving a job onto them is a one-line change to runs-on. One limit from the caching documentation: WarpBuild Cache is not supported on Windows runners, so keep Bun cache steps on Linux jobs.

Configuration

The workflow below installs Bun, restores the global install cache keyed on bun.lock, and runs typecheck, build, and test on a 4 vCPU WarpBuild runner.

name: bun-ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Restore Bun install cache
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - run: bun install --frozen-lockfile
      - run: bun run typecheck
      - run: bun run build
      - run: bun test

Four details in that file carry the behavior.

path points at the global install cache rather than node_modules. A restored node_modules is tied to the exact dependency tree and platform that produced it, while the install cache is keyed by package and version, so bun install decides what to link out of it on every run.

key hashes bun.lock, so the entry rolls exactly when the dependency set changes. Repositories still on the binary lockfile hash **/bun.lockb instead. Hashing package.json is the common mistake: a version bump in a script field then throws away a perfectly good cache.

restore-keys is what keeps dependency-update pull requests cheap. When the exact key misses, the action restores the most recent entry with the same prefix, and the install fetches only the packages that changed rather than the whole tree.

--frozen-lockfile makes the install fail instead of silently rewriting bun.lock, which keeps the cache key honest and keeps the build reproducible.

Three behaviors from the caching documentation shape how the entries age. The cache is scoped to key, version, and branch, so an entry saved on a feature branch is separate from the one on main; seed from main and let branch builds pick it up through restore-keys. The version part is a hash over the compression tool and the list of cached paths, which is why a cache written on a macOS runner cannot restore on a Linux runner. Entries expire 7 days after last use, so active repositories stay warm and abandoned branches stop costing storage without any cleanup job.

For repositories that pin the Bun version through mise, swap the setup step and keep the cache step:

      - uses: WarpBuilds/mise-action@v2
        with:
          version: 2024.10.0
          cache: true

      - name: Restore Bun install cache
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

With mise.toml holding the pin:

[tools]
bun = "1.2"

The full input list for every WarpBuild setup action is in the setup actions documentation.

Cache usage is metered, and the rates are small enough to state exactly: storage is $0.20 per GB-month, and each write or restore operation is $0.0001. Cache storage and operations are free on BYOC runners.

Sizing

Bun repositories run well on the smaller Linux sizes. These are the rows that matter:

Runner labelOSvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832GB150GB SSD$0.016
warp-ubuntu-latest-arm64-4xUbuntu 24.04416GB150GB SSD$0.006

Size each phase for what it can use.

Install is network and disk work. With a warm cache it is mostly linking, and cores past 2 change little. If install is still the long pole, the fix is the cache configuration above rather than a bigger machine.

Typecheck runs tsc on a single core, so vCPU count does nothing for it. Memory is the constraint that bites on a large program: 8GB on 2x covers most projects, and a heap failure is the signal to move that job to 4x.

Bundle with bun build walks the module graph and writes outputs. The graph walk does not scale linearly with cores, so 4x is the sensible ceiling for most bundles. Watch memory instead, especially when the build emits source maps for a large entrypoint or produces a standalone executable with bun build --compile.

Tests are the phase where sizing gets misread. bun test runs its files in one process instead of spawning a worker per core, so an 8 vCPU machine does not shorten a single test job by itself. Extra cores pay off when the suite starts real dependencies, a Postgres container, a browser, or several server processes, and when the suite is split across a matrix so several jobs run at once. Sharding is covered under bottlenecks below.

The ARM64 row is worth a run if the application also ships on ARM. Bun publishes native ARM64 builds, and warp-ubuntu-latest-arm64-4x costs $0.006 per minute against $0.008 for the x64 machine of the same shape.

Worked cost model

GitHub publishes per-minute list prices for its hosted runners at github.com/pricing and in the minute multipliers reference. Checked on 2026-08-13, standard ubuntu-latest for private repositories (2 vCPU) is $0.006 per minute, and the Linux larger runners are $0.012 for 4 vCPU and $0.022 for 8 vCPU.

MachinevCPUPer-minute rateSource
GitHub-hosted standard Linux2$0.006GitHub list price, checked 2026-08-13
warp-ubuntu-latest-x64-2x2$0.004WarpBuild pricing
GitHub-hosted Linux larger runner4$0.012GitHub list price, checked 2026-08-13
warp-ubuntu-latest-x64-4x4$0.008WarpBuild pricing
GitHub-hosted Linux larger runner8$0.022GitHub list price, checked 2026-08-13
warp-ubuntu-latest-x64-8x8$0.016WarpBuild pricing

Stated as list-price arithmetic: warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest (2 vCPU, 8 GB on private repositories): 33 percent lower list price. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. At 8 vCPU the pair is $0.016 against $0.022: 27 percent lower list price. GitHub list prices checked on 2026-08-13.

Take a concrete repository: 1,500 jobs a month at 4 minutes each on a 4 vCPU machine with a warm install cache, which is 6,000 runner minutes.

ScenarioRateMonthly minutesMonthly cost
GitHub-hosted Linux larger runner, 4 vCPU$0.012/min6,000$72.00
warp-ubuntu-latest-x64-4x, 4 vCPU$0.008/min6,000$48.00
Cache storage and operations (800MB, 3,000 ops)see aboven/a$0.46

Cache metering adds $0.16 for storage and $0.30 for operations, so the WarpBuild column lands at $48.46. Full rates for every size and platform are on the pricing page.

Bottlenecks

Three problems account for most slow Bun pipelines on GitHub Actions.

A cold install cache. Without a restored ~/.bun/install/cache, every job downloads the full dependency tree before anything else happens, and the cost scales with the size of the lockfile. Verify the fix from the job log: a warm run shows the cache step restoring an entry and bun install finishing without a long resolution phase. A cache that always misses usually means the key hashes a file that changes on every commit, or that the entry was only ever written on a feature branch and never on main. The same question across every JavaScript package manager is answered in how do I cache node modules in GitHub Actions.

Native module builds. Packages with native addons, the node-gyp and node-api family, install a prebuilt binary when one exists for the platform and Bun version and compile from source when it does not. Bun blocks lifecycle scripts by default and runs them only for packages listed in trustedDependencies in package.json, so the compile happens for exactly the packages you have trusted. Keep that list short and pin the Bun version in the workflow, because a runtime bump changes the ABI and sends every trusted package back through a source build. When a compile is unavoidable, it uses multiple cores, which is one reason install-heavy repositories with native dependencies run better on 4x than on 2x.

Workspace-wide test runs that never shard. In a Bun workspace, bun test from the root walks every package and runs the whole suite in one job, so wall time is the sum of every package's tests no matter how large the runner. Split the run across a matrix by path, and let every shard restore the same install cache:

  test:
    runs-on: warp-ubuntu-latest-x64-2x
    strategy:
      matrix:
        package: [api, web, worker]
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - uses: WarpBuilds/cache@v1
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-
      - run: bun install --frozen-lockfile
      - run: bun test packages/${{ matrix.package }}

Three shards on 2x spend roughly the same runner minutes as one job doing all the work, while wall time drops to the slowest package. Total minutes rise a little because each shard repeats checkout and install, which is one more reason the warm cache matters.

When a job is slow and the cause is unclear, WarpBuild's CI observability reports system metrics from the runner agent alongside GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on the network. For a closer look, the Action Debugger pauses a workflow and opens a session on the runner, so you can inspect ~/.bun/install/cache on the machine itself and confirm what the restore actually wrote.

Proof

Public OSS repositories running warp- labels are citable evidence, and the workflow files are open to read. The Trigger.dev end-to-end suite runs its matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x in triggerdotdev/trigger.dev's e2e.yml (checked on 2026-08-13).

If the repository mixes Bun with other JavaScript package managers across a monorepo, the Node.js builds on GitHub Actions page covers the store-caching decisions for the rest of the tree, and pnpm store caching on GitHub Actions covers the equivalent key and path for a pnpm workspace.

FAQ

What should a Bun cache key hash?

The lockfile. Hash bun.lock on Bun 1.2 and later, or bun.lockb on repositories still using the binary lockfile. Add a restore-keys prefix so a dependency bump restores the previous cache instead of starting from an empty install cache.

Is there a WarpBuilds/setup-bun action?

No. Install Bun with the upstream oven-sh/setup-bun action or through WarpBuilds/mise-action@v2, then cache ~/.bun/install/cache yourself with WarpBuilds/cache@v1, which is a drop-in replacement for actions/cache@v4.

What runner size does bun test need?

Start at warp-ubuntu-latest-x64-2x for a single package and move to 4x or 8x when tests start a database, a browser, or several server processes. Extra vCPUs shorten a bun test job only when the suite is split across a matrix.

Can a Bun cache saved on macOS restore on Linux?

No. The cache version is a hash over the compression tool and the list of cached paths, and those differ across operating systems, so a macOS entry and a Linux entry stay separate caches even under the same key.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.