feat(ci): post-publish verify workflow

Informational reachability gate that runs after `Build & Publish
Docker Image` finishes a `main` (or tag) build. Triggered via
`workflow_run` so it always observes the actual completion state of
the build, then:

  1. pulls `ghcr.io/${repo}:${tag}` (proves the manifest finished
     publishing — the v1.4.14 main run "succeeded" from a wall-clock
     POV but the image had to be retagged on the deploy host because
     main's manifest never finished pushing; this catches that)
  2. boots the container against an ephemeral Postgres service
  3. probes `/api/version` (smoke for the build SHA + version slug)
  4. probes `/api/health` (smoke for the runtime DB/worker probes)

Every probe runs `continue-on-error: true` — this workflow is
informational, not a deploy gate. A transient registry blip should
surface as a yellow run, not block subsequent builds.

Also wired up `workflow_dispatch` with an `image_tag` input so we
can spot-verify any tag (`v1.4.15`, `v1.4.14`, etc.) on demand
without re-publishing.

Marc-status: filed as proof-of-concept for v1.4.15 phase-C2's
auto-deploy work — Coolify webhook listener can build on top of
the same boot harness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:48:37 +02:00
1 parent 249c42bdd2
commit ffa4aac974
1 file changed
+144
+144
View File
@@ -0,0 +1,144 @@
name: Post-publish verify
# Informational reachability gate that runs AFTER `Build & Publish Docker
# Image` finishes a `main` (or tag) build. Pulls the just-published image
# from GHCR and probes:
# - the image manifest is fetchable (i.e. the push actually completed)
# - the container starts and `/api/version` returns the expected version
# - the container's `/api/health` endpoint reports OK
#
# Does NOT block deploys — `continue-on-error` is set on every probe step
# so a transient registry blip doesn't go red. This is a smoke gate the
# v1.4.14 release would have caught: the main push appeared to "succeed"
# from the deploy host's POV, but the host had to pull the tag image
# because main's manifest never finished pushing.
#
# Triggered by `workflow_run` so it only runs after the build workflow
# actually completes — chaining via `needs:` would force the build into
# the same workflow file.
on:
workflow_run:
workflows: ["Build & Publish Docker Image"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
image_tag:
description: "GHCR image tag to verify (defaults to `main`)"
required: false
default: "main"
permissions:
contents: read
packages: read
jobs:
verify:
name: Pull + smoke-test published image
runs-on: ubuntu-latest
timeout-minutes: 10
# Skip if the upstream build itself failed — there's nothing to verify.
# Manual dispatch always runs.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Resolve image tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=main" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
id: pull
continue-on-error: true
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}"
echo "Pulling $IMAGE"
docker pull "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Start container
if: steps.pull.outcome == 'success'
id: run
continue-on-error: true
run: |
# Minimal env to satisfy the strict-env validators on boot.
docker run -d --name healthlog-verify \
--network host \
-e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
-e AUTH_RP_NAME=HealthLog \
-e AUTH_RP_ID=localhost \
-e AUTH_RP_ORIGIN=http://localhost:3000 \
-e NODE_ENV=production \
-e HEALTHLOG_PROCESS_TYPE=web \
-e PORT=3000 \
"${{ steps.pull.outputs.image }}"
# Wait up to 60 s for the server to start answering.
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:3000/api/version; then
echo "ready after ${i}*2s"
exit 0
fi
sleep 2
done
echo "::warning::Container did not become ready within 60 s"
docker logs healthlog-verify || true
exit 1
- name: Probe /api/version
if: steps.run.outcome == 'success'
continue-on-error: true
run: |
set -e
BODY=$(curl -sf http://localhost:3000/api/version)
echo "$BODY" | jq .
VER=$(echo "$BODY" | jq -r '.data.version // .version')
echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER"
- name: Probe /api/health
if: steps.run.outcome == 'success'
continue-on-error: true
run: |
curl -sf http://localhost:3000/api/health | jq .
- name: Container logs (always)
if: always() && steps.run.outcome == 'success'
continue-on-error: true
run: docker logs healthlog-verify | tail -100
- name: Tear down
if: always()
continue-on-error: true
run: docker rm -f healthlog-verify || true