Commit Graph
16 Commits
Author SHA1 Message Date
Marc-André Bombeck 24b8d52f3c ci(docker): fire the release build once per tag
The Build & Publish Docker Image workflow fired twice on every
release. The squash-merge to `main` and the `v*` tag push landed
within a second of each other and each kicked off its own run
against the same SHA. Both runs finished green and pushed
overlapping tag sets — the main-branch run added the `latest`
alias, the tag run added the semver tags — but the duplication
doubled GHCR CI minutes per release with no operational benefit.

Drop the `push.branches: [main]` trigger so only `v*` tag pushes
fire the build on a release. Tighten the `latest` raw-tag rule in
docker/metadata-action so it enables on stable release tags
(`refs/tags/v*` without a pre-release suffix) rather than the
`is_default_branch` condition (false on tag refs). Net result: a
release tag pushes a single multi-arch image carrying both the
semver tags and the `latest` alias in one run, halving the CI
minutes per release. PR + workflow_dispatch triggers are
preserved so pre-merge validation and manual reruns still build.
2026-05-16 20:27:22 +02:00
Marc-André Bombeck 477e57f1f1 ci(image): close the Coolify auto-deploy race against GHCR cache propagation
Five releases in a row (v1.4.27 → v1.4.30.1) the publish workflow
returned a 2xx from the Coolify webhook but the running container
on apps01 still carried the prior `:latest` digest. Host-side SSH
fallback patched it every time at ~5 minutes per release.

Root cause: GHCR's CDN edge layer takes 30-90 s to propagate a
fresh manifest write to consumer pull endpoints. The
`docker buildx imagetools create` + `inspect` steps confirm the
manifest at registry-write time, but the webhook fires
immediately afterwards. Coolify's pull lands inside the
edge-propagation window, gets the prior digest, declares no-op,
and returns success. The webhook itself works; the spacing
assumption between push and pull was wrong.

Fix: add `sleep 90` immediately after the auth + skip-mode
guards in the `Trigger Coolify deploy` step. Diagnostic line
prints the resolved image version (`steps.meta.outputs.version`)
before the sleep so the workflow log surfaces the exact tag the
operator can sanity-check.

Cost: ~30% on the publish-job duration, but only after the image
is fully on GHCR. The release-flow walk-through clock is
unaffected; the host-side SSH fallback remains available as a
backstop.

Full root-cause + hypothesis matrix + escalation plan documented
in `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`.
2026-05-16 14:07:22 +02:00
Marc-André Bombeck 3a920661e7 ci(image): tag four-segment versions on the multi-arch manifest
The metadata-action semver patterns only match `vMAJOR.MINOR.PATCH`,
so a tag push for a hotfix like `v1.4.28.1` produced only the
`sha-<short>` manifest and no version-named tag. Deploys that pull
`ghcr.io/mbombeck/healthlog:1.4.28.1` then fail with a `manifest
unknown`.

Adding `type=match,pattern=v(.+),group=1` publishes the tag with the
leading `v` stripped, matching the established three-segment naming
on prior releases. `type=ref,event=tag` is a belt-and-braces fallback
that also keeps the raw tag (`v1.4.28.1`) addressable.
2026-05-16 09:39:33 +02:00
Marc-André Bombeck 9c265024f4 ci(image): assert the GeoLite2 MMDBs land in the image when the key is set
v1.4.28 shipped with `offlineGeoEnabled: false` on both production
hosts because the Fetch step could silently emit the `.empty` marker
and the workflow trusted the script's exit code. The script-side
hardening in the previous commit closes the fail-loud gap for download
errors; this commit adds a workflow-side belt-and-braces check so a
stale `.empty` marker or a workspace-cache surprise cannot survive
into the image either.

New "Verify GeoLite2 databases" step runs on each platform leg after
the Fetch step and before `docker/build-push-action`. When
`MAXMIND_LICENSE_KEY` is set it asserts both MMDBs exist in
`assets/geolite2/` with non-zero size and that no `.empty` marker
survived; either failure exits the leg with a clear `::error::` line.
When the secret is unset the step short-circuits to a notice — the
intentional "ship without offline tier" path stays usable.

`secrets.*` is not allowed in step-level `if:` expressions, so the
gate is implemented via env-surfaced presence check inside the step.
Validated with `actionlint`.

Co-references the live-environment shape in
`.planning/round-5-geolite2-ci-fix-report.md`.
2026-05-16 09:03:17 +02:00
Marc-André Bombeck 674c6b5b70 fix(ci): soften the GeoLite2 fetch guard so the build never blocks on a missing license key
Replace the hard exit 1 in the `Fetch GeoLite2 databases` step with a
`::warning::` line plus a `.empty` marker in `assets/geolite2/`. The
Dockerfile COPY still has a non-empty source, the build continues, and
the runtime detection layer (next commit) picks up the marker on first
geo lookup and emits a one-shot admin notification. The maintainer is
no longer forced to wire `MAXMIND_LICENSE_KEY` before every release.

`scripts/fetch-geolite2.sh` mirrors the same behaviour: when the key is
unset it drops the marker and exits 0 cleanly; when the key is present
it clears any stale marker before downloading so a freshly populated
directory is not misread as the fallback state.
2026-05-15 22:05:32 +02:00
Marc-André Bombeck 47d2071938 fix(ci): wire MAXMIND_LICENSE_KEY into the build workflow and fail fast on missing secret
Add a `Fetch GeoLite2 databases` step ahead of the buildx build-push so
the offline GeoLite2-City + GeoLite2-ASN MMDB files land in
`assets/geolite2/` before the Dockerfile COPY at line 78 reads the
directory. The step exports `MAXMIND_LICENSE_KEY` from the repo secrets
and fails the workflow with a clear error when the secret is unset,
matching the Coolify auto-deploy gate shape from v1.4.26 P6-3 so the
missing-secret state can no longer ship as a silent no-op offline tier.
2026-05-15 20:53:23 +02:00
Marc-André Bombeck e23dd28619 ci(coolify): explicit maintainer toggle for auto-deploy gate
Three releases in a row shipped via the host-side SSH retag fallback
because the COOLIFY_WEBHOOK / COOLIFY_TOKEN secrets were absent, the
deploy step warned and exited 0, and the workflow stayed green. The
silent miss meant the gap surfaced only at deploy time when /api/version
refused to advance.

The vars.COOLIFY_AUTO_DEPLOY repository variable now disambiguates
intent:
  on  — fail the step when secrets are absent (catches the gap loudly
        on the next release).
  off — unconditional skip with a notice (host-side SSH is the chosen
        path).
  unset / other — legacy warn-and-skip behaviour.

Variable lives under Settings → Secrets and variables → Actions →
Variables (not Secrets — it is intentionally readable). The warn-only
default keeps existing repos working; flipping the toggle to 'on'
turns the silent miss into a noisy error.
2026-05-14 21:41:58 +02:00
Marc-André Bombeck a6f94cfd95 fix(ci): lowercase GHCR image ref for the multi-arch publish workflow
The v1.4.25 W11a refactor moved the workflow off `docker/metadata-action`
as the single source of the image-ref string and started passing
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}` directly into the
`outputs:` of `docker/build-push-action`. `IMAGE_NAME` comes from
`github.repository` (`MBombeck/HealthLog`), and GHCR rejects pushes
with any uppercase character in the repo segment, so both per-platform
builds failed with `invalid reference format`. Resolve the lowercase
form once at the top of each job via `${VAR,,}` bash expansion and
reuse it across `metadata-action`, `build-push-action`, and the
`buildx imagetools` calls in the merge stage.
2026-05-14 19:18:07 +02:00
Marc-André Bombeck 3e78da6e35 ci(docker): build multi-arch image (linux/amd64 + linux/arm64) for Apple Silicon support
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.
2026-05-14 16:57:09 +02:00
Marc-André BombeckandClaude Opus 4.7 6d894a3179 ci(deploy): document the Coolify auto-deploy contract end-to-end
Two halves of the contract — workflow-side webhook (v1.4.22 C3 force=true
already in place) and Coolify-side image-digest watch toggle — must
both be on for tag pushes to land on production without an SSH retag.
The runbook in .planning/coolify-auto-deploy-howto.md now spells out
both pieces verbatim plus the verification curl + host-side fallback.

The workflow gains a single ::notice:: line after the webhook call so
future Actions runs surface the timestamp + sha for triage without
opening the verbose log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:12:06 +02:00
Marc-André BombeckandClaude Opus 4.7 b281c0655b ops(deploy): force=true on Coolify webhook + node-26-alpine investigation
C3 — every v1.4.19/20/21 release required a host-side `docker tag`
fallback because Coolify pulled `:latest` from its image cache without
re-checking the GHCR registry digest. The workflow now appends
`?force=true` (or `&force=true` if other params are present) to the
deploy webhook URL so a doc-only push still triggers a registry-digest
check. The complementary maintainer-side toggle ("Watch image registry
for new digests" in the Coolify application config) is documented in
`.planning/coolify-auto-deploy-howto.md` — one checkbox flip in the UI,
no code change required.

C5 — the dependabot PR bumping node:22-alpine → node:26-alpine fails
at build because node:26-alpine no longer ships with corepack
pre-installed (`/bin/sh: corepack: not found`, exit 127). Next.js 16
also still pins node 22 LTS as its supported minimum, so a corepack
workaround would put the runtime ahead of the framework. Closed PR
#162 with a comment; reopen when Next.js bumps its node-version pin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:24:00 +02:00
dependabot[bot] 1d3009d9ef chore(deps): Bump actions/checkout from 4 to 6 (#161)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-10 16:28:06 +00:00
Marc-André Bombeck cc0f3436ff fix(ci): docker-publish reliable on main-branch (drop qemu-arm64)
The main-branch build hang since v1.4.14 was qemu-arm64 SIGILL-crashing
Next.js static-page-generation workers — `Next.js build worker exited
with code: null and signal: SIGILL` at 64-66/86 pages, every time
under load. V8's optimising tier emits CPU instructions that qemu's
user-mode emulation cannot reliably translate, and Node 22 + Alpine
3.21 hit it deterministically. Tag-builds appeared to "work" only
because they sometimes finished the static phase before the crash
window; main-branch builds rarely got that lucky and looked like
hangs.

Marc's prod target is x86_64 (Coolify on apps-01, every other
self-hosted HealthLog we know of). The arm64 image was a courtesy
build for OSS users, not production. Dropping it removes the qemu
flake completely and brings build time from 17-30 min back to ~12 min
typical.

v1.5 plan (`docs/audit/v1416-auto-deploy-fix.md`): re-add arm64 via a
matrix that uses GitHub-hosted `ubuntu-24.04-arm` runners (free for
public repos, native arm64, no qemu). The current concurrency-group +
per-ref gha cache scope from v1.4.15 C3 stays in place.
2026-05-10 00:34:33 +02:00
Marc-André BombeckandClaude Opus 4.7 34c967c14a feat(achievements): dedicated /achievements page with locked/unlocked list
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.

Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.

The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.

i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.

Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:17:04 +02:00
Marc-André BombeckandClaude Opus 4.7 249c42bdd2 fix(ci): docker-publish reliability — separate cache scope per ref + 30min timeout
The v1.4.14 release exposed a hard hang: the `main` push and the
`v1.4.14` tag push both fired at SHA `e5fae9bce6cb` simultaneously.
The tag run finished cleanly in 17 minutes; the main run sat on
`#26 [linux/amd64 runner 2/14] RUN apk add tzdata` from 16:14:52Z
until Marc cancelled it at 17:00:15Z — 47 minutes of zero buildkit
output. Same SHA, same Dockerfile, same runner image, vastly
different outcome.

Root cause: both `cache-from: type=gha` and `cache-to: type=gha,mode=max`
used buildx's default scope name (`buildkit`). With `mode=max` every
stage of every platform is exported to the cache; the two concurrent
writers raced on the gha cache manifest and one deadlocked on the
exporter. This is the classic "shared buildkit cache between
parallel refs" failure mode — pinned down by the symmetry of
"same SHA, parallel runs, only the slower-to-arrive one hangs".

Fix is two layers:

1. Per-ref cache scope (`scope=build-${{ github.ref_name }}`). Each
   workflow invocation now writes to its own slot:
   - `build-main` for branch pushes
   - `build-v1.4.x` for tag pushes
   - `build-pr-N` for pull-request pushes
   `cache-from` reads BOTH the ref-specific scope AND `build-main`,
   so a tag built right after a main push still reuses main's
   layers — just without contending on the writer side.

2. Job-level `timeout-minutes: 30`. The fastest healthy build of the
   v1.4.7..v1.4.13 series finished in 12 min, the slowest in 18 min.
   30 min covers a slow exporter while still rescuing the runner
   automatically if buildkit deadlocks again, instead of waiting for
   a human to notice and click Cancel an hour later.

Pass-rate impact in the v1.4.15 marathon: 30/60 successes over
30 days (most "cancelled" entries are by-design — concurrency group
cancels superseded runs — but the v1.4.14 hang counts as a
preventable outlier). After this commit a deadlocked run is bounded
to 30 min and a release commit's two refs no longer fight each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:47:43 +02:00
Marc-André BombeckandClaude Opus 4.7 75d142df25 feat(ci): publish multi-arch Docker image to GHCR (#88)
Issue #88's biggest blocker was that docs and README pointed at
ghcr.io/mbombeck/healthlog:latest, but no workflow ever pushed an
image — every self-hoster trying to follow the docs hit "manifest
unknown".

This adds .github/workflows/docker-publish.yml. The workflow:

- Triggers on push to main, on v* tags, on PRs (build only, no push),
  and via workflow_dispatch
- Builds linux/amd64 + linux/arm64 multi-arch images via buildx
- Tags with `latest` (main only), semver (`1.2.0`, `1.2`), branch name,
  PR number, and short SHA — using docker/metadata-action defaults
- Attaches SLSA build provenance and an SBOM to every image
- Caches build layers in GitHub Actions cache for fast iterations
- Uses concurrency group + cancel-in-progress so superseded runs stop

The bundled docker-compose.yml now references
`ghcr.io/mbombeck/healthlog:latest` as the primary `image:` and keeps
`build:` as a fallback so contributors can still
`docker compose up --build` against their working tree.

SECURITY.md gains a Supply Chain section explaining where images come
from, how to verify provenance and SBOMs, and how to pin a specific
version. README's Quick Start updated to reflect that no build is
needed for self-hosters.

The first run of this workflow on main will publish the image; until
then `docker compose up` will fall back to the `build:` block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:38:08 +02:00