test / test (push) Waiting to run
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
8.9 KiB
YAML
231 lines
8.9 KiB
YAML
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"
|