mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
250 lines
11 KiB
YAML
250 lines
11 KiB
YAML
name: Build & Publish Docker Image
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
tags: ["v*"]
|
|
pull_request:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
env:
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: ${{ github.repository }}
|
|
|
|
# Cancel in-progress runs when a new commit is pushed to the same ref.
|
|
# main + a v* tag at the same SHA are different refs, so the per-ref
|
|
# gha cache scope below keeps them from contending on cache writes.
|
|
concurrency:
|
|
group: docker-publish-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# ── 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
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v3
|
|
|
|
# Skip login on PRs from forks — they don't have package:write.
|
|
# Image is built but not pushed in that case.
|
|
- name: Log in to GitHub Container Registry
|
|
if: github.event_name != 'pull_request'
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
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
|
|
with:
|
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
tags: |
|
|
type=ref,event=branch
|
|
type=ref,event=pr
|
|
type=semver,pattern={{version}}
|
|
type=semver,pattern={{major}}.{{minor}}
|
|
type=raw,value=latest,enable={{is_default_branch}}
|
|
type=sha,prefix=sha-,format=short
|
|
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 }}
|
|
|
|
- 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
|
|
# in `.planning/phase-8-report.md`.
|
|
#
|
|
# Runs only on the publish path (push to `main` or to a `v*` tag), never
|
|
# on PRs. `continue-on-error: true` because the image is *already*
|
|
# published to GHCR by the time we reach this step — a Coolify-side
|
|
# outage must not retroactively fail the build. Marc still has the
|
|
# manual recipe as fallback.
|
|
#
|
|
# Required GitHub repository secrets (set by Marc once):
|
|
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
|
|
# COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens
|
|
# Both secrets are referenced exclusively here. If either is unset, the
|
|
# step short-circuits to a skip rather than calling Coolify with empty
|
|
# auth.
|
|
- name: Trigger Coolify deploy
|
|
continue-on-error: true
|
|
env:
|
|
COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}
|
|
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
|
run: |
|
|
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
|
|
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable."
|
|
exit 0
|
|
fi
|
|
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
|
# and re-pull the registry digest. If the maintainer's secret
|
|
# URL already carries a `?force=` query, leave it alone;
|
|
# otherwise append `?force=true` (or `&force=true` if other
|
|
# query params are present) so doc-only deploys still trigger
|
|
# a registry-digest check.
|
|
deploy_url="$COOLIFY_WEBHOOK"
|
|
if ! echo "$deploy_url" | grep -q "force="; then
|
|
if echo "$deploy_url" | grep -q "?"; then
|
|
deploy_url="${deploy_url}&force=true"
|
|
else
|
|
deploy_url="${deploy_url}?force=true"
|
|
fi
|
|
fi
|
|
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
|
|
response=$(curl --silent --show-error --max-time 30 \
|
|
--write-out "\n__HTTP_STATUS__:%{http_code}" \
|
|
--request GET "$deploy_url" \
|
|
--header "Authorization: Bearer $COOLIFY_TOKEN")
|
|
status="${response##*__HTTP_STATUS__:}"
|
|
body="${response%__HTTP_STATUS__:*}"
|
|
echo "Coolify response (HTTP $status):"
|
|
echo "$body"
|
|
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
|
|
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md."
|
|
exit 1
|
|
fi
|
|
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md."
|
|
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
|