Context: Plan B (https://gist.github.com/vjt/48a75dc067f27108e4261d4c7380e00d) had each testnet container build: from source on first docker compose up (peak RAM ~200–300 MB for autoconf+gcc, too much for a 256 MB box). Hypnotize picked GHCR as hosting; this delta wires the push into the two existing CIs and flips the compose to image:.
Scope: minimal. One new step per CI workflow, compose switches to published images, first up becomes a pull instead of a compile.
- Multi-stage Dockerfile slimming. Current images are builder-shaped (~500 MB each, include gcc/autoconf). Works, just fat. Split into builder + runtime-slim is a separate PR.
- Semver tag strategy.
master+sha-<short>is enough for now;vX.Y.Ztags get added when upstream starts cutting releases. - Image signing / cosign / SBOM.
- Renovate / Dependabot pins on the compose side.
| Trigger | Tag(s) pushed |
|---|---|
push to master |
master, sha-<7char> |
push to any git tag refs/tags/* |
<tag-verbatim>, latest |
| push to any other branch | none (build-only, load locally for smoke-check) |
| pull_request | none (build-only, load locally for smoke-check) |
Tag nomenclature note (Hypnotize 2026-04-20 20:54 + Sonic is master of tags): existing upstream tags differ per repo — azzurra/bahamut uses bare versions (4.8, 4.7-beta5, no v prefix), azzurra/services is mixed (services-2.2.2 historic, v2.5.1 recent). Workflow passes the git-tag name verbatim into the image tag (no reformatting, no v-prefix enforcement), so whatever Sonic cuts upstream becomes the image tag as-is. latest only advances when a tag is cut (not on master push).
Compose defaults to :master. PR-against-PR testing pins BAHAMUT_TAG=sha-abcdef1 in .env (or SERVICES_TAG=…).
Both repos' docker job gets:
permissions:
contents: read
packages: writeUses ${{ secrets.GITHUB_TOKEN }} — no PAT, no org secret, no new moving parts. Org azzurra must allow workflows to create/write packages (default on for public repos).
Image visibility: public. First push creates the package; a one-time post-push step in the org's package settings flips it public (no CLI way to set-on-creation short of the image-description action; simpler to do it once manually).
Replace the current docker job:
docker:
name: docker build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: compute tags
id: tags
run: |
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/master" ]; then
echo "push=true" >> $GITHUB_OUTPUT
echo "tags=ghcr.io/azzurra/bahamut:master,ghcr.io/azzurra/bahamut:sha-$SHORT" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
VER="${GITHUB_REF#refs/tags/}"
echo "push=true" >> $GITHUB_OUTPUT
echo "tags=ghcr.io/azzurra/bahamut:$VER,ghcr.io/azzurra/bahamut:latest" >> $GITHUB_OUTPUT
else
echo "push=false" >> $GITHUB_OUTPUT
echo "tags=ghcr.io/azzurra/bahamut:ci" >> $GITHUB_OUTPUT
fi
- name: login to ghcr
if: steps.tags.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build (+ push on master/tag)
uses: docker/build-push-action@v6
with:
context: .
push: ${{ steps.tags.outputs.push == 'true' }}
load: ${{ steps.tags.outputs.push != 'true' }}
tags: ${{ steps.tags.outputs.tags }}
- name: smoke-check (non-push builds only)
if: steps.tags.outputs.push != 'true'
run: docker run --rm --entrypoint sh ghcr.io/azzurra/bahamut:ci -c 'test -x /build/src/ircd'Existing build job (matrix leaf/hub) stays untouched.
Identical structure, replace bahamut with services, binary path /build/src/services.
Drop the build anchor, switch each service to image::
services:
hub:
image: ghcr.io/azzurra/bahamut:${BAHAMUT_TAG:-master}
# ...rest unchanged
leaf-v4:
image: ghcr.io/azzurra/bahamut:${BAHAMUT_TAG:-master}
# ...
leaf-v6:
image: ghcr.io/azzurra/bahamut:${BAHAMUT_TAG:-master}
# ...
services:
image: ghcr.io/azzurra/services:${SERVICES_TAG:-master}
# ...Open question for reviewers: does the testnet-compose wrap the GHCR image, or is the published image self-sufficient?
- Option A (recommended): GHCR image stays builder-shaped (just produces
/build/src/ircd). testnet-compose'sbahamut/DockerfileisFROM ghcr.io/azzurra/bahamut:master+COPY entrypoint.sh conf.*.tmpl+ENTRYPOINT. Keeps upstream image concern-free, testnet owns its own runtime wiring. Firstuppulls GHCR (~500 MB once, cached forever) + builds 4 trivial wrapper layers (seconds). - Option B: Push the entrypoint + conf templates into azzurra/bahamut itself so the GHCR image is directly runnable. Conflates dev/testnet concerns with the build image, but simpler compose.
My vote: A. Keeps "what's in the upstream Dockerfile" tight.
- Open PR on azzurra/bahamut (CI delta). Merge → first push creates
ghcr.io/azzurra/bahamutpackage. Set to public in org settings. - Open PR on azzurra/services (CI delta). Merge → same for services package.
- Update Plan B gist in-place with the new compose snippet.
- testnet-compose PR (separate, later) actually consumes the images.
Steps 1+2 are independent and land in whatever order. Step 3 is cosmetic (gist only). Step 4 can wait until the Plan B review closes.
permissions: packages: writeon thedockerjob only — fine?- Tag strategy:
master+sha-<short>+<git-tag-verbatim>+latest(pass-through, no prefix enforcement). Sonic to confirm tag-name policy. Anyone wantdeveloptoo? - Option A vs Option B on testnet-compose wrapping.
- Public visibility confirmed OK (no private registry ceremony).