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: docsZipxAws
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 want | Reach for |
|---|---|
| Just the role, for a non-ECR job | ZipxAws.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_REGISTRY | ZipxAws.imageEnv(registry.image(EcrRepository("team/svc")), role) |
| Several registries for one image | ZipxAws.dockerPublishAll(registries) |
| Separate accounts with separate approvals | ZipxAws.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.