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"); + }); +});