feat: multi-arch (amd64+arm64) root image + haproxy upstream-version pin (2026.11.3) #19

Merged
jknapp merged 1 commits from feat/hmb-multiarch-and-haproxy-pin into main 2026-11-12 02:04:33 +00:00
3 changed files with 86 additions and 28 deletions

No files matched your search

+54 -11
View File
@@ -4,10 +4,26 @@ on:
push:
branches:
- main
# Manual publish (e.g. after a [skip ci] merge, or to re-publish after a
# failed arm64 leg).
workflow_dispatch:
# Multi-arch (linux/amd64 + linux/arm64) in ONE job on the trusted x86 runner.
# Each platform is pushed BY DIGEST (untagged) to BOTH registries, then
# scripts/assemble-multiarch-index.sh creates :latest, :<version> and :<sha>
# from those digests (same template as build-push-coraza.yaml; see
# docs/arm64/IMAGE-PIPELINE-PLAN.md sections 4.1 / 4.4 in the WHP repo).
# * amd64 is required and built exactly as before; the Dockerfile build gate
# (unit suites + `haproxy -c`) runs in it. Failure = nothing published.
# * arm64 is continue-on-error and runs the SAME Dockerfile gate under QEMU
# (the gate is RUN steps, so it executes on the arm64 binary). If arm64
# fails or times out the tags are published amd64-only, exactly as before
# multi-arch, and a previous run's arm64 is never re-attached (the digest is
# read only from THIS run's step output).
jobs:
Build-and-Push:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -30,6 +46,8 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
@@ -59,20 +77,45 @@ jobs:
id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- name: Build Image
# amd64: required. Pushed untagged by digest to both registries (same
# digest in each); tags are only created by the assemble step below.
- name: Build amd64 (push by digest)
id: amd64
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
push: true
build-args: |
VERSION=${{ steps.ver.outputs.version }}
PKG_CACHE=${{ steps.pkgcache.outputs.url }}
# Three tags per registry: :latest (moving), :<version> (human-readable
# release), :<sha> (immutable, guaranteed-unique rollback target).
tags: |
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ steps.ver.outputs.version }}
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ gitea.sha }}
ghcr.io/shadowdao/haproxy-manager-base:latest
ghcr.io/shadowdao/haproxy-manager-base:${{ steps.ver.outputs.version }}
ghcr.io/shadowdao/haproxy-manager-base:${{ gitea.sha }}
outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base,ghcr.io/shadowdao/haproxy-manager-base",push-by-digest=true,name-canonical=true,push=true
# arm64: best effort (QEMU, apt-based image). Must never block or alter
# the amd64 tags.
- name: Build arm64 (push by digest)
id: arm64
continue-on-error: true
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
platforms: linux/arm64
build-args: |
VERSION=${{ steps.ver.outputs.version }}
PKG_CACHE=${{ steps.pkgcache.outputs.url }}
outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base,ghcr.io/shadowdao/haproxy-manager-base",push-by-digest=true,name-canonical=true,push=true
# Three tags per registry: :latest (moving), :<version> (human-readable
# release), :<sha> (immutable, guaranteed-unique rollback target).
- name: Assemble tag indexes
env:
AMD64_DIGEST: ${{ steps.amd64.outputs.digest }}
ARM64_DIGEST: ${{ steps.arm64.outputs.digest }}
ARM64_OUTCOME: ${{ steps.arm64.outcome }}
VERSION: ${{ steps.ver.outputs.version }}
SHA: ${{ gitea.sha }}
run: |
scripts/assemble-multiarch-index.sh \
--amd64 "$AMD64_DIGEST" \
--arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \
--repo repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base \
--repo ghcr.io/shadowdao/haproxy-manager-base \
--tag latest --tag "$VERSION" --tag "$SHA"
+31 -16
View File
@@ -23,22 +23,30 @@ LABEL org.opencontainers.image.title="haproxy-manager-base" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="MIT"
# haproxy is PINNED. It was previously unpinned, so the binary could move under
# us at Debian's timing — an upstream release that rejected our config would have
# broken an unrelated commit's build, or worse, shipped an edge that refuses to
# start (see the `haproxy -c` gate below for why that matters: a config HAProxy
# haproxy is PINNED to a reviewed UPSTREAM version (HAPROXY_UPSTREAM), not to one
# Debian package revision. It was previously unpinned, so the binary could move
# under us at Debian's timing - an upstream release that rejected our config would
# have broken an unrelated commit's build, or worse, shipped an edge that refuses
# to start (see the `haproxy -c` gate below for why that matters: a config HAProxy
# rejects leaves the container Up with 80/443 unbound and /health still 200).
#
# Pinning does NOT make the gate redundant, and the gate does NOT make pinning
# unnecessary — they compose. Pinned means the version moves deliberately; the
# gate then answers immediately whether the new binary still accepts fleet config.
# It also makes the image reproducible, which it previously was not.
# History: this used to pin the exact Debian version (3.0.11-1+deb13u3). Debian
# security then shipped 3.0.11-1+deb13u4 (same upstream 3.0.11, same arch set) and
# the exact pin silently froze the image on the OLDER, unpatched revision, and
# would hard-fail the build as soon as u3 left the archive. Debian security
# revisions of the SAME upstream version are exactly what we want to take, so:
# * an apt preference (Pin: version 3.0.11-*) makes apt install the NEWEST
# Debian revision of that upstream version on any architecture;
# * the build then FAILS unless the installed package AND `haproxy -v` both
# report that upstream version, so a major/minor bump (3.0 -> 3.1, 3.0.12)
# can never arrive by accident: that still needs this ARG changed and the
# `haproxy -c` gate below to pass.
# The pin and the gate compose: pinned = the version moves deliberately; the gate
# answers immediately whether the new binary still accepts fleet config.
#
# To move it: bump the version here, rebuild, and let the gate verify. If Debian
# security-updates the package (e.g. -1+deb13u4) the build FAILS until this pin is
# updated — that failure is the point, not a bug. Check availability with:
# apt-cache policy haproxy
ARG HAPROXY_VERSION=3.0.11-1+deb13u3
# To move to another upstream version: change HAPROXY_UPSTREAM, rebuild, let the
# gate verify. Check what Debian offers with: apt-cache madison haproxy
ARG HAPROXY_UPSTREAM=3.0.11
# OPTIONAL internal package cache, e.g. --build-arg PKG_CACHE=https://192.168.1.40
# (CT130 pkg-cache, https only; CI sets it only after probing the cache with the
# pinned CA). Unset = the upstream Debian repositories, exactly as before. The
@@ -50,7 +58,14 @@ ARG PKG_CACHE=
COPY ./scripts/pkg-cache-repos.sh /usr/local/sbin/pkg-cache-repos.sh
COPY ./keys/pkg-cache/ /usr/local/share/pkg-cache/
RUN /usr/local/sbin/pkg-cache-repos.sh apt && \
apt update -y && apt dist-upgrade -y && apt install socat "haproxy=${HAPROXY_VERSION}" cron certbot curl jq net-tools -y && apt-mark hold haproxy && \
apt update -y && apt dist-upgrade -y && \
printf 'Package: haproxy\nPin: version %s-*\nPin-Priority: 1001\n' "${HAPROXY_UPSTREAM}" > /etc/apt/preferences.d/haproxy-upstream-pin && \
apt install socat haproxy cron certbot curl jq net-tools -y && apt-mark hold haproxy && \
rm -f /etc/apt/preferences.d/haproxy-upstream-pin && \
deb_ver="$(dpkg-query -W -f='${Version}' haproxy)" && \
case "${deb_ver}" in "${HAPROXY_UPSTREAM}"-*) ;; *) echo "FATAL: installed haproxy ${deb_ver} is not upstream ${HAPROXY_UPSTREAM}" >&2; exit 1;; esac && \
haproxy -v | head -n1 | grep -F "version ${HAPROXY_UPSTREAM}-" && \
echo "haproxy pin OK: upstream ${HAPROXY_UPSTREAM}, Debian package ${deb_ver}" && \
/usr/local/sbin/pkg-cache-repos.sh restore && \
apt clean && rm -rf /var/lib/apt/lists/*
WORKDIR /haproxy
@@ -95,8 +110,8 @@ RUN pip install -r requirements.txt
# * it cannot be skipped, and it protects local `docker build` too;
# * no workflow restructuring (build-push-action builds and pushes in one
# step, so gating in CI would mean splitting build from push);
# * it validates against the EXACT haproxy binary in this image. Line 26
# installs haproxy unpinned, so that binary moves between builds - this
# * it validates against the EXACT haproxy binary in this image. The
# installed haproxy can move between builds (Debian revisions) - this
# turns "the new haproxy rejects our config" from a silent production
# risk into a build failure.
#
+1 -1
View File
@@ -1 +1 @@
2026.11.2
2026.11.3