ci: run the Node app test suites in the required diagnostics gate - #1124
ci: run the Node app test suites in the required diagnostics gate#1124mdheller wants to merge 2 commits into
Conversation
The apps/* suites that use the Node built-in test runner (health-twin, hellgraph-service, lifecycle-warden, commons-search, search-gateway) were never executed by CI — only images.yml built their containers, and the Dockerfiles run npm install, never npm test. So the PHI-path exposure and auth-gate tests never ran on any PR. Wire them in as a matrix leg (node-app-suites) of the already-required app-test-diagnostics job, so coverage rides the single required context (diagnostics-gate) without adding a new job or touching the gate's needs:. One leg iterates every node --test app (Actions is spend-capped); each app runs under a hard per-app timeout so a hang is RED (exit 124), never a silently cancelled job. commons-search and search-gateway hang at teardown (unconsumed fetch bodies hold undici keep-alive sockets open so server.close() never completes); they are quarantined in a named, shrink-only allowlist with the root cause and the one-line fix, asserted <= ceiling on every run. The leg fails on zero matches and refuses to pass if everything is quarantined.
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Integrates previously unrun apps/* Node built-in test runner suites (node --test) into the already-required diagnostics-gate by adding a new matrix leg, plus a shared runner script and quarantine file to handle known hangs with enforced limits.
Changes:
- Add a
node-app-suitesmatrix leg to the existing required diagnostics job. - Introduce a CI script to discover and run all
apps/*usingnode --testwith per-app hard timeouts. - Add a quarantine allowlist JSON for known hanging suites with a ceiling concept.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| .github/workflows/validate-target-diagnostics.yml | Adds the node-app-suites matrix entry and sets up Node for that leg. |
| .github/app-ci/run-node-app-tests.sh | Discovers apps/* with node --test, applies quarantine rules, runs each suite with a hard timeout. |
| .github/app-ci/quarantine.json | Defines quarantined apps and a ceiling value used by the runner script. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Invariant 2: quarantine may only shrink. The ceiling is a ratchet that only goes down. | ||
| if [ "${#Q_APPS[@]}" -gt "$CEILING" ]; then | ||
| echo "FATAL: quarantine holds ${#Q_APPS[@]} app(s) but the ceiling is $CEILING." | ||
| echo " The ceiling may ONLY be lowered. Adding an app means raising it, which" | ||
| echo " a reviewer must consciously approve — that friction is the point." | ||
| exit 1 | ||
| fi |
There was a problem hiding this comment.
Valid and fixed in 5d78d11. You're right that len(apps) <= ceiling within one file can't stop an add-app and raise-ceiling landing in the same diff. Added Invariant 2b: diff against the origin/main baseline (fetched shallow when the checkout lacks it) — the ceiling may not rise and no new app key may appear; a newly-failing suite must be fixed, not quarantined. Proven locally: baseline {ceiling:1, apps:{a}} vs a change to {ceiling:2, apps:{a,b}} now exits RED (ceiling raised 1 -> 2; new quarantined app(s): b). This leg's introduction (no baseline yet) is printed, never silently skipped.
| read_test_script() { | ||
| node -e ' | ||
| const fs=require("fs"), path=require("path"); | ||
| try { | ||
| const p=JSON.parse(fs.readFileSync(path.resolve(process.argv[1]),"utf8")); | ||
| process.stdout.write(((p.scripts&&p.scripts.test)||"")); | ||
| } catch(e) { process.stdout.write(""); } | ||
| ' "$1" | ||
| } |
There was a problem hiding this comment.
Valid and fixed in 5d78d11 — this was precisely the silent omission the leg exists to prevent. read_test_script now exits 3 with a reason on an unreadable/unparseable package.json (a valid manifest that simply declares no test still returns empty, as before), and both discovery and the stale-check fail RED on it. Proven: a broken package.json now exits 1 rather than being dropped as 'no tests'.
| npm install --no-audit --no-fund | ||
| timeout "$PER_APP_TIMEOUT" npm test |
| - name: Create virtualenv | ||
| working-directory: ${{ matrix.working-directory }} | ||
| run: python3 -m venv .venv | ||
| - name: Setup Node | ||
| if: matrix.name == 'node-app-suites' | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '22' |
…en manifest is RED Two silent-omission holes in node-app-suites — the exact failure mode this leg exists to stop: - len(apps)<=ceiling in one file cannot stop a PR that adds an app AND raises the ceiling together. Add Invariant 2b: diff against the origin/main baseline (fetched shallow if absent) — the ceiling may not rise and no new app may appear. A newly-failing suite must be fixed, not quarantined. No baseline yet (this leg's introduction) -> printed, never silently skipped. Seams: QUARANTINE_BASELINE_REF/FILE. - read_test_script swallowed read/parse errors and returned "", so a broken package.json was treated as 'no tests' and silently dropped. It now exits 3 with a reason; discovery and the stale-check fail RED on an unreadable/unparseable manifest. Addresses Copilot inline on #1124.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
.github/workflows/validate-target-diagnostics.yml:204
- The
Create virtualenvstep appears to run for thenode-app-suitesleg where it isn’t needed, and withworking-directory: .it will create.venvat the repo root. Consider adding anif: matrix.name != 'node-app-suites'guard (or splitting language-specific setup) to avoid unnecessary work and potential cross-leg side effects in this required CI gate.
- name: Create virtualenv
working-directory: ${{ matrix.working-directory }}
run: python3 -m venv .venv
- name: Setup Node
if: matrix.name == 'node-app-suites'
uses: actions/setup-node@v4
with:
node-version: '22'
.github/app-ci/run-node-app-tests.sh:79
- The
node --testdetection pattern is currently redundant and can miss legitimate variants (e.g., multiple spaces likenode --test, or other formatting). This can incorrectly classifynode --testsuites asOTHER_APPSand silently stop running them in this leg. Use a single, clear match such as a regex onnode[[:space:]]+--test(or a more robust parse) to reliably detect the built-in runner.
[ -z "$script" ] && continue
if [[ "$script" == *"node "*"--test"* || "$script" == *"node --test"* ]]; then
NODE_APPS+=("$app")
else
OTHER_APPS+=("$app [${script}]")
fi
.github/app-ci/run-node-app-tests.sh:182
- Quarantine validation checks that the app exists and has a test script, but doesn’t ensure the quarantined app is actually in the
node --testdiscovery set. If an app switches harness (e.g., to vitest) it could remain quarantined forever even though this leg will never run it anyway, undermining the intended shrink pressure and correctness of the quarantine list. Consider additionally asserting that each quarantined app is a member ofNODE_APPS(i.e., still anode --testsuite), otherwise fail and force pruning + ceiling lowering.
# Invariant 3: no stale quarantine entries.
for qa in "${Q_APPS[@]}"; do
qpj="apps/$qa/package.json"
if [ ! -f "$qpj" ]; then
echo "FATAL: quarantined app '$qa' no longer exists ($qpj is missing)."
echo " Remove it from $QUARANTINE_FILE AND lower the ceiling."
exit 1
fi
qscript="$(read_test_script "$qpj")"; qrc=$?
if [ "$qrc" -ne 0 ]; then
echo "FATAL: quarantined app '$qa' has an unreadable/unparseable $qpj."
echo " A broken quarantined app must be fixed, not waved through."
exit 1
fi
if [ -z "$qscript" ]; then
echo "FATAL: quarantined app '$qa' no longer declares a test script."
echo " Remove it from $QUARANTINE_FILE AND lower the ceiling."
exit 1
fi
done
.github/app-ci/run-node-app-tests.sh:133
- The quarantine ratchet is currently 'best-effort' and fails open: if fetching/reading the baseline file fails,
BASELINE_JSONbecomes empty and the script skips the shrink-only enforcement. Since this leg is intended to mechanically prevent widening quarantine, it’s safer to fail closed once the baseline quarantine exists (e.g., if the baseline ref can’t be retrieved/read, exit non-zero with a clear error) so the ratchet can’t be bypassed by transient git/checkout constraints.
# Shallow PR checkouts don't carry origin/main; fetch it best-effort before reading.
if ! git cat-file -e "$BASELINE_REF:.github/app-ci/quarantine.json" 2>/dev/null; then
git fetch --no-tags --depth=1 origin main >/dev/null 2>&1 || true
fi
BASELINE_JSON="$(git show "$BASELINE_REF:.github/app-ci/quarantine.json" 2>/dev/null || true)"
fi
What this does
The
apps/*suites that use the Node built-in test runner (node --test) were never executed by CI. The only workflow that referenced them isimages.yml(it builds their containers), and the Dockerfiles runnpm install, nevernpm test. So the health-twin PHI-path exposure / auth-gate / consult-cap tests — and the sibling apps' suites — ran on nobody's PR.This wires them into the already-required gate.
The protection finding (why placement was the whole problem)
main: 404 (not protected).main-required-checks(19558691) requires exactly one context:diagnostics-gate.strict_required_status_checks_policy: false.diagnostics-gate(an aggregator over itsneeds:) gates merges. Adding a new job also means editingdiagnostics-gate'sneeds:line and registering the job intools/ci_diagnostics_gate.py— the exact lines ci: diagnostics-gate must fail closed on an unexplained skip [DO NOT MERGE until probe runs land] #1080 rewrites and a file ci: diagnostics-gate must fail closed on an unexplained skip [DO NOT MERGE until probe runs land] #1080/DO NOT MERGE BEFORE #1080: fail diagnostics-gate on a job declared in the workflow but never wired into needs: #1082 both edit (andtools/is off-limits here).So the only non-colliding, actually-blocking route is a matrix leg inside an already-required job. This adds
node-app-suitesto the existingapp-test-diagnosticsjob (already indiagnostics-gate.needs). Coverage rides the single required context; no new job; the gate and itsneeds:are untouched.merge-tree (this branch × contested PRs): both CLEAN.
HEAD × #1080→ exit 0, auto-mergesvalidate-target-diagnostics.yml, no conflicts.HEAD × #1082→ exit 0, auto-merges, no conflicts.ci.ymluntouched, so no collision with health-twin: a grant id is not a credential — authenticate the holder on the PHI consent path #1081/health-twin: consult the reconciliation verdict on the request path, seal the anti-clamp audit, and give the app CI at all #1089 (both add a top-levelhealth-twin:key).One leg, all apps, hard timeout
.github/app-ci/run-node-app-tests.sh(kept out oftools/): discovers everyapps/*with anode --testscript and runs them in one leg (Actions is spend-capped), each under a hard per-app timeout so a hang is RED (exit 124), never a silently-cancelled job. It fails on zero matches and refuses to pass if everything is quarantined.Sibling inventory (measured locally, direct exit codes)
node --testnode --testnode --testnode --testnode --testvitest runbuild-web; wants its own vitest job)The two hangs (root-caused, not hidden)
commons-searchandsearch-gatewaysrc/server.test.ts: all assertions pass, then the process can't exit. Several testsawait fetch(...)(theDELETEcalls, the rate-cap loops) without consuming the response body, so undici keeps the HTTP/1.1 keep-alive sockets open andafter(() => server.close())never completes. hellgraph passes because it consumes every body. One-line fix owned by each app lane:server.closeAllConnections()in theafter()hook.They are quarantined in
.github/app-ci/quarantine.json— named, with root cause + fix, and shrink-only: the runner assertslen(quarantined) <= ceiling(currently 2) on every run and fails if it grows; stale entries (app/test gone) also fail, forcing pruning. This is a quarantine, not a silent skip.Proof (local; not pushed to watch CI)
Runner exit codes, direct capture:
PER_APP_TIMEOUT=5→ 1 via exit 124, whole run bounded at 9s (proves a hang is RED, never a hang).Out of scope / not touched
apps/health-twin/src/**,gitops-promote.yml,images.yml,infra/**,tools/,ci.yml,Makefile.DO NOT MERGE pending review.