Verify
Verify is several Once jobs with no needs between them, so GitHub runs them in parallel: test, fmt,
workflow-check, and advisories. Publish does not wait on them in-workflow (tag runs already do not need test);
PR merge is the required-checks story.
On sbt 2, plain sbt test can skip suites it thinks are unaffected. zipx defaults to testFull so CI actually runs
every suite. Path-based job skipping (only run jobs this PR touched) lives on Affected, and only in Graph mode.
zipxVerify := ZipxVerify.Strict // all On; the default
zipxVerify := ZipxVerify.Strict.copy(
fmt = VerifyOpt.Skip("hotfix: scalafmt 3.x busts this branch, revert Monday"),
advisories = VerifyOpt.Skip("hotfix: GHSA-xxxx in checkout, Action bump in follow-up"),
)
Skip(reason) still emits the job: it prints zipx: skipping <gate>: <reason> and exits 0. The check name stays
on the PR. Empty or whitespace reason is a zipx: generate error. Replace-by-name still works for a totally custom
fmt command; Skip is for turning the gate off out loud.
fmt:
scalafmtCheckAll. No sbt-scalafmt means generate fails until you add the plugin or Skip fmt.workflow-check:
zipxWorkflowCheck. Drift fails the PR as its own job.advisories:
zipxAdvisoryCheck. OSV on catalogLibrows, resolved Action pins, and Pin vals when feeds exist. Any finding at or above default min-severity fails the job.
Test task and optional clean
zipxTestTask := zipxTasks.of(testFull) // Aggregate root; Graph/Layer per-module (plugin default)
zipxVerifyClean := VerifyClean.CleanFull // None (default) | Clean | CleanFull
For a one-off LocalDir / action-cache bust without making clean permanent, leave zipxVerifyClean at
None (default) and add the GitHub PR label clean. Verify then runs cleanFull; <task> only on that PR.
The version-updates and pin-updates companions always add that label. Override the label name with
zipxVerifyCleanLabel, or set it to None to disable.
zipxVerifyCleanLabel := Some("clean") // default
{
given PlanConfig = config.copy(verifyClean = VerifyClean.CleanFull)
DocsRender.jobs("test")(Capability.test) + "\n---\n" +
DocsRender.job("test-schema")(Capability.testGraph)
}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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'cleanFull; test'
---
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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'cleanFull; schema/test'{
given PlanConfig = config.copy(verifyCleanLabel = PlanConfig.verifyCleanLabel("clean"))
DocsRender.jobs("test")(Capability.test)
}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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: |
if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then
sbt 'cleanFull; test'
else
sbt 'test'
fi
env:
ZIPX_VERIFY_CLEAN_FULL: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'clean') }}Coverage (and why `test` is the wrong task here)
On sbt 2.0 plain test is testQuick. It skips tests it deems unaffected and prints No tests to run, which reads
like success. zipx's Verify default is testFull so CI cannot skip suites. Under coverage that skip is a silent
wrong answer: the hand-rolled alias everyone writes,
addCommandAlias("testWithCoverage", "cleanFull; coverage; compile; test; coverageAggregate; coverageReport; coverageOff")
measures whichever tests sbt happened to run, satisfies coverageMinimum on near-zero data, and goes green. So zipx
builds the command instead of taking one:
zipxCapabilities += Coverage.once() // coverage; testFull; coverageAggregate (one session)
zipxCapabilities += Coverage.graph() // one job per module, each measuring that module's own zipxTestTask
Coverage.graph() reads each module's zipxTestTask and substitutes testFull where it is still the default test.
A module that set the task itself is left alone, because an explicit choice outranks zipx's. Pass _.testTask for
literal inheritance, default and all:
zipxCapabilities += Coverage.graph(task = _.testTask)
Two things the alias has that the capability deliberately does not. No trailing coverageOff: the sbt session ends
with the job, and that command exists because a developer's shell outlives the command. And no cleanFull: use
zipxVerifyClean or the clean PR label above if you want one, so the choice is in one place.
coverage is a session-wide toggle, which is why enable / test / report are one sbt '…; …; …' invocation rather than
three steps.
Prefer once: coverageAggregate is a root task over every module's measurement data, so splitting it across jobs
means downloading and merging artifacts to get one number back. graph buys affected-gating (it is an ordinary Graph
Verify capability, so everything on the Affected page applies) at the cost of per-module minimums instead of a
build-wide one.
The report is uploaded with the already-pinned actions/upload-artifact, per module under graph so N jobs do not
collide on one artifact name. if-no-files-found: error, on purpose: a run that measured nothing produces no report,
and that should be a red job rather than an empty upload. Turn it off with uploadReport = false.
DocsRender.jobs("coverage")(Coverage.once())coverage:
name: coverage
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: coverage
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: coverage
run: sbt 'coverage; testFull; coverageAggregate'
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: coverage-report
path: "**/scoverage-report/**"
if-no-files-found: errorDocsRender.job("coverage-schema")(Coverage.graph())coverage-schema:
name: coverage 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: coverage-schema
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: coverage
run: sbt 'coverage; schema/testFull; schema/coverageReport'
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: coverage-report-schema
path: "**/scoverage-report/**"
if-no-files-found: errorAffected-only PRs (Graph only)
zipxAffectedOnPR (default true) emits an affected setup job only when a Graph Verify capability is present.
Aggregate and Layer always invoke their full stage command (they do not skip GitHub jobs). That is not the same as
"always recompile everything": Zinc and the cross-run task cache (restored by zipx at the epoch, or via remote cache)
still skip unaffected compile work, even on a cold JVM. Verify's default is testFull, so suites still run unless the
whole test task is a cache hit. See Execution modes ("Two kinds of affected") and the Affected page for the
fail-open handoff, who is gated, and zipxAffectedPublish, which extends the same narrowing to Graph Publish jobs as
a separate opt-in.
zipxAffectedOnPR := true // default with Graph Verify
{
given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
DocsRender.body(Capability.test) + "\n---\n" + 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:
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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'test'
---
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"
local-cache: "false"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'service/test'Changed files → owning module (longest base-dir prefix) → reverse-dependency closure. A .sbt change or anything under
project/ forces a full build. On push/tag everything builds unless zipxAffectedOnPush is enabled. If the diff
cannot run, zipx emits ["all"] (fail open) so a bad base ref never reports a green, untested PR.
Skip Verify after merge / on tags
By default (zipxSkipMergedPrPush := true), a push to main that lands a merged PR does not re-run Verify.
Direct pushes still Verify. Tag pushes never run Verify (release tags only need Publish / Deploy). Graph
Publish and Deploy still run after a merge; the affected job they read stays up and emits ["all"] on that
non-PR push, so they do not fromJson an empty output.
verify-gate looks up the landed SHA on GET /commits/{sha}/pulls. That index can lag a few seconds after a
squash (new SHA at merge). The gate retries with 1/2/4/8/16s sleeps rather than treating an empty first answer as a
direct push and running Verify again.
With LocalDir, that skip would otherwise leave main without an actions/cache save (PR caches are
branch-scoped; later PRs only warm from the default branch). So by default zipx also emits a minimal
cache-rehydrate job that runs only when verify-gate skips Verify: same checkout / JDK / LocalDir cache
path, then compile (override with zipxCacheRehydrateTask). No full test, no verifyClean. Set
zipxCacheRehydrateOnMerge := false to opt out; remote backends never emit it.
To also warm non-sbt blobs that live under target/ (e.g. Playwright browsers), opt into rehydrate-only
extraSteps. Prefer build-wide zipxEnv for vars needed on Verify and rehydrate; use
zipxCacheRehydrateEnv only for merge-only overlays. Neither is copied from Verify capability extraSteps /
env; assign the same setup function when you want step parity:
val browserSetup = Steps.built("browsers")(
Step.run(Script(Exec("npm", Word.lit("ci")))).named("Install browsers")
)
zipxSkipMergedPrPush := true // default
zipxCacheRehydrateOnMerge := true // default; LocalDir only
zipxCacheRehydrateTask := zipxTasks.of(compile) // default
zipxEnv := Map(
"PLAYWRIGHT_BROWSERS_PATH" -> EnvValue.typed(Expr.github("workspace") ++ Expr.lit("/target/ms-playwright")),
)
zipxCacheRehydrateExtraSteps := browserSetup // after cache restore, before compile
EnvValue.typed takes any Expr, so ++ concatenates a context reference with literal text instead of spelling the
${{ … }} out. EnvValue.expr still accepts a raw string, and zipxWorkflowGenerate warns when one is used.
browserSetup is a Steps bundle, not a lambda: it carries a name, composes with ++, gates with .when(...),
and reports escape-hatch use so zipxWorkflowGenerate can warn and name it. The step body is a typed Script, so
nothing here is a hand-written shell string. See Shell and steps for the whole DSL.
{
given PlanConfig = config.copy(skipMergedPrPush = true)
DocsRender.jobs("verify-gate", "cache-rehydrate", "test")(Capability.test)
}verify-gate:
name: verify-gate
runs-on: ubuntu-latest
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
pull-requests: read
outputs:
run: ${{ steps.check.outputs.run }}
steps:
- name: Skip Verify after merged PR push
id: check
run: |
# Commits landed by merging/squashing a PR are associated with that PR via the API.
# The commit-to-PR index can lag after squash; retry rather than fail-open into a second Verify.
prs=0
for attempt in 1 2 3 4 5 6; do
if [ "$prs" -eq 0 ]; then
if [ "$attempt" -eq 2 ]; then
sleep 1
elif [ "$attempt" -eq 3 ]; then
sleep 2
elif [ "$attempt" -eq 4 ]; then
sleep 4
elif [ "$attempt" -eq 5 ]; then
sleep 8
elif [ "$attempt" -eq 6 ]; then
sleep 16
fi
prs=$(gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" \
--jq "[.[] | select(.merged_at != null and .base.ref == \"${{ github.ref_name }}\")] | length")
fi
done
if [ "$prs" -gt 0 ]; then
echo "Merged PR push, skipping redundant Verify (already ran on the PR)"
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ github.token }}
cache-rehydrate:
name: cache-rehydrate
runs-on: ubuntu-latest
needs:
- verify-gate
if: needs.verify-gate.result == 'success' && needs.verify-gate.outputs.run == 'false'
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: cache-rehydrate
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: cache-rehydrate
run: sbt 'compile'
test:
name: test
runs-on: ubuntu-latest
needs:
- verify-gate
if: "!cancelled() && !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' && ((needs.verify-gate.result != 'success') || (needs.verify-gate.outputs.run == '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: test
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'test'{
given PlanConfig = config.copy(
skipMergedPrPush = true,
env = Map(
"PLAYWRIGHT_BROWSERS_PATH" ->
EnvValue.typed(Expr.github("workspace") ++ Expr.lit("/target/ms-playwright"))
),
cacheRehydrateExtraSteps = Steps.built("browsers")(
Step.run(Script(Exec("npm", Word.lit("ci")))).named("Install browsers")
),
)
DocsRender.jobs("cache-rehydrate", "test")(Capability.test)
}cache-rehydrate:
name: cache-rehydrate
runs-on: ubuntu-latest
needs:
- verify-gate
if: needs.verify-gate.result == 'success' && needs.verify-gate.outputs.run == 'false'
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/target/ms-playwright
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: cache-rehydrate
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: Install browsers
run: npm ci
- name: cache-rehydrate
run: sbt 'compile'
test:
name: test
runs-on: ubuntu-latest
needs:
- verify-gate
if: "!cancelled() && !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' && ((needs.verify-gate.result != 'success') || (needs.verify-gate.outputs.run == 'true'))"
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/target/ms-playwright
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"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: test
run: sbt 'test'