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: 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"
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 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"
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'