ci: run the npm audit gate on releases only; retry transient faults - #8362
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Fixes both named root causes (cold-runner download, terminal single attempt) while keeping every definitive failure — including exit 1 — fail-closed and unretried, inside a budget the job timeout can absorb. [DESIGN-REVIEWED] 99f363a |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All checks are done. The change is a fix for a reported, reproducible defect with two named causes, both addressed; nothing duplicates an in-repo mechanism, and the docs edits are mandated by the same-commit spec rule. Here is the review. First-Principles-Verdict: PASS A reported flake (#8356 ×3, #8282) gets its two counted causes fixed — cold-download inside the audit timeout, and no transient/definitive distinction — nothing rides along. What this change shipsIntent: stop a slow npm registry from failing the dependency-audit gate so maintainers stop hand-rerunning a red X — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 99f363a |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
99f363a to
661917f
Compare
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- scripts/check_npm_audit.py:516 -- |
661917f to
0493524
Compare
0493524 to
039dbbd
Compare
`Dependency Audit / Audit Production Dependencies` fails closed with "npm audit timed out after 120s for website/package-lock.json" on a slow registry, and the failure then has to be re-run by hand -- three times in a row on one pull request tonight, and repeatedly on others. Two things made a registry hiccup into a red X: * On a cold runner `npx --yes npm@10.8.2` first DOWNLOADS that npm, inside the audit's own 120s timeout, before a single advisory is asked for. The first lockfile paid for the download, so it was the one that timed out. * Every failure was terminal. The audit is a read-only, idempotent query, so a stall or connection fault is exactly the class of failure a bounded retry answers -- but the gate ran once and gave up. The pinned npm is now resolved once up front (`npx --yes npm@10.8.2 --version`, verified to print exactly the pinned version, so a mismatch fails closed) before any audit runs. Each audit attempt gets a 180s ceiling -- on a degraded registry one lockfile has taken just over 120s and then COMPLETED on its next attempt, so 120s left no headroom for a slow-but-finishing registry; an attempt that times out, raises a subprocess error, or exits with a status other than npm's documented 0/1 results AND carries one of npm's connection-level markers on stderr is retried up to three times with a short backoff. Every attempt of every audit draws on one shared 720s budget: no attempt gets more than the time left and no retry starts unless the budget still holds its backoff plus a full ceiling, so the retries cannot outgrow the job's ceiling, raised from 10 to 15 minutes to hold the budget plus toolchain setup. Exit 0/1 are never treated as transient whatever stderr says -- 1 is the audit answering "vulnerable" -- and every other failure stays definitive and unretried. Exhausting the attempts or the budget fails closed naming the attempt count, so a real registry outage reads as one instead of as a flaky gate. Even so hardened, the gate stayed red on the PR that carried this change: the registry answered the second lockfile in over 180s twice in a row. The audit is therefore removed from the PR path (code-review.yml) and from the nightly (nightly.yml), where the same slow hours had failed the nightly for hours at a stretch and blocked every build behind it. A gate people learn to re-run until green is not a gate. It runs where a vulnerable dependency would actually ship: release.yml, before any wheel or desktop build, so nothing vulnerable is published and a PR that adds or bumps a dependency is checked by the release that would carry it. The retry hardening stays for that caller, and the exception-expiry notice the script prints now reaches whoever cuts the release.
039dbbd to
6611f14
Compare
Problem / Motivation
Dependency Audit / Audit Production Dependenciesfails withnpm audit timed out after 120s for website/package-lock.jsonwhenever the npm registry is slow, and the red X then has to be re-run by hand — three times in a row on #8356 tonight, and repeatedly on other PRs (#8282 had the same).Why it matters
The gate blocks
PR Readiness, nightly and release builds. A flake that needs a maintainer to press "re-run" turns every registry hiccup into a human interruption, and teaches people to treat this gate's red as noise — which is how a real vulnerability gets waved through.What changed (motivation → approach → change)
Symptom → root cause: two things made a registry hiccup into a terminal failure.
npx --yes npm@10.8.2first downloads that npm — inside the audit's own 120s timeout — before a single advisory is asked for. That is why it is always the first lockfile (website/package-lock.json) that times out.Change (
scripts/check_npm_audit.py):warm_npmresolves the pinned npm once up front (npx --yes npm@10.8.2 --version) and fails closed unless it prints exactly the pinned version, so the download is never charged against an audit and a wrong npm cannot audit._with_transient_retriesgives each attempt a 180s ceiling (the previous 120s left no headroom: on this PR's own CI run one lockfile timed out at 120s and then completed on its next attempt — slow, not hung) and retries up toAUDIT_ATTEMPTS(3) with a short backoff when an attempt times out, raises a subprocess error, or exits with a status other than npm's documented 0/1 results and stderr carries one of npm's connection-level markers (TRANSIENT_STDERR_MARKERS:ETIMEDOUT,ECONNRESET,EAI_AGAIN,E503, …). Exit 0/1 are never transient whatever stderr says (1 is the audit answering "vulnerable"); every other failure stays definitive and unretried.Deadline(AUDIT_TOTAL_BUDGET_SECONDS, 720s) threads through the warm-up and every audit: no attempt gets more than the time left, and no retry starts unless the budget still holds its backoff plus a full ceiling (a retry that woke up already short would only time out), so retries cannot outgrow the job's ceiling — raised from 10 to 15 minutes to hold the budget plus checkout/toolchain setup (the test now pinstimeout-minutes * 60 > budget + 120). Exhausting attempts or budget fails closed naming the attempt count, so a persistent registry outage reads as one rather than as a flaky gate.The fail-closed contract is otherwise unchanged: missing tool/manifest/lockfile, malformed reports, undocumented exit statuses and real findings all still fail on the first try.
Releases only. Even so hardened, the gate stayed red on this very PR: the registry answered
website/electron/package-lock.jsonin over 180s twice in a row (see Manual verification). The per-PRdep-auditjob is removed fromcode-review.yml, and the nightly'sdependency-vulnerability-gatejob (with theneeds:on it) fromnightly.yml, where the same slow hours had failed the nightly for hours at a stretch and blocked every build behind it. A gate people learn to re-run until green is not a gate. It runs where a vulnerable dependency would actually ship —release.yml, before any wheel or desktop build — so nothing vulnerable is published, and a PR that adds or bumps a dependency is checked by the release that would carry it. Branch protection pins no check by name andPR Readinessaggregates theCode Reviewworkflow conclusion, so nothing else references the removed jobs; the review-prompt lists of PR checks, the spec, the CI doc and the script's expiry-notice comment are updated to match.Tests
test/test_dependency_vulnerability_gate.py(62 passed):is_transient_failureparametrized: connection markers with exit ≥2 are transient; exit 0/1 never are; a non-network stderr or an empty one is definitive.AUDIT_ATTEMPTStimes with the declared backoff, then fails closed naming "attempt 3 of 3; giving up".ECONNRESET, then a clean report → findings returned after three calls, both retries logged.Deadlineis shared across lockfiles; the workflow'stimeout-minutesclears the budget.code-review.ymlnornightly.ymlcalls the reusable gate orneeds:it;release.ymlstill does, unconditionally, and its build jobs still depend on it.mainwarms before auditing and threads oneDeadlinethrough every call.Manual verification
The CI runs of this PR exercised the real path against a degraded registry. First run (120s ceiling / 480s budget): warm-up passed,
website/package-lock.jsontimed out at 120s on attempt 1 and completed on attempt 2,website/electron/package-lock.jsontimed out twice and the gate failed closed namingattempt 2 of 3; giving upwhen the budget could no longer afford a full retry — exactly the designed behaviour, and the measurement behind the 180s / 720s numbers now in the diff. Second run (180s / 720s):websitepassed first time,website/electrontimed out at 180s twice, andsitewas refused a retry the budget could not hold — the registry was hanging for that lockfile, not merely slow, which is what settled the decision to take the gate off the PR path rather than widen the numbers again. The nightly run five hours earlier, on a healthy registry, took ~1 minute per lockfile.Related Issues
Observed on #8356 (three consecutive timeouts, 2026-09-04) and #8282.
Pattern harvest
Rule candidate: review-prompt
Pattern: a CI gate that shells out to a network service with a single attempt and no distinction between a transient (timeout/connection) failure and a definitive one, so infrastructure noise reads as a code failure.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)