name: release # Publishes an immutable, version-tagged image on ghcr.io. # Ordinary commits and bare git tag pushes do not publish anything. A published # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub). # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z. # # Native per-architecture builds (see docs/development.md), not qemu: running # the full Postfix and panel stack under emulation for the e2e gate below is # impractically slow. Each arch builds, e2e-gates and pushes its own tag on its # own native runner; a merge job then combines them into the one manifest tag # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest # then test) is deliberate: it means the bytes that get tagged are exactly the # bytes that passed e2e. The per-arch tags stay in GHCR next to the version # tag: they are the images the multi-arch manifest points at, and GHCR cannot # drop a tag without deleting the image behind it. on: release: types: [published] workflow_dispatch: inputs: version: description: "Image version as X.Y.Z (no v prefix)." required: true type: string permissions: contents: read packages: write jobs: prepare: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} steps: - name: Derive a SemVer X.Y.Z version id: version env: INPUT_VERSION: ${{ github.event.inputs.version }} RELEASE_TAG: ${{ github.event.release.tag_name }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail if [ "$EVENT_NAME" = "release" ]; then raw="${RELEASE_TAG:-}" else raw="${INPUT_VERSION:-}" fi raw="${raw#v}" if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}" exit 1 fi echo "version=${raw}" >> "$GITHUB_OUTPUT" build: needs: prepare strategy: fail-fast: false matrix: include: - runner: ubuntu-latest arch: amd64 - runner: ubuntu-24.04-arm arch: arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 with: ref: v${{ needs.prepare.outputs.version }} - uses: docker/setup-buildx-action@v3 - name: Build image (native, loaded locally for the e2e gate) uses: docker/build-push-action@v6 with: context: . file: build/Dockerfile load: true provenance: false build-args: | VERSION=${{ needs.prepare.outputs.version }} tags: selfpost:e2e - uses: actions/setup-go@v5 with: go-version: "1.26" cache-dependency-path: test/e2e/go.sum - name: e2e (gates publishing — see docs/development.md) # The e2e builds selfpost:e2e again through compose.override.yml, and # that rebuild is the image pushed below. Without the version here it # was stamped "e2e" (audit 2026-10-12, H1); the suite now checks the # stamp against this variable. env: SELFPOST_E2E_VERSION: ${{ needs.prepare.outputs.version }} run: cd test/e2e && go test -v -timeout 20m ./... - name: Check the version stamp of the image about to be pushed env: WANT: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail for bin in panel selfpost-backup; do got=$(docker run --rm --entrypoint "$bin" selfpost:e2e -version) if [ "$got" != "$WANT" ]; then echo "::error::selfpost:e2e $bin -version = '$got', want '$WANT'; refusing to push" exit 1 fi done - name: Log in to ghcr.io uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Push per-arch tag # GHCR sometimes rejects the final manifest with "unknown blob" after # every layer reports Pushed (registry race / overwrite of the same # version tag). Retries are idempotent for identical local bytes. run: | set -euo pipefail image="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}" docker tag selfpost:e2e "$image" attempt=1 max=4 backoff=15 while true; do set +e out=$(docker push "$image" 2>&1) rc=$? set -e if [ "$rc" -eq 0 ]; then printf '%s\n' "$out" exit 0 fi printf '%s\n' "$out" >&2 if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then echo "::error::docker push failed for ${image} (attempt ${attempt}/${max}, exit ${rc})" >&2 exit "$rc" fi echo "::warning::transient GHCR error pushing ${image} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2 sleep "$backoff" attempt=$((attempt + 1)) backoff=$((backoff * 2)) done merge: needs: [prepare, build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: v${{ needs.prepare.outputs.version }} - uses: docker/setup-buildx-action@v3 - name: Log in to ghcr.io uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Merge per-arch tags into the version manifest # Same GHCR "unknown blob" class of failure can hit imagetools create # when assembling the multi-arch version tag. run: | set -euo pipefail version_tag="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}" amd64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64" arm64="ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64" attempt=1 max=4 backoff=15 while true; do set +e out=$(docker buildx imagetools create -t "$version_tag" "$amd64" "$arm64" 2>&1) rc=$? set -e if [ "$rc" -eq 0 ]; then printf '%s\n' "$out" exit 0 fi printf '%s\n' "$out" >&2 if [ "$attempt" -ge "$max" ] || ! grep -qiE 'unknown blob|blob unknown|blob upload invalid|manifest unknown|received unexpected HTTP status: 5[0-9]{2}|429 Too Many Requests|temporarily unavailable' <<<"$out"; then echo "::error::imagetools create failed for ${version_tag} (attempt ${attempt}/${max}, exit ${rc})" >&2 exit "$rc" fi echo "::warning::transient GHCR error merging ${version_tag} (attempt ${attempt}/${max}); retry in ${backoff}s" >&2 sleep "$backoff" attempt=$((attempt + 1)) backoff=$((backoff * 2)) done - name: Remove orphaned untagged versions from GHCR # Re-publishing a version moves its tags to new manifests and leaves the # old ones behind as untagged "sha256:..." package versions. Delete only # those that no tagged manifest references: the images behind a # multi-arch tag are package versions too, and deleting one breaks pulls # of that tag. Any failed lookup aborts the step before anything is # deleted. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail image="ghcr.io/${{ github.repository }}" owner="${{ github.repository_owner }}" pkg="${{ github.event.repository.name }}" api="/users/${owner}/packages/container/${pkg}/versions" versions=$(gh api "${api}?per_page=100" --paginate \ --jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv') keep=$(mktemp) while IFS=$'\t' read -r id digest ntags; do [ "$ntags" -gt 0 ] || continue echo "$digest" >> "$keep" raw=$(docker buildx imagetools inspect --raw "${image}@${digest}") jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep" done <<<"$versions" while IFS=$'\t' read -r id digest ntags; do [ "$ntags" -eq 0 ] || continue if grep -qxF "$digest" "$keep"; then continue fi echo "deleting orphaned GHCR package version ${id} (${digest})" gh api -X DELETE "${api}/${id}" done <<<"$versions"