name: release # Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # Ordinary commits do not publish anything. A pushed tag matching vX.Y.Z does, # and so does workflow_dispatch when it supplies a SemVer X.Y.Z version (or # runs on such a tag). A dispatch from main without that input must fail in # prepare — it must not publish ghcr.io/...:main. The version is the single # source that goes into both the image tag and the panel binary's -ldflags # version, so the two can never drift apart (the invariant restore's version # check in spec 7.5.A depends on). # # 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, at the cost of per-arch tags lingering # in the registry as a side effect (harmless — the version tag's immutability, # spec 10.1, is about that tag, not these). on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" workflow_dispatch: inputs: version: description: "Image version as X.Y.Z (no v prefix). Required unless this run is already on a vX.Y.Z tag." required: false 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 }} run: | set -euo pipefail raw="${INPUT_VERSION:-}" if [ -z "$raw" ] && [[ "${GITHUB_REF_NAME}" == v[0-9]*.[0-9]*.[0-9]* ]]; then raw="${GITHUB_REF_NAME#v}" fi raw="${raw#v}" if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::release version is not SemVer X.Y.Z (ref is ${GITHUB_REF_NAME}; pass inputs.version on workflow_dispatch). Refusing to publish ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}" 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 - 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: 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