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.