From 3e78da6e35e32c0e09b6fc8c86d0ee8b34c966bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:57:09 +0200 Subject: [PATCH] ci(docker): build multi-arch image (linux/amd64 + linux/arm64) for Apple Silicon support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish workflow now builds each architecture on its native GitHub-hosted runner (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) and stitches the two digests into a single multi-arch manifest via 'docker buildx imagetools create' in a final merge job. Consumers pull one tag and Docker auto-selects the correct platform — Apple Silicon Macs get a native arm64 image with no Rosetta translation, the existing x86_64 Coolify hosts (apps-01, edge-01) keep getting the amd64 variant, and arm64-based hosting (Graviton, Ampere) is supported out of the box. The v1.4.16 C5 attempt at multi-arch on a single amd64 runner deterministically SIGILL-crashed Next 15's static export under qemu-arm64 user-mode emulation; v8's optimising tier emits CPU instructions qemu cannot reliably translate. ubuntu-24.04-arm is a standard GitHub-hosted arm64 runner, free for public repositories and native — no qemu in the loop. GHA cache scopes are now per-platform AND per-ref, so amd64 / arm64 caches stay isolated from each other and a tag build at the same SHA as a main build does not contend on writes. The Coolify deploy webhook moves to the merge job so it fires once, only after the manifest is final. --- .github/workflows/docker-publish.yml | 176 ++++++++++++++++++++------- 1 file changed, 131 insertions(+), 45 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e764ff85b..752cb854b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,16 +20,43 @@ concurrency: cancel-in-progress: true jobs: - build-and-push: - name: Build & push image - runs-on: ubuntu-latest - # 25 min wall clock — a healthy amd64-only build finishes inside 12 min. - # The qemu-arm64 path that was hanging at 47 min on `[linux/amd64 runner - # 2/14]` is gone; see commit message for v1.4.16 C5 rationale below. + # ── Stage 1 ──────────────────────────────────────────────────────── + # Build each architecture on its own native GitHub-hosted runner so + # Next.js's static-page workers run on real silicon instead of QEMU + # user-mode emulation. The v1.4.16 C5 attempt at + # `platforms: linux/amd64,linux/arm64` on a single amd64 runner + # deterministically SIGILL-crashed Next 15's static export under + # qemu-arm64 (`Next.js build worker exited with code: null and signal: + # SIGILL`); v8's optimising tier emits instructions qemu cannot + # reliably translate. ubuntu-24.04-arm is a standard GitHub-hosted + # arm64 runner, free for public repositories, and native — no qemu in + # the loop. + # + # Each platform pushes its layer set by digest only (`outputs: push-by-digest=true`). + # The `merge` job below stitches the two digests into a multi-arch + # manifest in a third step, so consumers see a single tag that + # auto-selects on `docker pull` per local platform. + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + # 25 min wall clock — native amd64 build finished inside 12 min + # at v1.4.24; the native arm64 path is comparable. The qemu-arm64 + # hang at 47 min on `[linux/amd64 runner 2/14]` is what motivated + # this matrix in the first place. timeout-minutes: 25 permissions: contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + cache_scope: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + cache_scope: arm64 steps: - name: Checkout @@ -48,6 +75,94 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute image labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=HealthLog + org.opencontainers.image.description=Self-hosted health-tracking PWA + org.opencontainers.image.licenses=AGPL-3.0-only + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + # Per-ref + per-platform gha cache scope. Tag builds warm-start + # off main's last good layer set for the same platform; tag and + # main builds at the same SHA do not contend on writes, and + # amd64 / arm64 caches stay isolated from each other (a stale + # arm64 layer never poisons an amd64 build). + - name: Build & push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: | + type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=gha,scope=build-${{ matrix.cache_scope }}-${{ github.ref_name }} + type=gha,scope=build-${{ matrix.cache_scope }}-main + cache-to: type=gha,mode=max,scope=build-${{ matrix.cache_scope }}-${{ github.ref_name }} + provenance: true + sbom: true + + # Persist the per-platform digest so the `merge` job can stitch + # both into a single multi-arch manifest. One artifact per + # platform avoids upload races. + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/digests + digest='${{ steps.build.outputs.digest }}' + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.cache_scope }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # ── Stage 2 ──────────────────────────────────────────────────────── + # Stitch the per-platform digests into a single multi-arch manifest + # under all the tags `docker/metadata-action` would normally apply. + # `docker buildx imagetools create` is a manifest-only operation — + # no layers are re-uploaded, the existing per-platform digests are + # referenced in place. This is the same end state as the + # single-buildx `platforms:` path would produce, just assembled in + # the registry instead of on one cross-emulated runner. + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + needs: [build] + timeout-minutes: 10 + permissions: + contents: read + packages: write + + steps: + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute image tags & labels id: meta uses: docker/metadata-action@v5 @@ -66,44 +181,16 @@ jobs: org.opencontainers.image.licenses=AGPL-3.0-only org.opencontainers.image.source=https://github.com/${{ github.repository }} - # v1.4.16 C5 — drop linux/arm64 from the multi-arch build. - # - # Root cause of the main-branch hang since v1.4.14: the qemu-arm64 - # path (`docker/setup-qemu-action` + `tonistiigi/binfmt`) was - # SIGILL-crashing Next.js's static-page-generation workers - # (`Next.js build worker exited with code: null and signal: SIGILL` - # at 64-66/86 pages). V8's optimising tier emits CPU instructions - # that qemu's user-mode emulation cannot reliably translate under - # heavy load, and Node 22 + Alpine 3.21 hit it deterministically. - # Tag-builds appeared to "work" only because they happened to - # finish their static pages before the crash window; main-branch - # builds rarely got that lucky. - # - # Marc's prod target is x86_64 only (Coolify on apps-01, all other - # self-hosted instances we know of); the arm64 image was a - # courtesy build for OSS users, not production. v1.5 will re-add - # arm64 via a matrix that uses the GitHub-hosted `ubuntu-24.04-arm` - # runner (free for public repos, native arm64, no qemu) — see - # `docs/audit/v1416-auto-deploy-fix.md` for the broader CI plan. - # - # Per-ref gha cache scope (kept from v1.4.15 C3): tag + main - # builds at the same SHA do not contend on writes; tag warm-starts - # off main's last good build for layer reuse. - - name: Build & push image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: | - type=gha,scope=build-${{ github.ref_name }} - type=gha,scope=build-main - cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }} - provenance: true - sbom: true + - name: Create multi-arch manifest + working-directory: /tmp/digests + run: | + # shellcheck disable=SC2046,SC2086 + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect published manifest + run: | + docker buildx imagetools inspect '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}' # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has # the new image. Removes the manual SSH + force-pull recipe documented @@ -122,7 +209,6 @@ jobs: # step short-circuits to a skip rather than calling Coolify with empty # auth. - name: Trigger Coolify deploy - if: github.event_name != 'pull_request' continue-on-error: true env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}