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 the suites it runs really run. On a PR it chooses them from the diff: the builtin test runs zipxTestAffected, which tests each module the PR can have broken and nothing else (see Affected-only PRs below, and Affected). A push, a dispatch, or a diff that cannot run tests everything.

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.

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 cleanFull without making it permanent, leave zipxVerifyClean at None (default) and add the GitHub PR label clean. Verify then runs cleanFull; <task> only on that PR. cleanFull deletes declared outputs, target/out, and the disk action cache (~/.cache/sbt/v2 on the runner). It does not delete ~/.cache/coursier or ~/.sbt, and it does not skip the LocalDir restore of those directories. target/ is not part of that restore.

The version-updates and pin-updates companions always add that label. Override the label name with zipxVerifyCleanLabel, or set it to None to disable. To skip that restore and save a fresh snapshot, use the purge label (see Caching).

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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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') }}

Test topology stays in the build

zipx does not assign testTopology, workerMaxInstances, Test / fork, testForkedParallel, or parallelExecution. testFull reads the values in build.sbt.

sbt's four topologies are TestTopology.Default (the default), ClassParallel, SubprojectExclusive, and SubprojectSplit(n). Default and ClassParallel ask for a split of 2, Exclusive asks for 1, and SubprojectSplit(n) asks for n. The split runs only when fork, parallelExecution, and testForkedParallel are all on. Fork left off is one group, which is what a zipx build does until the build itself turns fork on.

Coverage (and why `test` is the wrong task here)

On sbt 2 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. Prefer the companion workflow, which keeps coverage off every PR's required checks:

zipxCoverageWorkflow := Some(
  Coverage.workflow(
    CoverageTrigger.Scheduled(Cron.daily(hour = 3)),
    CoverageTrigger.Dispatch,
    CoverageTrigger.prLabel("coverage"),
  )
)

That writes .github/workflows/zipx-coverage.yml, one job running coverage; testFull; coverageAggregate:

  • Scheduled runs on the default branch, the only branch GitHub runs schedules on. Dispatch is Actions → Run workflow, on any branch.

  • prLabel("coverage") runs on a PR once the label goes on, and on every push while it stays. A PR without the label skips the job, and so does adding some other label to a PR that has it.

  • The job restores the build cache and never saves it, and nothing else runs in that workflow. An instrumented class therefore never reaches test, an image, or a publish.

The builtin test stays the build cache's owner. The check to require on the PR is verify: it needs every Verify job, so the ruleset does not grow when one is added. A skipped required check counts as passing, so verify runs unless the workflow was cancelled and fails itself when a Verify job failed or was cancelled. Generate refuses a coverage capability named test, which would make every PR wait on an instrumented build, and a coverage capability that saves the cache. Setting zipxCoverageWorkflow := None (the default) deletes the file on the next generate.

To measure in ci.yml instead, add a capability:

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 on the capabilities.

{
  val coverage = Coverage.workflow(
    CoverageTrigger.Scheduled(Cron.daily(hour = 3)),
    CoverageTrigger.Dispatch,
    CoverageTrigger.prLabel("coverage"),
  )
  CoverageWorkflow.render(coverage, config).yaml
}
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx coverage
"on":
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
      - labeled
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch: null
permissions:
  contents: read
concurrency:
  group: zipx-coverage-${{ github.ref }}
  cancel-in-progress: true
jobs:
  coverage:
    name: coverage
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'coverage') && (github.event.action != 'labeled' || github.event.label.name == 'coverage'))
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        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"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: coverage
        run: sbt 'coverage; testFull; coverageAggregate'
      - name: Upload coverage report
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: coverage-report
          path: "**/scoverage-report/**"
          if-no-files-found: error
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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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: error
DocsRender.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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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: error

Affected-only PRs

zipxAffectedOnPR (default true) narrows Verify to what a PR can have broken, in three ways:

  • The builtin test runs sbt 'zipxTestAffected <PR base>'. The command diffs against the same base as the affected job, then runs each affected module's own zipxTestTask in the same sbt session, as one all a/testFull b/testFull command: a single parallel task graph, as a root testFull is. It runs only CI-relevant modules the root aggregate reaches, since a root testFull never reaches the others and an aggregator's own test task would rerun what it aggregates. No base, an all-zero one, or a diff that fails runs the full command as before, and an empty affected set tests nothing. test takes no needs: affected, so it neither waits on that job nor pays a second sbt load. Locally, sbt "zipxTestAffected origin/main" runs the same selection.

  • A Graph Verify capability gets an affected setup job, and each per-module job skips when its module is not affected.

  • A Once job whose inputs the classpath cannot see, such as an integration test over images Docker/publishLocal builds, opts in with withAffectedBy, which gates it on the affected job:

zipxCapabilities += zipxTasks
  .once(name = CapabilityName("image-it"), command = imageIt / testFull, phase = Phase.Verify, gate = Gate.Always)
  .withAffectedBy(n => imageModules.contains(n.id))

Layer and other Aggregate capabilities still run their full stage command. That is not the same as "always recompile everything": Zinc and the cross-run task cache still skip unaffected compile work, even on a cold JVM. See Execution modes ("Two kinds of affected") and the Affected page for the fail-open handoff and zipxAffectedPublish, which extends the narrowing to Graph Publish jobs as a separate opt-in.

or Graph
{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
  val imageIt      = Capability
    .once(
      name = CapabilityName("image-it"),
      command = SbtCommand.unsafeTask("imageIt/testFull"),
      phase = Phase.Verify,
      gate = Gate.Always,
    )
    .withAffectedBy(_.id == "service")
  DocsRender.jobs("test")(Capability.testAffected(onPush = false)) + "\n---\n" +
    DocsRender.body(Capability.testGraph) + "\n---\n" + DocsRender.body(imageIt)
}
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 'zipxTestAffected ${{ github.event.pull_request.base.sha }}'
---
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
---
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"
  image-it:
    name: image-it
    runs-on: ubuntu-latest
    needs:
      - affected
    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')))
    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: image-it
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: image-it
        run: sbt 'imageIt/testFull'
  verify:
    name: verify
    runs-on: ubuntu-latest
    needs:
      - image-it
    if: "!cancelled()"
    steps:
      - name: Fail when a Verify job failed or was cancelled
        if: (needs.image-it.result == 'failure' || needs.image-it.result == 'cancelled')
        run: exit 1

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, which diffs a push against the head of this workflow's last successful push run on the branch. A cancelled or unapproved run ships nothing, so diffing against the previous push would drop what it changed. That lookup calls gh. The runner image has the client. The sbt image bakes it from Pin("sbt-image", "gh", "<x.y.z>") (see Caching, "Sbt image"). 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 Test/compile (override with zipxCacheRehydrateTask), so the next PR restores the action cache from that compile, including test outputs. It is the merge push's only LocalDir save (see Caching, "Who saves"). No full test, no verifyClean. Set zipxCacheRehydrateOnMerge := false to opt out; remote backends never emit it.

The snapshot does not keep files under target/. A browser install has to run in the job until the env image bakes it (see Caching, "Sbt image"). 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(Test / compile)  // default
zipxEnv := Map(
  "PLAYWRIGHT_BROWSERS_PATH" -> EnvValue.typed(Expr.github("workspace") ++ Expr.lit("/target/ms-playwright")),
)
zipxCacheRehydrateExtraSteps := browserSetup  // after cache restore, before Test/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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: cache-rehydrate
      run: sbt 'Test/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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - 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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: Install browsers
      run: npm ci
    - name: cache-rehydrate
      run: sbt 'Test/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"
        cache-mode: save
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: test
      run: sbt 'test'