Affected

Affected means: test and ship only what a PR can have broken. zipx turns the PR's diff into a set of modules (the changed ones plus everything that depends on them), and three kinds of job read that one set:

ReaderWhat it does with the setOn by default?
The builtin testzipxTestAffected runs those modules' test tasks inside the one Aggregate jobYes, on PRs
Graph jobs (testGraph, publishGraph, deployGraph)Each per-module job skips when its module is not in the setVerify yes; Publish and Deploy are opt-ins
Once jobs with withAffectedBySkip unless one of their named modules is in the setPer capability

test computes the set inside its own sbt session; the other two read the affected job's output. Both take the same diff against the same base, so they always agree. Everything below is about how that set is computed, and why a diff that cannot run makes it all rather than empty.

Closure flow

From git diff to owning module

The affected job takes the PR's changed paths (repo-root-relative, from git diff against the base ref) and maps each file to the module or modules that own it:

  1. Build files force everything. If any path ends in .sbt or sits under a project/ directory (root or nested), the whole module set is affected. Plugins and the graph may have changed, so nothing is safe to skip. The exceptions are the versions catalog and the root build.sbt, whose diffs zipx reads; see Catalog bumps and build.sbt edits below.

  2. Otherwise: longest owned-path prefix. A module owns its baseDir and its source directories (sbt's unmanagedSourceDirectories, Compile and Test). A file is owned by every module whose longest matching prefix is the longest match overall. Matching is directory-aware: core/ owns core/src/X.scala, but not core-lib/… or core-extra/…. Nested bases win: mods/inner/X.scala belongs to mods/inner, not mods.

  3. Unowned paths seed nothing. README.md, .github/…, and other files outside every module's owned paths are ignored (unless step 1 applies). Aggregators with empty baseDir never own files.

Step 2 returns a set, not a single module. One file can genuinely belong to more than one module; the next section is the case where it always does.

Those owning modules are the seeds. Step 3 of the chart expands them to the reverse-dependency closure. Step 5 gates each Graph job on whether its id (or all) appears in the published JSON, and, inside test, runs the closure's CI-relevant aggregated modules.

Changed pathOwning moduleAfter closure (example)
client/src/…client (leaf)just client
models/src/…modelsmodels + every transitive dependent
mods/inner/X.scalainner (longer than mods)inner + dependents
README.mdnoneempty (no Graph Verify)
project/plugins.sbt, project/*.scala(build file)all modules
build.sbt, inside one project's definitionthat projectthat project + dependents
build.sbt, a shared helper or bare setting(build-wide)all modules
project/ZipxVersions.scala, one Lib version movedthe modules that declare that librarythose modules + dependents
zipxAffectedOnPR   := true   // default: test runs zipxTestAffected; `affected` is emitted when a Graph or
                             // withAffectedBy job reads it
zipxAffectedOnPush := false  // opt-in: also scope branch pushes to changes since the last green push run.
                             // That job calls gh. With zipxSbtImage, add Pin("sbt-image", "gh", "<x.y.z>").
{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
  DocsRender.body(Capability.testGraph)
}
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
  affected:
    name: affected
    runs-on: ubuntu-latest
    if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
    outputs:
      modules: ${{ steps.compute.outputs.modules }}
    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: affected
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: "off"
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Compute affected modules
        id: compute
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE="${{ github.event.pull_request.base.sha }}"
            sbt -batch --error "zipxAffectedModules $BASE"
            modules=$(cat target/zipx-affected.json)
          else
            modules='["all"]'
          fi
          echo "modules=$modules" >> "$GITHUB_OUTPUT"
  test-schema:
    name: test schema
    runs-on: ubuntu-latest
    needs:
      - affected
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all')))
    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: test-schema
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: test
        run: sbt 'schema/test'
  test-api:
    name: test api
    runs-on: ubuntu-latest
    needs:
      - affected
      - test-schema
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'api') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-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: test-api
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: test
        run: sbt 'api/test'
  test-service:
    name: test service
    runs-on: ubuntu-latest
    needs:
      - affected
      - test-api
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'service') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-api.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: test-service
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: test
        run: sbt 'service/test'
  verify:
    name: verify
    runs-on: ubuntu-latest
    needs:
      - test-api
      - test-schema
      - test-service
    if: "!cancelled()"
    steps:
      - name: Fail when a Verify job failed or was cancelled
        if: (needs.test-api.result == 'failure' || needs.test-api.result == 'cancelled') || (needs.test-schema.result == 'failure' || needs.test-schema.result == 'cancelled') || (needs.test-service.result == 'failure' || needs.test-service.result == 'cancelled')
        run: exit 1

Catalog bumps

Most dependency PRs change one line: a version literal in the catalog. Treating the catalog as an ordinary build file would test and rebuild every module for a library only one of them uses, so zipx reads the catalog's diff instead.

It parses zipxVersionsFile at both commits and reads each change:

ChangeAffects
A Lib version (with its .mod copies and fromGraph rows)The modules whose libraryDependencies declare it, then their dependents
An Action pinNo module: it changes generated workflows, which no module owns
sbt, Scala, or a Plugin version; a Ship rowEvery module
A row added or removed, a group edited, or any other textEvery module
A Lib no module declaresEvery module: something the graph cannot see uses it, such as the meta-build

A narrowed reading needs proof that nothing else moved. The parser sees constructors only, and an edit to a group such as def service = library(fansi, upickle) changes which modules get a row without moving any constructor. So after reading the moved versions, zipx puts each one's old version back into the new file and requires the old file, exactly. Anything short of that is every module. The catalog is read at the commit the diff compares against: the merge base for a PR, and each Environment's last deployed commit for a deploy. The job log names each reading (zipx: project/ZipxVersions.scala: com.lihaoyi:fansi moved).

Rows that zipxDepUpdate and the version-updates companion rewrite always match, since they rewrite the version literal in place. A hand edit that also reformats the constructor reads as every module, which is the safe answer.

build.sbt edits

An edit to the root build.sbt usually touches one project's definition: a setting added to its .settings(...), a plugin enabled. zipx compares build.sbt statement by statement between the two commits and reads each change:

ChangeAffects
Inside one project's definition (lazy val svcA = project…)That project's modules (every projectMatrix row), every other project whose definition names it, then dependents
A shared helper (def publishedLibrary = …, val common = …)Every module
A bare setting (scalaVersion := …) or an importEvery module: sbt 2 applies bare settings to every project
An aggregator's definition, a definition added or removedEvery module
A comment or blank lineNothing

"Names it" includes a string: val svcAJvm = LocalProject("svcA") stands for svcA, so a project that reads (svcAJvm / dockerAlias).value is affected by a change to svcA. An aggregator that only lists a project in .aggregate(...) is not, since it runs nothing of its own. A file that does not parse at either commit, and any .sbt file other than the root build.sbt, affects every module.

This compares source rather than asking sbt, because sbt 2 does not record where a setting inside project.settings(...) is: it keeps one line, offset from the definition, and none at all for a multi-line setting. The job log names each reading (zipx: build.sbt: affects svcA, svcAJS, imageIt).

Cross-built modules (projectMatrix)

sbt 2 has projectMatrix built in, and a cross-built module is where baseDir stops being able to answer at all. Each platform row is a real project with its own id (core, coreJS), but sbt bases every row at a synthetic directory:

core / baseDirectory     = .sbt/matrix/core
coreJS / baseDirectory   = .sbt/matrix/coreJS

No source file is ever under those. A baseDir-only rule maps core/src/main/scala/Foo.scala to no module, so both rows skip and the PR is green having compiled nothing. That is why a module owns its source directories too:

core   / Compile / unmanagedSourceDirectories = core/src/main/{scala, scala-3, scalajvm, scalajvm-3, java, javajvm}
coreJS / Compile / unmanagedSourceDirectories = core/src/main/{scala, scala-3, scalajs,  scalajs-3,  java, javajs}

Those directories also carry the platform distinction, which is what makes the answer precise rather than merely non-empty:

Changed pathOwning modulesWhy
core/src/main/scala/Foo.scalacore and coreJSshared: on both rows' source dirs
core/src/main/scalajs/Foo.scalacoreJSon the JS row alone
core/src/main/scalajvm/Foo.scalacoreon the JVM row alone
core/README.mdnoneunder no row's baseDir or source dirs

A shared change reaching both rows is the property worth stating plainly: picking one would leave half of a cross-built module untested behind a green check, and which half you got would depend on iteration order. Ownership is a set for exactly this reason.

target/ and a row's .sbt/matrix/<id> are excluded from the owned paths: nobody edits them, and a generated-source directory under target/ would make every module affected on every commit.

Nothing changes for an ordinary project. Its source dirs are all under its baseDir, so recording them only ever adds ownership; baseDir still answers for a module's non-source files (a README, a Dockerfile, a test fixture).

Fail open, not closed

The affected handoff must never turn a broken git diff into a green, untested PR. Two outcomes used to look the same ([]): “diff succeeded and found nothing” versus “diff could not run.” Empty JSON makes every contains(..., '<id>') false, so every Graph Verify job skips.

yesempty Nil
Diff outcomeValueEmittedCI result
Succeeded, no changesSome(Nil)[]Skip Graph Verify, and test tests nothing (deliberate)
Could not run (bad ref, no git, …)None["all"]Run everything, and test runs its full command
Succeeded with filesSome(files)affected closureGate per module, and test runs the closure

A broken base ref costs runner minutes, not coverage. The affected job logs a warning when it disables gating for that run.

Who is gated

Capability shapePath-affected?Why
The builtin testYes, by defaultzipxTestAffected tests the affected modules the root aggregate reaches, as one parallel all command
Capability.testGraph (and other Graph + Verify)Yes, by defaultPer-module jobs can skip
A Once job with withAffectedByYes, when one of its modules is affectedFor inputs the classpath graph cannot see
Graph Publish (publishGraph, dockerGraph)Only under zipxAffectedPublishSee the next section: the two risks are not symmetric
Graph Deploy (deployGraph)Only under zipxAffectedDeploySo a deploy skips exactly when the publish it consumes did
Other Aggregate / Layer, any phaseNeverOne sbt session over every module: there is nothing in it to skip

Gate.AffectedOnly is a design seam, not a shipped gate. Affected-gating is derived from phase + scope + zipxAffectedOnPR / zipxAffectedPublish / zipxAffectedDeploy, not from Gate. The planner rejects Gate.AffectedOnly at generate time so it cannot silently mean Always.

Narrowing Publish (zipxAffectedPublish)

Rebuilding and pushing eight images because one module changed is the cost this setting removes. It is a separate switch from zipxAffectedOnPR rather than a widening of it, and that is a deliberate asymmetry:

Under-verifying is silently unsafe. Under-publishing is loudly broken.

A Verify job that wrongly skips gives you a green PR whose code was never tested, and nothing tells you. A Publish job that wrongly skips gives you a deploy that fails immediately for a missing artifact. One switch for both would price Publish's narrowing at Verify's risk, so Verify's gating is on by default and Publish's has to be asked for:

zipxAffectedOnPR    := true   // default: Graph Verify is narrowed on PRs
zipxAffectedPublish := true   // opt-in: Graph Publish is narrowed too

Three properties carry over unchanged, and each is what makes the opt-in safe rather than merely cheap:

  1. A release tag publishes everything. There is no base ref to diff a tag against, so affected emits ["all"] for a tag push without taking a diff at all (see the table above). The || contains(…, 'all') clause in every gated job is the other half. The question "affected relative to what, after a series of merges?" therefore does not arise.

  2. Fail open. A diff that could not run emits ["all"], so a bad base ref publishes too much rather than too little.

  3. A skipped image never silently skips its deploy. This is the trap the feature opens, so the planner closes it (see the next section).

The affected job itself now runs on tag pushes and on merged-PR pushes when a Publish or Deploy capability reads it, where a Verify-only setup skips there. That is free: on a non-PR event it takes no diff, and it emits ["all"].

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true)
  DocsRender.jobs("publish-schema", "publish-api")(Capability.publishGraph)
}
publish-schema:
  name: publish schema
  runs-on: ubuntu-latest
  needs:
    - affected
  if: "!cancelled() && startsWith(github.ref, 'refs/tags/v') && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all'))"
  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-schema
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: publish
      run: sbt 'schema/publish'
publish-api:
  name: publish api
  runs-on: ubuntu-latest
  needs:
    - affected
    - publish-schema
  if: "!cancelled() && startsWith(github.ref, 'refs/tags/v') && (contains(fromJson(needs.affected.outputs.modules), 'api') || contains(fromJson(needs.affected.outputs.modules), 'all')) && 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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: publish
      run: sbt 'api/publish'

With the switch off (the default), the same jobs carry the release gate alone and never mention affected, so turning it on is the only thing that changes a committed ci.yml:

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
  DocsRender.job("publish-api")(Capability.publishGraph)
}
publish-api:
  name: publish api
  runs-on: ubuntu-latest
  needs:
    - publish-schema
  if: startsWith(github.ref, 'refs/tags/v')
  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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: publish
      run: sbt 'api/publish'

Tolerating a skipped need

Narrowing Publish means a job can now skip in a phase where nothing skipped before, and that changes what its dependents see. GitHub's implicit success() treats a skipped need exactly like a failed one: the dependent is skipped too. So every job that needs something narrowable gains two kinds of clause:

ClausePurpose
!cancelled()Makes the job reachable at all once a need can skip, by displacing the implicit success()
needs.<id>.result != 'failure'Restores the blocking that !cancelled() just removed, per need

!= 'failure' rather than == 'success', because skipped is the answer being tolerated. Two needs are excluded from the guard because each already has a clause of its own: affected (read through its output) and verify-gate (fail-open by design, where a skipped gate means "run").

A failed need still blocks, which is the property worth checking after any change here: a red fmt job must not be let through by the same !cancelled() that lets a skipped publish through.

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true)
  DocsRender.job("announce")(
    Capability.publishGraph,
    Capability.once(
      CapabilityName("announce"),
      SbtCommand.unsafeTask("announce"),
      phase = Phase.Deploy,
      gate = Gate.OnReleaseTag,
      needsCapabilities = List(Capability.PublishName),
    ),
  )
}
announce:
  name: announce
  runs-on: ubuntu-latest
  needs:
    - publish-api
    - publish-schema
  if: (!cancelled() && needs.publish-api.result != 'failure' && needs.publish-schema.result != 'failure') && (startsWith(github.ref, 'refs/tags/v'))
  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: announce
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: announce
      run: sbt 'announce'

The one shape that is refused instead of tolerated

Tolerance is right for a job whose command does not name the skipped module: a build-wide announce loses nothing when one module did not publish. It is wrong for a job that names it.

Capability.deploy is exactly that, and it is Aggregate by default while needing docker. Under zipxAffectedPublish alone, an affected-skipped docker-<module> would leave the deploy running and pulling an image tag that run never pushed: a 404 on main, from a ci.yml in which nothing looks wrong. There is no way to drop one module's command from an already-joined sbt session at generate time, so the planner refuses the combination rather than generating it:

zipx: capability 'deploy' is Aggregate and needs 'docker', which zipxAffectedPublish lets skip per module. One
'docker' job skipping would leave 'deploy' running against an artifact nobody built, so this is refused rather than
generated. Fixes, in order of preference: give 'deploy' CapabilityScope.Graph so it skips with its own 'docker' job;
make its command resolve a moving tag that a skipped 'docker' cannot invalidate; or turn zipxAffectedPublish off.

Narrowing Deploy (zipxAffectedDeploy)

The first fix the error above recommends is the one to reach for, and zipxAffectedDeploy is what makes it hold: a Graph deploy carries the same per-module affected expression as its own docker-<module> job, so the two skip together. deploy-<module>-prod runs exactly when docker-<module> did.

Its own switch rather than a widening of zipxAffectedPublish, because narrowing image pushes while still reconciling every destination on every run is a legitimate combination, and one switch would take it away:

zipxAffectedPublish := true   // one changed module, one image pushed
zipxAffectedDeploy  := true   // and one destination reconciled, not all of them

Off by default: a deploy that does not run leaves a destination on its previous version, which is correct only when that module's artifacts really are unchanged. The cost of Graph over Aggregate is job count, one per (module × target), and with it one approval per module per environment.

Both safety properties carry over. A release tag deploys everything: the affected job is forced onto tag pushes and merged-PR pushes when a Deploy capability reads it, and on a non-PR event it emits ["all"] without taking a diff. And an unusable diff fails open to ["all"] the same way.

{
  given PlanConfig =
    config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true, affectedDeploy = true)
  DocsRender.job("deploy-service-prod")(
    Capability.dockerGraph,
    Capability.deployGraph(
      participates = _.docker,
      command = n => SbtCommand.module(n, SbtCommand.unsafeTask("deployTask")),
      targets = _ => List(Target(TargetName("prod"), environment = Some("production"))),
      gate = Gate.Always,
      condition = Some(JobCondition.refIs("refs/heads/main")),
    ),
  )
}
deploy-service-prod:
  name: deploy service (prod)
  runs-on: ubuntu-latest
  needs:
    - affected
    - docker-service
  if: (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'service') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.docker-service.result != 'failure') && (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-service-prod
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: deploy
      run: sbt 'service/deployTask'

Concurrency (cancel superseded runs)

Superseded PR pushes should not burn runners. zipx emits workflow-level concurrency by default:

concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

A half-cancelled Central publish can leave a staged-but-unreleased bundle; that is worse than a wasted runner. Opt out with zipxCancelSupersededRuns := false.

DocsRender.body(Capability.test)
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
  test:
    name: test
    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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: save
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: test
        run: sbt 'test'
  verify:
    name: verify
    runs-on: ubuntu-latest
    needs:
      - test
    if: "!cancelled()"
    steps:
      - name: Fail when a Verify job failed or was cancelled
        if: (needs.test.result == 'failure' || needs.test.result == 'cancelled')
        run: exit 1
{
  given PlanConfig = config.copy(cancelSupersededRuns = false)
  DocsRender.body(Capability.test)
}
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
jobs:
  test:
    name: test
    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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: save
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: test
        run: sbt 'test'
  verify:
    name: verify
    runs-on: ubuntu-latest
    needs:
      - test
    if: "!cancelled()"
    steps:
      - name: Fail when a Verify job failed or was cancelled
        if: (needs.test.result == 'failure' || needs.test.result == 'cancelled')
        run: exit 1