From Bazel
Skip unless you are leaving Bazel. If you adopted Bazel because sbt CI felt unsafe or slow, that impulse was
understandable. Many teams found peace in hermeticity talk and remote cache, then discovered a quieter cost: a
second graph in BUILD files while Scala engineers still thought in modules and dependsOn.
zipx is not Bazel-parity. It offers a different strategy for teams that already have the truth in sbt: one graph, generated CI, content-addressed reuse. Keep the vocabulary you learned; leave the duplicate edges behind.
For the broader story (hand-written YAML and cache products), see Why zipx. For shared digests across laptops, see Remote cache for teams.
Shared vocabulary, kinder boundaries
We speak Bazel-fluent on purpose. The mapping helps you translate without re-litigating the past:
| Bazel | sbt / zipx |
|---|---|
| Action | Task (executable step with digests) |
| Target | Project / module |
| Remote cache | sbt 2 action cache over Bazel-compat gRPC |
| Remote execution | Out of scope; use Graph for more runners |
maven_install / MODULE.bazel version strings | Lib / Plugin vals (collected, not listed twice); constructor rewrite; generate owns plugins.sbt |
In Bazel, many small packages often improve hit rates because the target is the cache boundary. In sbt 2, compile and test already invalidate at class and suite digests inside a module. You do not need to shatter the repo into packages just to feel cacheable. Reach for zipx Graph when you need job isolation or path-affected PRs, not when you are only chasing hits.
What you maintain (before / after)
What hurt: BUILD files restating edges, plus CI glue, plus optional remote cache or RBE config. Adding a library meant updating more than one world.
What heals: modules in build.sbt, typed zipxCapabilities, regenerate the workflow. One graph again.
| Maintenance surface | Disconnected / Bazel-shaped | zipx Aggregate |
|---|---|---|
| Add a library module | Edit BUILD (and usually CI) | Edit build.sbt; regenerate |
| Verify shape | Often N jobs or a matrix you list | One Aggregate test job plus parallel Once gates (fmt, workflow-check, advisories) |
| Cache | Separate product or RBE | Same planner (RemoteCacheProof / ManagedRemote) |
{
val agg = DocsRender.body(Capability.test)(using libGraph, config)
val pub = DocsRender.job("publish")(Capability.publish)(using libGraph, config)
agg + "\n---\n" + pub
}name: CI
"on":
push:
branches:
- main
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: 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'
---
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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: publish
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: publish
run: sbt 'schema/publish; api/publish'A gentle migration path
You do not have to boil the ocean on day one:
Add
sbt-zipx; keep Aggregate defaults (one root test job plus parallel Once gates).zipxWorkflowGenerate/zipxWorkflowCheckso drift cannot sneak back in.Opt into
ManagedRemoteorRemoteCacheProof.sidecarwhen the team is ready; measure hits (RemoteCacheItSpecis the in-repo proof).Use Graph only when wall clock or isolation truly needs per-module jobs, matrices, or multi-env deploy.
That is the escape hatch as a ladder: safety first, fan-out when earned.
{
val sidecar = DocsRender.job("test")(Capability.test)(using
libGraph,
config.copy(cache = RemoteCacheProof.sidecar),
)
val graph = DocsRender.jobs("test-schema", "test-api", "test-service")(Capability.testGraph)
sidecar + "\n---\n" + graph
}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: 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: "true"
local-cache: "false"
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'Easier to review, easier to help
One edit locus (build.sbt), checkable contracts (zipxWorkflowCheck, Specular docs-as-tests), regeneratable YAML,
and named packs give humans and AI assistants the same gift: they can change the build without keeping a second
graph honest. When the graph is the CI, nobody has to babysit a matrix that forgot a module.