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:
| Reader | What it does with the set | On by default? |
|---|---|---|
The builtin test | zipxTestAffected runs those modules' test tasks inside the one Aggregate job | Yes, on PRs |
Graph jobs (testGraph, publishGraph, deployGraph) | Each per-module job skips when its module is not in the set | Verify yes; Publish and Deploy are opt-ins |
Once jobs with withAffectedBy | Skip unless one of their named modules is in the set | Per 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:
Build files force everything. If any path ends in
.sbtor sits under aproject/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 rootbuild.sbt, whose diffs zipx reads; see Catalog bumps and build.sbt edits below.Otherwise: longest owned-path prefix. A module owns its
baseDirand its source directories (sbt'sunmanagedSourceDirectories, Compile and Test). A file is owned by every module whose longest matching prefix is the longest match overall. Matching is directory-aware:core/ownscore/src/X.scala, but notcore-lib/…orcore-extra/…. Nested bases win:mods/inner/X.scalabelongs tomods/inner, notmods.Unowned paths seed nothing.
README.md,.github/…, and other files outside every module's owned paths are ignored (unless step 1 applies). Aggregators with emptybaseDirnever 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 path | Owning module | After closure (example) |
|---|---|---|
client/src/… | client (leaf) | just client |
models/src/… | models | models + every transitive dependent |
mods/inner/X.scala | inner (longer than mods) | inner + dependents |
README.md | none | empty (no Graph Verify) |
project/plugins.sbt, project/*.scala | (build file) | all modules |
build.sbt, inside one project's definition | that project | that project + dependents |
build.sbt, a shared helper or bare setting | (build-wide) | all modules |
project/ZipxVersions.scala, one Lib version moved | the modules that declare that library | those 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 1Catalog 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:
| Change | Affects |
|---|---|
A Lib version (with its .mod copies and fromGraph rows) | The modules whose libraryDependencies declare it, then their dependents |
An Action pin | No module: it changes generated workflows, which no module owns |
sbt, Scala, or a Plugin version; a Ship row | Every module |
| A row added or removed, a group edited, or any other text | Every module |
A Lib no module declares | Every 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:
| Change | Affects |
|---|---|
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 import | Every module: sbt 2 applies bare settings to every project |
| An aggregator's definition, a definition added or removed | Every module |
| A comment or blank line | Nothing |
"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 path | Owning modules | Why |
|---|---|---|
core/src/main/scala/Foo.scala | core and coreJS | shared: on both rows' source dirs |
core/src/main/scalajs/Foo.scala | coreJS | on the JS row alone |
core/src/main/scalajvm/Foo.scala | core | on the JVM row alone |
core/README.md | none | under 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.
| Diff outcome | Value | Emitted | CI result |
|---|---|---|---|
| Succeeded, no changes | Some(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 files | Some(files) | affected closure | Gate 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 shape | Path-affected? | Why |
|---|---|---|
The builtin test | Yes, by default | zipxTestAffected tests the affected modules the root aggregate reaches, as one parallel all command |
Capability.testGraph (and other Graph + Verify) | Yes, by default | Per-module jobs can skip |
A Once job with withAffectedBy | Yes, when one of its modules is affected | For inputs the classpath graph cannot see |
Graph Publish (publishGraph, dockerGraph) | Only under zipxAffectedPublish | See the next section: the two risks are not symmetric |
Graph Deploy (deployGraph) | Only under zipxAffectedDeploy | So a deploy skips exactly when the publish it consumes did |
| Other Aggregate / Layer, any phase | Never | One 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:
A release tag publishes everything. There is no base ref to diff a tag against, so
affectedemits["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.Fail open. A diff that could not run emits
["all"], so a bad base ref publishes too much rather than too little.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:
| Clause | Purpose |
|---|---|
!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