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 starts release-on-publish.yml, which dispatches this workflow # with the release version. You can also run it manually via workflow_dispatch # with an explicit SemVer X.Y.Z version. # # 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: 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 }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail raw="${INPUT_VERSION:-}" 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}; pass inputs.version on workflow_dispatch). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}" exit 1 fi #region agent log echo "::group::agent-log prepare version" echo "event=${EVENT_NAME} version=${raw} checkout_ref=v${raw}" echo "::endgroup::" #endregion 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 }} - name: Debug — snapshot GitHub Release state (agent) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.prepare.outputs.version }} run: | #region agent log set -euo pipefail tag="v${VERSION}" echo "::group::agent-log release snapshot for ${tag}" if out=$(gh release view "$tag" --repo "${{ github.repository }}" --json isDraft,tagName,publishedAt,url 2>&1); then echo "release-exists: ${out}" else echo "release-missing: ${out}" fi echo "event=${{ github.event_name }} ref=${GITHUB_REF_NAME}" echo "::endgroup::" #endregion - 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" #region agent log echo "::group::agent-log ghcr tags published" echo "version=${{ needs.prepare.outputs.version }} event=${{ github.event_name }} ref=${GITHUB_REF_NAME}" echo "tags_pushed=${{ needs.prepare.outputs.version }},${{ needs.prepare.outputs.version }}-amd64,${{ needs.prepare.outputs.version }}-arm64" echo "::endgroup::" #endregion 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