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 hi

Custom 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.