ADR-0001: Refresh transitive lockfile pins on a schedule¶
- Status: Accepted
- Date: 2026-02-27
- Amended: 2026-09-10, 2026-10-16, 2026-10-29 — see the notes under Context
- Deciders: Repository maintainer
- Supersedes: the retired
dependency-check.ymlworkflow
Context¶
Dependabot raises the versions declared in package.json. Nothing raised the transitive versions recorded inside package-lock.json once their parents' ranges already allowed a newer release. npm only re-resolves those entries when something regenerates the lockfile, and no routine job did.
This is not a theoretical gap. It stopped work:
browserslistwas pinned at4.28.2in the committed lockfile.- Its parent,
header-generator, declaresbrowserslist: ^4.21.1— a range that already allowed the patched4.28.8. - The chain is entirely transitive:
browserslist←header-generator←fingerprint-generator←@hieutran094/camoufox-js←@sergienko4/israeli-bank-scrapers. No file in this repository names it. - A CVE against
4.28.2then failed bothnpm auditand Trivy against the production tree, and blocked four unrelated Dependabot pull requests at once — none of which had anything to do withbrowserslist.
Two escape hatches were considered and neither existed. classifyAdvisories() in config/check-audit.mjs deliberately forces production-tree advisories into violations, so no waiver entry could clear it, and Trivy does not read that policy file at all. The advisory could only be cleared by moving the pin.
Amended 2026-09-10. The sentence above describes the policy as it stood on the decision date and is no longer current.
classifyAdvisoriesnow lives inconfig/audit-policy.mjsand recognises a second, deliberately narrower class: a production-tree advisory may be accepted only when the entry admits reachability, records that no upstream fix exists, carries a tracking URL, states a rationale, and expires within 30 days. The decision this ADR records is unaffected — regeneration remains the primary remedy, and the acceptance class exists only for advisories that regeneration cannot fix, such asGHSA-vwc7-r8mq-g2x9inadm-zip, which had no patched release to move to until 0.6.1.Amended 2026-10-16. ADR-0002 changes how the refresh lands, not what it does. The workflow now closes older open refresh pull requests instead of skipping when one exists — but only once its own replacement is open or
mainalready carries every pin. It runs a non-fatalnpm audit fix --package-lock-only --ignore-scriptsafter the transitive refresh, and arms squash auto-merge on the commit it pushed, unless a pin crossed a major, which is labelledneeds-manual-reviewinstead. Arming pins only the commit it saw, so a "Restrict updates" ruleset on refresh branches blocks later pushes, and a companion workflow,lockfile-refresh-guard.yml, withdraws auto-merge and applies the same label, best effort, when a push gets through anyway. Dependabot now scans npm daily; Docker and GitHub Actions stay on Monday. The statements below about the duplicate check, the Monday Dependabot batch and the review burden describe the original decision. So does the claim thatnpm updatecannot cross a major: it stays inside each parent's declared range, but a nested entry can still land on a new major when an updated parent asks for one, which is why majors are detected.Amended 2026-10-29. ADR-0003 widens the refresh past "within ranges parents already permit" for one case: an advisory on a transitive package that its parent pins exactly. The refresh now raises an
overridesfloor for it under fixed rules, regenerates only the floored packages, proves the advisory cleared, and verifies thatpackage.jsonchanged only insideoverrides. A newadvisoryjob auditsmainevery four hours and starts the refresh when the audit is red, so the seven-day window in Consequences no longer applies to advisories. Weekly and dispatched runs also drop overrides that no longer change a locked version, which answers the "grows monotonically" objection to floors in Alternatives considered.
The decisive detail is that the fix was already inside the allowed range. Regeneration alone would have produced 4.28.8. The lockfile had simply never been refreshed. Adding overrides cleared that one advisory, but an overrides floor is inert on its own — it is applied only when the lockfile is regenerated. Shipping only the floor would have left the mechanism that caused the outage completely untouched, and the next stale pin would again be discovered by a CVE landing on unrelated work.
A second, independent problem surfaced while fixing the first. Running npm behind the corporate proxy rewrote resolved URLs to *.pkgs.visualstudio.com and substituted sha1- integrities for sha512-. Both are invisible in review — a lockfile diff is thousands of lines — and a sha1 integrity is a genuine downgrade, since SHA-1 is collision-broken.
Decision¶
Regenerate the lockfile on a schedule, and verify in CI that what lands is canonical.
-
.github/workflows/lockfile-refresh.ymlrunsnpm update --package-lock-only --ignore-scriptsevery Thursday at 05:00 UTC (plusworkflow_dispatch), and opens a pull request when anything moved. -
npm run lint:lockfile(scripts/refresh-lockfile.mjs --check) runs insidevalidate:ci, failing the build on a foreign registry host, asha1integrity, or a registry tarball with no integrity.npm run refresh-lockfilerepairs foreign hosts in place; missing or downgraded integrity fails closed, because only a run against the canonical registry can restore the hash (the scheduled refresh'snpm updatedoes). -
The commit type is derived from the lockfile, not hard-coded.
summarizeRefresh()marks the changefix(deps)when any non-deventry moved andchore(deps)otherwise.
Point 3 is load-bearing and easy to get wrong. npm update is documented not to change package.json:
by default
npm updatewill not update the semver values of direct dependencies in your projectpackage.json— https://docs.npmjs.com/cli/v11/commands/npm-update
The release-signal guard (scripts/release-signal-logic.mjs) compares package.json and the Dockerfile. A lockfile-only change is therefore invisible to it. Hard-coding chore would ship a transitive runtime change to users with no release — precisely the failure that guard exists to prevent — so the type is computed from which entries moved instead.
Scope is deliberately narrow: transitive refresh within ranges parents already permit. npm update cannot cross a major version, and raising a declared range remains Dependabot's job. This is not a Dependabot replacement.
Supporting decisions¶
-
Thursday, not Monday. GitHub evaluates
schedulein UTC only; unlike Dependabot, it has notimezonekey. Monday is already crowded — Dependabot batches at 06:00 Asia/Jerusalem, ande2e-schedule.ymlandstale.ymlboth fire that morning. Mid-week keeps this pull request out of that pile-up and leaves working days to react. -
RELEASE_TOKEN, notGITHUB_TOKEN. GitHub does not raise events for work pushed with the workflow token:
if a workflow run pushes code using the repository's
GITHUB_TOKEN, a new workflow will not run — https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow
Every gate in pr.yml would be skipped, and the refresh would look green without having been tested at all. The token is confined to the single step that pushes and opens the pull request.
-
One open refresh at a time. Without the duplicate check the workflow stacks a near-identical pull request weekly, and the oldest — the one whose CI has already run — looks the stalest.
-
sha1rejection is policy, not format. The lockfile format permits "asha512orsha1… string". Rejectingsha1is a deliberate choice: in this repository it has only ever appeared as a symptom of mirror substitution, and SHA-1 is collision-broken.
Alternatives considered¶
| Alternative | Why rejected |
|---|---|
overrides floors alone | What was shipped first, and insufficient by itself. A floor is only applied when the lockfile is regenerated, so with no scheduled regeneration it is inert — it fixes today's advisory and nothing else. It also grows monotonically: every floor is a hand-maintained entry that no process ever removes. |
| Dependabot alone | Cannot close this gap. Dependabot raises declared ranges; the failure was a transitive entry already inside its parent's range, which Dependabot has no reason to touch. header-generator's ^4.21.1 was never wrong. |
Revive dependency-check.yml | Retired for two recorded reasons. Its inability to cross majors is unchanged — but now explicitly out of scope rather than an unstated limitation. Its crash on the scraper's patch-package postinstall cannot recur, because --package-lock-only ignores node_modules and downloads nothing, so no install script exists to run. Restoring it as-is would restore the confusion about what it was for. |
| Renovate's lockfile maintenance | Does exactly this, and well. Rejected because it adds a second dependency bot alongside Dependabot, with its own configuration, permissions and failure modes, to solve one narrow problem that a small workflow already solves using tooling the repository has. Worth revisiting if lockfile maintenance ever stops being the only thing missing. |
| Pin every transitive dependency exactly | Converts an occasional stale pin into permanent manual maintenance of hundreds of entries, and guarantees the staleness this ADR exists to prevent. |
| Repair the lockfile by hand when audit fails | The status quo that failed. It is reactive by construction — the trigger is a CVE blocking unrelated work — and hand-editing integrity hashes is exactly how a sha1 downgrade gets committed unnoticed. |
Consequences¶
Positive
- Stale transitive pins surface on a schedule instead of via a CVE that blocks unrelated pull requests.
- The refresh arrives as a reviewable pull request listing every version change, split into runtime and development, rather than an opaque diff.
- A runtime bump is correctly typed
fix(deps), so release-please cuts a release and the change actually reaches users. - Mirror-rewritten
resolvedURLs andsha1downgrades now fail CI rather than merging invisibly. - The workflow installs and executes no third-party code:
--package-lock-only"will only use thepackage-lock.json, ignoringnode_modules", so no tarball is downloaded and no install script runs. That makes it strictly safer thandependency-bump.yml, whose header records an unresolved token-exposure concern from running a script withnode_modules/.binonPATH.
Negative
- A recurring pull request to review. Mitigated by the duplicate check (never more than one open) and by the body naming every changed package.
- Weekly cadence means a window of up to seven days between a fix being published and being picked up.
workflow_dispatchcovers the urgent case. - GitHub disables scheduled workflows after 60 days of repository inactivity. Silent by design; the repository is active enough that this is unlikely, but a long quiet period would stop the refresh without an error.
- A runtime refresh now produces a release that would previously not have happened. That is the intent, not a side effect, but it does raise release frequency.
Neutral
scripts/*.mjssits outside the coverageinclude(src/**), so the 90/95/90/90 thresholds do not apply. The logic is covered bytests/refresh-lockfile-logic.test.tsregardless.