Execution modes
Stay on Aggregate unless you have a reason not to. That is the default: one root test job, parallel Once gates
(fmt, workflow-check, advisories), and one publish job. It is enough for most libraries and for many
multi-module repos.
GitHub Actions charges per job and per minute. More jobs means more YAML to review and more checks to keep green. Aggregate is how zipx keeps CI cheaper and calmer. Escalate to Layer or Graph only when the workflow needs extra boundaries (ordered waves, per-module status, path-based skipping). Those modes are documented below.
| Mode | Test / publish / docker | Deploy | Best for |
|---|---|---|---|
| Aggregate (default) | 1 test job + parallel Once gates; 1 publish / docker | 1 per Target (modules batched) | Most repos, including multi-service monorepos: one stage, still incremental |
| Layer | 1 per toposort wave | 1 per wave × Target (Aggregate-by-target per wave) | Ordered waves without N JVMs; per-env approval without Graph's module×target cost |
| Graph | 1 per module (± matrix / targets) | 1 per module × Target | Per-module / multi-env boundaries; path gating; matrices |
Why one job is not slow
Aggregate is not "rebuild the world in one job." On sbt 2.x, a root .aggregate session already parallelizes
independent subprojects and incrementally recompiles only invalidated sources (Zinc). 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 compile results across pushes in the same epoch. Remote cache backends push
that reuse across machines. Zinc and the task cache are what skip compile (and a whole-task cache hit can skip redo).
On a PR, the one test job also skips the suites of modules the PR cannot have broken, by running zipxTestAffected
(see Two kinds of affected below). 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: whole GitHub jobs skipped per module, 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 the Aggregate test job (with the restored or remote cache) already skips.
Two kinds of affected
sbt and zipx answer different questions:
| Layer | Question | Who decides |
|---|---|---|
| sbt 2 (inside Aggregate) | Which sources need work, given cached task digests? | Incremental compiler + cross-run task cache |
zipx, inside the test job | Which modules' suites should run for this PR diff? | zipxTestAffected: git diff → owning module → reverse-dep closure, in the same sbt session |
| zipx Graph | Which GitHub jobs should run at all for this PR diff? | The affected job, by the same closure |
The builtin test is still one job. On a PR it runs zipxTestAffected, which tests each affected module the root
aggregate reaches with its own zipxTestTask, so an unaffected module's suites do not run. After zipx restores the
epoch cache (or a remote cache hits), sbt may also compile almost nothing. Graph goes further: it skips entire module
jobs when their reverse-dep closure is untouched, and shows a green check per module. 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.
When Graph (or Aggregate/Layer target fan-out) is right but the Actions UI is too noisy, see Matrix collapse: same job parallelism, one expandable matrix node. That is a presentation opt-in, not a fourth execution mode.
API cheat sheet
Capability.test // Once: root zipxTestTask (plugin default testFull)
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
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
The plugin's builtin test job uses zipxTestTask (default testFull). Planner snippets below call core
Capability.test / testGraph, so the YAML shows sbt 'test' / schema/test.
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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: test
node-version: ""
sbt-disk-cache: "false"
cache-mode: save
cache-epoch: "0.1.0-SNAPSHOT"
- 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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: test-schema
node-version: ""
sbt-disk-cache: "false"
cache-mode: restore
cache-epoch: "0.1.0-SNAPSHOT"
- 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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: test-api
node-version: ""
sbt-disk-cache: "false"
cache-mode: restore
cache-epoch: "0.1.0-SNAPSHOT"
- 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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: test-service
node-version: ""
sbt-disk-cache: "false"
cache-mode: restore
cache-epoch: "0.1.0-SNAPSHOT"
- 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 | ~5 sbt starts (test + fmt + workflow-check + advisories + optional publish) | JVM start; cache hits skip compiles |
| 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.