Files
dependabot[bot] 0497d60a89 build(deps): bump docker/login-action from 4.6.0 to 4.7.0 (#1289)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 01:32:51 +00:00

505 lines
24 KiB
YAML

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-24.04
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-24.04
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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# 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@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
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-24.04
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# 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@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.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:<tag>
#
# 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-24.04
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 POST "$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}."