Overview
zipx is an sbt 2 plugin (Scala 3) that turns your real dependsOn / .aggregate
graph into GitHub Actions CI. Generate the workflow from the build, commit it, and let zipxWorkflowCheck keep it
honest. The graph is the CI topology. How aggressively that topology fans out is an explicit execution mode
(Aggregate by default; Layer or Graph when you need waves, per-module isolation, or multi-environment deploys).
That power is for everyone who ships Scala on GitHub Actions: a single-library publish path, a multi-module monorepo with several services, Central and GitHub Packages side by side, docker and deploy stages you describe in Scala. Modes are how you schedule work; the graph stays the source of truth. You get typed capabilities, commit-stable caching, and SHA-pinned actions without hand-rolling YAML module lists.
If you already carry scar tissue from a second copy of the build (disconnected CI, or a Bazel graph that restated the same edges), you will feel the relief fastest. zipx does not ask you to flee sbt. It makes the build you already write the source of truth for CI again. Start with Why zipx when you want the recovery framing.
What you gain
Monorepos that stay honest
In a multi-module repo, edges already live in build.sbt. zipx turns them into jobs, needs, publish order, and
(when you opt into Graph) affected-only PRs (fail-open when the diff breaks; see Affected). Add a module the way you always do; regenerate; CI tracks the graph
instead of a hand-maintained matrix.
Libraries that skip hand-rolled release YAML
Even a small Aggregate library benefits: one root test job, a release-gated publish (or ZipxCentral.release /
ZipxGitHubPackages), docs Pages when you want them. No separate release.yml that drifts from who actually
publishes. Fork gates and job conditions are Scala, not pasted if: strings.
CI as a generated artifact
zipxWorkflowGenerate writes .github/workflows/ci.yml. zipxWorkflowCheck fails the PR when the committed file
no longer matches the build. Drift becomes a red check, not a surprise on tag day.
Especially if you have lived the alternatives
Many teams arrive tired. Slow or opaque sbt CI made “just put it in YAML” feel rational. Bazel looked like peace (hermeticity, remote cache) and delivered a second graph in BUILD files while CI still needed hand wiring. Others kept sbt but re-listed every module and recipe in workflows (sometimes plus an external config and resolver script).
Those approaches invent another copy of the build. Recognizing that tax is the first step toward something kinder.
Disconnected CI (YAML that redeclares the build)
Sketch of the “before”:
# .github/workflows/ci.yml (hand-maintained)
jobs:
test-schema:
runs-on: ubuntu-latest
steps:
- run: sbt 'schema/test'
test-api:
runs-on: ubuntu-latest
# api depends on schema in build.sbt; CI forgot needs:
steps:
- run: sbt 'api/test'
publish:
# every library in parallel; order lives only in sbt
strategy:
matrix:
module: [schema, api, client]
steps:
- run: sbt '${{{ matrix.module }}}/publish'
Typical failure modes (the bruises):
Two sources of truth drift: add, rename, or re-wire a module in sbt; CI silently keeps the old list.
Publish order is not modeled: the real graph exists only in sbt, so release jobs fan out flat and hope the registry already has upstreams (or recompile everything).
No honest affected model: every PR builds the world; cache is the only mitigation.
Stringly module ids: a typo is a green no-op, not a failed load.
Bazel as a second graph
Leaving sbt for Bazel is often well intentioned. The tax shows up later: Scala engineers still think in modules and
dependsOn, but the org owns BUILD files, macros, and CI glue that restate the same edges.
Sketch of the “before” (edges restated outside sbt):
# BUILD (illustrative): a second place that must stay in sync with build.sbt
scala_library(name = "schema", srcs = [...])
scala_library(name = "api", deps = [":schema"], srcs = [...])
scala_library(name = "service", deps = [":api"], srcs = [...])
zipx’s answer is not “Bazel was wrong.” It is that one graph in sbt is enough for CI topology when the build already knows the truth. That is a recovery strategy, not a parity claim: see Why zipx and From Bazel.
One graph, generated CI
| Approach | When you add a module you… |
|---|---|
| Disconnected CI | Edit workflow YAML (and often an external config / script) |
| Bazel second graph | Edit BUILD (and usually CI) |
| zipx | Edit build.sbt; run zipxWorkflowGenerate / zipxWorkflowCheck |
Topology (jobs, needs, gates, targets, cache keys) is derived. What to run is still your tasks, expressed as
typed capabilities in Scala: test, Central, GitHub Packages, docker, deploy, or stages you invent.
Fuller sketch:
// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "<version>")
// build.sbt
lazy val schema = project.settings(/* … */)
lazy val api = project.dependsOn(schema)
lazy val service = project.dependsOn(api).enablePlugins(DockerPlugin)
lazy val root = (project in file("."))
.aggregate(schema, api, service)
.settings(
// Built-in Aggregate test + publish; paved Central (and/or Packages) when you need it:
zipxCapabilities ++= {
val upstream = JobCondition.repositoryIs("acme/libs")
Seq(
ZipxCentral.release.withCondition(upstream),
ZipxDocs.pages().andCondition(upstream),
)
},
zipxJavaVersion := "25",
zipxWorkflowDispatch := true,
)
// Then: sbt zipxWorkflowGenerate && git add .github/workflows/ci.yml
// CI runs zipxWorkflowCheck so a graph change without regenerating fails the PR.
One graph. Generated CI. Drift fails the build.
What it derives
From the loaded sbt build, zipx emits:
Aggregate Verify by default: one root
sbt test(matching.aggregate), plus Aggregate publish/docker when neededLayer and Graph modes when you opt in (waves or one job per module, affected-only PRs, Scala matrix)
dependency-ordered publish under Layer/Graph, gated on release tags
commit-stable caching keyed by a version epoch (pairs with
sbt-dynver-ci)pluggable capabilities (test, publish, docker, deploy, or stages you invent in Scala)
typed job conditions (fork repo, PR label, vars) ANDed with Gate; see Job conditions
SHA-pinned GitHub Actions (
uses:), with an optional pin file + Dependabot sync path
Default Aggregate shape
For a typical library, defaults are enough: Aggregate test + publish. Optional packs replace the built-in publish
job with a paved Central release (or add GitHub Packages alongside it).
// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "<version>")
// build.sbt
lazy val lib = project.settings(publishMavenStyle := true)
lazy val root = (project in file("."))
.aggregate(lib)
.settings(
zipxCapabilities += ZipxCentral.release, // optional paved path
zipxJavaVersion := "25",
)
Generated Aggregate jobs (live output from the planner):
DocsRender.jobs("test", "publish")(Capability.test, Capability.publish)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'Why stay on sbt 2
zipx is built for sbt 2 as a capable substrate, not a compromise:
a machine-wide, content-addressed action cache (what makes Graph fan-out practical when you need it)
optional Bazel-gRPC remote cache transport (
sbt-remote-cache, bundled). That is cache plumbing, not “adopt Bazel as your build”Scala 3 plugins, so
zipx-core/zipx-workfloware ordinary unit-tested libraries with no sbt on the classpathcommon settings: a bare
zipxTestTask := "testFull"applies to every module; any module can override
You keep the ergonomics Scala teams already know. CI stops being a second language for the same edges.
Architecture
Three layers:
zipx-workflow: GitHub Actions AST + deterministic YAML printerzipx-core: pure planner (ModuleGraph→Workflow)sbt-zipx: AutoPlugin; the only layer that touchessbt.*
The plugin owns topology. The build owns what to run (capabilities).
Typed secrets
Secret references are first-class Scala. zipx never stores secret values; only names that render to GitHub Actions expressions:
env = Map(
"PGP_PASSPHRASE" -> secret"PGP_PASSPHRASE",
"AWS_REGION" -> EnvValue.plain("us-west-2"),
"DEPLOY_ROLE" -> EnvValue.env("DEPLOY_ROLE"),
)
Render.renderMapping(
ListMap(
"PGP_PASSPHRASE" -> EnvValue.secret("PGP_PASSPHRASE").render,
"AWS_REGION" -> EnvValue.plain("us-west-2").render,
"DEPLOY_ROLE" -> EnvValue.env("DEPLOY_ROLE").render,
)
)PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
AWS_REGION: us-west-2
DEPLOY_ROLE: ${{ env.DEPLOY_ROLE }}