mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
ci: harden image publishing
This commit is contained in:
1 parent
be1d0fb728
commit
914e7658db
4 files changed
+655
-172
No files matched your search
@@ -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}."
|
||||
@@ -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 // "<missing>"' <<< "$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 // "<missing>"' <<< "$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
|
||||
+10
-16
@@ -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 && \
|
||||
|
||||
@@ -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<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
uses?: string;
|
||||
with?: Record<string, string>;
|
||||
};
|
||||
type Workflow = {
|
||||
on?: {
|
||||
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
|
||||
};
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
};
|
||||
|
||||
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<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
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<string>; 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<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user