Custom capabilities
zipxCapabilities is append-able — any sbt task becomes a CI stage. Beyond the built-ins you mainly use
Capability.once / Capability.custom, or the typed zipxTasks / cmd helpers from the plugin.
Once gates
Capability.once emits a single build-wide job (not per module), e.g. format/lint that every test job waits on:
zipxCapabilities += zipxTasks.once("fmt", scalafmtCheckAll)
zipxCapabilities += Capability.test.copy(needsCapabilities = List("fmt"))
// or Layers: Capability.testLayers.copy(needsCapabilities = List("fmt"))
DocsRender.jobs("fmt", "test")(
Capability.once("fmt", "scalafmtCheckAll"),
Capability.test.copy(needsCapabilities = List("fmt")),
)fmt:
name: fmt
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 }}-fmt
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: fmt
run: sbt 'scalafmtCheckAll'
test:
name: test
runs-on: ubuntu-latest
needs:
- fmt
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'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 = "docker",
command = cmd"${Docker / publish}",
participates = _.docker,
phase = Phase.Publish,
targets = _ => List(
Target("us", env = Map("REGISTRY" -> EnvValue.plain("us.example"), "DEPLOY_ROLE" -> secret"US_ROLE")),
Target("eu", env = Map("REGISTRY" -> EnvValue.plain("eu.example"), "DEPLOY_ROLE" -> secret"EU_ROLE")),
),
permissions = Map("id-token" -> "write", "contents" -> "read"),
)
.copy(
extraSteps = _ => List(
Step(
name = Some("Login"),
uses = Some("aws-actions/configure-aws-credentials@v6"),
`with` = Map("role-to-assume" -> "${{ env.DEPLOY_ROLE }}"),
)
)
)
{
val docker = Capability
.custom(
name = "docker",
command = n => s"${n.id}/Docker/publish",
participates = _.docker,
phase = Phase.Publish,
targets = _ =>
List(
Target("us", env = Map("REGISTRY" -> EnvValue.plain("us.example"), "DEPLOY_ROLE" -> secret"US_ROLE")),
Target("eu", env = Map("REGISTRY" -> EnvValue.plain("eu.example"), "DEPLOY_ROLE" -> secret"EU_ROLE")),
),
permissions = Map("id-token" -> "write", "contents" -> "read"),
)
.copy(extraSteps =
_ =>
List(
Step(
name = Some("Login"),
uses = Some("aws-actions/configure-aws-credentials@v6"),
`with` = ListMap("role-to-assume" -> "${{ env.DEPLOY_ROLE }}"),
)
)
)
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: 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 }}-docker-service-us
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: Login
uses: aws-actions/configure-aws-credentials@v6
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: 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 }}-docker-service-eu
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: Login
uses: aws-actions/configure-aws-credentials@v6
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.
Typed task keys (`zipxTasks`)
String commands are what ultimately run at the sbt shell. 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("fmt", scalafmtCheckAll)
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.