From 914e7658dbb469a524b0e0bd528851c304fd6e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 23:40:49 +0200 Subject: [PATCH 1/5] ci: harden image publishing --- .github/workflows/docker-publish.yml | 193 +++++---- .github/workflows/post-publish-verify.yml | 184 +++++---- Dockerfile | 26 +- scripts/__tests__/release-workflows.test.ts | 424 ++++++++++++++++++++ 4 files changed, 655 insertions(+), 172 deletions(-) create mode 100644 scripts/__tests__/release-workflows.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 955498c29..e53c95d72 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -37,6 +37,57 @@ concurrency: cancel-in-progress: true jobs: + release-policy: + name: Validate release policy + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + steps: + - name: Checkout full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + 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 @@ -54,6 +105,7 @@ jobs: # 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 @@ -265,6 +317,11 @@ jobs: 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 @@ -335,9 +392,21 @@ jobs: docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.IMAGE_REF }}@sha256:%s ' *) - - name: Inspect published manifest + - name: Resolve published image + id: published + env: + IMAGE_TAG: ${{ github.ref_name }} run: | - docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' + 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 @@ -357,91 +426,59 @@ jobs: - name: Sign multi-arch manifest (keyless) env: - IMAGE_VERSION: ${{ steps.meta.outputs.version }} + IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }} run: | - digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')" - if [ -z "$digest" ]; then - echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted." - exit 1 - fi - echo "Signing ${IMAGE_REF}@${digest}" - cosign sign --yes "${IMAGE_REF}@${digest}" - echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}" + 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}" - # v1.4.15: trigger the Coolify deploy automatically once GHCR has - # the new image. Replaces the former manual SSH + force-pull recipe - # on the deployment host. - # - # Runs only on the publish path (push to `main` or to a `v*` tag), never - # on PRs. `continue-on-error: true` because the image is *already* - # published to GHCR by the time we reach this step — a Coolify-side - # outage must not retroactively fail the build. The maintainer still - # has the manual recipe as fallback. - # - # Required GitHub repository secrets (set by the maintainer once): - # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false - # COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens - # Both secrets are referenced exclusively here. If either is unset, the - # step short-circuits to a skip rather than calling Coolify with empty - # auth. - # - # v1.4.26 P6-3 — maintainer toggle. - # Three releases in a row shipped via the host-side SSH-retag fallback - # because the secrets above silently absent + the warn-only skip - # below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY` - # repository variable now distinguishes the two intended modes: - # - # COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent, - # FAIL the step so the maintainer sees the - # missing-secret gap on the next release. - # COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use - # this when the host-side SSH recipe is - # the intended deploy path. - # (unset / any other) → warn-and-skip (legacy behaviour). - # - # Set the variable under repo Settings → Secrets and variables → - # Actions → Variables (NOT Secrets — it is intentionally readable). + 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-latest + timeout-minutes: 5 + permissions: + contents: read + steps: - name: Trigger Coolify deploy - continue-on-error: true env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }} - IMAGE_VERSION: ${{ steps.meta.outputs.version }} + 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. Maintainer uses the host-side SSH recipe instead." + 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 - if [ "$mode" = "on" ]; then - echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence." - exit 1 - fi - echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning." - exit 0 + 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 - # v1.4.31 — wait for GHCR cache propagation before firing - # the Coolify webhook. Five releases in a row (v1.4.27 - # through v1.4.30.1) the webhook reported "finished" but - # Coolify still pulled the prior `:latest` digest. The - # `docker buildx imagetools create` + `inspect` steps - # above confirm the manifest exists at registry-write time, - # but GHCR's CDN edges can take 30-90 s to propagate the - # new digest. The webhook fires inside that window, so - # Coolify pulls "latest" while the edge still serves the - # prior digest, gets "no change", and returns success. - # Waiting out the propagation window before firing the - # webhook closes that gap. - echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…" + + echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}." sleep 90 - # v1.4.22 C3 — force=true tells Coolify to skip its image-cache - # and re-pull the registry digest. If the maintainer's secret - # URL already carries a `?force=` query, leave it alone; - # otherwise append `?force=true` (or `&force=true` if other - # query params are present) so doc-only deploys still trigger - # a registry-digest check. deploy_url="$COOLIFY_WEBHOOK" if ! echo "$deploy_url" | grep -q "force="; then if echo "$deploy_url" | grep -q "?"; then @@ -450,18 +487,18 @@ jobs: deploy_url="${deploy_url}?force=true" fi fi - echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})" - response=$(curl --silent --show-error --max-time 30 \ + + 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 GET "$deploy_url" \ - --header "Authorization: Bearer $COOLIFY_TOKEN") + --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 — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)." + echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2 exit 1 fi - echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)." - echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status." + echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index c2dc87df2..15900b5d9 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -1,33 +1,42 @@ 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_call: + inputs: + image_ref: + description: Exact registry repository to verify + required: true + type: string + image_tag: + description: Exact published release tag + required: true + type: string + image_digest: + description: Exact published multi-arch manifest digest + required: true + type: string + build_sha: + description: Expected short source commit SHA + required: true + type: string workflow_dispatch: inputs: + image_ref: + description: Exact registry repository to verify + required: true + type: string image_tag: - description: "GHCR image tag to verify (defaults to `main`)" - required: false - default: "main" + description: Exact published release tag + required: true + type: string + image_digest: + description: Exact published multi-arch manifest digest + required: true + type: string + build_sha: + description: Expected short source commit SHA + required: true + type: string permissions: contents: read @@ -35,14 +44,15 @@ permissions: jobs: verify: - name: Pull + smoke-test published image + name: Pull and smoke-test exact 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' + env: + IMAGE_REF: ${{ inputs.image_ref }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + BUILD_SHA: ${{ inputs.build_sha }} + DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public services: postgres: @@ -60,15 +70,6 @@ jobs: --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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: @@ -76,24 +77,38 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull image - id: pull - continue-on-error: true + - name: Pull exact image run: | - IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}" - echo "Pulling $IMAGE" - docker pull "$IMAGE" - echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + TAG_REF="${IMAGE_REF}:${IMAGE_TAG}" + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" - - name: Start container - if: steps.pull.outcome == 'success' - id: run - continue-on-error: true + echo "Pulling published tag $TAG_REF" + docker pull "$TAG_REF" + resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')" + if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then + echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2 + exit 1 + fi + + echo "Pulling immutable image $DIGEST_REF" + docker pull "$DIGEST_REF" + + - name: Run migrations run: | - # Minimal env to satisfy the strict-env validators on boot. + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" + docker run --rm \ + --network host \ + --entrypoint node \ + -e DATABASE_URL="$DATABASE_URL" \ + "$DIGEST_REF" \ + /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy + + - name: Start exact image + run: | + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" docker run -d --name healthlog-verify \ --network host \ - -e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \ + -e DATABASE_URL="$DATABASE_URL" \ -e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \ -e ENCRYPTION_ACTIVE_KEY_ID=v1 \ -e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \ @@ -101,44 +116,57 @@ jobs: -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 }}" + "$DIGEST_REF" - # Wait up to 60 s for the server to start answering. + - name: Verify exact version + run: | + body="" for i in $(seq 1 30); do - if curl -sf -o /dev/null http://localhost:3000/api/version; then - echo "ready after ${i}*2s" + if body="$(curl -sf http://localhost:3000/api/version)"; then + echo "Version endpoint ready after $((i * 2))s" + break + fi + sleep 2 + done + if [ -z "$body" ]; then + echo "::error::Exact image did not expose /api/version within 60 seconds." >&2 + docker logs healthlog-verify || true + exit 1 + fi + + echo "$body" | jq . + if ! jq -e --arg expected "$IMAGE_TAG" '.data.version == $expected' <<< "$body" >/dev/null; then + actual="$(jq -r '.data.version // ""' <<< "$body")" + echo "::error::Version mismatch: exact image reports '$actual', expected '$IMAGE_TAG'." >&2 + exit 1 + fi + if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then + actual="$(jq -r '.data.buildSha // ""' <<< "$body")" + echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2 + exit 1 + fi + + - name: Verify health + run: | + for i in $(seq 1 30); do + if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then + echo "$body" | jq . + echo "Health endpoint ready after $((i * 2))s" exit 0 fi sleep 2 done - echo "::warning::Container did not become ready within 60 s" + echo "::error::Exact image did not report healthy within 60 seconds." >&2 docker logs healthlog-verify || true exit 1 - - name: Probe /api/version - if: steps.run.outcome == 'success' + - name: Container logs + if: always() 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 + run: docker logs healthlog-verify - name: Tear down if: always() continue-on-error: true - run: docker rm -f healthlog-verify || true + run: docker rm -f healthlog-verify diff --git a/Dockerfile b/Dockerfile index aad0d0b4c..8f0861e5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ # ── Stage 1: Dependencies ────────────────────────────────── -FROM node:22-alpine AS deps -RUN corepack enable && corepack prepare pnpm@latest --activate +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps +RUN corepack enable && corepack prepare pnpm@10.31.0 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── -FROM node:22-alpine AS builder -RUN corepack enable && corepack prepare pnpm@latest --activate +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder +RUN corepack enable && corepack prepare pnpm@10.31.0 --activate WORKDIR /app # v1.4.43 B11 — version build-arg threaded into the layer cache key. @@ -45,17 +45,16 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build # ── Stage 3: Production runner ───────────────────────────── -FROM node:22-alpine AS runner +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently # falling back to UTC on Alpine images that ship without it. RUN apk add --no-cache tzdata && \ - corepack enable && corepack prepare pnpm@latest --activate + corepack enable && corepack prepare pnpm@10.31.0 --activate WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_PATH="/opt/pg-boss/node_modules" ENV TZ=Europe/Berlin # v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage @@ -80,6 +79,10 @@ RUN addgroup --system --gid 1001 nodejs && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Next's standalone output must carry every externalized worker dependency. +# Fail the image build here if output tracing ever stops shipping one rather +# than compensating with a second, independently versioned npm installation. +RUN node -e "require.resolve('pg-boss'); require.resolve('@prisma/adapter-pg'); require.resolve('pg'); require.resolve('@prisma/client')" # @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the # native binary's package into the standalone tree but NOT the pnpm symlinks that @@ -123,15 +126,6 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated -# Install all worker runtime dependencies in a shared prefix. -# NODE_PATH makes them available to Node.js module resolution. -# These packages are listed in next.config.ts serverExternalPackages -# and are NOT bundled by Next.js — they must exist at runtime. -RUN mkdir -p /opt/pg-boss && \ - cd /opt/pg-boss && \ - npm init -y && \ - npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20 - # Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree) RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts new file mode 100644 index 000000000..7a9b2ec7d --- /dev/null +++ b/scripts/__tests__/release-workflows.test.ts @@ -0,0 +1,424 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +function readRepoFile(path: string): string { + const testPath = expect.getState().testPath; + if (!testPath) throw new Error("Vitest did not expose the current test path"); + return readFileSync(join(dirname(testPath), "../..", path), "utf8"); +} +type WorkflowStep = { + name?: string; + run?: string; + "continue-on-error"?: boolean; +}; +type WorkflowJob = { + needs?: string | string[]; + outputs?: Record; + steps?: WorkflowStep[]; + uses?: string; + with?: Record; +}; +type Workflow = { + on?: { + workflow_call?: { inputs?: Record }; + }; + jobs: Record; +}; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +function loadWorkflow(name: string): Workflow { + return parse(readRepoFile(join(".github/workflows", name))) as Workflow; +} + +function stepScript(workflow: Workflow, jobName: string, stepName: string): string { + const step = workflow.jobs[jobName]?.steps?.find( + (candidate) => candidate.name === stepName, + ); + if (!step?.run) { + throw new Error(`Missing ${jobName}/${stepName} run step`); + } + return step.run; +} + +function runReleasePolicy( + overrides: Record = {}, +): SpawnSyncReturns { + const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-")); + temporaryDirectories.push(directory); + const binDirectory = join(directory, "bin"); + mkdirSync(binDirectory); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }), + ); + + const gitPath = join(binDirectory, "git"); + writeFileSync( + gitPath, + `#!/bin/sh +if [ "$1" = "fetch" ]; then exit 0; fi +if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi +exit 1 +`, + ); + chmodSync(gitPath, 0o755); + + const ghPath = join(binDirectory, "gh"); + writeFileSync( + ghPath, + `#!/bin/sh +if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi +`, + ); + chmodSync(ghPath, 0o755); + + const workflow = loadWorkflow("docker-publish.yml"); + return spawnSync( + "bash", + ["-euo", "pipefail", "-c", stepScript(workflow, "release-policy", "Validate release policy")], + { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + GITHUB_EVENT_NAME: "push", + GITHUB_REF_TYPE: "tag", + GITHUB_REF_NAME: "v1.31.4", + GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567", + GITHUB_REPOSITORY: "MBombeck/HealthLog", + GH_TOKEN: "fixture-token", + FIXTURE_MAIN_ANCESTRY: "yes", + FIXTURE_PRIOR_CI: "yes", + ...overrides, + }, + }, + ); +} + +function runExactPull( + resolvedDigest: string, +): { result: SpawnSyncReturns; commands: string } { + const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-")); + temporaryDirectories.push(directory); + const dockerPath = join(directory, "docker"); + const commandLog = join(directory, "commands.log"); + writeFileSync( + dockerPath, + `#!/bin/sh +printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG" +if [ "$1" = "pull" ]; then exit 0; fi +if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then + printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST" + exit 0 +fi +exit 1 +`, + ); + chmodSync(dockerPath, 0o755); + + const workflow = loadWorkflow("post-publish-verify.yml"); + const result = spawnSync( + "bash", + ["-euo", "pipefail", "-c", stepScript(workflow, "verify", "Pull exact image")], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ""}`, + IMAGE_REF: "ghcr.io/mbombeck/healthlog", + IMAGE_TAG: "v1.31.4", + IMAGE_DIGEST: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + FIXTURE_COMMAND_LOG: commandLog, + FIXTURE_RESOLVED_DIGEST: resolvedDigest, + }, + }, + ); + return { + result, + commands: readFileSync(commandLog, "utf8"), + }; +} + +function runPromotion( + overrides: Record = {}, +): SpawnSyncReturns { + const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-")); + temporaryDirectories.push(directory); + const sleepPath = join(directory, "sleep"); + writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n"); + chmodSync(sleepPath, 0o755); + const curlPath = join(directory, "curl"); + writeFileSync( + curlPath, + `#!/bin/sh +printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS" +`, + ); + chmodSync(curlPath, 0o755); + + const workflow = loadWorkflow("docker-publish.yml"); + return spawnSync( + "bash", + ["-euo", "pipefail", "-c", stepScript(workflow, "promote", "Trigger Coolify deploy")], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ""}`, + COOLIFY_AUTO_DEPLOY: "on", + COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture", + COOLIFY_TOKEN: "fixture-token", + IMAGE_TAG: "v1.31.4", + IMAGE_DIGEST: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + BUILD_SHA: "0123456789ab", + FIXTURE_HTTP_STATUS: "200", + ...overrides, + }, + }, + ); +} + +describe("release container inputs", () => { + it("pins every Node stage to one reviewed multi-arch digest", () => { + const dockerfile = readRepoFile("Dockerfile"); + const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? []; + + expect(fromLines).toHaveLength(3); + expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( + new Set([ + "FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2", + ]), + ); + }); + + it("uses the packageManager pnpm version instead of a moving tag", () => { + const dockerfile = readRepoFile("Dockerfile"); + const packageJson = JSON.parse(readRepoFile("package.json")) as { + packageManager: string; + }; + expect(packageJson.packageManager).toBe("pnpm@10.31.0"); + expect(dockerfile).not.toContain("pnpm@latest"); + expect(dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g)).toHaveLength( + 3, + ); + }); + + it("relies on and verifies standalone dependency resolution", () => { + const dockerfile = readRepoFile("Dockerfile"); + expect(dockerfile).not.toContain("/opt/pg-boss"); + expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"'); + expect(dockerfile).toMatch( + /require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]@prisma\/adapter-pg['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/, + ); + }); +}); + +describe("release pre-build policy", () => { + it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => { + const result = runReleasePolicy(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Release policy passed"); + }); + + it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])( + "rejects non-strict release tag %s", + (tag) => { + const result = runReleasePolicy({ GITHUB_REF_NAME: tag }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("strict SemVer"); + }, + ); + + it("rejects a tag that does not match package.json", () => { + const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("package.json version"); + }); + + it("rejects a release commit outside main ancestry", () => { + const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("ancestor of origin/main"); + }); + + it("rejects a release commit without successful prior quality CI", () => { + const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("successful prior Security & Quality"); + }); + + it("gates every image build on the release policy", () => { + const workflow = loadWorkflow("docker-publish.yml"); + const needs = workflow.jobs.build?.needs; + + expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy"); + }); +}); + +describe("exact post-publish verification", () => { + it("passes the published ref, tag, and digest into the blocking verifier", () => { + const workflow = loadWorkflow("docker-publish.yml"); + const merge = workflow.jobs.merge; + const verify = workflow.jobs["verify-published-image"]; + + expect(merge?.outputs).toEqual({ + 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 }}", + }); + expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml"); + expect(verify?.needs).toBe("merge"); + expect(verify?.with).toEqual({ + 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 }}", + }); + }); + + it("requires exact image identity inputs for every invocation", () => { + const workflow = loadWorkflow("post-publish-verify.yml"); + const inputs = workflow.on?.workflow_call?.inputs; + + expect(inputs?.image_ref?.required).toBe(true); + expect(inputs?.image_tag?.required).toBe(true); + expect(inputs?.image_digest?.required).toBe(true); + expect(inputs?.build_sha?.required).toBe(true); + }); + + it("pulls the exact tag and digest when the registry resolves correctly", () => { + const digest = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const { result, commands } = runExactPull(digest); + + expect(result.status, result.stderr).toBe(0); + expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4"); + expect(commands).toContain( + "pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + }); + + it("fails closed when the published tag resolves to another digest", () => { + const { result } = runExactPull( + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("digest mismatch"); + }); + + it("keeps pull, migration, start, version, and health checks blocking", () => { + const workflow = loadWorkflow("post-publish-verify.yml"); + const steps = workflow.jobs.verify?.steps ?? []; + const requiredSteps = [ + "Pull exact image", + "Run migrations", + "Start exact image", + "Verify exact version", + "Verify health", + ]; + + for (const name of requiredSteps) { + const step = steps.find((candidate) => candidate.name === name); + expect(step, `missing ${name}`).toBeDefined(); + expect(step?.["continue-on-error"], name).not.toBe(true); + } + + expect(stepScript(workflow, "verify", "Run migrations")).toContain( + '${IMAGE_REF}@${IMAGE_DIGEST}', + ); + expect(stepScript(workflow, "verify", "Start exact image")).toContain( + '${IMAGE_REF}@${IMAGE_DIGEST}', + ); + expect(stepScript(workflow, "verify", "Verify exact version")).toContain( + 'jq -e --arg expected "$IMAGE_TAG"', + ); + expect(stepScript(workflow, "verify", "Verify health")).toContain( + "jq -e '.status == \"ok\"'", + ); + }); +}); + +describe("Coolify promotion policy", () => { + it("waits for exact-image verification and keeps enabled promotion blocking", () => { + const workflow = loadWorkflow("docker-publish.yml"); + const promotion = workflow.jobs.promote; + const needs = promotion?.needs; + const trigger = promotion?.steps?.find( + (step) => step.name === "Trigger Coolify deploy", + ); + + expect(Array.isArray(needs) ? needs : [needs]).toEqual( + expect.arrayContaining(["merge", "verify-published-image"]), + ); + expect(trigger).toBeDefined(); + expect(trigger?.["continue-on-error"]).not.toBe(true); + expect( + workflow.jobs.merge?.steps?.some( + (step) => step.name === "Trigger Coolify deploy", + ), + ).toBe(false); + }); + + it("accepts an enabled promotion only after a successful webhook response", () => { + const result = runPromotion(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Coolify deploy queued"); + }); + + it.each([ + ["webhook", { COOLIFY_WEBHOOK: "" }], + ["token", { COOLIFY_TOKEN: "" }], + ])("rejects enabled promotion with missing %s credentials", (_name, env) => { + const result = runPromotion(env); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on"); + }); + + it("fails enabled promotion when Coolify rejects the webhook", () => { + const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("returned HTTP 503"); + }); + + it("allows explicit disabled promotion without credentials", () => { + const result = runPromotion({ + COOLIFY_AUTO_DEPLOY: "off", + COOLIFY_WEBHOOK: "", + COOLIFY_TOKEN: "", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off"); + }); +}); From 9adeb8fc19601437a76fe80d86d0c30c538220b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 21 Jul 2026 00:04:21 +0200 Subject: [PATCH 2/5] fix(runtime): harden process startup and health --- docker-compose.yml | 4 +- docker-entrypoint.sh | 44 ++++- src/__tests__/proxy-worker-health.test.ts | 28 +++ src/app/api/health/__tests__/route.test.ts | 119 ++++++++++++ src/app/api/health/route.ts | 10 +- src/lib/__tests__/db.test.ts | 96 +++++----- src/lib/__tests__/docker-entrypoint.test.ts | 99 ++++++++++ src/lib/db.ts | 77 +++++--- .../apple-health-import-send-policy.test.ts | 2 - src/lib/jobs/__tests__/boss-instance.test.ts | 124 +++++++++--- src/lib/jobs/apple-health-import-worker.ts | 20 +- src/lib/jobs/boss-instance.ts | 178 +++++++++++++----- src/lib/jobs/reminder-worker.ts | 5 +- .../jobs/reminder/__tests__/shared.test.ts | 88 ++------- src/lib/jobs/reminder/shared.ts | 32 +--- src/proxy.ts | 2 +- 16 files changed, 653 insertions(+), 275 deletions(-) create mode 100644 src/__tests__/proxy-worker-health.test.ts create mode 100644 src/app/api/health/__tests__/route.test.ts create mode 100644 src/lib/__tests__/docker-entrypoint.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index 469ab0a13..1987d0311 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -227,7 +227,7 @@ services: test: [ "CMD-SHELL", - "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1", + "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1", ] interval: 30s timeout: 5s @@ -245,7 +245,7 @@ services: # dockerfile: Dockerfile # container_name: healthlog-worker # environment: - # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public" + # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" # NODE_ENV: production # ENCRYPTION_KEY: "${ENCRYPTION_KEY}" # ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 968c88d10..3386a8879 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -9,7 +9,7 @@ set -e # Validating here guarantees a clear, deploy-platform-agnostic # failure message before we touch the database. missing="" -for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do +for var in DATABASE_URL API_TOKEN_HMAC_KEY; do eval "value=\$$var" if [ -z "$value" ]; then missing="$missing $var" @@ -21,6 +21,48 @@ if [ -n "$missing" ]; then exit 1 fi +# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same +# fail-closed shape the runtime crypto loader accepts before touching the DB. +if ! node -e ' + const validKey = (value) => { + if (typeof value !== "string") return false; + if (/^[0-9a-fA-F]{64}$/.test(value)) return true; + if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false; + try { return Buffer.from(value, "base64").length === 32; } + catch { return false; } + }; + const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim(); + if (rawKeyring) { + let keyring; + try { keyring = JSON.parse(rawKeyring); } + catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); } + if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) { + throw new Error("ENCRYPTION_KEYS is invalid: expected an object map"); + } + const entries = Object.entries(keyring); + if ( + entries.length === 0 || + entries.some(([id, value]) => + !/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value) + ) + ) { + throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key"); + } + const activeId = + (process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() || + (entries.length === 1 ? entries[0][0] : ""); + if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) { + throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS"); + } + } else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) { + throw new Error("ENCRYPTION_KEY is missing or invalid"); + } +'; then + echo "HealthLog: ERROR — encryption configuration is invalid." >&2 + echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2 + exit 1 +fi + # ── Wait for PostgreSQL ────────────────────────────────────── MAX_RETRIES=30 RETRY=0 diff --git a/src/__tests__/proxy-worker-health.test.ts b/src/__tests__/proxy-worker-health.test.ts new file mode 100644 index 000000000..de5880d45 --- /dev/null +++ b/src/__tests__/proxy-worker-health.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => false, +})); + +import { proxy } from "../proxy"; + +function request(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`); +} + +describe("worker-only process HTTP boundary", () => { + it("allows the process-aware health endpoint through", () => { + const response = proxy(request("/api/health")); + + expect(response.status).toBe(200); + expect(response.headers.get("X-HealthLog-Process-Type")).toBeNull(); + }); + + it("continues to refuse non-health HTTP traffic", () => { + const response = proxy(request("/api/version")); + + expect(response.status).toBe(503); + expect(response.headers.get("X-HealthLog-Process-Type")).toBe("worker"); + }); +}); diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts new file mode 100644 index 000000000..60d87ca9a --- /dev/null +++ b/src/app/api/health/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface WorkerStatusFixture { + running: boolean; + startedAt: string | null; + lastHeartbeat: string | null; + lastReminderCheck: string | null; + lastWithingsSync: string | null; + lastInsightsRun: string | null; + jobsProcessed: number; + errors: number; +} + +const { queryRaw, getWorkerStatus, getGlobalBoss, getSession } = vi.hoisted( + () => ({ + queryRaw: vi.fn(async () => 1), + getWorkerStatus: vi.fn((): WorkerStatusFixture => ({ + running: false, + startedAt: null, + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + })), + getGlobalBoss: vi.fn((): object | null => null), + getSession: vi.fn(async () => null), + }), +); + +vi.mock("@/lib/db", () => ({ + prisma: { $queryRaw: queryRaw }, +})); +vi.mock("@/lib/jobs/worker-status", () => ({ getWorkerStatus })); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss })); +vi.mock("@/lib/auth/session", () => ({ getSession })); +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { GET } from "../route"; + +async function health() { + const response = await (GET as unknown as () => Promise)(); + return { response, body: (await response.json()) as { status: string } }; +} + +beforeEach(() => { + vi.clearAllMocks(); + queryRaw.mockResolvedValue(1); + getWorkerStatus.mockReturnValue({ + running: false, + startedAt: null, + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + }); + getGlobalBoss.mockReturnValue(null); + getSession.mockResolvedValue(null); +}); + +describe("GET /api/health process topology", () => { + it("web mode requires DB and producer readiness, not an in-process worker", async () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + getGlobalBoss.mockReturnValue({}); + + const { response, body } = await health(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "ok" }); + }); + + it("worker mode requires DB and worker readiness, not web producer readiness", async () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker"); + getWorkerStatus.mockReturnValue({ + running: true, + startedAt: "2026-07-20T00:00:00.000Z", + lastHeartbeat: "2026-07-20T00:00:00.000Z", + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + }); + + const { response, body } = await health(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "ok" }); + }); + + it("all mode requires both producer and worker readiness", async () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "all"); + getWorkerStatus.mockReturnValue({ + running: true, + startedAt: "2026-07-20T00:00:00.000Z", + lastHeartbeat: "2026-07-20T00:00:00.000Z", + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + }); + + const missingProducer = await health(); + expect(missingProducer.response.status).toBe(503); + expect(missingProducer.body).toEqual({ status: "degraded" }); + + getGlobalBoss.mockReturnValue({}); + const ready = await health(); + expect(ready.response.status).toBe(200); + expect(ready.body).toEqual({ status: "ok" }); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index e3490fb9f..3524d61c0 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { shouldRunWeb, shouldRunWorker } from "@/lib/process-type"; import { getSession } from "@/lib/auth/session"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; @@ -19,7 +21,13 @@ export const GET = apiHandler(async () => { } const worker = getWorkerStatus(); - const status = dbOk && worker.running ? "ok" : "degraded"; + const producerReady = getGlobalBoss() !== null; + const status = + dbOk && + (!shouldRunWeb() || producerReady) && + (!shouldRunWorker() || worker.running) + ? "ok" + : "degraded"; const statusCode = status === "ok" ? 200 : 503; const cacheHeaders = { diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 73f395e39..95d2fac1e 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -1,65 +1,71 @@ /** - * v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`. - * - * The v1.4.39 empirical cold-mount trace - * (`.planning/round-v1439-empirical-trace.md` § B2) traced the - * Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling - * back to the library default ceiling of 10 connections. W-POOL - * pinned the ceiling to 20 via `getPoolMax()`; this test prevents a - * future refactor from silently dropping the override and re-introducing - * the saturation regression on a power-user cold mount. - * - * The test only exercises the `getPoolMax()` env-resolver. Constructing - * a real `PrismaClient` requires a live `DATABASE_URL`, which would - * make this an integration test instead of a unit test — the helper - * isolation is intentional. + * Pool configuration stays unit-tested here so connection ceilings and wait + * timeouts cannot silently fall back to separate library defaults. */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildSessionOptions, getPoolMax, getStatementTimeoutMs } from "../db"; +import { + buildSessionOptions, + getConnectionBudget, + getPgBossPoolMax, + getPoolConnectionTimeoutMs, + getPrismaPoolMax, + getStatementTimeoutMs, +} from "../db"; -describe("getPoolMax", () => { - const originalEnv = process.env.DATABASE_POOL_MAX; +describe("per-process database connection budget", () => { + const originalDatabaseUrl = process.env.DATABASE_URL; + const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT; + const originalPoolTimeout = process.env.DB_POOL_TIMEOUT; + const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX; beforeEach(() => { + process.env.DATABASE_URL = + "postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20"; + delete process.env.DB_CONNECTION_LIMIT; + delete process.env.DB_POOL_TIMEOUT; delete process.env.DATABASE_POOL_MAX; }); afterEach(() => { - if (originalEnv === undefined) { - delete process.env.DATABASE_POOL_MAX; - } else { - process.env.DATABASE_POOL_MAX = originalEnv; - } + const restore = (name: string, value: string | undefined) => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + }; + restore("DATABASE_URL", originalDatabaseUrl); + restore("DB_CONNECTION_LIMIT", originalConnectionLimit); + restore("DB_POOL_TIMEOUT", originalPoolTimeout); + restore("DATABASE_POOL_MAX", originalLegacyPoolMax); }); - it("defaults to 20 when DATABASE_POOL_MAX is unset", () => { - expect(getPoolMax()).toBe(20); + it("splits the URL connection_limit across Prisma and pg-boss", () => { + expect(getConnectionBudget()).toBe(20); + expect(getPrismaPoolMax()).toBe(18); + expect(getPgBossPoolMax()).toBe(2); + expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe( + getConnectionBudget(), + ); }); - it("honours DATABASE_POOL_MAX when it parses to a positive int", () => { + it("uses the existing pool_timeout seconds for both pool constructors", () => { + expect(getPoolConnectionTimeoutMs()).toBe(20_000); + }); + + it("prefers explicit DB_CONNECTION_LIMIT and DB_POOL_TIMEOUT values", () => { + process.env.DB_CONNECTION_LIMIT = "12"; + process.env.DB_POOL_TIMEOUT = "7"; + + expect(getConnectionBudget()).toBe(12); + expect(getPrismaPoolMax()).toBe(10); + expect(getPgBossPoolMax()).toBe(2); + expect(getPoolConnectionTimeoutMs()).toBe(7_000); + }); + + it("keeps DATABASE_POOL_MAX as a backward-compatible explicit override", () => { process.env.DATABASE_POOL_MAX = "30"; - expect(getPoolMax()).toBe(30); - }); - it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => { - process.env.DATABASE_POOL_MAX = "not-a-number"; - expect(getPoolMax()).toBe(20); - }); - - it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => { - process.env.DATABASE_POOL_MAX = "0"; - expect(getPoolMax()).toBe(20); - process.env.DATABASE_POOL_MAX = "-5"; - expect(getPoolMax()).toBe(20); - }); - - it("never returns the library default of 10 on the happy path", () => { - // The whole point of the W-POOL change. If anybody removes the - // `max:` config from `createPrismaClient` and reverts to the - // library default, this assertion stops the regression before the - // next saturated cold mount. - expect(getPoolMax()).toBeGreaterThanOrEqual(20); + expect(getConnectionBudget()).toBe(30); + expect(getPrismaPoolMax()).toBe(28); }); }); diff --git a/src/lib/__tests__/docker-entrypoint.test.ts b/src/lib/__tests__/docker-entrypoint.test.ts new file mode 100644 index 000000000..25a3c9b29 --- /dev/null +++ b/src/lib/__tests__/docker-entrypoint.test.ts @@ -0,0 +1,99 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const entrypoint = join(repoRoot, "docker-entrypoint.sh"); +const key = "a".repeat(64); +let fakeBin: string; + +beforeAll(() => { + fakeBin = mkdtempSync(join(tmpdir(), "healthlog-entrypoint-")); + const fakeNode = join(fakeBin, "node"); + writeFileSync( + fakeNode, + `#!/bin/sh +case "$1" in + -e) + case "$2" in + *ENCRYPTION_KEYS*) exec "$REAL_NODE" "$@" ;; + *) exit 0 ;; + esac + ;; + *) exit 0 ;; +esac +`, + ); + chmodSync(fakeNode, 0o755); +}); + +afterAll(() => { + rmSync(fakeBin, { recursive: true, force: true }); +}); + +function runEntrypoint(overrides: Record) { + const env: NodeJS.ProcessEnv = { + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + REAL_NODE: process.execPath, + NODE_ENV: "test", + DATABASE_URL: "postgresql://healthlog:test@db:5432/healthlog", + API_TOKEN_HMAC_KEY: "test-hmac-key", + ...overrides, + }; + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete env[name]; + } + return spawnSync("sh", [entrypoint, "true"], { + cwd: repoRoot, + env, + encoding: "utf8", + }); +} + +describe("docker entrypoint crypto configuration", () => { + it("accepts the legacy ENCRYPTION_KEY configuration", () => { + const result = runEntrypoint({ + ENCRYPTION_KEY: key, + ENCRYPTION_KEYS: undefined, + ENCRYPTION_ACTIVE_KEY_ID: undefined, + }); + + expect(result.status, result.stderr).toBe(0); + }, 20_000); + + it("accepts a keyring-only configuration", () => { + const result = runEntrypoint({ + ENCRYPTION_KEY: undefined, + ENCRYPTION_KEYS: JSON.stringify({ v2: key }), + ENCRYPTION_ACTIVE_KEY_ID: undefined, + }); + + expect(result.status, result.stderr).toBe(0); + }, 20_000); + + it("fails closed when ENCRYPTION_KEYS is invalid even if a legacy key exists", () => { + const result = runEntrypoint({ + ENCRYPTION_KEY: key, + ENCRYPTION_KEYS: JSON.stringify({ v2: "not-a-32-byte-key" }), + ENCRYPTION_ACTIVE_KEY_ID: "v2", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("ENCRYPTION_KEYS"); + expect(result.stderr).toContain("invalid"); + }); + + it("fails closed when the active key is absent from the keyring", () => { + const result = runEntrypoint({ + ENCRYPTION_KEY: undefined, + ENCRYPTION_KEYS: JSON.stringify({ v2: key }), + ENCRYPTION_ACTIVE_KEY_ID: "v3", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("ENCRYPTION_ACTIVE_KEY_ID"); + }); +}); diff --git a/src/lib/db.ts b/src/lib/db.ts index 0c3b8472f..7b46e69ad 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -5,34 +5,54 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; -/** - * v1.4.40 W-POOL — Prisma `pg.Pool` ceiling raised from the library - * default of 10 → 20. - * - * The v1.4.39 empirical cold-mount trace - * (`.planning/round-v1439-empirical-trace.md` § B2) showed thick - * `/api/analytics` holding ≥ 8 of the 10 default pool slots for - * 6.5 s on the maintainer's 347k-row tenant, starving every other Wave-B and - * Wave-C useQuery fan-out for the duration. The production Postgres runs a - * `max_connections` of 200 (raised from the stock 100), so a 20-slot - * Node-side pool sits well under the server's hard cap with plenty of - * headroom for concurrent power-users. Note the web + worker containers - * share this one Postgres, so the effective ceiling covers both pools. - * Complements the W-POOL `p-limit(4)` cap on the analytics fan-out itself — - * the bounded concurrency keeps any single analytics call to ≤ 4 slots, so - * the remaining 16 stay available for every other dashboard query. - * - * Env-overridable via `DATABASE_POOL_MAX` so an operator running - * with a smaller Postgres `max_connections` can dial down without a - * code change. - */ -export function getPoolMax(): number { - const raw = process.env.DATABASE_POOL_MAX; - if (raw) { - const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed > 0) return parsed; +const DEFAULT_CONNECTION_BUDGET = 20; +const DEFAULT_POOL_TIMEOUT_SECONDS = 20; +const PG_BOSS_CONNECTIONS = 2; + +function positiveInteger(raw: string | undefined): number | null { + if (!raw) return null; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function databaseUrlParameter(name: string): string | undefined { + const rawUrl = process.env.DATABASE_URL; + if (!rawUrl) return undefined; + try { + return new URL(rawUrl).searchParams.get(name) ?? undefined; + } catch { + return undefined; } - return 20; +} + +/** + * Total PostgreSQL connections this process may own. Compose already exposes + * this as DB_CONNECTION_LIMIT through DATABASE_URL's `connection_limit`. + * DATABASE_POOL_MAX remains a backward-compatible explicit override. + */ +export function getConnectionBudget(): number { + const configured = + positiveInteger(process.env.DB_CONNECTION_LIMIT) ?? + positiveInteger(process.env.DATABASE_POOL_MAX) ?? + positiveInteger(databaseUrlParameter("connection_limit")) ?? + DEFAULT_CONNECTION_BUDGET; + return Math.max(2, configured); +} + +export function getPgBossPoolMax(): number { + return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1); +} + +export function getPrismaPoolMax(): number { + return getConnectionBudget() - getPgBossPoolMax(); +} + +export function getPoolConnectionTimeoutMs(): number { + const seconds = + positiveInteger(process.env.DB_POOL_TIMEOUT) ?? + positiveInteger(databaseUrlParameter("pool_timeout")) ?? + DEFAULT_POOL_TIMEOUT_SECONDS; + return seconds * 1_000; } /** @@ -79,7 +99,8 @@ function createPrismaClient() { const sessionOptions = buildSessionOptions(); const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, - max: getPoolMax(), + max: getPrismaPoolMax(), + connectionTimeoutMillis: getPoolConnectionTimeoutMs(), ...(sessionOptions ? { options: sessionOptions } : {}), }); return new PrismaClient({ adapter }); diff --git a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts index c6659c456..64367ac29 100644 --- a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts @@ -16,8 +16,6 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: {}, - buildSessionOptions: () => null, - getPoolMax: () => 5, toJson: (value: unknown) => value, })); diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 9de31b2ca..1afc12fad 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -1,44 +1,118 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; -const { start, PgBoss } = vi.hoisted(() => { - const start = vi.fn(async () => undefined); - const stop = vi.fn(async () => undefined); - const on = vi.fn(); - const PgBoss = vi.fn(function MockPgBoss(this: Record) { - this.start = start; - this.stop = stop; - this.on = on; +interface MockBoss { + start: Mock<() => Promise>; + stop: Mock<(options?: { graceful?: boolean }) => Promise>; + on: Mock< + (event: string, handler: (error: Error) => void) => MockBoss + >; + emitError(error: Error): void; +} + +const { PgBoss, instances, startOutcomes } = vi.hoisted(() => { + const instances: MockBoss[] = []; + const startOutcomes: Array<() => Promise> = []; + const PgBoss = vi.fn(function MockPgBoss(this: MockBoss) { + let errorHandler: ((error: Error) => void) | undefined; + this.start = vi.fn(() => + (startOutcomes.shift() ?? (() => Promise.resolve(undefined)))(), + ); + this.stop = vi.fn(async () => undefined); + this.on = vi.fn((event: string, handler: (error: Error) => void) => { + if (event === "error") errorHandler = handler; + return this; + }); + this.emitError = (error: Error) => errorHandler?.(error); + instances.push(this); }); - return { start, PgBoss }; + return { PgBoss, instances, startOutcomes }; }); vi.mock("pg-boss", () => ({ PgBoss })); -import { getGlobalBoss, startGlobalBossProducer } from "../boss-instance"; +import { + createWorkerBoss, + getGlobalBoss, + startGlobalBossProducer, +} from "../boss-instance"; + +const globalState = globalThis as Record; + +beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + instances.length = 0; + startOutcomes.length = 0; + delete globalState.__healthlog_pgboss__; + delete globalState.__healthlog_pgboss_start__; + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); describe("web-process pg-boss producer", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + it("waits for delayed pg-boss schema readiness with capped exponential backoff", async () => { + vi.useFakeTimers(); + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + startOutcomes.push( + () => Promise.reject(new Error("schema not ready")), + () => Promise.reject(new Error("schema still not ready")), + () => Promise.reject(new Error("schema still not ready")), + () => Promise.resolve(undefined), + ); - it("retries startup and caps the send-only connection pool", async () => { - start - .mockRejectedValueOnce(new Error("schema not ready")) - .mockResolvedValueOnce(undefined); - - const boss = await startGlobalBossProducer("postgres://healthlog@test/db", { - maxAttempts: 2, - retryDelayMs: 0, + const starting = startGlobalBossProducer("postgres://healthlog@test/db", { + maxAttempts: 4, + retryBaseDelayMs: 10, + retryMaxDelayMs: 20, }); + await vi.runAllTimersAsync(); + const boss = await starting; - expect(PgBoss).toHaveBeenCalledTimes(2); - expect(PgBoss).toHaveBeenCalledWith({ + expect(PgBoss).toHaveBeenCalledTimes(4); + expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20, 20]); + expect(PgBoss).toHaveBeenLastCalledWith({ connectionString: "postgres://healthlog@test/db", max: 2, + connectionTimeoutMillis: 20_000, migrate: false, schedule: false, supervise: false, }); - expect(start).toHaveBeenCalledTimes(2); expect(boss).toBe(getGlobalBoss()); }); + + it("keeps supervised reconnect active after a bounded failed batch", async () => { + vi.useFakeTimers(); + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + startOutcomes.push(() => Promise.resolve(undefined)); + await startGlobalBossProducer("postgres://healthlog@test/db", { + maxAttempts: 2, + retryBaseDelayMs: 10, + retryMaxDelayMs: 20, + }); + + startOutcomes.push( + () => Promise.reject(new Error("db unavailable 1")), + () => Promise.reject(new Error("db unavailable 2")), + () => Promise.resolve(undefined), + ); + instances[0].emitError(new Error("connection lost")); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(PgBoss).toHaveBeenCalledTimes(4); + expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20]); + expect(instances[0].stop).toHaveBeenCalledWith({ graceful: false }); + expect(getGlobalBoss()).toBe(instances[3]); + }); +}); + +describe("worker pg-boss pool", () => { + it("uses the pg-boss share of the process connection budget", () => { + createWorkerBoss("postgres://healthlog@test/db"); + + expect(PgBoss).toHaveBeenCalledWith({ + connectionString: "postgres://healthlog@test/db", + max: 2, + connectionTimeoutMillis: 20_000, + }); + }); }); diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 888f80c29..e599c1e8a 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -25,9 +25,8 @@ * Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1. */ import { unlinkSync } from "node:fs"; -import { PrismaClient } from "@/generated/prisma/client"; -import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db"; -import { PrismaPg } from "@prisma/adapter-pg"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma, toJson } from "@/lib/db"; import type { Job } from "pg-boss"; import { extractExportXml } from "@/lib/import/unzip-export-xml"; @@ -94,20 +93,7 @@ export function _setWorkerPrismaForTests(client: PrismaClient | null): void { } function getWorkerPrisma(): PrismaClient { - if (!workerPrismaSingleton) { - // Inherit the web client's pool ceiling + per-session statement timeouts - // (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the - // long-running bulk import stays safe while a single wedged UPSERT can no - // longer pin a worker connection indefinitely. - const sessionOptions = buildSessionOptions(); - const adapter = new PrismaPg({ - connectionString: process.env.DATABASE_URL!, - max: getPoolMax(), - ...(sessionOptions ? { options: sessionOptions } : {}), - }); - workerPrismaSingleton = new PrismaClient({ adapter }); - } - return workerPrismaSingleton; + return workerPrismaSingleton ?? prisma; } /** diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index 6925e3cad..7e29c2b95 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -7,15 +7,27 @@ */ import { PgBoss } from "pg-boss"; +import { + getPgBossPoolMax, + getPoolConnectionTimeoutMs, +} from "@/lib/db"; + const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; -const PRODUCER_POOL_MAX = 2; const PRODUCER_START_MAX_ATTEMPTS = 5; -const PRODUCER_START_RETRY_DELAY_MS = 1_000; +const PRODUCER_RETRY_BASE_DELAY_MS = 250; +const PRODUCER_RETRY_MAX_DELAY_MS = 2_000; export interface ProducerStartOptions { maxAttempts?: number; - retryDelayMs?: number; + retryBaseDelayMs?: number; + retryMaxDelayMs?: number; +} + +interface ProducerRetryPolicy { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; } function wait(ms: number): Promise { @@ -24,20 +36,122 @@ function wait(ms: number): Promise { return promise; } -export function setGlobalBoss(boss: PgBoss) { - (globalThis as Record)[BOSS_KEY] = boss; +function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy { + const maxAttempts = Math.max( + 1, + Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS), + ); + const baseDelayMs = Math.max( + 0, + Math.trunc(options.retryBaseDelayMs ?? PRODUCER_RETRY_BASE_DELAY_MS), + ); + const maxDelayMs = Math.max( + baseDelayMs, + Math.trunc(options.retryMaxDelayMs ?? PRODUCER_RETRY_MAX_DELAY_MS), + ); + return { maxAttempts, baseDelayMs, maxDelayMs }; +} + +function retryDelayMs(failedAttempt: number, policy: ProducerRetryPolicy): number { + return Math.min( + policy.maxDelayMs, + policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1), + ); +} + +export function setGlobalBoss(boss: PgBoss | null): void { + const globalState = globalThis as Record; + if (boss) globalState[BOSS_KEY] = boss; + else delete globalState[BOSS_KEY]; } export function getGlobalBoss(): PgBoss | null { return ((globalThis as Record)[BOSS_KEY] as PgBoss) ?? null; } +function databaseOptions(connectionString: string) { + return { + connectionString, + max: getPgBossPoolMax(), + connectionTimeoutMillis: getPoolConnectionTimeoutMs(), + } as const; +} + +export function createWorkerBoss(connectionString: string): PgBoss { + return new PgBoss(databaseOptions(connectionString)); +} + +function producerOptions(connectionString: string) { + return { + ...databaseOptions(connectionString), + migrate: false, + schedule: false, + supervise: false, + } as const; +} + +async function startProducerAttempts( + connectionString: string, + policy: ProducerRetryPolicy, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) { + const boss = new PgBoss(producerOptions(connectionString)); + let ready = false; + boss.on("error", (error: unknown) => { + console.error("[pg-boss-producer] error", error); + if (ready) superviseProducerReconnect(boss, connectionString, policy); + }); + try { + await boss.start(); + ready = true; + setGlobalBoss(boss); + return boss; + } catch (error) { + lastError = error; + await boss.stop({ graceful: false }).catch(() => undefined); + if (attempt < policy.maxAttempts) { + await wait(retryDelayMs(attempt, policy)); + } + } + } + throw lastError; +} + +function superviseProducerReconnect( + failedBoss: PgBoss, + connectionString: string, + policy: ProducerRetryPolicy, +): void { + const globalState = globalThis as Record; + if (getGlobalBoss() !== failedBoss || globalState[BOSS_START_KEY]) return; + + setGlobalBoss(null); + const reconnecting = (async () => { + await failedBoss.stop({ graceful: false }).catch(() => undefined); + for (;;) { + try { + return await startProducerAttempts(connectionString, policy); + } catch (error) { + console.error("[pg-boss-producer] reconnect batch exhausted", error); + await wait(policy.maxDelayMs); + } + } + })(); + globalState[BOSS_START_KEY] = reconnecting; + void reconnecting.finally(() => { + if (globalState[BOSS_START_KEY] === reconnecting) { + delete globalState[BOSS_START_KEY]; + } + }); +} + /** * Start the web process's send-only pg-boss connection exactly once. * - * `PgBoss.start()` is still required to open/check the database connection. - * Disabling migration, schedules, and supervision keeps this process from - * performing worker duties; `send()` remains available through the manager. + * `PgBoss.start()` is required to confirm the worker-owned schema exists. + * Migration, schedules, and pg-boss maintenance stay disabled in web mode; + * this module only supervises the producer connection itself. */ export async function startGlobalBossProducer( connectionString: string | null | undefined, @@ -50,50 +164,20 @@ export async function startGlobalBossProducer( if (existing) return existing; const starting = globalState[BOSS_START_KEY] as - Promise | undefined; + | Promise + | undefined; if (starting) return starting; - const maxAttempts = Math.max( - 1, - Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS), + const startPromise = startProducerAttempts( + connectionString, + resolveRetryPolicy(options), ); - const retryDelayMs = Math.max( - 0, - options.retryDelayMs ?? PRODUCER_START_RETRY_DELAY_MS, - ); - const startPromise = (async () => { - let lastError: unknown; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const boss = new PgBoss({ - connectionString, - max: PRODUCER_POOL_MAX, - migrate: false, - schedule: false, - supervise: false, - }); - boss.on("error", (error: unknown) => { - console.error("[pg-boss-producer] error", error); - }); - try { - await boss.start(); - setGlobalBoss(boss); - return boss; - } catch (error) { - lastError = error; - await boss.stop({ graceful: false }).catch(() => {}); - if (attempt < maxAttempts) { - await wait(retryDelayMs); - } - } - } - throw lastError; - })(); - globalState[BOSS_START_KEY] = startPromise; try { return await startPromise; - } catch (error) { - delete globalState[BOSS_START_KEY]; - throw error; + } finally { + if (globalState[BOSS_START_KEY] === startPromise) { + delete globalState[BOSS_START_KEY]; + } } } diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index d3ccefc3a..e61f62348 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -14,9 +14,8 @@ * Usage: Run as a standalone process or call startReminderWorker() from a * custom server setup. In dev, use: npx tsx src/lib/jobs/reminder-worker.ts */ -import { PgBoss } from "pg-boss"; import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status"; -import { setGlobalBoss } from "@/lib/jobs/boss-instance"; +import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { DATABASE_URL, workerLog } from "./reminder/shared"; import { @@ -50,7 +49,7 @@ export async function startReminderWorker() { return; } - const boss = new PgBoss(DATABASE_URL); + const boss = createWorkerBoss(DATABASE_URL); boss.on("error", (error: unknown) => { workerLog("error", "boss emitted error", error); diff --git a/src/lib/jobs/reminder/__tests__/shared.test.ts b/src/lib/jobs/reminder/__tests__/shared.test.ts index feecd51c3..bb6b11ed1 100644 --- a/src/lib/jobs/reminder/__tests__/shared.test.ts +++ b/src/lib/jobs/reminder/__tests__/shared.test.ts @@ -1,15 +1,4 @@ -/** - * F-DB-1 — the worker Prisma client must inherit the same pool ceiling + - * session timeouts as the web client (`src/lib/db.ts`). - * - * Before this fix the worker adapter was built with a bare - * `{ connectionString }`, so a pathological nightly drain on a heavy tenant - * could run unbounded, pin a connection indefinitely, and wedge the shared - * worker pool. This test pins the contract: `getWorkerPrisma()` constructs its - * `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup - * string from `buildSessionOptions()`. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const adapterCtor = vi.fn(); const clientCtor = vi.fn(); @@ -23,6 +12,7 @@ vi.mock("@prisma/adapter-pg", () => ({ })); vi.mock("@/generated/prisma/client", () => ({ + Prisma: {}, PrismaClient: class { constructor(...args: unknown[]) { clientCtor(...args); @@ -30,78 +20,24 @@ vi.mock("@/generated/prisma/client", () => ({ }, })); -describe("getWorkerPrisma", () => { +describe("worker Prisma connection ownership", () => { beforeEach(() => { vi.resetModules(); adapterCtor.mockClear(); clientCtor.mockClear(); - process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db"; - delete process.env.DATABASE_POOL_MAX; - delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; + process.env.DATABASE_URL = + "postgres://user:pass@localhost:5432/db?connection_limit=20&pool_timeout=20"; }); - afterEach(() => { - delete process.env.DATABASE_POOL_MAX; - delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; - }); - - it("builds the adapter with a pool cap and session timeouts", async () => { - const { getWorkerPrisma } = await import("../shared"); - // Importing ../shared pulls in src/lib/db.ts, which constructs the web - // client's adapter at module load. Clear so we count only the worker's. - adapterCtor.mockClear(); - clientCtor.mockClear(); - getWorkerPrisma(); + it("reuses the process Prisma client instead of opening a second pool", async () => { + const [{ prisma }, { getWorkerPrisma }] = await Promise.all([ + import("@/lib/db"), + import("../shared"), + ]); + expect(getWorkerPrisma()).toBe(prisma); + expect(getWorkerPrisma()).toBe(prisma); expect(adapterCtor).toHaveBeenCalledTimes(1); - const config = adapterCtor.mock.calls[0][0] as { - connectionString: string; - max: number; - options?: string; - }; - expect(config.connectionString).toBe( - "postgres://user:pass@localhost:5432/db", - ); - // Default pool ceiling — must not fall back to the pg library default of 10. - expect(config.max).toBe(20); - // Both session timeouts applied at connection establishment. - expect(config.options).toContain("-c statement_timeout=60000"); - expect(config.options).toContain( - "-c idle_in_transaction_session_timeout=60000", - ); - }); - - it("honours the shared env knobs", async () => { - process.env.DATABASE_POOL_MAX = "12"; - process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000"; - const { getWorkerPrisma } = await import("../shared"); - adapterCtor.mockClear(); - getWorkerPrisma(); - - const config = adapterCtor.mock.calls[0][0] as { - max: number; - options?: string; - }; - expect(config.max).toBe(12); - expect(config.options).toContain("-c statement_timeout=30000"); - }); - - it("omits the options string when the timeout is disabled", async () => { - process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; - const { getWorkerPrisma } = await import("../shared"); - adapterCtor.mockClear(); - getWorkerPrisma(); - - const config = adapterCtor.mock.calls[0][0] as { options?: string }; - expect(config.options).toBeUndefined(); - }); - - it("reuses a single client across calls", async () => { - const { getWorkerPrisma } = await import("../shared"); - clientCtor.mockClear(); - const a = getWorkerPrisma(); - const b = getWorkerPrisma(); - expect(a).toBe(b); expect(clientCtor).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/jobs/reminder/shared.ts b/src/lib/jobs/reminder/shared.ts index 079ab63f7..5a14af029 100644 --- a/src/lib/jobs/reminder/shared.ts +++ b/src/lib/jobs/reminder/shared.ts @@ -4,9 +4,8 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ -import { PrismaClient } from "@/generated/prisma/client"; -import { PrismaPg } from "@prisma/adapter-pg"; -import { buildSessionOptions, getPoolMax } from "@/lib/db"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; export function parseTimeToMinutes(value: string): number { const [h, m] = value.split(":").map(Number); @@ -31,31 +30,10 @@ export const DATABASE_URL = process.env.DATABASE_URL!; // immediate semantics. export const BOOT_BACKFILL_STAGGER_SECONDS = 30; -// Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion - -let workerPrisma: PrismaClient | null = null; - +// Every queue handler shares the process-level client and its single bounded +// pg.Pool. Separate worker clients multiplied the configured connection limit. export function getWorkerPrisma(): PrismaClient { - if (!workerPrisma) { - // Apply the same pool ceiling + session timeouts the web client uses - // (`src/lib/db.ts`). Without them the worker client ran unbounded: a - // pathological nightly drain on a heavy tenant could pin a connection - // indefinitely and wedge the shared worker pool. `getPoolMax()` caps the - // slot count; `buildSessionOptions()` applies `statement_timeout` + - // `idle_in_transaction_session_timeout` at connection establishment so - // every worker session is timeout-bounded from its first query. Both share - // the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs - // as the web tier, and the web + worker containers share one Postgres so a - // single ceiling covers both pools. - const sessionOptions = buildSessionOptions(); - const adapter = new PrismaPg({ - connectionString: DATABASE_URL, - max: getPoolMax(), - ...(sessionOptions ? { options: sessionOptions } : {}), - }); - workerPrisma = new PrismaClient({ adapter }); - } - return workerPrisma; + return prisma; } /** diff --git a/src/proxy.ts b/src/proxy.ts index f8712927d..f42321058 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -194,7 +194,7 @@ export function proxy(request: NextRequest) { // Worker-only container: refuse HTTP traffic with a clear hint instead of // serving requests that would either crash (no DB pool ready for writes from // the worker) or duplicate work that the dedicated web container is doing. - if (!shouldRunWeb()) { + if (!shouldRunWeb() && pathname !== "/api/health") { return NextResponse.json( { data: null, From 3ce67266463fc00f83cbcaec4fb196eaa35e6679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 21 Jul 2026 00:35:29 +0200 Subject: [PATCH 3/5] fix(ci): harden test determinism and dependencies --- .github/workflows/e2e.yml | 5 +- Dockerfile | 1 + package.json | 5 +- playwright.config.ts | 8 +- pnpm-lock.yaml | 42 ++----- scripts/__tests__/ci-determinism.test.ts | 115 +++++++++++++++++++ scripts/__tests__/prepare.test.ts | 136 +++++++++++++++++++++++ scripts/prepare.mjs | 17 +++ tests/integration/ci-environment.test.ts | 30 +++++ tests/integration/environment-setup.ts | 15 +++ tests/integration/global-setup.ts | 10 +- tests/integration/setup.ts | 32 +----- vitest.integration.config.mts | 13 ++- 13 files changed, 354 insertions(+), 75 deletions(-) create mode 100644 scripts/__tests__/ci-determinism.test.ts create mode 100644 scripts/__tests__/prepare.test.ts create mode 100644 scripts/prepare.mjs create mode 100644 tests/integration/ci-environment.test.ts create mode 100644 tests/integration/environment-setup.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e118f4dee..43e332643 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -87,10 +87,11 @@ jobs: env: E2E_BASE_URL: http://localhost:3000 - - name: Upload Playwright HTML report on failure - if: failure() + - name: Upload Playwright HTML report + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: playwright-report/ retention-days: 7 + if-no-files-found: ignore diff --git a/Dockerfile b/Dockerfile index 8f0861e5c..fe0462e13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN corepack enable && corepack prepare pnpm@10.31.0 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── diff --git a/package.json b/package.json index 490d269eb..c1651b073 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "private": true, "packageManager": "pnpm@10.31.0", "scripts": { - "prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'", + "prepare": "node scripts/prepare.mjs", "dev": "next dev", "predev": "node scripts/generate-i18n-boot.mjs", "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs", @@ -142,6 +142,9 @@ "@prisma/engines" ], "overrides": { + "dompurify@<=3.4.10": "^3.4.11", + "postcss@<8.5.10": "^8.5.10", + "@hono/node-server@<1.19.13": "^1.19.13", "hono@<4.12.25": "^4.12.25", "picomatch@<4.0.4": "^4.0.4" } diff --git a/playwright.config.ts b/playwright.config.ts index 8987415c2..f4936ad87 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,13 +22,13 @@ export default defineConfig({ expect: { timeout: 10_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, + failOnFlakyTests: !!process.env.CI, // Two retries on CI: under a loaded shared runner, DOM-settle-gated // assertions (wizard step transitions, list refetch → card provenance, // disclosure toggles) intermittently sample a mid-transition frame. A - // rotating single test failed each run while 211 passed; a second retry - // absorbs that transient contention without masking a real failure (a true - // break fails all three attempts). The default expect timeout is also - // lifted from 5s → 10s for the same settle headroom. + // retry distinguishes transient contention from a persistent break, while + // failOnFlakyTests keeps either outcome visible to CI. The default expect + // timeout is lifted from 5s → 10s for the same settle headroom. retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5981cb3c..d4e2fd88c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: + dompurify@<=3.4.10: ^3.4.11 + postcss@<8.5.10: ^8.5.10 + '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -879,12 +882,6 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4.12.25 - '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -4006,8 +4003,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} @@ -5355,11 +5352,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5733,10 +5725,6 @@ packages: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -7777,10 +7765,6 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.11(hono@4.12.25)': - dependencies: - hono: 4.12.25 - '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: hono: 4.12.25 @@ -8538,7 +8522,7 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.11(hono@4.12.25) + '@hono/node-server': 1.19.14(hono@4.12.25) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 @@ -10760,7 +10744,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.3.3: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 optional: true @@ -11897,7 +11881,7 @@ snapshots: optionalDependencies: canvg: 3.0.11 core-js: 3.49.0 - dompurify: 3.3.3 + dompurify: 3.4.12 html2canvas: 1.4.1 jsx-ast-utils@3.3.5: @@ -12236,8 +12220,6 @@ snapshots: nanoid@3.3.12: {} - nanoid@3.3.15: {} - nanoid@3.3.16: {} napi-postinstall@0.3.4: {} @@ -12257,7 +12239,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 - postcss: 8.4.31 + postcss: 8.5.20 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) @@ -12637,12 +12619,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.4.31: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.14: dependencies: nanoid: 3.3.12 diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts new file mode 100644 index 000000000..312fb5fb8 --- /dev/null +++ b/scripts/__tests__/ci-determinism.test.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +import integrationConfig from "../../vitest.integration.config.mts"; + +function repositoryRoot(): string { + const testPath = expect.getState().testPath; + if (!testPath) throw new Error("Vitest did not expose the current test path"); + return join(dirname(testPath), "../.."); +} + +function readRepoFile(path: string): string { + return readFileSync(join(repositoryRoot(), path), "utf8"); +} + +type WorkflowStep = { + if?: string; + uses?: string; + with?: Record; +}; + +type Workflow = { + jobs: Record; +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("Playwright CI determinism", () => { + it("fails CI when a retry resolves a flaky test", async () => { + vi.stubEnv("CI", "true"); + // Re-import after stubbing CI so config conditionals are evaluated as CI. + const { default: config } = await import("../../playwright.config"); + + expect(config.retries).toBe(2); + expect(config.failOnFlakyTests).toBe(true); + expect(config.reporter).toEqual([ + ["github"], + ["html", { open: "never" }], + ]); + }); + + it("retains the Playwright report after every workflow outcome", () => { + const workflow = parse( + readRepoFile(".github/workflows/e2e.yml"), + ) as Workflow; + const upload = workflow.jobs.e2e?.steps?.find((step) => + step.uses?.startsWith("actions/upload-artifact@"), + ); + + expect(upload).toBeDefined(); + expect(upload?.if).toBe("always()"); + expect(upload?.with).toMatchObject({ + name: "playwright-report", + path: "playwright-report/", + "retention-days": 7, + "if-no-files-found": "ignore", + }); + }); +}); + +describe("integration environment isolation", () => { + it("registers a worker setup bridge and authoritative test secrets", () => { + const test = integrationConfig.test; + + expect(test?.setupFiles ?? []).toContain( + "./tests/integration/environment-setup.ts", + ); + expect(test?.env).toMatchObject({ + TZ: "UTC", + ENCRYPTION_KEY: + "0000000000000000000000000000000000000000000000000000000000000000", + ENCRYPTION_KEYS: "", + ENCRYPTION_ACTIVE_KEY_ID: "", + API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum", + SESSION_SECRET: "integration-test-session-secret-32-bytes", + }); + expect( + existsSync( + join(repositoryRoot(), "tests/integration/environment-setup.ts"), + ), + ).toBe(true); + }); +}); + +describe("container dependency installation", () => { + it("copies the prepare helper before the Git-free frozen install", () => { + const dockerfile = readRepoFile("Dockerfile"); + + expect(dockerfile).toContain( + "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" + + "COPY scripts/prepare.mjs scripts/prepare.mjs\n" + + "RUN pnpm install --frozen-lockfile --prod=false", + ); + }); +}); + +describe("production dependency advisory floors", () => { + it("pins vulnerable transitive ranges to compatible patched versions", () => { + const packageJson = JSON.parse(readRepoFile("package.json")) as { + pnpm?: { overrides?: Record }; + }; + + expect(packageJson.pnpm?.overrides).toMatchObject({ + "dompurify@<=3.4.10": "^3.4.11", + "postcss@<8.5.10": "^8.5.10", + "@hono/node-server@<1.19.13": "^1.19.13", + "hono@<4.12.25": "^4.12.25", + }); + }); +}); diff --git a/scripts/__tests__/prepare.test.ts b/scripts/__tests__/prepare.test.ts new file mode 100644 index 000000000..70b45136d --- /dev/null +++ b/scripts/__tests__/prepare.test.ts @@ -0,0 +1,136 @@ +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +type GitMarker = "absent" | "directory" | "file"; + +type PrepareResult = { + invocationLog: string | null; + result: SpawnSyncReturns; +}; + +function runPrepareFixture( + gitMarker: GitMarker, + lefthookStatus = 0, +): PrepareResult { + const testPath = expect.getState().testPath; + if (!testPath) + throw new Error("Vitest did not expose the current test path"); + const root = join(dirname(testPath), "../.."); + const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-")); + temporaryDirectories.push(directory); + + const packageJson = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ) as { + scripts: { prepare: string }; + }; + const prepareHelper = join(root, "scripts/prepare.mjs"); + if (existsSync(prepareHelper)) { + mkdirSync(join(directory, "scripts")); + cpSync(prepareHelper, join(directory, "scripts/prepare.mjs")); + } + + if (gitMarker === "directory") { + mkdirSync(join(directory, ".git")); + } else if (gitMarker === "file") { + writeFileSync( + join(directory, ".git"), + "gitdir: ../shared/.git/worktrees/fixture\n", + ); + } + + const binDirectory = join(directory, "bin"); + const invocationLog = join(directory, "lefthook.log"); + mkdirSync(binDirectory); + const lefthook = join( + binDirectory, + process.platform === "win32" ? "lefthook.cmd" : "lefthook", + ); + writeFileSync( + lefthook, + process.platform === "win32" + ? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n' + : "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FIXTURE_LEFTHOOK_LOG\"\nexit \"$FIXTURE_LEFTHOOK_STATUS\"\n", + ); + if (process.platform !== "win32") chmodSync(lefthook, 0o755); + + const result = spawnSync(packageJson.scripts.prepare, { + cwd: directory, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + FIXTURE_LEFTHOOK_LOG: invocationLog, + FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus), + }, + shell: true, + }); + + return { + result, + invocationLog: existsSync(invocationLog) + ? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n") + : null, + }; +} + +describe("package prepare hook installation", () => { + it("enables command-shim execution on Windows only", () => { + const testPath = expect.getState().testPath; + if (!testPath) + throw new Error("Vitest did not expose the current test path"); + const source = readFileSync( + join(dirname(testPath), "../prepare.mjs"), + "utf8", + ); + + expect(source).toContain('shell: process.platform === "win32"'); + }); + + it("skips hook installation only when Git metadata is absent", () => { + const { invocationLog, result } = runPrepareFixture("absent"); + + expect(result.status, result.stderr).toBe(0); + expect(invocationLog).toBeNull(); + }); + + it.each(["directory", "file"] as const)( + "installs hooks when .git is a %s", + (gitMarker) => { + const { invocationLog, result } = runPrepareFixture(gitMarker); + + expect(result.status, result.stderr).toBe(0); + expect(invocationLog).toBe("install --force\n"); + }, + ); + + it.each(["directory", "file"] as const)( + "propagates Lefthook failure when .git is a %s", + (gitMarker) => { + const { invocationLog, result } = runPrepareFixture(gitMarker, 42); + + expect(invocationLog).toBe("install --force\n"); + expect(result.status).toBe(42); + }, + ); +}); diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 000000000..6d6575def --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +if (!existsSync(".git")) { + console.log("Skipping Lefthook install: Git metadata is absent"); +} else { + const result = spawnSync("lefthook", ["install", "--force"], { + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error) { + console.error(`Failed to run Lefthook: ${result.error.message}`); + process.exitCode = 1; + } else { + process.exitCode = result.status ?? 1; + } +} diff --git a/tests/integration/ci-environment.test.ts b/tests/integration/ci-environment.test.ts new file mode 100644 index 000000000..405791f95 --- /dev/null +++ b/tests/integration/ci-environment.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { getPrismaClient } from "./setup"; + +const TEST_ENCRYPTION_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; +const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; +const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes"; + +describe("integration worker environment", () => { + it( + "replaces ambient database and encryption configuration with test values", + async () => { + expect(process.env.DATABASE_URL).toContain("healthlog_test"); + expect(process.env.DATABASE_URL).not.toContain("ambient_database"); + expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); + expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); + expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); + expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); + expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); + + const rows = await getPrismaClient().$queryRaw< + Array<{ database_name: string }> + >` + SELECT current_database() AS database_name + `; + expect(rows).toEqual([{ database_name: "healthlog_test" }]); + }, + ); +}); diff --git a/tests/integration/environment-setup.ts b/tests/integration/environment-setup.ts new file mode 100644 index 000000000..82c6e8864 --- /dev/null +++ b/tests/integration/environment-setup.ts @@ -0,0 +1,15 @@ +import { inject } from "vitest"; + +declare module "vitest" { + export interface ProvidedContext { + integrationDatabaseUrl: string; + } +} + +process.env.DATABASE_URL = inject("integrationDatabaseUrl"); +process.env.ENCRYPTION_KEY = + "0000000000000000000000000000000000000000000000000000000000000000"; +delete process.env.ENCRYPTION_KEYS; +delete process.env.ENCRYPTION_ACTIVE_KEY_ID; +process.env.API_TOKEN_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; +process.env.SESSION_SECRET = "integration-test-session-secret-32-bytes"; diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index c460a98eb..5ee724dd9 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -18,6 +18,7 @@ import { execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import type { TestProject } from "vitest/node"; import { PostgreSqlContainer, type StartedPostgreSqlContainer, @@ -29,7 +30,9 @@ const PROJECT_ROOT = resolve(__dirname, "..", ".."); let container: StartedPostgreSqlContainer | null = null; -export default async function setup(): Promise<() => Promise> { +export default async function setup( + project: TestProject, +): Promise<() => Promise> { container = await new PostgreSqlContainer("postgres:16-alpine") .withDatabase("healthlog_test") .withUsername("healthlog") @@ -38,10 +41,9 @@ export default async function setup(): Promise<() => Promise> { const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; process.env.DATABASE_URL = url; + project.provide("integrationDatabaseUrl", url); - // Provided so child processes (e.g. Prisma's CLI) inherit the URL. - // Vitest also forwards process.env to test workers, so the - // application Prisma singleton sees this URL when it loads. + // The explicit child environment keeps Prisma CLI on the same container. execSync("pnpm db:migrate:deploy", { cwd: PROJECT_ROOT, stdio: "inherit", diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 56fdd8094..5ba15dbac 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -1,39 +1,11 @@ /** * Per-test helpers for the integration suite. The Postgres testcontainer - * is started ONCE in `global-setup.ts` and torn down at the end of the - * run, so test files only need: - * - * import { getPrismaClient, truncateAllTables } from "./setup"; - * - * beforeEach(async () => { await truncateAllTables(getPrismaClient()); }); - * - * No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the - * container is alive whenever the test file is loaded, and Vitest's - * worker inherits `process.env.DATABASE_URL` from globalSetup so the - * application's Prisma singleton (`src/lib/db.ts`) connects to the - * testcontainer transparently. + * is started once in global-setup.ts and bridged into each worker by + * environment-setup.ts before application modules load. */ import { prisma } from "@/lib/db"; import type { PrismaClient } from "@/generated/prisma/client"; -// v1.23 — every integration test imports this module. The free-text health-note -// columns (mood + measurement) are now AES-256-GCM at rest, so any test that -// writes/reads a note needs an encryption key. Crypto reads the key lazily on -// first `encrypt()`, so a `??=` default here covers every test without touching -// each file (individual tests that set their own key still win — `??=`). -process.env.ENCRYPTION_KEY ??= - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what -// lands in `sessions.token_hash`, so `createSession` needs this key exactly -// like token issuance already did. It is a Core-required variable in -// `scripts/env-manifest.json` (the app refuses to boot without it), so -// defaulting it here matches production rather than papering over a gap. -// Same `??=` treatment as the encryption key above: files that set their own -// still win. -process.env.API_TOKEN_HMAC_KEY ??= - "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; - /** * The application's Prisma singleton. Tests use this so any code * imported via `await import("@/lib/...")` shares the exact same client diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 3a36a7989..a6175a20e 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -15,7 +15,15 @@ export default defineConfig({ environment: "node", // Same host-timezone pin as the unit config — CI runs UTC, so the // integration contracts must be read against the same clock. - env: { TZ: "UTC" }, + env: { + TZ: "UTC", + ENCRYPTION_KEY: + "0000000000000000000000000000000000000000000000000000000000000000", + ENCRYPTION_KEYS: "", + ENCRYPTION_ACTIVE_KEY_ID: "", + API_TOKEN_HMAC_KEY: "integration-test-hmac-key-32-bytes-minimum", + SESSION_SECRET: "integration-test-session-secret-32-bytes", + }, include: ["tests/integration/**/*.test.ts"], // Container boot + migration apply is slow; give each test a generous // budget and beforeAll/afterAll twice that. @@ -39,6 +47,9 @@ export default defineConfig({ // expensive boot cost is unaffected. isolate: true, // One container for the whole run; tests truncate between them. + // Bridge the global Testcontainers URL into each isolated worker before + // application modules read DATABASE_URL at import time. + setupFiles: ["./tests/integration/environment-setup.ts"], globalSetup: ["./tests/integration/global-setup.ts"], }, resolve: { From b38f41b99d9769dcbde1fb57f3c046a511068965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 21 Jul 2026 01:34:29 +0200 Subject: [PATCH 4/5] chore(release): prepare v1.31.4 --- CHANGELOG.md | 21 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- scripts/__tests__/ci-determinism.test.ts | 5 +-- scripts/__tests__/prepare.test.ts | 5 +-- scripts/__tests__/release-workflows.test.ts | 44 ++++++++++++++------ src/app/api/devices/route.ts | 32 +++++++------- src/lib/__tests__/db.test.ts | 4 +- src/lib/jobs/__tests__/boss-instance.test.ts | 4 +- src/lib/jobs/boss-instance.ts | 17 ++++---- tests/integration/ci-environment.test.ts | 29 ++++++------- 12 files changed, 99 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1458ab52b..815f047f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.31.4] — 2026-07-21 + +- **Web and background processes now start and recover predictably.** Invalid + encryption-key configuration fails before the service accepts traffic, + worker-only processes report their real health, connection limits are + divided safely across replicas, and provider backfills keep their bounded + queues during reconnects. +- **Published images are reproducible and verified before promotion.** The + release pipeline builds one multi-architecture image, validates its digest, + startup, migrations, health, and reported version, and only then promotes + that exact artifact to production. +- **CI now uses isolated, deterministic test environments.** Integration tests + receive their own database and cryptographic settings before application + code loads, worktrees install hooks consistently, and browser tests use the + intended application process on every platform. +- **Concurrent iOS registrations no longer fail intermittently.** Device and + APNs notification-channel reconciliation now share one database lock and + transaction, so simultaneous first registration remains idempotent. + +No migrations. No breaking changes. + ## [1.31.3] — 2026-07-20 - **Document summaries now stay in the language you chose.** The same account diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index facc77ecb..2dd09cddc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.31.3 + version: 1.31.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c1651b073..38b3eb619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.31.3", + "version": "1.31.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8ab47acb8..975888820 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.31.3"; + /* @sw-version-fallback */ "v1.31.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 312fb5fb8..79e00d3b5 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -38,10 +38,7 @@ describe("Playwright CI determinism", () => { expect(config.retries).toBe(2); expect(config.failOnFlakyTests).toBe(true); - expect(config.reporter).toEqual([ - ["github"], - ["html", { open: "never" }], - ]); + expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]); }); it("retains the Playwright report after every workflow outcome", () => { diff --git a/scripts/__tests__/prepare.test.ts b/scripts/__tests__/prepare.test.ts index 70b45136d..be48b7bb6 100644 --- a/scripts/__tests__/prepare.test.ts +++ b/scripts/__tests__/prepare.test.ts @@ -33,8 +33,7 @@ function runPrepareFixture( lefthookStatus = 0, ): PrepareResult { const testPath = expect.getState().testPath; - if (!testPath) - throw new Error("Vitest did not expose the current test path"); + if (!testPath) throw new Error("Vitest did not expose the current test path"); const root = join(dirname(testPath), "../.."); const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-")); temporaryDirectories.push(directory); @@ -70,7 +69,7 @@ function runPrepareFixture( lefthook, process.platform === "win32" ? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n' - : "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FIXTURE_LEFTHOOK_LOG\"\nexit \"$FIXTURE_LEFTHOOK_STATUS\"\n", + : '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n', ); if (process.platform !== "win32") chmodSync(lefthook, 0o755); diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index 7a9b2ec7d..19966d3e2 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -48,7 +48,11 @@ function loadWorkflow(name: string): Workflow { return parse(readRepoFile(join(".github/workflows", name))) as Workflow; } -function stepScript(workflow: Workflow, jobName: string, stepName: string): string { +function stepScript( + workflow: Workflow, + jobName: string, + stepName: string, +): string { const step = workflow.jobs[jobName]?.steps?.find( (candidate) => candidate.name === stepName, ); @@ -93,7 +97,12 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi const workflow = loadWorkflow("docker-publish.yml"); return spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "release-policy", "Validate release policy")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "release-policy", "Validate release policy"), + ], { cwd: directory, encoding: "utf8", @@ -114,9 +123,10 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi ); } -function runExactPull( - resolvedDigest: string, -): { result: SpawnSyncReturns; commands: string } { +function runExactPull(resolvedDigest: string): { + result: SpawnSyncReturns; + commands: string; +} { const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-")); temporaryDirectories.push(directory); const dockerPath = join(directory, "docker"); @@ -138,7 +148,12 @@ exit 1 const workflow = loadWorkflow("post-publish-verify.yml"); const result = spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "verify", "Pull exact image")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "verify", "Pull exact image"), + ], { encoding: "utf8", env: { @@ -179,7 +194,12 @@ printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_S const workflow = loadWorkflow("docker-publish.yml"); return spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "promote", "Trigger Coolify deploy")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "promote", "Trigger Coolify deploy"), + ], { encoding: "utf8", env: { @@ -219,9 +239,9 @@ describe("release container inputs", () => { }; expect(packageJson.packageManager).toBe("pnpm@10.31.0"); expect(dockerfile).not.toContain("pnpm@latest"); - expect(dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g)).toHaveLength( - 3, - ); + expect( + dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g), + ).toHaveLength(3); }); it("relies on and verifies standalone dependency resolution", () => { @@ -352,10 +372,10 @@ describe("exact post-publish verification", () => { } expect(stepScript(workflow, "verify", "Run migrations")).toContain( - '${IMAGE_REF}@${IMAGE_DIGEST}', + "${IMAGE_REF}@${IMAGE_DIGEST}", ); expect(stepScript(workflow, "verify", "Start exact image")).toContain( - '${IMAGE_REF}@${IMAGE_DIGEST}', + "${IMAGE_REF}@${IMAGE_DIGEST}", ); expect(stepScript(workflow, "verify", "Verify exact version")).toContain( 'jq -e --arg expected "$IMAGE_TAG"', diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index f9fb33118..09f25831c 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { id: true }, }); + // Keep channel reconciliation under the same identity locks as the device + // row. Moving it after commit lets two first-time registrations race the + // compound unique key and intermittently turns one valid request into 500. + if (apnsToken) { + await tx.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "APNS" } }, + create: { + userId: user.id, + type: "APNS", + enabled: true, + config: encrypt("{}"), + }, + update: {}, + }); + } + return { id: device.id }; }); @@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => { const { id } = registration; - // Reconcile APNs channel state only after the canonical device transaction - // commits. Config is an encrypted empty record by design — the per-device - // token and environment live on the Device row. - if (apnsToken) { - await prisma.notificationChannel.upsert({ - where: { userId_type: { userId: user.id, type: "APNS" } }, - create: { - userId: user.id, - type: "APNS", - enabled: true, - config: encrypt("{}"), - }, - update: {}, - }); - } - await auditLog("devices.register", { userId: user.id, details: { diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 95d2fac1e..baf6e00f0 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -42,9 +42,7 @@ describe("per-process database connection budget", () => { expect(getConnectionBudget()).toBe(20); expect(getPrismaPoolMax()).toBe(18); expect(getPgBossPoolMax()).toBe(2); - expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe( - getConnectionBudget(), - ); + expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget()); }); it("uses the existing pool_timeout seconds for both pool constructors", () => { diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 1afc12fad..08b70ed83 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -3,9 +3,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; interface MockBoss { start: Mock<() => Promise>; stop: Mock<(options?: { graceful?: boolean }) => Promise>; - on: Mock< - (event: string, handler: (error: Error) => void) => MockBoss - >; + on: Mock<(event: string, handler: (error: Error) => void) => MockBoss>; emitError(error: Error): void; } diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index 7e29c2b95..b807477d5 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -7,10 +7,7 @@ */ import { PgBoss } from "pg-boss"; -import { - getPgBossPoolMax, - getPoolConnectionTimeoutMs, -} from "@/lib/db"; +import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; @@ -36,7 +33,9 @@ function wait(ms: number): Promise { return promise; } -function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy { +function resolveRetryPolicy( + options: ProducerStartOptions, +): ProducerRetryPolicy { const maxAttempts = Math.max( 1, Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS), @@ -52,7 +51,10 @@ function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy return { maxAttempts, baseDelayMs, maxDelayMs }; } -function retryDelayMs(failedAttempt: number, policy: ProducerRetryPolicy): number { +function retryDelayMs( + failedAttempt: number, + policy: ProducerRetryPolicy, +): number { return Math.min( policy.maxDelayMs, policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1), @@ -164,8 +166,7 @@ export async function startGlobalBossProducer( if (existing) return existing; const starting = globalState[BOSS_START_KEY] as - | Promise - | undefined; + Promise | undefined; if (starting) return starting; const startPromise = startProducerAttempts( diff --git a/tests/integration/ci-environment.test.ts b/tests/integration/ci-environment.test.ts index 405791f95..19b5cf3f9 100644 --- a/tests/integration/ci-environment.test.ts +++ b/tests/integration/ci-environment.test.ts @@ -8,23 +8,20 @@ const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes"; describe("integration worker environment", () => { - it( - "replaces ambient database and encryption configuration with test values", - async () => { - expect(process.env.DATABASE_URL).toContain("healthlog_test"); - expect(process.env.DATABASE_URL).not.toContain("ambient_database"); - expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); - expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); - expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); - expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); - expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); + it("replaces ambient database and encryption configuration with test values", async () => { + expect(process.env.DATABASE_URL).toContain("healthlog_test"); + expect(process.env.DATABASE_URL).not.toContain("ambient_database"); + expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); + expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); + expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); + expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); + expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); - const rows = await getPrismaClient().$queryRaw< - Array<{ database_name: string }> - >` + const rows = await getPrismaClient().$queryRaw< + Array<{ database_name: string }> + >` SELECT current_database() AS database_name `; - expect(rows).toEqual([{ database_name: "healthlog_test" }]); - }, - ); + expect(rows).toEqual([{ database_name: "healthlog_test" }]); + }); }); From 5fe952b63de6ce9ec4aa14f1929ddb9f12f065be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 21 Jul 2026 02:30:40 +0200 Subject: [PATCH 5/5] fix(ci): harden release and e2e verification --- .github/workflows/e2e.yml | 3 ++- .github/workflows/integration.yml | 2 +- .github/workflows/post-publish-verify.yml | 7 +++--- Dockerfile | 8 +++---- e2e/settings-export.spec.ts | 18 +++++++++++++++ e2e/share-documents.spec.ts | 25 +++++++++++++++++---- next.config.ts | 1 - scripts/__tests__/ci-determinism.test.ts | 20 +++++++++++++++-- scripts/__tests__/release-workflows.test.ts | 24 ++++++++++++++------ vitest.integration.config.mts | 3 ++- 10 files changed, 87 insertions(+), 24 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 43e332643..526a6e57b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,10 +37,11 @@ jobs: DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' ENCRYPTION_ACTIVE_KEY_ID: v1 - API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x + API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb AUTH_RP_NAME: HealthLog AUTH_RP_ID: localhost AUTH_RP_ORIGIN: http://localhost:3000 + SESSION_COOKIE_SECURE: "false" NODE_ENV: production steps: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 867ce3ac2..d1e16580b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -38,6 +38,6 @@ jobs: # string "0" — failing crypto's hex/base64 length check and breaking # the backups + integration-status suites for 50+ runs (regression # since v1.4.13). Quoting forces a literal scalar. - API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod" + API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 15900b5d9..6f4347795 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -111,7 +111,7 @@ jobs: -e DATABASE_URL="$DATABASE_URL" \ -e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \ -e ENCRYPTION_ACTIVE_KEY_ID=v1 \ - -e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \ + -e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \ -e AUTH_RP_NAME=HealthLog \ -e AUTH_RP_ID=localhost \ -e AUTH_RP_ORIGIN=http://localhost:3000 \ @@ -135,10 +135,11 @@ jobs: exit 1 fi + EXPECTED_VERSION="${IMAGE_TAG#v}" echo "$body" | jq . - if ! jq -e --arg expected "$IMAGE_TAG" '.data.version == $expected' <<< "$body" >/dev/null; then + if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then actual="$(jq -r '.data.version // ""' <<< "$body")" - echo "::error::Version mismatch: exact image reports '$actual', expected '$IMAGE_TAG'." >&2 + echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2 exit 1 fi if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then diff --git a/Dockerfile b/Dockerfile index fe0462e13..540a6b0f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,10 +80,10 @@ RUN addgroup --system --gid 1001 nodejs && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Next's standalone output must carry every externalized worker dependency. -# Fail the image build here if output tracing ever stops shipping one rather -# than compensating with a second, independently versioned npm installation. -RUN node -e "require.resolve('pg-boss'); require.resolve('@prisma/adapter-pg'); require.resolve('pg'); require.resolve('@prisma/client')" +# Next's standalone output must carry the externalized worker dependencies. +# Prisma's pure-JS adapter stays bundled; only modules loaded through native +# Node resolution belong in this runtime assertion. +RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the # native binary's package into the standalone tree but NOT the pnpm symlinks that diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index e8a04d1af..b116e6a16 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => { }) => { // v1.18.0 (S5) — the panel (and its disclosure) live on the // dedicated Gesundheitsakte section now. + await page.route("**/api/share-links", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: [], error: null, meta: null }), + }); + }); + const sharingDataReady = page.waitForResponse( + (response) => + response.status() === 200 && + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); await page.goto("/settings/gesundheitsakte", { waitUntil: "domcontentloaded", }); + await sharingDataReady; const toggle = page.getByTestId("health-record-included-data-toggle"); await expect(toggle).toBeVisible({ timeout: 10_000 }); // Collapsed on first render — the checklist panel is absent. diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 1163bd826..d33cfe1de 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -57,7 +57,14 @@ interface CreatedShare { async function createShareWithDocs(page: Page): Promise { const label = `${SHARE_DOC_PREFIX} ${Date.now()}`; + const sharingDataReady = page.waitForResponse( + (response) => + response.status() === 200 && + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); await page.goto("/settings/sharing"); + await sharingDataReady; await page.locator("#share-label").fill(label); // Open the document picker and isolate the seeded trio. @@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise { // Exactly the three share-fixture documents match the prefix. const rows = list.getByRole("button"); await expect(rows).toHaveCount(3); - for (let i = 0; i < 3; i++) await rows.nth(i).click(); + for (let i = 0; i < 3; i++) { + const row = rows.nth(i); + await row.click(); + await expect(row).toHaveAttribute("aria-pressed", "true"); + } await page.getByRole("button", { name: "Done" }).click(); // Three chips staged before create. await expect( page.getByTestId("share-attached-chips").getByRole("listitem"), ).toHaveCount(3); + await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled(); await page.getByRole("button", { name: "Create link" }).click(); @@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => { clinician.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // Class A image → inline at the token-scoped serve route. - await expect( - clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`), - ).toBeVisible(); + const inlineImage = clinician.getByRole("img", { + name: "Share e2e scan", + }); + await expect(inlineImage).toBeVisible(); + await expect(inlineImage).toHaveAttribute( + "src", + new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`), + ); // Class A PDF → inline