Job conditions

[[JobCondition]] is a typed AST for optional job if: filters (fork repo, PR label, branch, repo var, …). [[Gate]] is still the timeline (Always vs OnReleaseTag). The planner ANDs Gate clauses with capability and target conditions.

Default on every capability and target: condition = None (no extra filter). Prefer withCondition(...) to set a filter, or andCondition(...) to layer onto a pack that already ships one (e.g. ZipxDocs.pages).

Compose with && and ||

val deployDocs =
  JobCondition.onReleaseTag || JobCondition.onWorkflowDispatch

val upstreamOnly =
  JobCondition.repositoryIs("acme/libs") && deployDocs

// Negation:
val notFork = !JobCondition.repositoryIs("acme/other")

JobCondition.and / or / not remain available; infix && / || / ! are the usual style. Precedence matches Boolean ops: && binds tighter than || (a || b && ca || (b && c)); both are left-associative. Parenthesize when you mean (a || b) && c. Typed leaves also include eventIs, onWorkflowDispatch, and onReleaseTag.

{
  val c = (JobCondition.onReleaseTag || JobCondition.onWorkflowDispatch) &&
    JobCondition.repositoryIs("early-effect/zipx")
  Render.renderMapping(ListMap("if" -> c.render))
}
if: ((startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch')) && (github.repository == 'early-effect/zipx')

Defaults and Gate vs condition

CapabilityDefault GateDefault JobCondition
test / testJoined / Layers / GraphAlwaysNone
publish / docker / deployOnReleaseTagNone
ZipxCentral / ZipxGitHubPackagesOnReleaseTagNone (unless you pass one)
ZipxDocs.pagesAlwaysonReleaseTag or onWorkflowDispatch

Important: Gate and JobCondition are ANDed. A capability with Gate.OnReleaseTag will not run on a PR even if a Target has HasPrLabel. For stage-on-PR + prod-on-tag, use Gate.Always with per-Target conditions, or two capabilities.

// Footgun: OnReleaseTag ∧ HasPrLabel still requires a v* tag
Capability.dockerGraph.copy(
  gate = Gate.OnReleaseTag,
  targets = _ => List(Target("stg", condition = Some(JobCondition.hasPrLabel("deploy-stg")))),
)
DocsRender.job("docker-service-stg")(
  Capability.dockerGraph.copy(
    gate = Gate.OnReleaseTag,
    targets = _ => List(Target("stg", condition = Some(JobCondition.hasPrLabel("deploy-stg")))),
  )
)(using dockerLibGraph)
docker-service-stg:
  name: docker service (stg)
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (contains(github.event.pull_request.labels.*.name, 'deploy-stg'))
  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-stg
        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: docker
      run: sbt 'service/Docker/publish'

Fork / upstream publish gate

zipxCapabilities += Capability.publish.withCondition(
  JobCondition.repositoryIs("acme/my-fork"),
)
DocsRender.job("publish")(
  Capability.publish.withCondition(JobCondition.repositoryIs("acme/my-fork"))
)
publish:
  name: publish
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (github.repository == 'acme/my-fork')
  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 }}-publish
        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: publish
      run: sbt 'schema/publish; api/publish'

Repo-variable opt-in

Mechanoid-style: only publish when a repo variable is set.

zipxCapabilities += ZipxGitHubPackages.sameRepo(
  condition = Some(JobCondition.varNonEmpty("PUBLISH_PACKAGES_REPO")),
)
DocsRender.job("github-packages")(
  ZipxGitHubPackages.sameRepo(condition = Some(JobCondition.varNonEmpty("PUBLISH_PACKAGES_REPO")))
)
github-packages:
  name: github-packages
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (vars.PUBLISH_PACKAGES_REPO != '')
  permissions:
    contents: read
    packages: write
  env:
    GITHUB_TOKEN: ${{ github.token }}
    PUBLISH_GITHUB_PACKAGES: "true"
  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 }}-github-packages
        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: github-packages
      run: sbt 'schema/publish; api/publish'

Multi-publish: Central + GitHub Packages

Distinct capability names coexist. zipx wires permissions + token env; sbt owns publishTo / Credentials when PUBLISH_GITHUB_PACKAGES=true.

zipxCapabilities ++= Seq(
  ZipxCentral.release,
  ZipxGitHubPackages.sameRepo(repository = Some("acme/my-fork")),
)
DocsRender.jobs("publish", "github-packages")(
  ZipxCentral.release,
  ZipxGitHubPackages.sameRepo(repository = Some("acme/fork")),
)
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: 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 }}-publish
        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: 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: publish
      run: sbt 'publishSigned; sonaRelease'
github-packages:
  name: github-packages
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (github.repository == 'acme/fork')
  permissions:
    contents: read
    packages: write
  env:
    GITHUB_TOKEN: ${{ github.token }}
    PUBLISH_GITHUB_PACKAGES: "true"
  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 }}-github-packages
        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: github-packages
      run: sbt 'schema/publish; api/publish'

PR → stage/dev ECR before merge

Publish container images to stg/dev ECR from a labeled PR without waiting for merge or a release tag.

  1. Ensure pull_request triggers fire (zipx default). If labels are added after open, also allow types: [opened, synchronize, reopened, labeled] (zipx does not auto-emit that yet — set triggers in a companion workflow or extend PlanConfig later).

  2. Use a custom docker capability with Gate.Always and per-Target conditions.

  3. Point Target env at the ECR registry + OIDC role; keep Docker/publish as the command (native-packager / REGISTRY still choose the repository URL).

zipxCapabilities += Capability
  .custom(
    name = "docker",
    command = cmd"${Docker / publish}",
    participates = _.docker,
    phase = Phase.Publish,
    gate = Gate.Always,
    targets = _ => List(
      Target(
        name = "stg",
        env = Map(
          "REGISTRY"    -> EnvValue.plain("111.dkr.ecr.us-east-1.amazonaws.com/stg"),
          "DEPLOY_ROLE" -> secret"STG_REGISTRY_ROLE",
        ),
        condition = Some(JobCondition.hasPrLabel("deploy-stg")),
      ),
      Target(
        name = "prod",
        env = Map(
          "REGISTRY"    -> EnvValue.plain("111.dkr.ecr.us-east-1.amazonaws.com/prod"),
          "DEPLOY_ROLE" -> secret"PROD_REGISTRY_ROLE",
        ),
        condition = Some(JobCondition.refStartsWith("refs/tags/v")),
      ),
    ),
    permissions = Map("id-token" -> "write", "contents" -> "read"),
  )
  .copy(
    extraSteps = _ => List(
      Step(
        name = Some("Login to registry"),
        uses = Some("aws-actions/configure-aws-credentials@v6"),
        `with` = Map("role-to-assume" -> "${{ env.DEPLOY_ROLE }}"),
      )
    )
  )

Add label deploy-stg on the PR → only the stg job's if is true; prod still waits for a v* tag.

{
  val cap = Capability
    .custom(
      name = "docker",
      command = n => s"${n.id}/Docker/publish",
      participates = _.docker,
      gate = Gate.Always,
      targets = _ =>
        List(
          Target("stg", condition = Some(JobCondition.hasPrLabel("deploy-stg"))),
          Target("prod", condition = Some(JobCondition.refStartsWith("refs/tags/v"))),
        ),
      permissions = Map("id-token" -> "write"),
    )
  DocsRender.jobs("docker-service-stg", "docker-service-prod")(cap)(using dockerLibGraph)
}
docker-service-stg:
  name: docker service (stg)
  runs-on: ubuntu-latest
  if: contains(github.event.pull_request.labels.*.name, 'deploy-stg')
  permissions:
    id-token: write
  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-stg
        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: docker
      run: sbt 'service/Docker/publish'
docker-service-prod:
  name: docker service (prod)
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  permissions:
    id-token: write
  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-prod
        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: docker
      run: sbt 'service/Docker/publish'

Capability-level docker-stg

Alternate to per-Target conditions: a separate capability name so it does not replace builtin docker.

zipxCapabilities += Capability.dockerGraph
  .copy(name = "docker-stg", gate = Gate.Always)
  .withCondition(JobCondition.hasPrLabel("deploy-stg"))
DocsRender.job("docker-stg-service")(
  Capability.dockerGraph
    .copy(name = "docker-stg", gate = Gate.Always)
    .withCondition(JobCondition.hasPrLabel("deploy-stg"))
)(using dockerLibGraph)
docker-stg-service:
  name: docker-stg service
  runs-on: ubuntu-latest
  if: contains(github.event.pull_request.labels.*.name, 'deploy-stg')
  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-stg-service
        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: docker-stg
      run: sbt 'service/Docker/publish'

Main-only target

Target(
  "prod",
  environment = Some("production"),
  condition = Some(JobCondition.refIs("refs/heads/main")),
)
DocsRender.job("deploy-prod")(
  Capability.deploy(
    participates = _.id == "service",
    command = n => s"${n.id}/promote",
    targets = _ =>
      List(
        Target(
          "prod",
          environment = Some("production"),
          condition = Some(JobCondition.refIs("refs/heads/main")),
        )
      ),
    needsCapabilities = Nil,
    gate = Gate.Always,
  )
)
deploy-prod:
  name: deploy (prod)
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  environment: production
  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 }}-deploy-prod
        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: deploy
      run: sbt 'service/promote'

Raw escape hatch

JobCondition.raw("always()")

Prefer typed leaves and && / || when possible; Raw is for expressions the AST does not cover yet.

Render.renderMapping(ListMap("if" -> JobCondition.raw("always()").render))
if: always()