Docker and deploy

Skip until you ship a service image or more than one environment. Enable sbt-native-packager's DockerPlugin on the module; zipx adds the docker job. Targets are named environments (staging, production) with separate GitHub Environment approvals.

Green is the image path (plugin → Aggregate Docker/publish). Amber is the target fan-out: one deploy job per Target, each wired to its own GitHub Environment (approvals stay independent).

Where they run is one setting. By default (DeployTrigger.OnMerge) images and deploys are jobs in ci.yml, gated like any other job: on a release tag, or on a condition you add. DeployTrigger.Manual moves them into a dispatched zipx-deploy.yml that ships only what changed since each Environment's last deploy, so a merge ships nothing and a pending production approval never holds up the next merge. DeployTrigger.Staged runs the same workflow on every merge and on labeled PRs too, for pre-production targets only. The sections below apply to all three; the last two cover Manual and Staged. CI for a busy monorepo shows them beside the other settings a large repo turns on.

Docker paved path

lazy val service = project
  .dependsOn(coreLib)
  .enablePlugins(JavaAppPackaging, DockerPlugin)
  .settings(
    publishArtifact     := false,
    Compile / mainClass := Some("example.Main"),
    dockerBaseImage     := "eclipse-temurin:21-jre",
    Docker / packageName := "example-service",
  )

zipx detects DockerPlugin and emits a release-gated Aggregate docker job joining …/Docker/publish (or use dockerGraph). Pushing one image to several registries stays one job; see Registries are destinations, targets are environments below. For PR-label stage ECR (before merge), see Job conditions. Ship / ShipGroup rows release libraries from zipx-release.yml; the image and deploy still wait on a human v* tag in ci.yml. See Independent versions.

DocsRender.job("docker")(Capability.docker)
docker:
  name: docker
  runs-on: ubuntu-latest
  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: docker
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: docker
      run: sbt 'service/Docker/publish'

Registries are destinations, targets are environments

The rule of thumb, because getting it backwards is expensive:

The destinations areShapeWhy
Registries for one imagewithSharedTargets (TargetFanOut.SharedJob)Docker / publish builds once and pushes every dockerAliases entry, so N registries is one job
Deploy environmentswithTargets (TargetFanOut.JobPerTarget, the default)Each really is a separate job: its own approval, its own environment:, its own if:

targets multiplies jobs. That is right for the second row and wrong for the first: 6 registries across 8 images is 48 jobs under JobPerTarget and 8 under SharedJob, and the 48 each rebuild the same image, so nothing guarantees the registries hold identical bytes. One build pushed N times does guarantee it.

zipxCapabilities += Capability.docker.withSharedTargets(
  List(
    Target(TargetName("us"), env = Map("AWS_REGION" -> EnvValue.plain("us-east-1"), "AWS_ROLE_TO_ASSUME" -> secret"US_ROLE")),
    Target(TargetName("eu"), env = Map("AWS_REGION" -> EnvValue.plain("eu-west-1"), "AWS_ROLE_TO_ASSUME" -> secret"EU_ROLE")),
  )
).copy(extraSteps = ZipxAws.sharedLoginSteps)

One job, one image, OIDC then ECR login per destination. On AWS, ZipxAws.dockerPublishAll(registries) is that whole expression (see Packs).

DocsRender.job("docker")(
  Capability.docker.withSharedTargets(
    List(
      Target(
        TargetName("us"),
        env = Map("AWS_REGION" -> EnvValue.plain("us-east-1"), "AWS_ROLE_TO_ASSUME" -> secret"US_ROLE"),
      ),
      Target(
        TargetName("eu"),
        env = Map("AWS_REGION" -> EnvValue.plain("eu-west-1"), "AWS_ROLE_TO_ASSUME" -> secret"EU_ROLE"),
      ),
    )
  )
)
docker:
  name: docker
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    ZIPX_EU_AWS_REGION: eu-west-1
    ZIPX_EU_AWS_ROLE_TO_ASSUME: ${{ secrets.EU_ROLE }}
    ZIPX_US_AWS_REGION: us-east-1
    ZIPX_US_AWS_ROLE_TO_ASSUME: ${{ secrets.US_ROLE }}
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: docker
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: docker
      run: sbt 'service/Docker/publish'

Why the env keys are prefixed

Both destinations want AWS_ROLE_TO_ASSUME. Merging unprefixed would keep whichever one came last, and the job would push twice to one account while silently skipping the other, so a shared job puts each destination's env under Target.envKey: ZIPX_<TARGET>_<KEY>. Target.envName(name) is how a step reads it back, and the fixed ZIPX_ anchor is what makes that total: a target named github would otherwise derive a GITHUB_… name, which EnvName refuses because GitHub reserves the prefix.

extraSteps receives every destination as StepContext.destinations (and StepContext.target is None, since there is no single target a shared job belongs to), which is how one bundle emits one login per registry.

What a shared job refuses

A Target.condition or Target.environment under SharedJob is a generate-time error, not a silently dropped field:

zipx: capability 'docker' target 'us' sets a condition, which one shared job cannot honor per destination.
Use TargetFanOut.JobPerTarget (the default) when destinations need their own condition, or drop it and gate
the whole job with Capability.condition.

Dropping it would push to a registry the author said to skip; applying it job-wide would skip the ones that were fine. Both are wrong answers arrived at quietly, so zipx declines to pick one. Per-destination approval is the second row of the table: that is what JobPerTarget is for.

Aggregate-by-target deploy

Default (Capability.deploy / zipxTasks.deploy): one job per Target; participating modules' commands are joined. GitHub Environments stay independent.

Escape hatch (Capability.deployGraph / zipxTasks.deployGraph): one job per (module × target).

val promote = taskKey[Unit]("promote the image")

zipxCapabilities += zipxTasks.deploy(
  participates = _.id == "service",
  command = promote,
  targets = _ => List(
    Target(TargetName("staging"), env = Map("TIER" -> EnvValue.plain("staging"))),
    Target(
      TargetName("prod"),
      environment = Some("production"),
      env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
      condition = Some(JobCondition.varNonEmpty("DEPLOY_PROD_ENABLED")),
    ),
  ),
  needsCapabilities = List(Capability.DockerName),
  permissions = Map("id-token" -> "write", "contents" -> "read"),
)

Note what the prod condition is not: refIs("refs/heads/main"). Capability.deploy gates OnReleaseTag, the two are ANDed, and no ref is both a v* tag and refs/heads/main, so zipx refuses to generate that pair outright (see [[JobConditions]]). Pass gate = Gate.Always if deploy-from-main is what you want.

{
  val targets = List(
    Target(TargetName("staging"), env = Map("TIER" -> EnvValue.plain("staging"))),
    Target(
      TargetName("prod"),
      environment = Some("production"),
      env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
      condition = Some(JobCondition.varNonEmpty("DEPLOY_PROD_ENABLED")),
    ),
  )
  DocsRender.jobs("deploy-staging", "deploy-prod")(
    Capability.deploy(
      participates = _.id == "service",
      command = n => SbtCommand.module(n, SbtCommand.unsafeTask("promote")),
      targets = _ => targets,
      needsCapabilities = Nil,
    )
  )
}
deploy-staging:
  name: deploy (staging)
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    TIER: staging
  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-staging
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: deploy
      run: sbt 'service/promote'
deploy-prod:
  name: deploy (prod)
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (vars.DEPLOY_PROD_ENABLED != '')
  environment: production
  env:
    DEPLOY_ROLE: ${{ secrets.PROD_ROLE }}
    TIER: prod
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: deploy-prod
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: deploy
      run: sbt 'service/promote'

Approval is enforced by GitHub, not zipx. zipx emits the environment: binding; GitHub pauses for protection rules. Put deploy config in project/*.scala as typed lists (see examples/monorepo).

Deploy by hand (DeployTrigger.Manual)

By default images and deploys run in ci.yml, on whatever their gates select. On a busy main branch that means every merge builds images, and a production approval that nobody answers holds ci.yml's concurrency group, so later merges queue behind it. DeployTrigger.Manual moves them into their own workflow that someone runs:

zipxDeployTrigger := DeployTrigger.Manual()

// on each image module: what the tag check looks up
zipxImageRefs := (Docker / dockerAliases).value.map(_.toString)

Target(TargetName("stg"), environment = Some("staging"), group = Some(TargetGroup("pre-prod")))

ci.yml keeps Verify and library publish. .github/workflows/zipx-deploy.yml takes the image capability (Capability.DockerName), every Deploy capability, and everything that needs one. It runs from Actions → Run workflow with three inputs:

InputValues
moduleschanged (default), all, or one module
targetchoose (the default, which deploys nothing), a target name, or a Target.group that deploys every target in it
shaa commit on the dispatched branch, for a rollback; empty deploys the branch head

changed is per Environment. The resolve job reads each Environment's deployments from GitHub and diffs the last successful deploy of each module against the commit being deployed. A module never deployed there is deployed, and so is every module when the diff cannot run: an over-deploy costs minutes, an under-deploy ships a stale service. GitHub records each job that binds an Environment as a deployment of the run's commit, so every deploy job sets its environment url to …/commit/<sha>#<module>, and that url is what resolve reads back. A rollback is recorded as the commit it deployed.

An image is pushed once per commit. Image jobs bind the zipx-images Environment so each push is recorded too, and run <module>/zipxImageMissing first: it checks every zipxImageRefs entry with docker manifest inspect and the push runs only when one is missing. A rebuild is not byte-identical, and an immutable-tag registry rejects a second push.

Every job checks out the plan's commit and exports it as ZIPX_DEPLOY_SHA; a build that tags images by commit should read it before GITHUB_SHA. Each job that binds an Environment is its own concurrency group across runs, so two dispatches reaching one Environment by different choices (prod, or a group containing it) queue instead of interleaving, and two pushes of one image never race. Nothing is cancelled in progress.

Production deploys only from the default branch. A Target's stage is DeployStage.Production unless it says otherwise, and resolve refuses a dispatch from any other branch that selects one. The sha input must be an ancestor of the dispatched branch's head, so a dispatch from main cannot name an unmerged commit. Pair each production Environment with a deployment branch policy on the default branch: GitHub enforces it even against a workflow edited on a branch, which no check inside the workflow can.

Generate refuses what the deploy workflow could not run as declared: a capability that is not Graph-scoped, a Verify capability that needs an image, a need on a capability left in ci.yml, a condition that requires a push (a dispatch never is one), a deploy target with no Environment, a group named like a target, a target named choose, and a target declared Production in one capability and PreProduction in another. Release gates do not apply there, since the plan is the gate. Jobs are always one per module and target: a collapsed matrix binds its Environment on every leg, so a skipped leg would still wait for approval and record a deploy that never happened.

{
  val targets = List(
    Target(TargetName("stg"), environment = Some("staging"), group = Some(TargetGroup("pre-prod"))),
    Target(TargetName("prod"), environment = Some("production")),
  )
  val deploys = List(
    Capability.dockerGraph.copy(gate = Gate.Always),
    Capability.deployGraph(
      participates = _.id == "service",
      command = n => SbtCommand.module(n, SbtCommand.unsafeTask("promote")),
      targets = _ => targets,
      gate = Gate.Always,
    ),
  )
  DeployWorkflow.render(libGraph, deploys, config, DeployTrigger.Manual()).yaml
}
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx deploy
"on":
  workflow_dispatch:
    inputs:
      modules:
        description: "changed: what differs from each Environment's last deploy; all; or one module"
        type: choice
        required: true
        default: changed
        options:
          - changed
          - all
          - service
      target:
        description: Target or group to deploy to (choose deploys nothing)
        type: choice
        required: true
        default: choose
        options:
          - choose
          - prod
          - stg
          - pre-prod
      sha:
        description: Commit to deploy (40 hex). Empty deploys the dispatched branch head.
        type: string
        required: false
concurrency:
  group: zipx-deploy-${{ inputs.target }}
  cancel-in-progress: false
jobs:
  resolve:
    name: resolve
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: read
    outputs:
      sha: ${{ steps.plan.outputs.sha }}
      images: ${{ steps.plan.outputs.images }}
      targets: ${{ steps.plan.outputs.targets }}
    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: resolve
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Resolve deploy plan
        id: plan
        run: |
          sbt zipxDeployPlan
          printf '%s\n' "sha=$(cat target/zipx-deploy/sha)" "images=$(cat target/zipx-deploy/images.json)" "targets=$(cat target/zipx-deploy/targets.json)" >> "$GITHUB_OUTPUT"
        env:
          ZIPX_DEPLOY_MODULES: ${{ inputs.modules }}
          ZIPX_DEPLOY_REQUESTED_SHA: ${{ inputs.sha }}
          ZIPX_GITHUB_TOKEN: ${{ github.token }}
          ZIPX_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
          ZIPX_DEPLOY_TARGET: ${{ inputs.target }}
  docker-service:
    name: docker service
    runs-on: ubuntu-latest
    needs:
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.images), 'service')"
    environment:
      name: zipx-images
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-docker-service
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          fetch-depth: "0"
          fetch-tags: "true"
      - name: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: docker-service
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Check image tags
        id: image-tags
        run: |
          sbt "service/zipxImageMissing"
          echo "missing=$(cat target/zipx-image-missing)" >> "$GITHUB_OUTPUT"
      - name: docker
        if: steps.image-tags.outputs.missing == 'true'
        run: sbt 'service/Docker/publish'
  deploy-service-prod:
    name: deploy service (prod)
    runs-on: ubuntu-latest
    needs:
      - docker-service
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.targets)['prod'], 'service') && needs.docker-service.result != 'failure'"
    environment:
      name: production
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-deploy-service-prod
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          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/promote'
  deploy-service-stg:
    name: deploy service (stg)
    runs-on: ubuntu-latest
    needs:
      - docker-service
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.targets)['stg'], 'service') && needs.docker-service.result != 'failure'"
    environment:
      name: staging
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-deploy-service-stg
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          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-stg
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: deploy
        run: sbt 'service/promote'

Staging on merge, branches by label (DeployTrigger.Staged)

Manual makes every deploy a dispatch. Most teams want staging to follow main on its own, a way to try a branch on staging, and production only when someone asks. DeployTrigger.Staged is that, in the same zipx-deploy.yml:

zipxDeployTrigger := DeployTrigger.staged(deployLabel = "deploy-stg", skipLabel = "no-deploy")

Target(TargetName("stg"), environment = Some("staging"), stage = DeployStage.PreProduction)
Target(TargetName("prod"), environment = Some("production")) // Production, the default
What happensWhat it deploysTo
A merge to the default branchIts changed modules, unless the merged PR carries skipLabelEvery PreProduction target
A push to a PR carrying deployLabel (or adding the label)The PR head's changed modules, and only the images those needEvery PreProduction target
A dispatch from the default branchWhatever the form selectsAny target

A branch reaches nothing without the label: an unlabeled PR's run stops at resolve's job condition before a runner boots sbt, and a dispatch from a branch is refused (the label is the one way a branch deploys). Nothing but a dispatch from the default branch reaches a Production target; no merge or PR plan can contain one. Generate refuses a Staged build with no PreProduction target, and one label used for both deploying and skipping.

skipLabel defers; it does not exclude. A merge labeled no-deploy deploys nothing, but staging always deploys the default branch's head, so the next merge that deploys ships that code too, because changed compares each module's last deploy with the new commit. That comparison runs in both directions, so the merge after a labeled PR's deploy also puts the default branch back on every module the branch changed.

Runs from one source share a group: a PR's runs replace each other, merges replace each other, and neither replaces a pending dispatch. The per-Environment job groups from Deploy by hand still order everything that reaches one Environment. resolve reads the merged PR's labels through GitHub's GraphQL API, so it needs pull-requests: read, which zipx emits.

{
  val targets = List(
    Target(TargetName("stg"), environment = Some("staging"), stage = DeployStage.PreProduction),
    Target(TargetName("prod"), environment = Some("production")),
  )
  val deploys = List(
    Capability.dockerGraph.copy(gate = Gate.Always),
    Capability.deployGraph(
      participates = _.id == "service",
      command = n => SbtCommand.module(n, SbtCommand.unsafeTask("promote")),
      targets = _ => targets,
      gate = Gate.Always,
    ),
  )
  DeployWorkflow.render(libGraph, deploys, config, DeployTrigger.staged("deploy-stg", "no-deploy")).yaml
}
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx deploy
"on":
  push:
    branches:
      - main
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
      - labeled
  workflow_dispatch:
    inputs:
      modules:
        description: "changed: what differs from each Environment's last deploy; all; or one module"
        type: choice
        required: true
        default: changed
        options:
          - changed
          - all
          - service
      target:
        description: Target or group to deploy to (choose deploys nothing)
        type: choice
        required: true
        default: choose
        options:
          - choose
          - prod
          - stg
      sha:
        description: Commit to deploy (40 hex). Empty deploys the dispatched branch head.
        type: string
        required: false
concurrency:
  group: zipx-deploy-${{ github.event_name }}-${{ github.event.pull_request.number }}${{ inputs.target }}
  cancel-in-progress: false
jobs:
  resolve:
    name: resolve
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'deploy-stg')
    permissions:
      contents: read
      deployments: read
      pull-requests: read
    outputs:
      sha: ${{ steps.plan.outputs.sha }}
      images: ${{ steps.plan.outputs.images }}
      targets: ${{ steps.plan.outputs.targets }}
    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: resolve
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Resolve deploy plan
        id: plan
        run: |
          sbt zipxDeployPlan
          printf '%s\n' "sha=$(cat target/zipx-deploy/sha)" "images=$(cat target/zipx-deploy/images.json)" "targets=$(cat target/zipx-deploy/targets.json)" >> "$GITHUB_OUTPUT"
        env:
          ZIPX_DEPLOY_MODULES: ${{ inputs.modules }}
          ZIPX_DEPLOY_REQUESTED_SHA: ${{ inputs.sha || github.event.pull_request.head.sha }}
          ZIPX_GITHUB_TOKEN: ${{ github.token }}
          ZIPX_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
          ZIPX_DEPLOY_TARGET: ${{ inputs.target }}
          ZIPX_DEPLOY_PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
  docker-service:
    name: docker service
    runs-on: ubuntu-latest
    needs:
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.images), 'service')"
    environment:
      name: zipx-images
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-docker-service
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          fetch-depth: "0"
          fetch-tags: "true"
      - name: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: docker-service
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Check image tags
        id: image-tags
        run: |
          sbt "service/zipxImageMissing"
          echo "missing=$(cat target/zipx-image-missing)" >> "$GITHUB_OUTPUT"
      - name: docker
        if: steps.image-tags.outputs.missing == 'true'
        run: sbt 'service/Docker/publish'
  deploy-service-prod:
    name: deploy service (prod)
    runs-on: ubuntu-latest
    needs:
      - docker-service
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.targets)['prod'], 'service') && needs.docker-service.result != 'failure'"
    environment:
      name: production
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-deploy-service-prod
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          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/promote'
  deploy-service-stg:
    name: deploy service (stg)
    runs-on: ubuntu-latest
    needs:
      - docker-service
      - resolve
    if: "!cancelled() && needs.resolve.result == 'success' && contains(fromJson(needs.resolve.outputs.targets)['stg'], 'service') && needs.docker-service.result != 'failure'"
    environment:
      name: staging
      url: ${{ github.server_url }}/${{ github.repository }}/commit/${{ needs.resolve.outputs.sha }}#service
    concurrency: zipx-deploy-deploy-service-stg
    env:
      ZIPX_DEPLOY_SHA: ${{ needs.resolve.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.resolve.outputs.sha }}
          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-stg
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: deploy
        run: sbt 'service/promote'