moon Monorepo Builds on GitHub Actions

moon ci selects tasks by hash across changed projects. Cache .moon/cache and the proto toolchain, shard with --job, and size warp- runners per shard.

Last verified:

Overview

To run moon on GitHub Actions, point runs-on at a warp- label, let moon ci select the affected task subset against a base ref, and persist two directories between runs: ~/.proto for the toolchain and .moon/cache for task hashes, states, and outputs. Those two directories decide most of a moon pipeline's wall clock, because a cold toolchain install and a cold hash store both scale with the size of the repository rather than with the size of the change.

moon builds a task graph from four configuration layers. .moon/workspace.yml lists the projects, .moon/toolchain.yml pins the language versions that proto installs, .moon/tasks.yml holds the task definitions every project inherits, and each project's own moon.yml adds or overrides tasks locally. A task carries a command, an inputs list, an outputs list, and a deps list pointing at other tasks.

Every task run starts with a hash. moon folds in the resolved command and arguments, the contents of the files matched by inputs, the values of any environment variables declared as inputs, the hashes of every task named in deps, and the toolchain versions the task runs under. That hash addresses an entry under .moon/cache. A hit replays the stored outputs and logs. A miss runs the command and writes a new entry.

moon ci adds two behaviors on top of moon run. It diffs the working tree against a base ref to find touched files, then narrows the task list to the projects those files affect, so a one-package change stops short of walking the whole graph. It also shards: --job and --jobTotal split the resulting task list deterministically across parallel GitHub Actions jobs, and every shard computes the same list before taking its slice.

moon work belongs on the Linux runners. The sizes reach 32 vCPU, and WarpBuild Cache is enabled by default on Linux runners while Windows runners do not support it.

Configuration

Start with the toolchain. .moon/toolchain.yml is what proto reads when it installs into ~/.proto, and its contents are the right cache key for that directory:

# .moon/toolchain.yml
node:
  version: '22.14.0'
  packageManager: 'pnpm'
  pnpm:
    version: '9.15.0'
  addEnginesConstraint: true

Then a project. This one declares narrow inputs so unrelated churn stays out of the hash:

# packages/api/moon.yml
type: 'library'
language: 'typescript'

dependsOn:
  - 'shared'

tasks:
  build:
    command: 'tsc --build'
    inputs:
      - 'src/**/*'
      - 'tsconfig.json'
    outputs:
      - 'dist'
    deps:
      - '^:build'

  test:
    command: 'vitest run'
    inputs:
      - 'src/**/*'
      - 'tests/**/*'
      - 'vitest.config.ts'
    deps:
      - '~:build'

  lint:
    command: 'eslint src'
    inputs:
      - 'src/**/*'
      - '.eslintrc.cjs'

^:build means the same task in every project this one depends on. ~:build means another task in this project. Both edges enter the hash, so a change in shared moves the hash of everything downstream of it.

Now the workflow. Four shards on 8 vCPU runners, with both warm directories restored before moon starts:

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

jobs:
  ci:
    runs-on: warp-ubuntu-latest-x64-8x
    strategy:
      fail-fast: false
      matrix:
        index: [0, 1, 2, 3]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Restore the proto toolchain
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.proto
          key: ${{ runner.os }}-proto-${{ hashFiles('.moon/toolchain.yml') }}
          restore-keys: |
            ${{ runner.os }}-proto-

      - name: Restore the moon cache
        uses: WarpBuilds/cache/restore@v1
        with:
          path: .moon/cache
          key: ${{ runner.os }}-moon-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-moon-

      - uses: moonrepo/setup-toolchain@v0
        with:
          auto-install: true

      - name: Run the affected tasks for this shard
        run: >-
          moon ci
          --base origin/${{ github.base_ref || 'main' }}
          --job ${{ matrix.index }}
          --jobTotal ${{ strategy.job-total }}

      - name: Save the moon cache
        if: matrix.index == 0
        uses: WarpBuilds/cache/save@v1
        with:
          path: .moon/cache
          key: ${{ runner.os }}-moon-${{ github.sha }}

Five details in that file earn their place.

fetch-depth: 0 gives moon the history it needs. moon ci resolves --base to a real commit and diffs against it, and a shallow clone leaves that commit missing.

The toolchain key hashes .moon/toolchain.yml, because ~/.proto changes only when a pinned version changes. The restore-keys prefix still catches the previous toolchain after a version bump, so the install becomes incremental instead of complete.

The moon key uses the commit SHA with a prefix fallback, because .moon/cache changes on every commit and the most recent entry is the one worth having. The prefix is what turns a per-commit key into a rolling warm cache.

Only shard 0 saves. Four shards writing one key produce three collisions and three wasted uploads, and each shard holds a different slice of the outputs anyway. Restoring everywhere and saving once keeps the operation count down.

--jobTotal reads strategy.job-total, so the shard count lives in one place. Change the matrix length and moon follows.

When the toolchain install dominates the run

A cache action moves the toolchain over the network on every job. Snapshot runners capture the runner disk mid-workflow instead, so ~/.proto, node_modules, and .moon/cache sit on disk when the job boots:

jobs:
  ci:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=moon-warm' }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          clean: false

      - uses: moonrepo/setup-toolchain@v0
        with:
          auto-install: true

      - run: moon ci --base origin/main

      - name: Cleanup credentials
        if: github.ref == 'refs/heads/main'
        run: rm -rf $HOME/.ssh $HOME/.aws

      - name: Save snapshot
        if: github.ref == 'refs/heads/main'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: "moon-warm"
          fail-on-error: true
          wait-timeout-minutes: 60

Four constraints shape where this works.

Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot label on a BYOC, Windows, or macOS runner is silently ignored, and the job runs normally without snapshot behavior.

clean: false matters more for moon than for most tools, because .moon/cache sits inside the workspace and git ignores it. A default checkout runs git clean -ffdx and deletes the cache the snapshot just carried in. For the same reason, scope the pre-snapshot cleanup to credentials rather than running a blanket clean.

Snapshots are deleted after 15 days, and /tmp does not survive a snapshot boot because that directory is cleaned on reboot.

Booting from a snapshot takes 45 to 60 seconds and can be slower than starting a default runner. Subtract that from whatever the snapshot saves before comparing it against the cache action.

Sizing

WarpBuild Linux x64 runners hold 4GB of memory per core across the range. These are the sizes a moon shard uses:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064

Shard count and runner size are one decision, because moon ci --jobTotal N gives you N full runners billed in parallel. Four shards on warp-ubuntu-latest-x64-8x and two shards on warp-ubuntu-latest-x64-16x buy the same 32 vCPU at the same $0.064 per minute of wall clock. The tiebreaker is the fixed cost each shard pays before moon runs a task: a checkout with full history, a toolchain restore, and a .moon/cache restore. Doubling the shard count doubles that overhead. Wide sharding stops paying once the per-shard fixed cost approaches the per-shard task time.

Which size a given shard wants comes from what its tasks do.

Shards dominated by single-threaded tasks. tsc --build, eslint, and most codegen occupy one core each. moon derives its parallelism bound from the cores it sees, and the global --concurrency option overrides that bound. Cores are the constraint here, so 8 or 16 vCPU per shard converts directly into throughput.

Shards dominated by test runners. Vitest and Jest fan out into their own worker pools. Several of those in flight on an 8 vCPU shard produce dozens of workers contending for 8 cores and 32GB. Lower moon's concurrency and cap the inner tool with vitest run --maxWorkers=2, so the total worker count stays near the core count.

Shards with deep tsc --build chains. Memory decides these more often than cores do. A large TypeScript project graph can hold several gigabytes per process, and 64GB on warp-ubuntu-latest-x64-16x is what keeps a wide fan-out alive after warp-ubuntu-latest-x64-8x starts throwing heap errors.

Linux ARM64 runners carry the same shapes at lower rates, so warp-ubuntu-latest-arm64-8x costs $0.012 per minute against $0.016 for warp-ubuntu-latest-x64-8x. That swap works when every pinned toolchain and native dependency in .moon/toolchain.yml has an arm64 build.

What the warm directories cost

Cache and snapshot usage are metered separately from runner minutes:

MetricRate
Cache storage$0.20 per GB-month
Cache write, restore, or list$0.0001 per operation
Snapshot restore$0.04 per job
Snapshot storage$0.025 per snapshot-hour

Worked cost model

GitHub publishes per-minute list prices for its hosted runners in the GitHub Actions minute multipliers reference. Checked on 2026-08-13, those prices against the matching WarpBuild sizes are:

ShapeWarpBuild labelWarpBuild rateGitHub-hosted rateLower by
2 vCPU, 8GBwarp-ubuntu-latest-x64-2x$0.004$0.006 (ubuntu-latest, private repositories)33 percent
4 vCPU, 16GBwarp-ubuntu-latest-x64-4x$0.008$0.012 (4-core larger runner)33 percent
8 vCPU, 32GBwarp-ubuntu-latest-x64-8x$0.016$0.022 (8-core larger runner)27 percent
16 vCPU, 64GBwarp-ubuntu-latest-x64-16x$0.032$0.042 (16-core larger runner)24 percent
32 vCPU, 128GBwarp-ubuntu-latest-x64-32x$0.064$0.082 (32-core larger runner)22 percent

Take a moon repository with 60 projects running 900 pull request and push workflows a month, sharded four ways on 8 vCPU machines. With no warm directories, each shard installs the toolchain and rebuilds every affected hash in 12 minutes, so a run costs 48 job-minutes. With ~/.proto and .moon/cache restored, moon ci replays most of the graph and each shard finishes in 5 minutes, so a run costs 20 job-minutes.

ScenarioRateMonthly minutesMonthly cost
GitHub-hosted 8 vCPU, cold$0.022/min43,200$950.40
GitHub-hosted 8 vCPU, warm$0.022/min18,000$396.00
warp-ubuntu-latest-x64-8x, cold$0.016/min43,200$691.20
warp-ubuntu-latest-x64-8x, warm$0.016/min18,000$288.00

Add the cache layer. A 6GB combined toolchain and moon cache costs 6 x $0.20 = $1.20 per month in storage. The workflow runs 3,600 shard jobs a month. The toolchain entry restores and saves on each of them, 7,200 operations. The moon entry restores on each of them and saves only from shard 0, another 3,600 plus 900, or 4,500 operations. Together that is 11,700 operations at $0.0001, or $1.17. The cache layer for this workload comes to $2.37 a month against $288.00 of runner time.

Now price the snapshot alternative for the same 3,600 shard jobs: 3,600 restores at $0.04 is $144.00, plus one live snapshot held for a 730-hour month at $0.025 per snapshot-hour, or $18.25. Every shard pays its own restore, so sharding multiplies that fee while the cache operations stay nearly flat. The snapshot earns its place when the toolchain install is the dominant cost. At $0.016 per minute, the $0.04 restore buys 2.5 minutes of runner time, so a 4-minute install replaced by a 1-minute snapshot boot saves 3 minutes worth $0.048 and clears the fee by a small margin. On warp-ubuntu-latest-x64-16x at $0.032 per minute the same 3 minutes is worth $0.096 and the margin is comfortable.

Every cost and performance number on this page carries its source and a checked-on date. Every rate for every size and platform is listed on the WarpBuild pricing page.

Bottlenecks

Cold toolchain installs. proto downloads and installs every version pinned in .moon/toolchain.yml on a machine that has never seen the repository. A repository pinning Node, a package manager, and a second language pays that install on all four shards, in parallel, on every run. This is the cost the ~/.proto cache step and the snapshot runner both target, and it is the one that grows when a team adds a language rather than when it adds code.

Hash misses on shared project inputs. moon adds a set of implicit inputs to every task in the workspace: the configuration under .moon/, the root manifest, and the lockfile. Touch any of them and every hash in the repository moves, so a lockfile bump turns an affected-only moon ci run into a full rebuild. The related trap is per-project: a task with no inputs list inherits everything git tracks in the project, so editing a README invalidates its build. Declaring inputs explicitly, as in the moon.yml above, keeps documentation and configuration churn out of the hash. The opposite mistake is worse. An inputs list that omits a file the task actually reads produces a hit that replays stale output.

Serialized dependency chains. deps edges are ordering constraints, and moon respects them regardless of how many cores a shard has. A chain of five projects each depending on the previous one runs five tasks back to back on a 16 vCPU machine with 15 cores idle. Depth in the dependency graph, rather than task count, is what caps a shard's parallelism. The fixes are structural: flatten dependsOn where a project imports from a grandparent instead of a parent, split large aggregator projects that everything depends on, and check the task options that control whether dependencies run in parallel before assuming the graph is the limit. A shard whose runtime stays flat as you move from 8 to 16 vCPU is telling you it is chain-bound rather than core-bound.

Checkout wiping the warm state. actions/checkout defaults clean to true, which runs git clean -ffdx and removes every ignored file in the workspace. .moon/cache and node_modules are both ignored, so the default checkout deletes exactly the state a snapshot runner booted with. Set clean: false on snapshot jobs.

Shallow clones breaking task selection. Without full history, moon ci cannot resolve --base to a commit it can diff against. Depending on the version and the ref, the run either fails outright or falls back to treating everything as affected, which reads as a mysteriously slow pipeline rather than an error.

For the runs where none of this is obvious, WarpBuild ships CI observability, which correlates OpenTelemetry-based system metrics from the runner agent with GitHub Actions job logs, so a shard starved of memory at high concurrency looks different from one stalled on a dependency chain. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect .moon/cache and the restored ~/.proto on the machine that produced them.

Proof

Moving a moon pipeline onto WarpBuild changes the runs-on label and the two cache steps. Nothing in .moon/ or in any project moon.yml moves, because moon's hashing and task selection are unaware of which runner they land on.

If the monorepo has to build inside your own cloud account for data residency or policy reasons, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. The tradeoff for this page specifically: snapshot runners are unavailable on BYOC, so a BYOC moon repository keeps the two cache steps and skips the snapshot layer.

SSO is available for a flat $250 per month, whatever the user count.

Three neighboring pages cover adjacent decisions. Monorepo pipelines on GitHub Actions covers the sharding and caching pattern independent of build tool, Turborepo cache configuration on GitHub Actions walks the same two-layer caching for a Turborepo graph, and Pants builds on GitHub Actions covers the equivalent for a Pants workspace. The runner-side detail behind the snapshot workflow above lives on the snapshot runners page.

FAQ

Does moon ci need the full git history?

Yes. moon ci resolves --base to a real commit and diffs the working tree against it to find touched files. Set fetch-depth: 0 on actions/checkout. A shallow clone leaves the base commit missing, so the diff either fails or widens the affected set to the whole repository.

Which directories does a moon job need to keep warm?

Two. The proto toolchain at ~/.proto, which holds the language versions pinned in .moon/toolchain.yml, and .moon/cache, which holds task hashes, states, and cached outputs. The package manager store is a third if installs are slow.

How should the .moon/cache key work across sharded jobs?

Key on the commit SHA with a restore-keys prefix, restore on every shard, and save from one shard only. Four shards writing the same key produce collisions and wasted uploads, and each shard holds a different slice of the outputs.

Do snapshot runners work for moon jobs on macOS or Windows?

No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior.

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.