Files
picpeak/.github/workflows/install-smoke.yml
T
Paul Nothaft a40ab6a9b1 ci: required-check workflows now fire on every PR (no paths filter)
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.

Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.

This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
  service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
  Compose boot + login).

Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.

Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.

## What this does NOT fix

`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
2026-06-30 00:07:44 +02:00

222 lines
8.9 KiB
YAML

name: Fresh-install smoke
# Verifies that a clean Postgres install boots cleanly under the same
# conditions a new user hits on their first `docker compose up -d`. The
# specific scenarios this guards against — see #484 for the original
# reproduction:
#
# 1. Bind-mounted host directories owned by a UID other than 1001
# (the container's nodejs user). The entrypoint must self-chown
# and drop privileges via su-exec.
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
# in #494, the index/created_at error fixed in #511, and any
# future migration-order issue that only surfaces on an empty DB).
#
# Triggers only on changes that touch the install path so unrelated PRs
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
workflow_dispatch:
permissions:
contents: read
jobs:
fresh-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build for the runner's arch only — we just need a runnable image.
# The full multi-arch build is the docker-build workflow's job.
- name: Build backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
# ignore-error: a flaky GHA cache write must not fail the build.
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
- name: Create Docker network
run: docker network create picpeak-smoke
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
# common mismatch case on Linux hosts). The entrypoint must chown
# this to 1001 itself — that's the regression we're guarding.
- name: Prepare host bind-mount dirs owned by UID 1000
run: |
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
chmod 755 smoke-mounts smoke-mounts/*
ls -ld smoke-mounts/*
- name: Start Postgres
run: |
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
-e POSTGRES_USER=picpeak \
-e POSTGRES_PASSWORD=smokepass \
-e POSTGRES_DB=picpeak_prod \
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
--health-interval=2s --health-timeout=2s --health-retries=30 \
postgres:15-alpine
- name: Wait for Postgres healthy
run: |
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
if [ "$status" = "healthy" ]; then
echo "postgres healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "postgres did not become healthy in 60s"
docker logs picpeak-smoke-pg
exit 1
- name: Start backend with mismatched-UID bind mounts (fresh install)
run: |
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
-e NODE_ENV=production \
-e JWT_SECRET=smoketestsecretvalueof32characters \
-e DB_HOST=picpeak-smoke-pg \
-e DB_USER=picpeak \
-e DB_PASSWORD=smokepass \
-e DB_NAME=picpeak_prod \
-e ADMIN_EMAIL=admin@smoke.local \
-e ADMIN_PASSWORD=smokeAdminPass12345 \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke
- name: Wait for backend healthy
run: |
for i in $(seq 1 120); do
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
if [ "$status" = "exited" ]; then
echo "FAIL: backend exited during cold-start (restart loop scenario)"
docker logs picpeak-smoke-bk
echo "--- error.log ---"
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
exit 1
fi
if [ "$health" = "healthy" ]; then
echo "backend healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "FAIL: backend did not become healthy in 120s"
docker ps -a
docker logs picpeak-smoke-bk
exit 1
- name: Verify chown happened (container view)
run: |
# All three dirs should now be owned by nodejs (UID 1001).
# If the entrypoint's self-chown branch didn't fire, they'd
# still be owned by the runner UID and node would have hit
# EACCES creating storage subdirs.
for d in /app/storage /app/data /app/logs; do
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
if [ "$owner_uid" != "1001" ]; then
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
exit 1
fi
echo "ok: $d owned by UID $owner_uid"
done
- name: Verify app is actually serving
run: |
# /health is what docker's HEALTHCHECK polls, but hit it
# directly to confirm the response shape matches what the
# frontend + reverse proxy expect.
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
echo "/health => $body"
echo "$body" | grep -q '"status":"ok"' || {
echo "FAIL: /health did not return status:ok"
exit 1
}
- name: Verify node runs as nodejs (not root)
run: |
# dumb-init runs as root (PID 1), node must be running as
# nodejs (UID 1001) — if su-exec drop didn't happen the app
# would be running as root which is the security regression
# we're guarding against. Alpine ships BusyBox ps, which
# doesn't support `-p PID` or pgrep, so list + awk instead.
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
if [ "$user" != "nodejs" ]; then
echo "FAIL: node running as '$user' (expected nodejs)"
docker exec picpeak-smoke-bk ps -o pid,user,comm
exit 1
fi
echo "ok: node running as $user"
- name: Verify no restart loop
run: |
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
if [ "$restart_count" -gt 0 ]; then
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
docker logs picpeak-smoke-bk
exit 1
fi
echo "ok: 0 restarts"
# Restart with `--user 5005:5005` (no root, can't chown) against
# bind mounts owned by 1000 — entrypoint must fail loud with the
# actionable preflight error, not silently restart-loop.
- name: Verify preflight fails loud on unwritable mounts
run: |
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
set +e
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
-e NODE_ENV=production -e JWT_SECRET=x \
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke 2>&1)
rc=$?
set -e
echo "$out"
if [ $rc -eq 0 ]; then
echo "FAIL: preflight should have exited non-zero"
exit 1
fi
echo "$out" | grep -q "is not writable by UID 5005" || {
echo "FAIL: preflight error message missing or wrong"
exit 1
}
echo "ok: preflight failed loud with actionable error"
- name: Cleanup
if: always()
run: |
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
docker network rm picpeak-smoke 2>/dev/null || true