Job conditions
Skip until you need a job to run only on some PRs (forks, labels, branches). Most jobs use the default timeline:
tests always, publish on a version tag (or, with Ship rows, on merge to the default branch).
[[JobCondition]] is a typed AST for optional job if: filters. [[Gate]] is still the timeline (Always,
OnReleaseTag, OnDefaultPush). 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 && c ≡ a || (b && c)); both are left-associative. Parenthesize
when you mean (a || b) && c. Typed leaves also include eventIs, onWorkflowDispatch, onReleaseTag, and
onDefaultPush (push to zipxPushBranches or workflow_dispatch; the same clause Gate.OnDefaultPush renders).
{
val c = (JobCondition.onReleaseTag || JobCondition.onWorkflowDispatch) &&
JobCondition.repositoryIs("early-effect/zipx")
Render.renderMapping(ListMap("if" -> c.render)).yaml
}if: ((startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch')) && (github.repository == 'early-effect/zipx')Defaults and Gate vs condition
| Capability | Default Gate | Default JobCondition |
|---|---|---|
| test / testJoined / Layers / Graph | Always | None |
| publish / docker / deploy | OnReleaseTag | None |
| ZipxCentral / ZipxGitHubPackages | OnReleaseTag | None (unless you pass one) |
| ZipxModver.publish | OnDefaultPush | None (planner ANDs the version-moved contains) |
| ZipxDocs.pages | Always | onReleaseTag 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(TargetName("stg"), condition = Some(JobCondition.hasPrLabel("deploy-stg")))),
)
DocsRender.job("docker-service-stg")(
Capability.dockerGraph.copy(
gate = Gate.OnReleaseTag,
targets = _ => List(Target(TargetName("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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker-service-stg
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: docker
run: sbt 'service/Docker/publish'OnDefaultPush
Independent library publish uses Gate.OnDefaultPush: a push to zipxPushBranches (default main) or
workflow_dispatch. Merge to the default branch is the release signal. The planner groups the rendered if: so && /
|| precedence cannot swallow a later clause. Full guide: Independent versions.
zipxCapabilities += ZipxModver.publish()
DocsRender.job("publish-api")(
ZipxModver.publish(SbtCommand.unsafeTask("zipxModverPublishSigned"))
)(using libGraph, config.copy(modverPublish = true))publish-api:
name: publish api
runs-on: ubuntu-latest
needs:
- modver
- publish-schema
if: "!cancelled() && (((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')) && contains(fromJson(needs.modver.outputs.modules), 'api') && needs.publish-schema.result != 'failure'"
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-api
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: publish
run: sbt '+api/zipxModverPublishSigned'A conjunction that can never be true is refused
The footgun above is worth catching, and where zipx can prove the conjunction is never true, it refuses to generate the workflow rather than emitting a job that silently never runs:
// Fails `zipxWorkflowGenerate`: no ref both starts with `refs/tags/v` and equals `refs/heads/main`
Capability.publish.copy(
gate = Gate.OnReleaseTag,
condition = Some(JobCondition.refIs("refs/heads/main")),
)
The error names the capability (and the target, when the clause is on one), quotes both rendered clauses, and says
which fact makes them incompatible. That is deliberate: the gate typically comes from a pack, the condition from
build.sbt, and the target condition from a project/*.scala list, so nobody reading one file sees the conjunction.
examples/monorepo shipped exactly this bug: a deploy-prod job gated on a release tag and on refs/heads/main.
What is checked is a small decidable subset over the single-valued github contexts, inside a conjunction:
| Shape | Why it is refused |
|---|---|
refIs(a) with refIs(b), a != b | github.ref holds one value per run |
eventIs / repositoryIs likewise | same, per context |
refIs(r) with refStartsWith(p), r not under p | that ref does not start with that prefix |
refStartsWith(p) with refStartsWith(q), neither a prefix of the other | no ref starts with both |
| a clause and its own negation | one negates the other |
What is not checked, and passes in silence: anything under || (Any), Raw, varNonEmpty, hasPrLabel, and two
negated claims (excluding two values always leaves a third). Gate.OnReleaseTag with hasPrLabel is therefore still
a footgun zipx cannot catch, which is why the section above exists.
The subset is narrow on purpose. An unsound rejection is worse than a missed one: a missed contradiction is the
status quo, while a wrong rejection is a build that cannot generate its own CI and no way for the author to argue with
it. Nesting does not help it escape, though: All and !(a || b) are flattened first, so a contradiction buried in a
nested conjunction is still found.
{
val contradiction = Capability.deploy(
participates = _.id == "service",
command = n => SbtCommand.module(n, SbtCommand.unsafeTask("promote")),
targets = _ => List(Target(TargetName("prod"), condition = Some(JobCondition.refIs("refs/heads/main")))),
needsCapabilities = Nil,
gate = Gate.OnReleaseTag,
)
scala.util
.Try(DocsRender.plan(contradiction))
.fold(_.getMessage, _ => "planned (no error)")
}zipx: capability 'deploy' target 'prod' can never run: Gate.OnReleaseTag requires `startsWith(github.ref, 'refs/tags/v')` and target 'prod' condition requires `github.ref == 'refs/heads/main'`, which cannot both hold: that ref does not start with that prefix. The two are ANDed, so this job's `if:` is never true and it would silently never run.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: 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: github-packages
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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(condition = Some(JobCondition.repositoryIs("acme/my-fork"))),
)
DocsRender.jobs("publish", "github-packages")(
ZipxCentral.release,
ZipxGitHubPackages.sameRepo(condition = Some(JobCondition.repositoryIs("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: 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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 'schema/publishSigned; api/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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: github-packages
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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.
Ensure
pull_requesttriggers fire (zipx default). If labels are added after open, also allowtypes: [opened, synchronize, reopened, labeled](zipx does not auto-emit that yet; set triggers in a companion workflow or extend PlanConfig later).Use a custom docker capability with
Gate.Alwaysand per-Target conditions.Point Target env at the ECR registry + OIDC role; keep
Docker/publishas the command (native-packager /REGISTRYstill choose the repository URL).
zipxCapabilities += Capability
.custom(
name = CapabilityName("docker"),
command = cmd"${Docker / publish}",
participates = _.docker,
phase = Phase.Publish,
gate = Gate.Always,
targets = _ => List(
Target(
name = TargetName("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 = TargetName("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
.uses("aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c")
.named("Login to registry")
.withInput("role-to-assume", Expr.env("DEPLOY_ROLE"))
.build
)
)
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 = Capability.DockerName,
command = n => SbtCommand.module(n, SbtCommand.unsafeTask("Docker/publish")),
participates = _.docker,
gate = Gate.Always,
targets = _ =>
List(
Target(TargetName("stg"), condition = Some(JobCondition.hasPrLabel("deploy-stg"))),
Target(TargetName("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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker-service-stg
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker-service-prod
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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 = CapabilityName("docker-stg"), gate = Gate.Always)
.withCondition(JobCondition.hasPrLabel("deploy-stg"))
DocsRender.job("docker-stg-service")(
Capability.dockerGraph
.copy(name = CapabilityName("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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker-stg-service
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: docker-stg
run: sbt 'service/Docker/publish'Main-only target
Target(
TargetName("prod"),
environment = Some("production"),
condition = Some(JobCondition.refIs("refs/heads/main")),
)
DocsRender.job("deploy-prod")(
Capability.deploy(
participates = _.id == "service",
command = n => SbtCommand.module(n, SbtCommand.unsafeTask("promote")),
targets = _ =>
List(
Target(
TargetName("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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: deploy-prod
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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)).yamlif: always()