Execution modes
This is the main CI cost and isolation lever. Aggregate is not "rebuild the world in one job." On sbt 2.x, a root
.aggregate sbt test already parallelizes independent subprojects, incrementally recompiles only invalidated
sources, and reruns only suites that failed, are new, or transitively depend on recompiled code (including code in
another project). Task results are content-addressed and survive JVM restarts: sbt's machine-wide cache, plus
zipx's epoch-keyed CI restore (zipxCacheEpoch), means a cold runner still hits prior task/test results across
pushes in the same epoch. Remote cache backends push that reuse across machines. You get a large share of "don't redo
unaffected work" from one Aggregate job, without paying for N runners. Pair that with a CI-hydrated remote cache
so teammate laptops share the same digests (see Remote cache for teams).
Graph mode buys a different kind of selectivity: path-based affected gating, per-module needs, Scala matrix
isolation, and independent logs/statuses. Pick Graph when the workflow needs those boundaries, not merely to avoid
compile/test work that sbt (and the restored/remote cache) can already skip.
| Mode | Test / publish / docker | Deploy | Best for |
|---|---|---|---|
| Aggregate (default) | 1 per stage (Verify = root sbt test) | 1 per Target (modules batched) | Most repos, including multi-service monorepos: one stage, still incremental |
| Layer | 1 per toposort wave | Same as Aggregate-by-target | Ordered waves without N JVMs |
| Graph | 1 per module (± matrix / targets) | 1 per module × Target | Per-module / multi-env boundaries; path gating; matrices |
Two kinds of affected
sbt and zipx answer different questions:
| Layer | Question | Who decides |
|---|---|---|
| sbt 2 (inside Aggregate) | Which sources and test suites need work, given cached task digests? | Incremental compiler + incremental test + cross-run task cache |
| zipx Graph | Which GitHub jobs should run at all for this PR diff? | git diff → owning module → reverse-dep closure |
Aggregate always starts the stage command (one root test job). That is fine: after zipx restores the epoch cache
(or a remote cache hits), sbt may compile almost nothing and rerun almost no suites even on a cold JVM. Graph can
skip entire module jobs when their reverse-dep closure is untouched, and it can show a green check per module. Use
testFull (zipxTestTask := "testFull") when CI must
run every suite every time, uncached. See Caching and Affected (fail-open handoff, who is gated).
When to use which
zipx is built for all sorts of sbt repos, and especially monorepos: the same typed capabilities scale from a single library to many services. Modes choose how work is scheduled in GitHub Actions, not whether zipx understands your graph.
Aggregate — the default, and often enough even for multi-service monorepos. One root
testjob, onepublish/ZipxCentral.release(modules batched where that makes sense). Lean on sbt 2 incrementality and epoch/remote caching; escalate only when the workflow needs Graph's boundaries.Layer — dependency-ordered waves (L0 → L1 → L2) with fewer sbt starts than Graph. Inspect with
zipxGraph/zipxPublishOrder.Graph — when CI itself needs per-module or per-destination isolation: multi-environment deploys, independent approvals/logs/status, per-module Scala matrices, or path-based affected gating at the job level. See
examples/monorepo.
API cheat sheet
Capability.test // Once: root zipxTestTask (default "test")
Capability.testJoined // Aggregate escape hatch: join module/<testTask>
Capability.publish
Capability.docker
Capability.deploy(participates, command, targets)
Capability.testLayers / publishLayers / dockerLayers
Capability.testGraph / publishGraph / dockerGraph
Capability.deployGraph(participates, command, targets)
ZipxCentral.release // Aggregate Central
ZipxCentral.publishSigned + ZipxCentral.releaseOnce // Graph + staging
Same-name override: a user capability whose name matches a built-in replaces it.
Aggregate vs Graph job shape
zipxCapabilities += Capability.test // one root job
// or
zipxCapabilities += Capability.testGraph // one job per module
DocsRender.jobs("test")(Capability.test) + "\n---\n" +
DocsRender.jobs("test-schema", "test-api", "test-service")(Capability.testGraph)test:
name: test
runs-on: ubuntu-latest
if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: Setup JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
java-version: "21"
- uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
with:
disk-cache: "false"
- name: Cache sbt
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.sbt
~/.cache/sbt
~/.cache/coursier
target
key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test
restore-keys: |
ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
ubuntu-latest-jdk21-sbt-0.1.0-ci-
ubuntu-latest-jdk21-sbt-0.1.0-
ubuntu-latest-jdk21-sbt-
- name: test
run: sbt 'test'
---
test-schema:
name: test schema
runs-on: ubuntu-latest
needs:
- affected
if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: Setup JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
java-version: "21"
- uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
with:
disk-cache: "false"
- name: Cache sbt
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.sbt
~/.cache/sbt
~/.cache/coursier
target
key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-schema
restore-keys: |
ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
ubuntu-latest-jdk21-sbt-0.1.0-ci-
ubuntu-latest-jdk21-sbt-0.1.0-
ubuntu-latest-jdk21-sbt-
- name: test
run: sbt 'schema/test'
test-api:
name: test api
runs-on: ubuntu-latest
needs:
- affected
- test-schema
if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'api') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-schema.result != 'failure')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: Setup JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
java-version: "21"
- uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
with:
disk-cache: "false"
- name: Cache sbt
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.sbt
~/.cache/sbt
~/.cache/coursier
target
key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-api
restore-keys: |
ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
ubuntu-latest-jdk21-sbt-0.1.0-ci-
ubuntu-latest-jdk21-sbt-0.1.0-
ubuntu-latest-jdk21-sbt-
- name: test
run: sbt 'api/test'
test-service:
name: test service
runs-on: ubuntu-latest
needs:
- affected
- test-api
if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'service') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-api.result != 'failure')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: Setup JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
with:
distribution: temurin
java-version: "21"
- uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
with:
disk-cache: "false"
- name: Cache sbt
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.sbt
~/.cache/sbt
~/.cache/coursier
target
key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-service
restore-keys: |
ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
ubuntu-latest-jdk21-sbt-0.1.0-ci-
ubuntu-latest-jdk21-sbt-0.1.0-
ubuntu-latest-jdk21-sbt-
- name: test
run: sbt 'service/test'Modules batch; targets do not
For docker/deploy, participants still come from the graph. Targets (GitHub environment: and per-destination
env:) always fan out: you cannot merge staging and prod into one job without losing independent approval.
Aggregate deploy →
deploy-staging,deploy-prod(modules joined inside each)Graph deploy →
deploy-service-staging,deploy-service-prod
Cost intuition
For a 4-module library with cross-Scala and release publish (leaf-only PR vs full fan-out):
| Mode | Typical PR shape | What you pay for |
|---|---|---|
| Aggregate | ~2 sbt starts (test + optional gates) | JVM start; cache hits skip suites |
| Graph (full) | N modules × Scala matrix + affected setup | Isolation, matrices, per-module status |
| Graph (affected leaf) | setup + closure jobs only | Same isolation, fewer runners when the diff is narrow |
Escalate to Graph when the workflow needs job boundaries (matrices, path gating, independent logs), not merely to avoid compile work Aggregate + remote cache already skip. Details and fail-open policy: Affected.