Pants Builds on GitHub Actions
Cache the Pants lmdb_store and named_caches with WarpBuilds/cache@v1 on a warp- Linux label, or boot a snapshot runner that already holds both warm on disk.
Last verified:
Run Pants on GitHub Actions by pinning local_store_dir and named_caches_dir to stable paths under the runner home, then restoring both with WarpBuilds/cache@v1 before the first pants invocation. On WarpBuild the same job runs on a label such as warp-ubuntu-latest-x64-8x, and a snapshot runner can carry the local store forward on disk so there is no archive to download at all.
Both approaches restore the same directories. They differ in what the restore costs, and the crossover point depends on how large your store has grown. This page covers the config file, both workflows, how the Pants local store interacts with runner-local state, how to map process parallelism to a runner size, and what the whole setup bills per month.
Overview
Pants keeps its incremental speed on local disk, spread across directories that behave differently under GitHub Actions.
| Location | Option | What it holds | Shared across machines |
|---|---|---|---|
~/.cache/pants/lmdb_store | local_store_dir | Content addressed process results, file contents, and directory digests | No, local disk only |
~/.cache/pants/named_caches | named_caches_dir | Tool caches handed into process sandboxes, such as the PEX root and the pip download cache | No, local disk only |
.pants.d | pants_workdir | Per-run scratch: logs, materialized sandboxes, and the daemon state | No, local disk only |
| A gRPC endpoint | remote_store_address | The same content addressed process results as the lmdb_store | Yes |
The lmdb_store is where nearly all of the win lives. Pants computes a digest over each process it is about to run, covering the argv, the environment, the input file digests, and the execution platform, then looks that digest up in the store before executing anything. A hit means the process never runs. The remote cache serves the same content addressed data over the network, so the two tiers hold identical entries and a local hit means Pants never issues the remote lookup at all.
The named caches work differently. Pants mounts those directories into process sandboxes so the underlying tools reuse their own downloads, and nothing in them is content addressed or reachable over the network. A cold named_caches means pex and pip refetch every wheel your requirements resolve to.
The workdir at .pants.d is scratch. Delete it whenever you want; Pants rebuilds it.
On a fresh GitHub Actions runner all three local directories start empty. The first pants test :: on that machine therefore executes every process in the graph, downloads every wheel, and writes tens of thousands of entries into an LMDB database that then evaporates when the runner terminates. Runner storage on WarpBuild is ephemeral and is deleted when the runner terminates, exactly as it is on GitHub-hosted runners, so the entire question is how you get that directory back.
Pants runs on the Linux labels the same way it runs on a GitHub-hosted Ubuntu runner. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored while the job runs normally.
One layout decision matters before the configuration. A Pants remote cache is chatty, because a large graph issues tens of thousands of small lookups, so the distance between the runner and the cache endpoint shows up directly in wall clock time. BYOC runs on AWS, GCP, and Azure if you would rather put the runners in the same account and region as the cache service, and Terraform support exists for BYOC on AWS.
Configuration
Keep the workspace settings in pants.toml and put the GitHub Actions overrides in a second file that only the workflow selects. Pants reads the PANTS_CONFIG_FILES environment variable, so the split needs no flag plumbing in each step.
[GLOBAL]
pantsd = false
dynamic_ui = false
colors = true
local_store_dir = "%(homedir)s/.cache/pants/lmdb_store"
named_caches_dir = "%(homedir)s/.cache/pants/named_caches"
local_store_files_max_size_bytes = 32000000000
local_store_directories_max_size_bytes = 4000000000
local_store_processes_max_size_bytes = 4000000000
[stats]
log = trueThree settings in that file carry the weight.
Both cache directories are pinned under the runner home rather than left at a platform default. Snapshot runners do not persist /tmp, which is cleaned on reboot, and booting from a snapshot is a reboot. Anything you want carried forward has to live outside /tmp, and the runner home satisfies that on hosted Linux labels.
The three local_store_*_max_size_bytes values bound the LMDB database. Ubuntu labels carry a 150GB SSD, and an unbounded store on a long-lived snapshot alias grows until it competes with the checkout and the sandboxes for that disk. The 40 GB total above leaves room for a large monorepo checkout plus concurrent sandboxes on every size in the catalog.
[stats] log = true prints cache counters at the end of every run. That output is the verification loop in the Proof section, and it costs nothing.
Caching the store
The two directories want different cache keys, so they want separate entries. The lmdb_store is content addressed, which means an entry written last Tuesday is still correct today and only incomplete. It wants a rolling key with a prefix fallback. The named caches track your lockfiles, so they want a lockfile hash.
name: pants
on:
push:
branches: [main]
pull_request:
env:
PANTS_CONFIG_FILES: pants.ci.toml
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: WarpBuilds/setup-python@v6
with:
python-version: "3.12"
- name: Compute the rolling store key
id: day
run: echo "day=$(date -u +%Y%m%d)" >> $GITHUB_OUTPUT
- name: Restore the Pants local store
id: lmdb
uses: WarpBuilds/cache/restore@v1
with:
path: ~/.cache/pants/lmdb_store
key: pants-lmdb-${{ runner.os }}-${{ steps.day.outputs.day }}
restore-keys: |
pants-lmdb-${{ runner.os }}-
- name: Restore the Pants named caches
uses: WarpBuilds/cache@v1
with:
path: ~/.cache/pants/named_caches
key: pants-named-${{ runner.os }}-${{ hashFiles('**/*.lock') }}
restore-keys: |
pants-named-${{ runner.os }}-
- name: Lint, check, test, package
run: |
pants lint check ::
pants test ::
pants package ::
- name: Save the Pants local store
if: github.ref == 'refs/heads/main' && steps.lmdb.outputs.cache-hit != 'true'
uses: WarpBuilds/cache/save@v1
with:
path: ~/.cache/pants/lmdb_store
key: pants-lmdb-${{ runner.os }}-${{ steps.day.outputs.day }}WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4, so the inputs above are the ones you already know. The split restore and save form is what gates the write: the store is uploaded once per day from the default branch and every other run reads it, which keeps storage from growing by one full archive per job. Read the caching documentation for the full input list.
Two properties of the cache decide whether a restore lands. Entries are scoped to the key, the version, and the branch, and the version is a hash over the compression tool and the list of cached paths. Change either path in the path: block and every previous entry becomes unreachable. That hash is also why a cache written on warp-macos-15-arm64-6x cannot restore on warp-ubuntu-latest-x64-8x. Entries expire after 7 days of last use, so an alias that only builds on release days will find nothing.
Storage bills at $0.20 per GB-month and cache writes or restores at $0.0001 per operation on hosted runners, and both are free on BYOC.
Booting from a snapshot instead
Past roughly 10 GB the archive round trip stops paying for itself. A snapshot carries the whole runner disk forward, so the lmdb_store, the named caches, and the git checkout all arrive without a compress or decompress step.
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=pants-main' }}
env:
PANTS_CONFIG_FILES: pants.ci.toml
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Report snapshot state
run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}"
- name: Lint, check, test, package
run: |
pants lint check ::
pants test ::
pants package ::
- name: Report store size before capture
if: github.ref == 'refs/heads/main'
run: du -sh ~/.cache/pants/lmdb_store ~/.cache/pants/named_caches
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "pants-main"
fail-on-error: true
wait-timeout-minutes: 60snapshot.enabled=true always boots from the base image and lets the run capture a fresh snapshot; snapshot.key=<alias> boots from the existing snapshot for that alias and falls back to the base image when none exists. The snapshot runner documentation covers both labels.
The cleanup step is worth reading closely for a Pants repository. git clean -ffdx removes .pants.d and dist/ because both are gitignored, which is the outcome you want: the workdir is scratch and the packaged artifacts are outputs. The lmdb_store and the named caches live outside the build root, so they survive that command and land in the snapshot. Run the cleanup before capture in any case, because WarpBuild provisions runners at the organization level and GitHub may hand a snapshot-backed runner to a different job in the organization.
Snapshots are deleted after 15 days, so the default branch job doubles as the refresher. If the default branch is quiet, add a scheduled trigger. The general shape of this tradeoff, across build systems, is written up in incremental builds on GitHub Actions.
Sizing
Pants schedules a graph, so the runner size question is about how wide your graph gets rather than how large the repository is. process_execution_local_parallelism sets how many processes Pants runs at once and defaults to the core count of the machine.
On a cold store every process slot is doing real work, so parallelism at the vCPU count is correct. On a warm store most slots resolve to a lookup instead of an execution, and the bottleneck moves to store reads, so raising parallelism above the core count buys nothing and costs memory.
| Label | vCPU | RAM | Storage | Suggested parallelism | USD per minute |
|---|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | 4 | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | 8 | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | 16 | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | 32 | $0.064 |
warp-ubuntu-latest-arm64-16x | 16 | 64 GB | 150GB SSD | 16 | $0.024 |
Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides whether parallelism at the core count is safe. Pants materializes an input sandbox per process, so a test target that pulls a large model file or a wide dependency set holds that content on disk and in page cache for the life of the process. Thirty-two concurrent pytest processes each holding a gigabyte of fixtures will hit memory before they hit CPU, and the fix is a lower parallelism value rather than a larger label.
The 150GB SSD is the other constraint, and it binds hardest on a snapshot alias. Three consumers share it: the checkout, the lmdb_store, and the sandboxes materialized for in-flight processes. The size caps in pants.ci.toml hold the store at 40 GB; a monorepo checkout with full git history is often 5 GB to 20 GB; the sandboxes scale with parallelism. On a 32x label running 32 concurrent processes, budget the sandbox space explicitly before assuming the disk is free.
Architecture matters for cache reuse. A warp-ubuntu-latest-arm64-16x job and a warp-ubuntu-latest-x64-16x job maintain separate key spaces in both tiers, because the execution platform is part of the Pants process digest and the compression tool plus paths hash is part of the cache version. Running the same targets on both architectures doubles your store rather than sharing it.
Bottlenecks
A cold lmdb_store. This is the dominant cost on any runner that has never seen the workspace, and it is invisible in the logs unless you look for it. Pants reports counters, so read them rather than guessing. A run with local_cache_requests_cached near zero executed the entire graph.
Remote cache round trips. A remote cache removes execution and adds latency. On a graph with tens of thousands of small processes, the per-lookup round trip dominates the transfer time, so endpoint distance matters more than bandwidth. Set remote_cache_read = true everywhere and keep remote_cache_write off for pull request jobs so an unreviewed branch cannot poison the shared store. Override it per job with PANTS_REMOTE_CACHE_WRITE=false rather than maintaining a third config file.
pantsd warmup. The daemon holds the parsed BUILD graph and a file watcher in memory. On a laptop that state amortizes across dozens of invocations; inside a GitHub Actions job that terminates after one or two, the startup cost lands on the critical path and the memory competes with process sandboxes. Booting from a snapshot does not change this, because a snapshot boot is a reboot and no process survives it. Set pantsd = false and consolidate goals into a single invocation where the goal ordering allows it.
Shallow checkouts breaking --changed-since. Selecting targets with pants --changed-since=origin/main --changed-dependents=transitive test needs the merge base in the local repository. The default actions/checkout depth of 1 does not have it, and Pants then either errors or selects the wrong target set. fetch-depth: 0 in both workflows above is there for this reason.
Blind spots during the run. CI observability reports OpenTelemetry system metrics from the runner agent correlated with the GitHub Actions job logs, which separates a CPU-bound run from one waiting on the network. When a cache miss only reproduces on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can inspect the store and rerun pants by hand.
Proof
Pants reports its own cache behavior, so verification is one config line and one look at the log tail. With [stats] log = true set, every run ends with a counters block:
Counters:
local_cache_requests: 4128
local_cache_requests_cached: 3901
local_cache_requests_uncached: 227
remote_cache_requests: 227
remote_cache_requests_cached: 154
remote_cache_requests_uncached: 73Track the ratio of local_cache_requests_cached to local_cache_requests over time. A drop after a toolchain bump means an input digest changed for every process, which surfaces here days before anyone notices the job got longer. Compare the same commit run twice: once on a cold label, once after a snapshot boot, and the difference between the two counter blocks is what the snapshot is worth on your graph.
A worked monthly cost model
Take a Pants monorepo running 900 GitHub Actions builds per month across pull requests and the default branch, at a median of 9 minutes of wall clock each on 16 vCPU. That is 8,100 runner minutes, with one snapshot alias kept warm for the month.
| Line item | Rate | Quantity | Monthly USD |
|---|---|---|---|
warp-ubuntu-latest-x64-16x minutes | $0.032 per minute | 8,100 minutes | $259.20 |
| Snapshot restore | $0.04 per job | 900 jobs | $36.00 |
| Snapshot storage | $0.025 per snapshot-hour | 1 alias for 730 hours | $18.25 |
| Cache storage | $0.20 per GB-month | 25 GB | $5.00 |
| Cache write and restore operations | $0.0001 per operation | 1,800 operations | $0.18 |
| Total | $318.63 |
The cache lines stay in the model even on a snapshot setup, because the alias expires after 15 days of no refresh and the daily store entry is what a cold boot falls back to.
The same 8,100 minutes on a GitHub-hosted 16-core Linux x64 larger runner cost 8,100 multiplied by $0.042, which is $340.20, with no snapshot equivalent to fold in. GitHub list prices were checked on 2026-08-13 in the GitHub Actions minute multipliers reference.
| vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted Linux x64 USD per minute | Lower list price |
|---|---|---|---|---|
| 4 | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 | 33 percent |
| 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | 27 percent |
| 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | 24 percent |
| 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | 22 percent |
Runner shapes are matched on both sides: each row compares the same vCPU and RAM. GitHub list prices checked on 2026-08-13.
The full per-minute rate list is on the pricing page.
A workspace that also drives Bazel or moon targets has the same layered-state problem with different directory names; running Bazel on GitHub Actions with remote cache and moon monorepo builds on GitHub Actions cover those.
FAQ
Should I cache the Pants lmdb_store or boot a snapshot runner?
Cache the store while it is small enough that the archive round trip stays under about a minute. Once the store passes roughly 10 GB the compress, upload, download, and decompress cycle costs more than the process executions it saves, and a snapshot runner that carries the same directory forward on disk removes the archive step entirely. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners.
Why cache lmdb_store and named_caches as separate entries?
The cache version is a hash over the compression tool and the list of cached paths, so a single entry covering both directories is invalidated whenever you change either path. Separate entries also let you key them differently: the lmdb_store is content addressed and stays valid forever, so it wants a rolling date key, while named_caches tracks your lockfiles and wants a lockfile hash.
Does pantsd help inside a GitHub Actions job?
Only when the job invokes pants more than once. The daemon holds the parsed BUILD graph and the file watcher in memory, and that state dies with the runner, so a job that runs a single pants command pays the startup cost and collects none of the benefit. Set pantsd = false in pants.ci.toml and run lint, check, test, and package in one invocation where the goals allow it.
Can I use snapshot runners for a macOS Pants build?
No. Snapshot labels on macOS, Windows, and BYOC runners are silently ignored and the job runs normally with no snapshot behavior. WarpBuild cache is also unsupported on Windows runners. Keep the Pants remote cache and a pinned local store for those platforms.
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.