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/e2e.yml b/.github/workflows/e2e.yml index e118f4dee..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: @@ -87,10 +88,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/.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 c2dc87df2..6f4347795 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,69 +77,97 @@ 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 \ + -e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \ -e AUTH_RP_NAME=HealthLog \ -e AUTH_RP_ID=localhost \ -e AUTH_RP_ORIGIN=http://localhost:3000 \ -e NODE_ENV=production \ - -e HEALTHLOG_PROCESS_TYPE=web \ -e PORT=3000 \ - "${{ steps.pull.outputs.image }}" + "$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 + + EXPECTED_VERSION="${IMAGE_TAG#v}" + echo "$body" | jq . + 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 '$EXPECTED_VERSION' (release tag '$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/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/Dockerfile b/Dockerfile index aad0d0b4c..540a6b0f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ # ── 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 ./ +COPY scripts/prepare.mjs scripts/prepare.mjs 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 +46,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 +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 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 @@ -123,15 +127,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/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/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/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