Packs

Skip this page until you are ready to publish or deploy. A pack is a ready-made path: one line in the build. Secret names live in Scala; values stay in GitHub.

Amber is the knob (zipxCapabilities += …). Each green pack is a paved Publish/docs capability that lands in its destination; you only name secrets in code, values stay in GitHub. Ship rows release from zipx-release.yml instead; see Release workflows below.

ZipxCentral

// Aggregate (preferred for libraries / dogfood)
zipxCapabilities += ZipxCentral.release   // GPG import + publishSigned; sonaRelease

// Append without restating gpg-import (withExtraSteps would replace it)
zipxCapabilities += ZipxCentral.release.plusExtraSteps(publishCleanFull)
DocsRender.job("publish")(ZipxCentral.release)
publish:
  name: publish
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    PGP_KEY_HEX: ${{ secrets.PGP_KEY_HEX }}
    PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
    SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
    SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
  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
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: Import signing key
      run: |
        mkdir -p ~/.gnupg && chmod 700 ~/.gnupg
        echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
        echo "pinentry-mode loopback"   >> ~/.gnupg/gpg.conf
        gpgconf --kill gpg-agent || true
        echo "$PGP_SECRET" | base64 --decode | gpg --batch --import
      env:
        PGP_SECRET: ${{ secrets.PGP_SECRET }}
    - name: publish
      run: sbt 'schema/publishSigned; api/publishSigned; sonaRelease'

Release workflows

A build with Ship / ShipGroup rows publishes from zipx-release.yml, never from ci.yml. The workflow is one registry. These three are the same shape: a ships field that opens on all, one sbt session, one deployment. Signing and sonaRelease stay on Central. GitHub Packages and a two-URL Maven repository publish unsigned. How a run chooses ships, and why one run is one deployment: Snapshots and releases.

zipxReleaseWorkflow := Some(ZipxCentral.releases)
zipxCapabilities += ZipxCentral.snapshots   // each merge: unreleased rows at <row>-<sha>-SNAPSHOT, unsigned

zipxReleaseWorkflow := Some(
  ZipxGitHubPackages.releases("iterable", "maven-packages", token = secret"GH_PACKAGES_TOKEN")
)

zipxReleaseWorkflow := Some(
  ZipxMaven.releases(
    snapshots = "https://acme.artifactory.example/maven-snapshots",
    releases  = "https://acme.artifactory.example/maven-releases",
    username  = secret"MAVEN_USERNAME",
    password  = secret"MAVEN_PASSWORD",
  )
)

A bearer token is the other Maven constructor: ZipxMaven.releases(snapshots, releases, token = secret"CODEARTIFACT_TOKEN"). A fixed username such as CodeArtifact's aws is EnvValue.plain("aws") on the username/password constructor. Minting that token is a workflow step the build adds, the same way Central imports a signing key. The registry does not learn AWS.

ReleaseWorkflow.render(ZipxCentral.releases, config, TagScheme.PerRow).yaml
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx release
"on":
  push:
    tags:
      - "*/v*"
  workflow_dispatch:
    inputs:
      ships:
        description: all, or comma-separated ship names (client, libs)
        type: string
        required: true
        default: all
permissions:
  contents: write
  pull-requests: write
concurrency:
  group: zipx-release
  cancel-in-progress: false
jobs:
  release:
    name: release
    runs-on: ubuntu-latest
    if: github.event_name == 'push' || github.ref_name == github.event.repository.default_branch
    environment: zipx-release
    env:
      PGP_KEY_HEX: ${{ secrets.PGP_KEY_HEX }}
      PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
      SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
      SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
    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: release
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Tag is on the default branch
        if: github.event_name == 'push'
        run: |
          set -euo pipefail
          if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$ZIPX_DEFAULT_BRANCH"; then
            echo "::error title=zipx release::zipx: tag $GITHUB_REF_NAME is not on $ZIPX_DEFAULT_BRANCH"
            exit 1
          fi
        env:
          ZIPX_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
      - name: Import signing key
        run: |
          mkdir -p ~/.gnupg && chmod 700 ~/.gnupg
          echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
          echo "pinentry-mode loopback"   >> ~/.gnupg/gpg.conf
          gpgconf --kill gpg-agent || true
          echo "$PGP_SECRET" | base64 --decode | gpg --batch --import
        env:
          PGP_SECRET: ${{ secrets.PGP_SECRET }}
      - name: Release request
        run: |
          set -euo pipefail
          if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
            echo "ZIPX_RELEASE_REF=$ZIPX_SHIPS" >> "$GITHUB_ENV"
          else
            echo "ZIPX_RELEASE_REF=$GITHUB_REF" >> "$GITHUB_ENV"
          fi
        env:
          ZIPX_SHIPS: ${{ inputs.ships }}
      - name: Release
        run: |
          set -euo pipefail
          sbt "zipxRelease $ZIPX_RELEASE_REF"
      - name: Tag and publish GitHub Releases
        if: github.event_name == 'workflow_dispatch'
        run: |
          set -euo pipefail
          for tag in $(cat "target/zipx-release-tags.txt"); do
            gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
          done
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Open the next snapshot
        run: |
          set -euo pipefail
          echo "## Open the next snapshot" >> "$GITHUB_STEP_SUMMARY"
          echo "After the deployment, that line hides further snapshots of it. Run sbt zipxModverBump and merge it so the next line opens." >> "$GITHUB_STEP_SUMMARY"
          cat target/zipx-release-tags.txt >> "$GITHUB_STEP_SUMMARY"
      - name: Open the bump pull request
        run: |
          set -euo pipefail
          sbt zipxModverBump
          if [ -z "$(git status --porcelain)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout -B "zipx/modver-bump-${GITHUB_RUN_ID}"
          git add --all -- . ":!.github/workflows"
          if [ -z "$(git diff --cached --name-only)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git commit -m "Open the next snapshot line."
          git push -u origin HEAD
          gh label create clean --force --description "zipx: Verify runs cleanFull" || true
          cat > /tmp/zipx-pr-body.md <<'EOF'
          The release tag stays on the release commit. This branch opens the next catalog line with sbt zipxModverBump.
          EOF
          gh pr create --title "Open the next snapshot line" --body-file /tmp/zipx-pr-body.md --head "zipx/modver-bump-${GITHUB_RUN_ID}" --label clean

        env:
          GH_TOKEN: ${{ github.token }}
ReleaseWorkflow
  .render(
    ZipxGitHubPackages.releases("iterable", "maven-packages", token = secret"GH_PACKAGES_TOKEN"),
    config,
    TagScheme.PerRow,
  )
  .yaml
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx release
"on":
  push:
    tags:
      - "*/v*"
  workflow_dispatch:
    inputs:
      ships:
        description: all, or comma-separated ship names (client, libs)
        type: string
        required: true
        default: all
permissions:
  contents: write
  pull-requests: write
  packages: write
concurrency:
  group: zipx-release
  cancel-in-progress: false
jobs:
  release:
    name: release
    runs-on: ubuntu-latest
    if: github.event_name == 'push' || github.ref_name == github.event.repository.default_branch
    environment: zipx-release
    env:
      GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
    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: release
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Tag is on the default branch
        if: github.event_name == 'push'
        run: |
          set -euo pipefail
          if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$ZIPX_DEFAULT_BRANCH"; then
            echo "::error title=zipx release::zipx: tag $GITHUB_REF_NAME is not on $ZIPX_DEFAULT_BRANCH"
            exit 1
          fi
        env:
          ZIPX_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
      - name: Release request
        run: |
          set -euo pipefail
          if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
            echo "ZIPX_RELEASE_REF=$ZIPX_SHIPS" >> "$GITHUB_ENV"
          else
            echo "ZIPX_RELEASE_REF=$GITHUB_REF" >> "$GITHUB_ENV"
          fi
        env:
          ZIPX_SHIPS: ${{ inputs.ships }}
      - name: Release
        run: |
          set -euo pipefail
          sbt "zipxRelease $ZIPX_RELEASE_REF"
      - name: Tag and publish GitHub Releases
        if: github.event_name == 'workflow_dispatch'
        run: |
          set -euo pipefail
          for tag in $(cat "target/zipx-release-tags.txt"); do
            gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
          done
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Open the next snapshot
        run: |
          set -euo pipefail
          echo "## Open the next snapshot" >> "$GITHUB_STEP_SUMMARY"
          echo "After the deployment, that line hides further snapshots of it. Run sbt zipxModverBump and merge it so the next line opens." >> "$GITHUB_STEP_SUMMARY"
          cat target/zipx-release-tags.txt >> "$GITHUB_STEP_SUMMARY"
      - name: Open the bump pull request
        run: |
          set -euo pipefail
          sbt zipxModverBump
          if [ -z "$(git status --porcelain)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout -B "zipx/modver-bump-${GITHUB_RUN_ID}"
          git add --all -- . ":!.github/workflows"
          if [ -z "$(git diff --cached --name-only)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git commit -m "Open the next snapshot line."
          git push -u origin HEAD
          gh label create clean --force --description "zipx: Verify runs cleanFull" || true
          cat > /tmp/zipx-pr-body.md <<'EOF'
          The release tag stays on the release commit. This branch opens the next catalog line with sbt zipxModverBump.
          EOF
          gh pr create --title "Open the next snapshot line" --body-file /tmp/zipx-pr-body.md --head "zipx/modver-bump-${GITHUB_RUN_ID}" --label clean

        env:
          GH_TOKEN: ${{ github.token }}
ReleaseWorkflow
  .render(
    ZipxMaven.releases(
      snapshots = "https://acme.artifactory.example/maven-snapshots",
      releases = "https://acme.artifactory.example/maven-releases",
      username = secret"MAVEN_USERNAME",
      password = secret"MAVEN_PASSWORD",
    ),
    config,
    TagScheme.PerRow,
  )
  .yaml
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx release
"on":
  push:
    tags:
      - "*/v*"
  workflow_dispatch:
    inputs:
      ships:
        description: all, or comma-separated ship names (client, libs)
        type: string
        required: true
        default: all
permissions:
  contents: write
  pull-requests: write
concurrency:
  group: zipx-release
  cancel-in-progress: false
jobs:
  release:
    name: release
    runs-on: ubuntu-latest
    if: github.event_name == 'push' || github.ref_name == github.event.repository.default_branch
    environment: zipx-release
    env:
      MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
      MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
    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: release
          node-version: ""
          sbt-disk-cache: "false"
          cache-mode: restore
          cache-epoch: "0.1.0-SNAPSHOT"
      - name: Tag is on the default branch
        if: github.event_name == 'push'
        run: |
          set -euo pipefail
          if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/$ZIPX_DEFAULT_BRANCH"; then
            echo "::error title=zipx release::zipx: tag $GITHUB_REF_NAME is not on $ZIPX_DEFAULT_BRANCH"
            exit 1
          fi
        env:
          ZIPX_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
      - name: Release request
        run: |
          set -euo pipefail
          if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
            echo "ZIPX_RELEASE_REF=$ZIPX_SHIPS" >> "$GITHUB_ENV"
          else
            echo "ZIPX_RELEASE_REF=$GITHUB_REF" >> "$GITHUB_ENV"
          fi
        env:
          ZIPX_SHIPS: ${{ inputs.ships }}
      - name: Release
        run: |
          set -euo pipefail
          sbt "zipxRelease $ZIPX_RELEASE_REF"
      - name: Tag and publish GitHub Releases
        if: github.event_name == 'workflow_dispatch'
        run: |
          set -euo pipefail
          for tag in $(cat "target/zipx-release-tags.txt"); do
            gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
          done
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Open the next snapshot
        run: |
          set -euo pipefail
          echo "## Open the next snapshot" >> "$GITHUB_STEP_SUMMARY"
          echo "After the deployment, that line hides further snapshots of it. Run sbt zipxModverBump and merge it so the next line opens." >> "$GITHUB_STEP_SUMMARY"
          cat target/zipx-release-tags.txt >> "$GITHUB_STEP_SUMMARY"
      - name: Open the bump pull request
        run: |
          set -euo pipefail
          sbt zipxModverBump
          if [ -z "$(git status --porcelain)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout -B "zipx/modver-bump-${GITHUB_RUN_ID}"
          git add --all -- . ":!.github/workflows"
          if [ -z "$(git diff --cached --name-only)" ]; then
            echo "No released row to bump."
            exit 0
          fi
          git commit -m "Open the next snapshot line."
          git push -u origin HEAD
          gh label create clean --force --description "zipx: Verify runs cleanFull" || true
          cat > /tmp/zipx-pr-body.md <<'EOF'
          The release tag stays on the release commit. This branch opens the next catalog line with sbt zipxModverBump.
          EOF
          gh pr create --title "Open the next snapshot line" --body-file /tmp/zipx-pr-body.md --head "zipx/modver-bump-${GITHUB_RUN_ID}" --label clean

        env:
          GH_TOKEN: ${{ github.token }}

ZipxGitHubPackages

zipxCapabilities ++= Seq(
  ZipxCentral.release,
  ZipxGitHubPackages.sameRepo(condition = Some(JobCondition.repositoryIs("acme/my-fork"))),
)
// Shared registry PAT: ZipxGitHubPackages.sharedRegistry(token = secret"GH_PACKAGES_TOKEN")

Thin CI wiring (packages: write + token + PUBLISH_GITHUB_PACKAGES=true) for a build that publishes without ships. Ship rows use ZipxGitHubPackages.releases in Release workflows instead. sbt owns publishTo / Credentials on this capability path. See Job conditions for fork gates and multi-publish recipes.

DocsRender.job("github-packages")(
  ZipxGitHubPackages.sameRepo(condition = Some(JobCondition.repositoryIs("acme/fork")))
)
github-packages:
  name: github-packages
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (github.repository == 'acme/fork')
  permissions:
    contents: read
    packages: write
  env:
    GITHUB_TOKEN: ${{ github.token }}
    PUBLISH_GITHUB_PACKAGES: "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: github-packages
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: github-packages
      run: sbt 'schema/publish; api/publish'

ZipxDocs

zipxCapabilities += ZipxDocs.pages()
zipxWorkflowDispatch := true  // Actions → Run workflow (docs without a release tag)

// Layer a fork gate; andCondition keeps the built-in tag|dispatch filter:
zipxCapabilities += ZipxDocs.pages().andCondition(JobCondition.repositoryIs("acme/libs"))

ZipxDocs.pages calls the org reusable workflow on v* tags or workflow_dispatch. Verify is skipped on dispatch so a manual run is docs-cheap; publish stays tag-only. No hand-rolled docs.yml.

DocsRender.job("docs")(ZipxDocs.pages())(using GraphFixture(Nil))
docs:
  name: docs
  if: (startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch')
  permissions:
    contents: read
    pages: write
    id-token: write
  uses: early-effect/.github/.github/workflows/specular-docs.yml@main
  with:
    sbt-project: docs

ZipxAws

zipx-aws is the AWS paved path: assume a role by OIDC, push to ECR. It holds no credentials and no account numbers of its own; you pass a validated account id and region, and a secret name.

// project/plugins.sbt already has sbt-zipx; the pack ships with it.
import zipx.aws.*

val registry = EcrRegistry(AwsAccountId("111122223333"), AwsRegion("us-east-1"))

zipxCapabilities += ZipxAws.dockerPublish(registry, role = secret"DEPLOY_ROLE")

That one line is Capability.docker plus three things it is easy to get wrong by hand: id-token: write (naming any permission drops the default set, so contents: read has to come back with it), the job env: the login step reads, and the login step itself.

DocsRender.job("docker")(ZipxAws.dockerPublish(registry, role = secret"DEPLOY_ROLE"))
docker:
  name: docker
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  permissions:
    id-token: write
    contents: read
  env:
    AWS_ACCOUNT_ID: "111122223333"
    AWS_ECR_REGISTRY: "111122223333.dkr.ecr.us-east-1.amazonaws.com"
    AWS_REGION: us-east-1
    AWS_ROLE_TO_ASSUME: ${{ secrets.DEPLOY_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: zipx AWS login
      uses: ./.github/actions/zipx-aws-login
      with:
        role-env: AWS_ROLE_TO_ASSUME
        region-env: AWS_REGION
        account-env: AWS_ACCOUNT_ID
        login-ecr: "true"
        name-suffix: ""
    - name: docker
      run: sbt 'service/Docker/publish'

The region is a constructor parameter, not a field you might forget

EcrRegistry derives its host from the account and the region, so there is no registry value with no region for the login step to omit aws-region from. configure-aws-credentials requires that input, and omitting it fails on the runner reporting a credentials problem, which sends the reader to the role's trust policy instead of to the missing line.

EcrRegistry(AwsAccountId("111122223333"), AwsRegion("us-east-1")).host
// 111122223333.dkr.ecr.us-east-1.amazonaws.com

EcrRegistry(AwsAccountId("111122223333"))  // does not compile: no such constructor
AwsAccountId("11112222333")                // does not compile: 12 digits
AwsRegion("us-east1")                      // does not compile

The account id checks length (11 digits still yields a syntactically fine host, so the failure would otherwise surface as DNS on the runner) and the region checks shape rather than membership of a list, so a region added after this release still works.

Steps, env, and targets

You wantReach for
Just the role, for a non-ECR jobZipxAws.oidcLoginSteps + ZipxAws.oidcPermissions + ZipxAws.registryEnv
OIDC plus ECR docker login (including Docker / publish)ZipxAws.ecrLoginSteps
One repository rather than a whole account in AWS_ECR_REGISTRYZipxAws.imageEnv(registry.image(EcrRepository("team/svc")), role)
Several registries for one imageZipxAws.dockerPublishAll(registries)
Separate accounts with separate approvalsZipxAws.registryTargets(…) via withTargets

The bundle reads its role and region from the job's env:, which is what lets one bundle serve every destination: a per-target env block changes which account the same steps log into.

Those last two rows are the same list of registries and two different shapes, so pick by asking whether the destinations need separate approval:

// One job: one image built once, one login per registry, one push per dockerAliases entry.
zipxCapabilities += ZipxAws.dockerPublishAll(
  List(
    (TargetName("us"), EcrRegistry(AwsAccountId("111122223333"), AwsRegion("us-east-1")), secret"US_ROLE"),
    (TargetName("eu"), EcrRegistry(AwsAccountId("444455556666"), AwsRegion("eu-west-1")), secret"EU_ROLE"),
  )
)
DocsRender.job("docker")(
  ZipxAws.dockerPublishAll(
    List(
      (TargetName("us"), registry, secret"US_ROLE"),
      (TargetName("eu"), EcrRegistry(AwsAccountId("444455556666"), AwsRegion("eu-west-1")), secret"EU_ROLE"),
    )
  )
)
docker:
  name: docker
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  permissions:
    id-token: write
    contents: read
  env:
    ZIPX_EU_AWS_ACCOUNT_ID: "444455556666"
    ZIPX_EU_AWS_ECR_REGISTRY: "444455556666.dkr.ecr.eu-west-1.amazonaws.com"
    ZIPX_EU_AWS_REGION: eu-west-1
    ZIPX_EU_AWS_ROLE_TO_ASSUME: ${{ secrets.EU_ROLE }}
    ZIPX_US_AWS_ACCOUNT_ID: "111122223333"
    ZIPX_US_AWS_ECR_REGISTRY: "111122223333.dkr.ecr.us-east-1.amazonaws.com"
    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: zipx AWS login (eu)
      uses: ./.github/actions/zipx-aws-login
      with:
        role-env: ZIPX_EU_AWS_ROLE_TO_ASSUME
        region-env: ZIPX_EU_AWS_REGION
        account-env: ZIPX_EU_AWS_ACCOUNT_ID
        login-ecr: "true"
        name-suffix: eu
    - name: zipx AWS login (us)
      uses: ./.github/actions/zipx-aws-login
      with:
        role-env: ZIPX_US_AWS_ROLE_TO_ASSUME
        region-env: ZIPX_US_AWS_REGION
        account-env: ZIPX_US_AWS_ACCOUNT_ID
        login-ecr: "true"
        name-suffix: us
    - name: docker
      run: sbt 'service/Docker/publish'

registryTargets with withTargets is the shape to reach for last, and the cost of reaching for it by mistake is multiplicative: Docker / publish pushes every dockerAliases entry from one build, so a target per registry costs N*M jobs for M modules, each rebuilding the same image, and stops guaranteeing the registries hold identical bytes. Point dockerAliases at every registry (EcrImage.taggedAll builds that list from the same EcrRegistry values, so the two sides cannot drift) and let dockerPublishAll set up the credentials for each. See Docker and deploy for the general rule and what a shared job refuses.

{
  val targets = ZipxAws.registryTargets(
    List(
      (TargetName("us"), registry, secret"US_ROLE"),
      (TargetName("eu"), EcrRegistry(AwsAccountId("444455556666"), AwsRegion("eu-west-1")), secret"EU_ROLE"),
    )
  )
  DocsRender.jobs("docker-us", "docker-eu")(
    ZipxAws.dockerPublish(registry, secret"DEPLOY_ROLE").withTargets(_ => targets)
  )
}
docker-us:
  name: docker (us)
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  permissions:
    id-token: write
    contents: read
  env:
    AWS_ACCOUNT_ID: "111122223333"
    AWS_ECR_REGISTRY: "111122223333.dkr.ecr.us-east-1.amazonaws.com"
    AWS_REGION: us-east-1
    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-us
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: zipx AWS login
      uses: ./.github/actions/zipx-aws-login
      with:
        role-env: AWS_ROLE_TO_ASSUME
        region-env: AWS_REGION
        account-env: AWS_ACCOUNT_ID
        login-ecr: "true"
        name-suffix: ""
    - name: docker
      run: sbt 'service/Docker/publish'
docker-eu:
  name: docker (eu)
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  permissions:
    id-token: write
    contents: read
  env:
    AWS_ACCOUNT_ID: "444455556666"
    AWS_ECR_REGISTRY: "444455556666.dkr.ecr.eu-west-1.amazonaws.com"
    AWS_REGION: eu-west-1
    AWS_ROLE_TO_ASSUME: ${{ secrets.EU_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-eu
        node-version: ""
        sbt-disk-cache: "false"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: zipx AWS login
      uses: ./.github/actions/zipx-aws-login
      with:
        role-env: AWS_ROLE_TO_ASSUME
        region-env: AWS_REGION
        account-env: AWS_ACCOUNT_ID
        login-ecr: "true"
        name-suffix: ""
    - name: docker
      run: sbt 'service/Docker/publish'

Image tags

ImageTag is the registry's own rule, so a tag that would go somewhere unexpected is refused where it is written rather than pushed. The default branch gets the moving tags:

ImageTag.forCommit("1.4.2", "abc1234", branch = "main")
Right(List(1.4.2-abc1234, 1.4.2-main-abc1234, 1.4.2-main-latest))

Any other branch gets only the immutable one, because a moving tag on a feature branch is a race between two PRs pushing the same name:

ImageTag.forCommit("1.4.2", "abc1234", branch = "feat-x")
Right(List(1.4.2-abc1234))

A / is refused rather than silently replaced: example:main-feat/x parses as a different repository, so the image would publish where nothing deploys from while the build stayed green:

ImageTag.branchCommit("1.4.2", "feat/x", "abc1234")
Left(invalid image tag '1.4.2-feat/x-abc1234': must start with a letter, digit or _ and contain only letters, digits, . _ - (a branch name with a / in it needs ImageTag.slug))

ImageTag.slug is there when mangling is what you want, and it truncates to the registry's limit rather than producing a name ECR rejects:

ImageTag.slug("feat/x")
Right(feat-x)

Pinning the action

aws-actions/configure-aws-credentials is an extra pin (see Action pins), not a typed field, because zipx's own planner never emits it: it arrives through this pack, so pinning it must not wait on a zipx release. The pack carries a SHA-pinned fallback when the jar has no extra pin. This repository lists both as catalog rows so zipxActionUpdate owns them and jar defaults ship the SHAs. A consumer overlays the same way to move ahead of a zipx release:

val awsCredentials = Action(
  "aws-actions/configure-aws-credentials",
  "v6.2.3",
  sha = "<40-hex>",
)
val ecrLogin = Action("aws-actions/amazon-ecr-login", "v2.1.6", sha = "<40-hex>")

zipxActionUpdate bumps them like any other Action. Overlay keys extra by owner/repo, and the pack looks up by that prefix.

Cloud agent

zipxAgentEnv renders the Cursor cloud agent from the same toolchain CI already uses. Default off. docker is explicit.

zipxAgentEnv := ZipxAgentEnv.cursor(docker = true)

zipxWorkflowGenerate writes .cursor/environment.json, .cursor/Dockerfile, and .github/zipx-agent/install.sh. The Dockerfile and environment.json are the only files under .cursor/. Ignore the rest of that directory so local rules and MCP stay untracked:

.cursor/*
!.cursor/environment.json
!.cursor/Dockerfile

Secret names from zipxEnv are checked before anything resolves. So is the release lookup credential: GITHUB_TOKEN when the registry reads GitHub's token, otherwise the secret's env name. install.sh fails if any of those variables is empty, and the message says to set each as a Cursor Runtime Secret. A Build Secret is present only while the image is built. repositoryDependencies widens the git token. Neither one authenticates Maven. The warm command is zipxCacheRehydrateTask, run as sbt --server --batch. Caches and tokens stay out of the image. The agent's warm disk is the install step, not actions/cache.

With zipxSbtImage off, the Dockerfile is Ubuntu. The JDK comes from project/zipx-ci.env at install time. The sbt runner reads project/build.properties. Ubuntu, Docker CE 28, and the sbt runner are catalog Pin vals (agent-base, agent-docker, agent-sbt) so zipxPinUpdate can move them. Docker 29 is refused. docker = true installs Docker CE from apt and starts it with sudo service docker start.

With zipxSbtImage on, those three pins are unused. Delete them. The agent Dockerfile is the public sbtscala/scala-sbt pin, then the same RUN lines the env image publishes, then a daemon when docker = true. It does not FROM ghcr.io/<owner>/<repo>/zipx-env. Cursor cannot pull that tag. The daemon is the static dockerd from the docker-cli pin, plus exact fuse-overlayfs and iptables pins on feed agent-daemon (ubuntu-suite, fuse-overlayfs, iptables). ubuntu-suite names the Ubuntu release those package versions belong to. Apt installs them from the image's own archives, so a suite that image does not have fails the image build. start is /usr/local/bin/zipx-dockerd. With docker = false and no derived Dockerfile, environment.json sets image to the public pin. With a derived Dockerfile, the agent builds that text. Plain job env (HOME, -Duser.home, git safe.directory, COURSIER_TTL) is written into that Dockerfile. Secrets are not.

CursorEnv
  .render(
    AgentEnv(
      docker = true,
      rehydrate = SbtCommand.unsafeTask("Test/compile"),
      secretEnv = List("REGISTRY_TOKEN"),
      pins = Some(AgentPins(AgentPins.DefaultBase, AgentPins.DefaultDocker, AgentPins.DefaultSbtRunner)),
    )
  )
  .fold(identity, files => files.environmentJson + files.dockerfile.getOrElse(""))
{
  "build": {
    "dockerfile": "Dockerfile"
  },
  "install": "bash .github/zipx-agent/install.sh",
  "start": "sudo service docker start"
}
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
FROM ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    curl \
    git \
    gnupg \
    sudo \
    tar \
    unzip \
    && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/sbt.zip "https://github.com/sbt/sbt/releases/download/v2.0.7/sbt-2.0.7.zip" \
    && unzip -o /tmp/sbt.zip -d /opt \
    && ln -sfn /opt/sbt/bin/sbt /usr/local/bin/sbt \
    && rm /tmp/sbt.zip
RUN set -eux; \
    install -m 0755 -d /etc/apt/keyrings; \
    curl --retry 3 --retry-delay 5 -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg; \
    chmod a+r /etc/apt/keyrings/docker.gpg; \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
    docker-ce=5:28.5.2-1~ubuntu.24.04~noble \
    docker-ce-cli=5:28.5.2-1~ubuntu.24.04~noble \
    containerd.io \
    docker-buildx-plugin \
    docker-compose-plugin \
    fuse-overlayfs \
    iptables; \
    rm -rf /var/lib/apt/lists/*; \
    mkdir -p /etc/docker; \
    printf '%s\n' '{' '  "storage-driver": "fuse-overlayfs"' '}' > /etc/docker/daemon.json; \
    update-alternatives --set iptables /usr/sbin/iptables-legacy; \
    update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy; \
    groupadd -f docker; \
    if ! id -u ubuntu >/dev/null 2>&1; then useradd -m -s /bin/bash ubuntu; fi; \
    usermod -aG docker ubuntu

RUN id -u ubuntu >/dev/null 2>&1 || useradd -m -s /bin/bash ubuntu \
    && echo "ubuntu ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ubuntu \
    && chmod 0440 /etc/sudoers.d/ubuntu
USER ubuntu
WORKDIR /home/ubuntu
{
  val image = "sbtscala/scala-sbt:eclipse-temurin-21.0.12_8_2.x@sha256:" + ("ab" * 32)
  CursorEnv
    .render(
      AgentEnv(
        docker = true,
        rehydrate = SbtCommand.unsafeTask("Test/compile"),
        secretEnv = List("GITHUB_TOKEN"),
        toolchain = Some(
          AgentImage(
            upstream = image,
            baked = None,
            dockerCli = Some("28.5.2"),
            fuseOverlayfs = Some(AgentDaemon.DefaultFuse),
            iptables = Some(AgentDaemon.DefaultIptables),
            env = Map("HOME" -> "/home/sbtuser", "COURSIER_TTL" -> "0s"),
          )
        ),
      )
    )
    .fold(identity, files => files.environmentJson + files.dockerfile.getOrElse("") + files.installSh)
}
{
  "build": {
    "dockerfile": "Dockerfile"
  },
  "install": "bash .github/zipx-agent/install.sh",
  "start": "/usr/local/bin/zipx-dockerd"
}
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
FROM sbtscala/scala-sbt:eclipse-temurin-21.0.12_8_2.x@sha256:abababababababababababababababababababababababababababababababab
USER root
WORKDIR /root
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends fuse-overlayfs=1.14-1build2 iptables=1.8.11-2ubuntu3; \
    rm -rf /var/lib/apt/lists/*; \
    mkdir -p /etc/docker; \
    printf '%s\n' '{' '  "storage-driver": "fuse-overlayfs"' '}' > /etc/docker/daemon.json; \
    update-alternatives --set iptables /usr/sbin/iptables-legacy; \
    update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy; \
    case "$(uname -m)" in \
      x86_64) docker_arch=x86_64 ;; \
      aarch64) docker_arch=aarch64 ;; \
      *) echo "zipx: unsupported architecture $(uname -m)" >&2; exit 1 ;; \
    esac; \
    curl -fsSL -o /tmp/docker.tgz "https://download.docker.com/linux/static/stable/${docker_arch}/docker-28.5.2.tgz"; \
    tar -xzf /tmp/docker.tgz -C /tmp; \
    mv /tmp/docker/* /usr/local/bin/; \
    rm -rf /tmp/docker /tmp/docker.tgz; \
    printf '%s\n' \
    '#!/bin/sh' \
    'set -eu' \
    'mkdir -p /var/run /var/log' \
    'setsid dockerd --host unix:///var/run/docker.sock --config-file /etc/docker/daemon.json >/var/log/dockerd.log 2>&1 </dev/null &' \
    'i=0' \
    'while [ "$i" -lt 100 ]; do' \
    '  if docker info >/dev/null 2>&1; then' \
    '    exit 0' \
    '  fi' \
    '  i=$((i + 1))' \
    '  sleep 0.2' \
    'done' \
    'echo "zipx: dockerd did not become ready" >&2' \
    'exit 1' \
    > /usr/local/bin/zipx-dockerd; \
    chmod 0755 /usr/local/bin/zipx-dockerd
ENV COURSIER_TTL="0s"
ENV HOME="/home/sbtuser"
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
set -euo pipefail
. project/zipx-ci.env
zipx_missing=""
if [ -z "${GITHUB_TOKEN:-}" ]; then
  zipx_missing="$zipx_missing GITHUB_TOKEN"
fi
if [ -n "$zipx_missing" ]; then
  echo "zipx: cloud agent is missing env (set each as a Cursor Runtime Secret):$zipx_missing" 1>&2
  exit 1
fi
sbt --server --batch 'Test/compile'