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. The plugin default is testFull, so CI still runs every suite; Zinc and the task cache
are what skip compile (and a whole-task cache hit can skip redo). 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.
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 (Verify default is testFull, so suites still run) |
| 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). After zipx restores the epoch cache (or a remote cache
hits), sbt may compile almost nothing. The plugin default is zipxTestTask := zipxTasks.of(testFull), so CI still runs
every suite; Zinc is what skips compile. Graph can skip entire module jobs when their reverse-dep closure is
untouched, and it can show 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
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
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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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.