Capabilities

A capability is something CI should do: run tests, publish a library, build a docker image, deploy.

You already get test, fmt, workflow-check, and advisories (parallel Verify), plus publish. Add a pack when you want Maven Central, GitHub Pages, or AWS (see Packs). You can invent extra stages later (Custom capabilities).

Execution mode and Matrix collapse decide how many GitHub jobs appear for a stage. Stay on Aggregate and you get one job per stage. Graph and Layer are opt-in; ignore them until one job is not enough.

Built-ins

CapabilityDefault modeRunsParticipatesPhaseGate
testAggregate (Once)on a PR, zipxTestAffected <base> (the affected modules' zipxTestTask, in parallel); otherwise root zipxTestTaskwhole build (.aggregate)Verifyalways
fmtOncescalafmtCheckAllwhole buildVerifyalways (zipxVerify.fmt)
workflow-checkOncezipxWorkflowCheckwhole buildVerifyalways (zipxVerify.workflowCheck)
advisoriesOncezipxAdvisoryCheckwhole buildVerifyalways (zipxVerify.advisories)
publishAggregate+?<module>/<publishTask> (joined)modules that publishPublishrelease tag
dockerAggregate<module>/Docker/publish (joined)DockerPlugin modulesPublishrelease tag, or in zipx-deploy.yml under DeployTrigger.Manual or Staged

Verify jobs have empty needs versus each other (GitHub runs them in parallel). Pin-feed OSV folds into advisories when feeds are present. Capability.pinCheck remains if you want a dedicated job; see Pin feeds. Skip a gate with VerifyOpt.Skip(reason) (the job still emits). See Verify.

Use testGraph / publishGraph / dockerGraph for one-job-per-module. Use *Layers for wave scheduling. Use testJoined if Aggregate must join <module>/<testTask> instead of a root task. Capability.testAffected is the builtin test a build gets under zipxAffectedOnPR; replacing test by name opts out of it. Packs (ZipxCentral.release, ZipxGitHubPackages, ZipxDocs.pages, AWS helpers) replace or extend these by name; see Packs and Docker and deploy. Ship rows publish from zipx-release.yml, never from a capability; see Snapshots and releases.

Phases and replace-by-name

Capabilities run Verify → Publish → Deploy. When the build has Verify jobs, the planner adds one verify job that needs every one of them. It runs no sbt. It fails if any Verify job failed or was cancelled, and it passes when a Verify job skipped (an affected module, modver-check off a pull request, Verify skipped after a merged PR). A Publish job whose gate can run where Verify runs (Always or OnDefaultPush, including snapshots and snapshots-pr) needs verify. A release-tag Publish job (publish, docker) does not: Verify does not run on a tag. Deploy does not. Require verify in the ruleset. That list then stays put when a Verify job is added. needsCapabilities is still how one capability waits on another.

Path gating reaches Graph capabilities only (zipxAffectedOnPR / zipxAffectedOnPush; fail open): Verify by default, Publish under zipxAffectedPublish, where the release gate and the affected clause compose. Deploy is destination-driven and never path-affected.

Gate today is Always | OnReleaseTag | OnDefaultPush | AffectedOnly. AffectedOnly is rejected at generate time: affected gating is derived from phase, scope and the two settings, not from Gate, so this would be a silent Always. See Affected. OnDefaultPush runs on a default-branch push or a workflow_dispatch (see Job conditions).

zipxCapabilities += ... merges with built-ins; the same name replaces a built-in (e.g. turn Aggregate docker into a multi-registry Graph capability). A custom extraSteps uses: should be a full commit SHA (or an Action catalog row a pack looks up), not a floating @v6 tag; zipx-emitted steps are already SHA-pinned.

zipxCapabilities += Capability.publish.copy(
  env = Map(
    "PGP_PASSPHRASE"    -> secret"PGP_PASSPHRASE",
    "SONATYPE_USERNAME" -> secret"SONATYPE_USERNAME",
  )
)
DocsRender.job("publish")(
  Capability.publish.copy(
    env = Map(
      "PGP_PASSPHRASE"    -> secret"PGP_PASSPHRASE",
      "SONATYPE_USERNAME" -> EnvValue.secret("SONATYPE_USERNAME"),
    )
  )
)
publish:
  name: publish
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
    SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
  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: publish
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: publish
      run: sbt 'schema/publish; api/publish'

Replace vs plus vs drop

withEnv / withExtraSteps / withPostSteps replace the field. Packs already fill extras (ZipxCentral.release ships GPG import). To add a step without restating the pack bundle, use the layer combinators, the same split as plusEnv / andCondition / thenOnce:

ReplaceLayer
withEnvplusEnv
withExtraSteps / withPostStepsplusExtraSteps / plusPostSteps
dropExtraSteps(name) / dropPostSteps(name) (leaf [[Steps]] name, not the composed a+b string)
zipxCapabilities += ZipxCentral.release.plusExtraSteps(publishCleanFull)

zipxCapabilities += ZipxCentral.release
  .dropExtraSteps("gpg-import")
  .plusExtraSteps(customGpg ++ publishCleanFull)
{
  val clean = Steps.of("clean-full")(
    Step.run(Script(Exec("true"))).named("cleanFull").build
  )
  val yaml  = DocsRender.job("publish")(ZipxCentral.release.plusExtraSteps(clean))
  val names =
    ZipxCentral.release.plusExtraSteps(clean).extraSteps match
      case s: Steps => s.leaves.map(_.name).mkString(",")
      case _        => ""
  s"leaves: $names\n---\n$yaml"
}
leaves: gpg-import,clean-full
---
publish:
  name: publish
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    PGP_KEY_HEX: ${{ secrets.PGP_KEY_HEX }}
    PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
    SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
    SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
  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: publish
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: Import signing key
      run: |
        mkdir -p ~/.gnupg && chmod 700 ~/.gnupg
        echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
        echo "pinentry-mode loopback"   >> ~/.gnupg/gpg.conf
        gpgconf --kill gpg-agent || true
        echo "$PGP_SECRET" | base64 --decode | gpg --batch --import
      env:
        PGP_SECRET: ${{ secrets.PGP_SECRET }}
    - name: cleanFull
      run: "true"
    - name: publish
      run: sbt 'schema/publishSigned; api/publishSigned; sonaRelease'

Verify knobs

Shared across Aggregate, Layer, and Graph (details on the Verify page):

zipxTestTask    := zipxTasks.of(testFull)
zipxVerifyClean := VerifyClean.CleanFull
// Aggregate → sbt 'cleanFull; testFull'
// Graph     → sbt 'cleanFull; core/testFull' (per job)