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."