name: release # Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # 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/OpenDKIM 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. Per-arch tags are pushed only so # imagetools can assemble the multi-arch manifest; merge removes them from GHCR # so operators see a single version tag (spec 10.1). 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) run: cd test/e2e && go test -v -timeout 20m ./... - 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 per-arch tags from GHCR # Side-effect tags for imagetools assembly only — not part of the public # version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64). run: | set -euo pipefail repo="ghcr.io/${{ github.repository }}" version="${{ needs.prepare.outputs.version }}" for suffix in amd64 arm64; do tag="${repo}:${version}-${suffix}" set +e out=$(docker buildx imagetools rm "$tag" 2>&1) rc=$? set -e if [ "$rc" -eq 0 ]; then printf '%s\n' "$out" else echo "::warning::could not remove ${tag}: ${out}" >&2 fi done