Why zipx
If your team has lived through hand-maintained CI matrices, a second BUILD graph, or a cache product that made tasks faster while humans still juggled two sources of truth: you are in the right place.
zipx is not “almost Bazel,” and it is not another acceleration appliance bolted onto an opaque build. It is a gentler path home: one sbt graph becomes generated GitHub Actions, with sbt 2’s content-addressed cache along for the ride. Speed follows. The headline is ergonomics: fewer things to read, sync, and apologize for in review.
When you are ready for the migration stories, see From Bazel, Caching, and Remote cache for teams. Live
Put/Get is proven by RemoteCacheItSpec (same RemoteCacheProof pins as the YAML examples here).
Three paths, one that heals
Most teams we meet are somewhere on this triangle. None of the first two are foolish; they were reasonable responses to real pain. zipx is the recovery path that keeps your Scala mental model intact.
| Approach | Source of truth | What drifts (the bruise) |
|---|---|---|
| Disconnected CI | build.sbt and hand YAML | Module lists, needs, publish order |
| Bazel second graph | BUILD (+ often CI) | Edges restated outside sbt |
| zipx | build.sbt / .dependsOn | CI is derived; zipxWorkflowCheck catches drift early |
You do not need a second graph to feel safe. You need one honest graph, and a check that fails when CI lies.
Faster tasks are not the same as kinder CI
Acceleration layers (Develocity-class remote build cache, build scans, predictive test selection) can be wonderful at making an existing build feel snappier. They earn their keep. They are also a different category of tool: they rarely remove the second maintenance surface of hand YAML or restated edges.
| Acceleration layer | zipx | |
|---|---|---|
| Primary artifact | Agent/plugin + server + scan UI | Planner + generated workflow from the sbt graph |
| What you maintain | Build + CI lists + cache config (often independent) | Modules + typed zipxCapabilities; CI is derived |
| How you know you’re safe | Faster greens / scan insights | zipxWorkflowCheck + docs-as-tests + live cache IT |
| Scope | Speed / observability of tasks | CI topology + cache wiring + packs as one system |
If mornings still start with “did we update the workflow?”, caching alone will not heal that. zipx retires disconnected CI (and skips restating the graph in BUILD), then leans on sbt 2’s cache so Aggregate stays light to live with.
What you open on a good day
The hard years: a hand ci.yml module matrix, or BUILD files plus CI glue, plus cache product config. Every “add a
module” meant a scavenger hunt.
The recovery: build.sbt and a small typed capability list. Generated .github/workflows/ci.yml is an output
you commit and drift-gate. Reviewers (and future you) see intent, not archaeology.
Default Aggregate Verify is one calm job (test), not one job per module. Reach for Graph when the workflow
needs isolation, not when you are only trying to make caching feel less lonely.
{
val aggregate = DocsRender.body(Capability.test, Capability.publish)
val graph = DocsRender.body(Capability.testGraph)
aggregate + "\n---\n" + graph
}name: CI
"on":
push:
branches:
- main
tags:
- v[0-9]+.[0-9]+.[0-9]+
pull_request: null
concurrency:
group: CI-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
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'
publish:
name: publish
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
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 }}-publish
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: publish
run: sbt 'schema/publish; api/publish'
---
name: CI
"on":
push:
branches:
- main
pull_request: null
concurrency:
group: CI-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
affected:
name: affected
runs-on: ubuntu-latest
if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
outputs:
modules: ${{ steps.compute.outputs.modules }}
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: Compute affected modules
id: compute
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
sbt -batch --error "zipxAffectedModules $BASE"
modules=$(cat target/zipx-affected.json)
else
modules='["all"]'
fi
echo "modules=$modules" >> "$GITHUB_OUTPUT"
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'Kind to humans and AI teammates
A self-documenting, single-graph build is easier to nurture, whether the reviewer is a person or an agent:
One place to edit when adding a module (no “also update workflow / BUILD”).
A contract that cares:
zipxWorkflowCheck+ Specular DocSpecs fail when examples drift from planner output.Narrow diffs: capability and graph changes are typed Scala; generated YAML is regeneratable when you want a clean re-diff.
Named paved paths: packs like
ZipxCentralandZipxDocssay what you meant, instead of a paste of secret/step soup.
The everyday loop: edit build.sbt → zipxWorkflowGenerate → the PR shows the graph and a regeneratable workflow.
That is the experience we are trying to give you back.
Cache that travels with the topology
Remote backends are not a side confessional. The same planner that emits jobs also emits services and env. Shared proof pins:
DocsRender.job("test")(Capability.test)(using
libGraph,
config.copy(cache = RemoteCacheProof.sidecar),
)test:
name: test
runs-on: ubuntu-latest
if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
services:
bazel-remote:
image: buchgr/bazel-remote-cache:v2.6.1
ports:
- "9092:9092"
options: --max_size=1
env:
ZIPX_REMOTE_CACHE: grpc://localhost:9092
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
- name: test
run: sbt 'test'