diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 66ed18e..bf25543 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -287,6 +287,12 @@ jobs: echo "✓ /health returned 200" + # Same binary + script path as Docker HEALTHCHECK (exec form); catches ESM/require + # regressions and confirms 127.0.0.1:3000/health from inside the container. + echo "Validating /app/healthcheck.js (ESM, distroless node)..." + docker exec api-ci-test /nodejs/bin/node /app/healthcheck.js + echo "✓ healthcheck.js exited 0" + # Smoke tests: admin endpoints must reject unauthenticated requests with 401 for ENDPOINT in /admin/audit-log /admin/webhook-dlq; do ECODE=$(docker run --rm \ @@ -541,6 +547,10 @@ jobs: exit 1 fi + echo "Validating /app/healthcheck.js (same as Docker HEALTHCHECK)..." + docker exec api-blue /nodejs/bin/node /app/healthcheck.js + echo "✓ healthcheck.js exited 0" + # Smoke: auth guards must reject unauthenticated requests with 401. for ENDPOINT in /admin/audit-log /admin/webhook-dlq; do CODE=$(docker run --rm \ diff --git a/healthcheck.js b/healthcheck.js index c43844c..7bbf775 100644 --- a/healthcheck.js +++ b/healthcheck.js @@ -1,11 +1,10 @@ // Distroless-compatible liveness probe (no curl). Semantics align with: // curl -fsS http://127.0.0.1:3000/health || exit 1 +// ESM: package.json has "type":"module"; this file must not use require(). // - 127.0.0.1 + IPv4 only (avoid ::1 / dual-stack quirks) // - exit 0 only on HTTP 200; any other status or error → exit 1 // - bounded wall time < Docker --timeout (5s) -'use strict'; - -const http = require('http'); +import http from 'node:http'; const TIMEOUT_MS = 4500; let settled = false; @@ -18,26 +17,51 @@ function finish(code) { process.exit(code); } -const req = http.request( - { - host: '127.0.0.1', - port: 3000, - path: '/health', - method: 'GET', - family: 4, - }, - (res) => { - res.on('data', () => {}); - res.on('end', () => { - finish(res.statusCode === 200 ? 0 : 1); - }); - res.on('error', () => finish(1)); - }, -); +function logErr(prefix, err) { + console.error(`[healthcheck] ${prefix}`, err); +} + +process.on('uncaughtException', (err) => { + logErr('uncaughtException', err); + finish(1); +}); -req.on('error', () => finish(1)); -req.setTimeout(TIMEOUT_MS, () => { - req.destroy(); +process.on('unhandledRejection', (reason) => { + logErr('unhandledRejection', reason); finish(1); }); -req.end(); + +try { + const req = http.request( + { + host: '127.0.0.1', + port: 3000, + path: '/health', + method: 'GET', + family: 4, + }, + (res) => { + res.on('data', () => {}); + res.on('end', () => { + finish(res.statusCode === 200 ? 0 : 1); + }); + res.on('error', (err) => { + logErr('response error', err); + finish(1); + }); + }, + ); + + req.on('error', (err) => { + logErr('request error', err); + finish(1); + }); + req.setTimeout(TIMEOUT_MS, () => { + req.destroy(); + finish(1); + }); + req.end(); +} catch (err) { + logErr('fatal', err); + finish(1); +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index da128bb..2eebc8e 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -188,11 +188,13 @@ _ft_snapshot() { } # --------------------------------------------------------------------------- -# GITHUB ACTIONS SUMMARY +# GITHUB ACTIONS SUMMARY (optional; unset outside GitHub Actions — must not trip set -u) # --------------------------------------------------------------------------- _ft_github_summary() { local status="$1" container="${2:-unknown}" image="${3:-unknown}" reason="${4:-}" - [ -z "$GITHUB_STEP_SUMMARY" ] && return 0 + if [ -z "${GITHUB_STEP_SUMMARY:-}" ]; then + return 0 + fi { echo "### 🚀 Deployment Summary" echo "| Field | Value |"