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 # GHCR rejects repository refs with uppercase letters. `IMAGE_NAME` # is derived from `github.repository`, which preserves the case of # the GitHub org/user (`MBombeck/HealthLog`). `docker/metadata-action` # lowercases its `images:` input transparently, so the legacy # single-job workflow worked; the multi-arch matrix now feeds # `outputs:` of build-push-action directly and needs the lowercase # form there too. Resolve it once at the top of the job and reuse # via $GITHUB_ENV. - name: Lowercase image ref run: | echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" # 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.IMAGE_REF }} 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.IMAGE_REF }},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 # GHCR requires the repository ref to be lowercase. See the same # step in the `build` job above for the longer rationale. - name: Lowercase image ref run: | echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" - 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.IMAGE_REF }} 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.IMAGE_REF }}@sha256:%s ' *) - name: Inspect published manifest run: | docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ 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 the maintainer 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. # # v1.4.26 P6-3 — maintainer toggle. # Three releases in a row shipped via the host-side SSH-retag fallback # because the secrets above silently absent + the warn-only skip # below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY` # repository variable now distinguishes the two intended modes: # # COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent, # FAIL the step so the maintainer sees the # missing-secret gap on the next release. # COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use # this when the host-side SSH recipe is # the intended deploy path. # (unset / any other) → warn-and-skip (legacy behaviour). # # Set the variable under repo Settings → Secrets and variables → # Actions → Variables (NOT Secrets — it is intentionally readable). - name: Trigger Coolify deploy continue-on-error: true env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }} run: | mode="${COOLIFY_AUTO_DEPLOY:-}" if [ "$mode" = "off" ]; then echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead." exit 0 fi if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then if [ "$mode" = "on" ]; then echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence." exit 1 fi echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning." 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."