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. ZipxModver is topology for independent library versions, not a registry pack; see Independent versions.

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)

// Graph escape hatch
zipxCapabilities ++= Seq(ZipxCentral.publishSigned, ZipxCentral.releaseOnce)
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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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'

ZipxModver

Independent outbound versions (Ship / ShipGroup) cannot use Aggregate ZipxCentral.release on a tag. ZipxModver.publish is Graph library publish on Gate.OnDefaultPush, MatrixCollapse.Off. Default command zipxModverPublishSigned. Full guide: Independent versions.

zipxCapabilities += ZipxModver.publish()

// Optional: Central sonaRelease once after Graph publish
zipxCapabilities += ZipxCentral.releaseOnce.copy(gate = Gate.OnDefaultPush)

The monorepo example uses ZipxModver without Central secrets. Compose releaseOnce only when the repo actually publishes to Maven Central.

DocsRender.jobs("modver", "publish-api", "central-release")(
  ZipxModver.publish(SbtCommand.unsafeTask("zipxModverPublishSigned")),
  ZipxCentral.releaseOnce.copy(gate = Gate.OnDefaultPush),
)(using libGraph, config.copy(modverPublish = true))
modver:
  name: modver
  runs-on: ubuntu-latest
  if: ((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')
  permissions:
    contents: read
  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: modver
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "false"
        cache-epoch: "0.1.0-ci"
    - name: Compute version-moved modules
      id: compute
      run: |
        if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
          sbt -batch --error zipxModverPublishModules
          modules=$(cat target/zipx-modver-modules.json)
        elif [ "${{ github.event_name }}" = "push" ]; then
          before="${{ github.event.before }}"
          if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
            echo "zipx: github.event.before is missing or all-zero; refusing to guess the publish set"
            exit 1
          else
            sbt -batch --error "zipxModverPublishModules $before"
            modules=$(cat target/zipx-modver-modules.json)
          fi
        else
          echo "zipx: github.event.before is missing or all-zero; refusing to guess the publish set"
          exit 1
        fi
        echo "modules=$modules" >> "$GITHUB_OUTPUT"
publish-api:
  name: publish api
  runs-on: ubuntu-latest
  needs:
    - modver
    - publish-schema
  if: "!cancelled() && (((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')) && contains(fromJson(needs.modver.outputs.modules), 'api') && needs.publish-schema.result != 'failure'"
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: publish-api
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: publish
      run: sbt '+api/zipxModverPublishSigned'
central-release:
  name: central-release
  runs-on: ubuntu-latest
  needs:
    - publish-api
    - publish-schema
  if: (!cancelled() && needs.publish-api.result != 'failure' && needs.publish-schema.result != 'failure') && ((((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')))
  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: central-release
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: Download sona staging
      uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
      with:
        pattern: sona-staging-*
        path: target/sona-staging
        merge-multiple: "true"
    - 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: central-release
      run: sbt 'sonaRelease'

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). sbt owns publishTo / Credentials. 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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - 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:

ImageTag.forCommit(version, sha, branch)
// on main:      List("1.4.2-abc1234", "1.4.2-main-abc1234", "1.4.2-main-latest")
// on feat-x:    List("1.4.2-abc1234")

ImageTag.branchCommit("1.4.2", "feat/x", "abc1234")  // Left: a tag may not contain '/'
ImageTag.slug("feat/x")                              // Right("feat-x"): the opt-in mangle

The moving tags land only on the default branch, because a moving tag on a feature branch is a race between two PRs pushing the same name. And 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.slug is there when mangling is what you want, and it truncates to the registry's limit rather than producing a name ECR rejects.

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.