Files
HealthLog/.github/workflows/security.yml
T
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00

257 lines
11 KiB
YAML

name: Security & Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Daily, 05:47 UTC. Carries the dependency audit only — every other job
# here answers "is this change safe" and has nothing to say about a tree
# nobody touched. See the note on `dependency-audit`.
- cron: "47 5 * * *"
permissions:
contents: read
jobs:
# The static checks: everything in the old single quality job except the
# unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
unit:
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
name: unit shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# The hero-geometry check in the unit suite renders the real panel in a
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set. Every slice
# installs it, because the sequencer decides which slice the file lands in.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
# full headed build is several times the download for nothing this job
# uses. The e2e job keeps `--with-deps chromium` — it drives a real app.
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
# vitest directly, not `pnpm test --`: pnpm hands the separator through
# literally and the shard flag would never reach vitest.
- name: Unit tests
run: >-
pnpm exec vitest run --shard=${{ matrix.shard }}/3
--reporter=default --reporter=json
--outputFile.json=unit-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unit-report-${{ matrix.shard }}
path: unit-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-24.04
needs: [static, unit]
if: always() && github.event_name != 'schedule'
timeout-minutes: 5
steps:
- name: The static checks and every unit slice finished green
run: |
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
exit 1
fi
# Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday
# and fails on Tuesday because someone else published, and the branch that
# goes red changed nothing. Seventeen of the forty-one failures this workflow
# produced in thirty days were exactly that, and a gate that fails for
# reasons the author cannot act on is a gate people learn to re-run rather
# than read. It runs on every push to `main` and once a day on the schedule
# above, which is the cadence the question actually has. What ships is still
# gated: `docker-security` scans the built image on every push to `main`, and
# `trivy.yml` scans the lockfile (dev dependencies included) and the
# published image daily.
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-24.04
# Runs on pull requests too, but advisory: an advisory published against a
# dependency the branch did not touch must not block the branch. The push
# run on main and the daily schedule stay blocking, and a tag is only ever
# cut from a main whose push run is green.
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
# Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a
# production dependency fails the build. If an advisory has no
# upstream fix yet, pin an `auditConfig.ignoreCves` entry in
# package.json (pnpm's documented suppression mechanism) with a
# comment referencing the tracking issue, rather than flipping
# this step back to warn-only.
- name: Audit for known vulnerabilities
run: pnpm audit --prod --audit-level=high
secret-scan:
name: Secret Scanning
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Gitleaks secret scan
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker-security:
name: Container Security
runs-on: ubuntu-24.04
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image
run: docker build -t healthlog:scan .
# The release binary, pinned, straight from the Trivy release. The apt
# route ran `apt-get update` across every repository on the runner
# image, and an unrelated one (the Chrome repository the runner ships
# with) answering with a corrupt index failed the step twice in a row
# on a release commit. The installer downloads the tarball for this
# exact version and checks it against the published checksums.
#
# The installer itself is fetched from a COMMIT and verified before it
# runs. A tag can be moved to other content, and piping a moving ref
# straight into a shell runs whatever the ref points at that morning
# with the runner's privileges. `TRIVY_INSTALL_COMMIT` is the commit
# `v0.74.0` points at; bump both together, and take the new checksum
# from the file at that commit.
- name: Install Trivy
env:
TRIVY_VERSION: v0.74.0
TRIVY_INSTALL_COMMIT: e1fd17a0ea4a8cf24bc4b4dd7e2cfbf4bb31b994
TRIVY_INSTALL_SHA256: e00df553be558995b994758bc8995956554a16937f456dc6615b0cc411bfec7a
run: |
curl -sfL \
"https://raw.githubusercontent.com/aquasecurity/trivy/${TRIVY_INSTALL_COMMIT}/contrib/install.sh" \
-o trivy-install.sh
echo "${TRIVY_INSTALL_SHA256} trivy-install.sh" | sha256sum -c -
sh trivy-install.sh -b /usr/local/bin "${TRIVY_VERSION}"
rm trivy-install.sh
trivy --version
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
# image fails the job. For a finding without an upstream fix
# (e.g. a base-image CVE Wolfi has not patched yet), add the CVE
# id to a `.trivyignore` file at the repository root — Trivy reads
# it automatically — one CVE per line, each with a comment naming
# the tracking issue and the re-check condition. Never suppress a
# finding that has a fixed version available; bump instead.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan