Custom capabilities
Skip until the built-in test / fmt / workflow-check / advisories / publish / docker jobs are not enough. This page is how you add another lint job, or any other sbt task, as its own CI job.
zipxCapabilities is append-able: any sbt task becomes a CI stage. Beyond the built-ins you mainly use
Capability.once / Capability.steps / Capability.custom, or the typed zipxTasks / cmd helpers from the plugin.
Once gates
Capability.once emits a single build-wide job (not per module). Builtin fmt is already a parallel Verify
Once (scalafmtCheckAll); do not add another fmt and make test wait on it. Use Once for a different lint, or to
replace the builtin command:
A capability's name becomes a jobs.<job_id> key, so it is a CapabilityName rather than a bare String: a literal is
checked where you write it, and naming the val once is what lets a dependent capability refer to it without repeating
the string.
val Lint = CapabilityName("lint")
zipxCapabilities += zipxTasks.once(Lint, lintAll)
// optional: make test wait on it
zipxCapabilities += Capability.test.copy(needsCapabilities = List(Lint))
To skip builtin fmt out loud instead of replacing it: zipxVerify := ZipxVerify.Strict.copy(fmt = VerifyOpt.Skip("reason")).
See Verify.
{
val lint = CapabilityName("lint")
DocsRender.jobs("lint", "test")(
Capability.once(lint, SbtCommand.unsafeTask("lintAll")),
Capability.test.copy(needsCapabilities = List(lint)),
)
}lint:
name: lint
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: lint
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: lint
run: sbt 'lintAll'
test:
name: test
runs-on: ubuntu-latest
needs:
- lint
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'Action-only jobs
When the job should run GitHub Actions only (no sbt), use Capability.steps. It is the same Once topology as
Capability.once (permissions, needsCapabilities, gate, condition), but skips JDK / sbt / cache setup and emits no
command step:
val Notify = CapabilityName("notify")
zipxCapabilities += Capability.steps(
name = Notify,
steps = _ => List(Step(name = Some("Ping"), run = Some("curl -X POST $HOOK"))),
needsCapabilities = List(Capability.PublishName),
permissions = Map("contents" -> "read"),
)
{
val notify = CapabilityName("notify")
DocsRender.jobs("notify")(
Capability.steps(
name = notify,
steps = _ => List(Step(name = Some("Ping"), run = Some("echo hi"))),
needsCapabilities = List(Capability.PublishName),
),
Capability.publish,
)
}notify:
name: notify
runs-on: ubuntu-latest
needs:
- publish
if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: Ping
run: echo hiCustom stages (Graph by default)
Capability.custom exposes all topology knobs and defaults to Graph so target fan-out matches multi-registry
examples. Same name as a built-in replaces it.
zipxCapabilities += Capability
.custom(
name = CapabilityName("docker"),
command = cmd"${Docker / publish}",
participates = _.docker,
phase = Phase.Publish,
targets = _ => List(
Target(TargetName("us"), env = Map("REGISTRY" -> EnvValue.plain("us.example"), "DEPLOY_ROLE" -> secret"US_ROLE")),
Target(TargetName("eu"), env = Map("REGISTRY" -> EnvValue.plain("eu.example"), "DEPLOY_ROLE" -> secret"EU_ROLE")),
),
permissions = Map("id-token" -> "write", "contents" -> "read"),
)
.copy(
extraSteps = _ => List(
Step
.uses("aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c")
.named("Login")
.withInput("role-to-assume", Expr.env("DEPLOY_ROLE"))
.build
)
)
{
val docker = Capability
.custom(
name = Capability.DockerName,
command = n => SbtCommand.module(n, SbtCommand.unsafeTask("Docker/publish")),
participates = _.docker,
phase = Phase.Publish,
targets = _ =>
List(
Target(
TargetName("us"),
env = Map("REGISTRY" -> EnvValue.plain("us.example"), "DEPLOY_ROLE" -> secret"US_ROLE"),
),
Target(
TargetName("eu"),
env = Map("REGISTRY" -> EnvValue.plain("eu.example"), "DEPLOY_ROLE" -> secret"EU_ROLE"),
),
),
permissions = Map("id-token" -> "write", "contents" -> "read"),
)
.copy(extraSteps =
_ =>
List(
Step
.uses("aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c")
.named("Login")
.withInput("role-to-assume", Expr.env("DEPLOY_ROLE"))
.build
)
)
DocsRender.jobs("docker-service-us", "docker-service-eu")(docker)
}docker-service-us:
name: docker service (us)
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
id-token: write
contents: read
env:
DEPLOY_ROLE: ${{ secrets.US_ROLE }}
REGISTRY: us.example
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: docker-service-us
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: Login
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c
with:
role-to-assume: ${{ env.DEPLOY_ROLE }}
- name: docker
run: sbt 'service/Docker/publish'
docker-service-eu:
name: docker service (eu)
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
id-token: write
contents: read
env:
DEPLOY_ROLE: ${{ secrets.EU_ROLE }}
REGISTRY: eu.example
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: docker-service-eu
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: Login
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c
with:
role-to-assume: ${{ env.DEPLOY_ROLE }}
- name: docker
run: sbt 'service/Docker/publish'Also override runsOn = Some(List("self-hosted", "linux")) and permissions, the same knobs built-ins use.
Sidecars and containers
A capability that needs a database (or a Redis, or a Kafka) declares it as a service, which becomes GitHub's
services: on every job that capability produces:
zipxCapabilities += Capability.testGraph
.withService("postgres", JobService("postgres:17", ports = List("5432:5432")))
The service id (postgres) is the hostname. Ports are <host>:<container>, so a step reaches the database at
localhost:5432. withService adds one and keeps the rest; withServices replaces the whole set. Both work on any
scope, so a Capability.once integration job and a per-module Capability.testGraph declare a sidecar the same way.
inContainer is the other half, Job.container: every step runs inside the image instead of on the runner.
zipxCapabilities += Capability.testGraph.inContainer("ghcr.io/acme/build-base:1")
Reach for it only when the toolchain is what has to differ. zipx already pins the JDK and sbt, and inside a container
the runner's own tooling is gone: actions/setup-java and sbt/setup-sbt install into the container, so an image
without tar, curl or git fails during setup rather than during your build. A sidecar plus the default runner covers
almost every case.
DocsRender.job("test-service")(
Capability.testGraph
.withService("postgres", JobService("postgres:17", ports = List("5432:5432")))
.withService("redis", JobService("redis:7", ports = List("6379:6379")))
)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')
services:
postgres:
image: postgres:17
ports:
- "5432:5432"
redis:
image: redis:7
ports:
- "6379:6379"
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'There is no readiness signal
GitHub starts a service before the first step and then runs the steps. It does not wait for the service to be
ready, and the only lever it offers is a health check inside options:
JobService("postgres:17", ports = List("5432:5432"), options = Some("--health-cmd pg_isready --health-retries 5"))
Even with that, the contract is weak: the check is the image's own, the failure mode is a step that connects to a port
nothing is listening on yet, and nothing about it is expressible in your test code. If a suite needs a container to be
ready, it is usually better off owning the container's lifecycle itself with Testcontainers, which waits on a
strategy you choose and reports a startup failure as a test failure. zipx's own live remote-cache suite does exactly
that (BazelRemoteTestContainer in core tests, waiting on HTTP /status 200 because a listening gRPC port alone races)
and runs under Aggregate Verify / sbt core/testFull like any other suite. Docker must be available; if it is not, the
suite fails with a clear message rather than being ignored.
The rule of thumb: a service the job just needs present (and can retry against) is a withService; a container a test
needs ready, or needs to inspect and restart, belongs to the test.
What you cannot combine
container and services are refused on a capability that also sets workflowCall. A uses: job delegates its whole
runtime to the called workflow, so GitHub rejects both keys beside it, and there is nowhere for zipx to put them.
Generation fails naming the capability rather than dropping them, because a silently sidecar-less job fails later and
further from the cause. Declare them in the called workflow instead.
One more collision, decided rather than left to merge order: if a service id clashes with the remote-cache sidecar's
(bazel-remote, when zipxCacheBackend is the sidecar backend), the cache sidecar wins. A build cannot function
without it, since the sbt invocation is configured to reach it, whereas your own lost sidecar surfaces as a connection
error in the test that wanted it. Pick a different id.
Typed task keys (`zipxTasks`)
An SbtCommand is what ultimately runs at the sbt shell: validated as text that cannot corrupt the generated file, but
not parsed as sbt syntax. For the common "one task" case, the plugin's zipxTasks constructors take a real TaskKey /
InputKey so renamed tasks fail at build load:
val promote = taskKey[Unit]("promote the image")
zipxCapabilities += zipxTasks.once(CapabilityName("lint"), lintAll)
zipxCapabilities += zipxTasks.deploy(_.id == "service", promote, targets)
zipxCapabilities += zipxTasks.deployGraph(_.id == "service", promote, targets)
A key renders to <module>/<label>; config-scoped keys keep their axis (Docker / publish →
<module>/Docker/publish); a Once gate renders the bare label. zipxTasks mirrors once / custom / deploy /
deployGraph.
The `cmd` interpolator
When you need shell syntax around a key (+, ++, ;), use the cmd interpolator: literals are verbatim; each
$ splice is a typed key or a String (anything else is a compile error):
command = cmd"+ ${testFull}" // -> +<module>/testFull
command = cmd"${Docker / publish}" // -> <module>/Docker/publish
command = cmd"++${scalaVersion.value}; ${publish}" // String + key
The interpolator produces the command function for Capability.custom / .deploy / .once. Key splices are
module-scoped.