Why zipx
Hand-written GitHub Actions YAML is a second copy of your build: module lists, job order, JDK setup. It drifts. zipx
generates that YAML from build.sbt so CI stays honest without you learning a second language.
If you are new to CI, Quick start is enough. Come back here when you want the "why," or when you already maintain a painful workflow.
If your team has lived through hand-maintained job lists, a second BUILD graph, or a cache product that made tasks faster while humans still juggled two sources of truth: the rest of this page is the recovery story.
When you are ready for those migration stories, see From Bazel, Caching, and Remote cache for teams.
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 path that keeps your Scala mental model: one build.sbt, generated CI.
| 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.
Versions were the other stringly mess
CI YAML is not the only second copy. Scala versions usually live as "org" %% "name" % "1.2.3" strings, and the bump
tools search the repo with regex. That is how most of the ecosystem still works, and it is why dependency PRs feel
brittle.
zipx keeps Lib / Plugin / Pin / Action vals in one object you extend from ZipxVersions. Every val is a catalog
row; there is no second list to keep in sync. MyVersions.settings is the usual sbt wiring (scalaVersion, catalog
keys, zipxCheckDeps). Apply rewrites those constructors. Generate owns plugins.sbt. A raw coordinate that is not in
the catalog fails generate. Other plugins extend the same trait. See Versions. Plugin authors: Extending
Versions.
Outbound versions are a fourth collection: Ship / ShipGroup when a monorepo publishes libraries on different
cadences. Merge to main is the release signal. zipx-the-product stays lockstep on a v* tag. See Independent
versions.
import zipx.*
object MyVersions extends ZipxVersions:
val sbt: SbtVersion = SbtVersion("2.1.0-M1")
val scala: ScalaVersion = ScalaVersion("3.9.0")
val zio = Lib("dev.zio", "zio", "2.1.26")
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 plus
.github/actions/zipx-* composites are outputs you commit and drift-gate. Reviewers (and future you) see intent,
not archaeology: short jobs that call local composites, matrix collapse when safe, and named packs instead of pasted
secret/step soup.
Default Aggregate Verify is a handful of build-wide jobs (test, fmt, workflow-check, advisories), 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. MatrixCollapse.Auto keeps Graph / multi-target fan-out readable when legs are isomorphic.
{
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: 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'
---
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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: affected
node-version: ""
sbt-disk-cache: "false"
local-cache: "false"
cache-epoch: "0.1.0-ci"
- 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: 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'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. Composites and Auto collapse keep that YAML short enough to actually review.
Named paved paths: packs like
ZipxCentral,ZipxDocs, and AWS login say 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: 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'