name: Build & Publish Docker Image # v1.4.34 IW-A — release pipeline fires on tag pushes only. Earlier # releases triggered the workflow twice on each cut: the squash-merge # to `main` and the corresponding `v*` tag push landed within a second # of each other and each kicked off its own build. Both runs ended # green and produced the same image at the same SHA with overlapping # tag sets (the main run added `latest`, the tag run added the # semver tags), but the duplication doubled GHCR CI minutes for # every release. Dropping `push.branches: [main]` collapses the # pipeline to a single run per release tag. The `latest` rule in # the metadata-action (`type=raw,value=latest,...`) now keys on # the v* tag presence so each release still refreshes the `latest` # alias in the same run. PR + workflow_dispatch triggers stay so # the image still builds on PR validation and manual reruns. on: push: tags: ["v*"] pull_request: branches: [main] workflow_dispatch: # Default for any job that doesn't declare its own block; both jobs # below elevate to packages:write explicitly. permissions: contents: read env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # Cancel in-progress runs when a new commit is pushed to the same ref. # Different ref types (tag vs PR head) don't share a group, so a tag # build and a concurrent PR build won't cancel each other. concurrency: group: docker-publish-${{ github.ref }} cancel-in-progress: true jobs: release-policy: name: Validate release policy runs-on: ubuntu-latest timeout-minutes: 5 permissions: actions: read contents: read steps: - name: Checkout full history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Validate release policy env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then echo "::notice::Pull-request image validation does not publish release tags." exit 0 fi if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2 exit 1 fi package_version="$(node -p "require('./package.json').version")" tag_version="${GITHUB_REF_NAME#v}" if [ "$package_version" != "$tag_version" ]; then echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2 exit 1 fi git fetch --no-tags origin main:refs/remotes/origin/main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2 exit 1 fi successful_quality_runs="$( gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \ --jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length' )" if [ "$successful_quality_runs" -lt 1 ]; then echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2 exit 1 fi echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA." # ── 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: needs: [release-policy] 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Compute image labels id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.IMAGE_REF }} labels: | org.opencontainers.image.title=HealthLog org.opencontainers.image.description=Self-hosted health-tracking PWA org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0 org.opencontainers.image.source=https://github.com/${{ github.repository }} # v1.4.27 B3 — GeoLite2-City + GeoLite2-ASN databases are too large # (~80 MB combined) to vendor in git. `scripts/fetch-geolite2.sh` # downloads both editions into `assets/geolite2/` so the Dockerfile # COPY at line 78 picks them up. # # Two intentional paths: # - `MAXMIND_LICENSE_KEY` unset → emit a `::warning::`, drop a # `.empty` marker into the assets directory so the Dockerfile # COPY still has a non-empty source, and continue cleanly. The # runtime resolver in `src/lib/geo.ts` detects the marker on # first lookup and emits a one-shot admin notification — the # maintainer hears about the gap from the running app rather # than from a red CI badge that blocks every release. # - `MAXMIND_LICENSE_KEY` set → run `fetch-geolite2.sh` and # fail the build if it errors. The "Verify GeoLite2 databases" # step below adds a belt-and-braces assertion that both MMDB # files landed in `assets/geolite2/` before the Dockerfile # COPY reads the directory, so v1.4.28-era silent fallback # (offlineGeoEnabled: false on both nodes after a tag build) # can no longer ship. # # To enable the offline feature, set the secret under repo # Settings → Secrets and variables → Actions and redeploy. - name: Fetch GeoLite2 databases env: MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} run: | if [ -z "$MAXMIND_LICENSE_KEY" ]; then echo "::warning::MAXMIND_LICENSE_KEY secret is unset. The image will ship without the offline GeoLite2 databases; the runtime resolver continues to fall back to the online ipwho.is provider and will send a one-shot admin notification on first use. To enable the offline tier, set MAXMIND_LICENSE_KEY under repo Settings → Secrets and variables → Actions." mkdir -p assets/geolite2 touch assets/geolite2/.empty exit 0 fi ./scripts/fetch-geolite2.sh # Build-time assertion: with the license key present, both MMDB # files must land in `assets/geolite2/` before the Dockerfile # COPY at line 78 picks them up. The fetch script already fails # loud on a curl / tarball error, but a stale `.empty` marker # from a previous keyless run or a workspace-cache surprise could # otherwise survive into the image — this step is the operator # guarantee that "key set" really means "MMDBs in the image". # # `secrets.*` is not available in step-level `if:` expressions, # so we surface the presence flag through env and gate inline. - name: Verify GeoLite2 databases env: MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} run: | if [ -z "$MAXMIND_LICENSE_KEY" ]; then echo "MAXMIND_LICENSE_KEY unset — verification skipped (offline geo tier intentionally absent on this build)." exit 0 fi missing=() for mmdb in GeoLite2-City.mmdb GeoLite2-ASN.mmdb; do if [ ! -s "assets/geolite2/$mmdb" ]; then missing+=("$mmdb") fi done if [ "${#missing[@]}" -gt 0 ]; then echo "::error::MAXMIND_LICENSE_KEY is set but the following GeoLite2 MMDB files are missing or empty in assets/geolite2/: ${missing[*]}. The Dockerfile would COPY them into /opt/geolite2/ in the final image, leaving the offline geo tier disabled at runtime. Investigate the previous 'Fetch GeoLite2 databases' step before retrying." exit 1 fi if [ -f assets/geolite2/.empty ]; then echo "::error::Stale .empty marker present in assets/geolite2/ despite MAXMIND_LICENSE_KEY being set. The runtime resolver would treat the image as missing the offline tier. Investigate the previous 'Fetch GeoLite2 databases' step before retrying." exit 1 fi city_size="$(stat -c%s assets/geolite2/GeoLite2-City.mmdb)" asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)" echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes." # Build provenance for /api/version: the short commit SHA and an # ISO-8601 build timestamp travel into the image as build-args # (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the # deploy runbook can verify which commit a running container # actually carries — the `:latest` pull is the source of truth, # not the queued deploy status. 12 hex chars is plenty to be # unambiguous in this repository. - name: Resolve build provenance id: provenance run: | echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" # 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} # v1.4.43 B11 — thread the tag ref into the build as # NEXT_PUBLIC_APP_VERSION. The Dockerfile picks it up as an # ARG/ENV and /api/version reads it at runtime. The build-arg # becomes part of the BuildKit layer cache key, so a release # tag bump invalidates every downstream layer that depends on # it (`pnpm build`) and the prior release's version string # can never survive a cache hit into the new image. build-args: | NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }} NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }} NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }} 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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 outputs: image_ref: ${{ steps.published.outputs.image_ref }} image_tag: ${{ steps.published.outputs.image_tag }} image_digest: ${{ steps.published.outputs.image_digest }} build_sha: ${{ steps.published.outputs.build_sha }} permissions: contents: read packages: write # v1.16.4 — keyless image signing. The job requests an OIDC # token from GitHub's issuer; cosign exchanges it for a # short-lived Fulcio certificate bound to this workflow identity # and records the signature in the Rekor transparency log. No # long-lived signing key exists anywhere in this pipeline. id-token: write steps: - name: Download digest artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digest-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Compute image tags & labels id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.IMAGE_REF }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=match,pattern=v(.+),group=1 type=ref,event=tag # v1.4.34 IW-A — release pipeline fires on `v*` tag pushes # only (the `push.branches: [main]` trigger was dropped to # eliminate the per-release double-build). `is_default_branch` # is false for tag refs, so the prior condition stopped # refreshing the `latest` alias once main pushes no longer # trigger the workflow. Enable on every release tag that # does not carry a pre-release suffix (`-rc.1`, `-beta.2`, # etc.) so each stable release rotates the `latest` alias # while pre-release tags stay off it. type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }} 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=PolyForm-Noncommercial-1.0.0 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: Resolve published image id: published env: IMAGE_TAG: ${{ github.ref_name }} run: | image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')" if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2 exit 1 fi echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT" echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT" echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}" # v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore). # Signing the INDEX digest covers both per-platform manifests in # one signature, and the digest is the only stable identity — every # tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry # compromise could repoint. Operators verify with: # # cosign verify \ # --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \ # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ # ghcr.io/mbombeck/healthlog: # # See docs/ops/deploy.md ("Verify the image signature") for the # digest-pinned deploy recommendation that pairs with this. - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Sign multi-arch manifest (keyless) env: IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }} run: | echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}" cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}" echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_DIGEST}" verify-published-image: name: Verify exact published image needs: merge permissions: contents: read packages: read uses: ./.github/workflows/post-publish-verify.yml with: image_ref: ${{ needs.merge.outputs.image_ref }} image_tag: ${{ needs.merge.outputs.image_tag }} image_digest: ${{ needs.merge.outputs.image_digest }} build_sha: ${{ needs.merge.outputs.build_sha }} secrets: inherit promote: name: Promote verified image to Coolify needs: [merge, verify-published-image] runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read steps: - name: Trigger Coolify deploy env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }} IMAGE_TAG: ${{ needs.merge.outputs.image_tag }} IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }} BUILD_SHA: ${{ needs.merge.outputs.build_sha }} run: | mode="${COOLIFY_AUTO_DEPLOY:-}" if [ "$mode" = "off" ]; then echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook." exit 0 fi if [ "$mode" != "on" ]; then echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables." exit 0 fi if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2 exit 1 fi echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}." sleep 90 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 ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})." 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 for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2 exit 1 fi echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."