From 440475001cc7ddbed2db90dd28ea6cbf6ecf45bb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 17:41:43 +1000 Subject: [PATCH 1/2] ci(deps): guard pnpm-workspace floor-pin upper bounds (LAB-1513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `overrides` upper bound in pnpm-workspace.yaml is a decision (LAB-1020), and four are load-bearing — widening `uuid` past `<12` alone breaks Node <22.12, which `engines` allows and CI on 22-latest would never catch. Renovate proposes exactly that: a 43.288.0 dry-run against this tree has cross-major widenings queued on all four bounded pins right now (protobufjs <8->9, brace-expansion@2 <3->6, js-yaml <5->6, uuid <12->15). Until now the only defence was prose asking reviewers to look harder. Two layers, because neither is sufficient alone. renovate.json gates cross-major updates to these entries behind dependency-dashboard approval. Matching on depType (`pnpm-workspace.overrides`) rather than matchFileNames scopes it to the override entries themselves, so a pnpm catalog added to the same file later is not silently swept in, and same-major floor moves still flow freely. That is prevention: no branch is ever created. But it covers routine majors only. A GitHub vulnerability-alert update carries `force: {...vulnerabilityAlerts}`, applied last by mergeChildConfig, which resets `dependencyDashboardApproval` to false — and, with this repo's preset setting `vulnerabilityAlerts.enabled`, `enabled` back to true — regardless of repo config; `force` is globalOnly so it cannot be countered from renovate.json at all. Verified by executing Renovate's own applyPackageRules with the alert-rule shape vulnerability.ts builds. PR #94 came through that path, so the config rule alone would not have stopped the PR that prompted this work. So the CI step is what actually closes it. It declares the bounded pins and their upper-bound majors and requires the file to match that set exactly. An earlier revision derived the rule instead (` ", sorted. Adding or + # removing a bounded pin means editing this list — that edit IS the + # human decision the bound represents. + expected='brace-expansion@2 3 + js-yaml 5 + protobufjs 8 + uuid 12' + expected=$(printf '%s\n' "${expected}" | sed 's/^ *//') + + # Slice the overrides block (top-level key to next top-level key or + # EOF), drop comments, then match ONLY a plain `>=floor =[0-9][0-9.]*[[:space:]]+<([0-9]+)(\.[0-9.]*)?['\"].*/\1 \2/p" \ + | sort) + + if [ "${found}" != "${expected}" ]; then + echo "::error::Bounded floor-pins in pnpm-workspace.yaml no longer match the set this check declares. A bound is a deliberate constraint, not drift (see the notes beside each pin, and CONTRIBUTING.md). If crossing a major is genuinely required, remove the bound in a hand-written commit that says why and update the expected list in this step." + echo "--- expected ---"; printf '%s\n' "${expected}" + echo "--- found ---"; printf '%s\n' "${found:-(none — the parser matched nothing; the file shape moved)}" + exit 1 + fi + echo "Bounded floor-pins verified (package upper-bound-major):" + printf '%s\n' "${found}" | sed 's/^/ /' + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 483831c..5eebb5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,12 @@ If you need stable, depend on a published version on npm. **Vulnerable transitives have no bot.** Find them with `pnpm audit`, then floor-pin them in `overrides` in `pnpm-workspace.yaml` — bounded to the major of the version you pin, because an unbounded floor re-resolves into new majors. Maintainers additionally watch the repo's Dependabot alerts (that page needs write access, so `pnpm audit` is the check to run from a fork). -Two traps in that loop. Pinning hands the dependency _back_ to Renovate — an `overrides` entry reads as a manifest dep — but Renovate will also propose **widening** an upper bound across majors, as open PR #94 does to `brace-expansion@2`. An upper bound here is a deliberate decision; re-derive it before approving a bot PR that moves one. And Dependabot's _alert feed_, though still enabled, is not a Renovate input: as of 2026-07-30 the acting Renovate App lacks `vulnerability_alerts: read`, which is what the `Cannot access vulnerability alerts` warning on every Dependency Dashboard reports. Granting that scope would silence the warning and let Renovate prioritise advisories on deps it can already see — it would not produce transitive fix PRs, because that capability is gone upstream regardless. +Two traps in that loop. Pinning hands the dependency _back_ to Renovate — an `overrides` entry reads as a manifest dep, `depType` `pnpm-workspace.overrides` — but Renovate will also propose **widening** an upper bound across majors, as [#94](https://github.com/cachekit-io/cachekit-ts/pull/94) did to `brace-expansion@2` (`'>=2.1.3 <3'` → `'<6'`). That PR autoclosed; the behaviour that produced it did not change. An upper bound here is a deliberate decision, and it is now enforced in two layers rather than left to reviewer vigilance: + +- `renovate.json` puts cross-major updates to `pnpm-workspace.overrides` entries behind **dependency-dashboard approval** — they still show up under _Pending Approval_, but no branch exists until a human clicks. This covers routine majors only. A GitHub vulnerability-alert update carries `force: {...vulnerabilityAlerts}`, which resets `dependencyDashboardApproval` to `false` no matter what repo config says (`force` is `globalOnly`), so this layer would _not_ have stopped [#94](https://github.com/cachekit-io/cachekit-ts/pull/94) — that PR came through the alert path. +- CI's **Verify bounded floor-pins stay in-major** step declares the bounded pins and their upper-bound majors, and fails the security job unless the file still matches that set exactly. It catches widening, bound deletion, a floor moved into a new major, an undeclared new bounded pin, and any rewrite into a range shape it cannot parse (a `||` union, say) — anything unrecognised drops out of the comparison and fails closed. This is the layer that holds against vulnerability-alert PRs and hand-written diffs. A genuinely necessary cross-major security fix — as `uuid` needed when no in-major fix existed — stays possible: CI goes red, you read why, and you remove the bound and its entry in that step in a commit that says so. Red is the discovery channel, not a wall. + +And Dependabot's _alert feed_, though still enabled, is not a Renovate input: as of 2026-07-30 the acting Renovate App lacks `vulnerability_alerts: read`, which is what the `Cannot access vulnerability alerts` warning on every Dependency Dashboard reports. Granting that scope would silence the warning and let Renovate prioritise advisories on deps it can already see — it would not produce transitive fix PRs, because that capability is gone upstream regardless. **Release-age quarantine**: `pnpm-workspace.yaml` pins `minimumReleaseAge: 1440` (24 h). `pnpm install --frozen-lockfile` in CI rejects any lockfile entry younger than that — so a lockfile refresh that picks up a just-published version will fail CI until the release ages past the window. pnpm applies the same window when resolving, so a plain `pnpm install` on your machine normally picks mature versions automatically. Two cases still fail locally: a lockfile generated by a tool that ignores the window, and a range with **no** aged-in candidate at all — if every version satisfying a dependency (or an `overrides` floor) is younger than 24 h, resolution has nothing legal to pick and errors out rather than falling back. For a security backport that can't wait out the window — the usual cause of the second case — add a _version-scoped_ entry to `minimumReleaseAgeExclude` with a comment saying when it can be removed. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de75fa4..273fd3e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,10 +54,13 @@ allowBuilds: # Floors are bounded to the major of the version they pin, where a newer major # exists — an unbounded floor re-resolves to the newest match, majors included. # Renovate takes these entries over once they exist (they read as manifest deps) -# but its idea of maintenance includes WIDENING the bound across majors: see the -# open PR #94, which proposes 'brace-expansion@2': '>=2.1.3 <3' -> '<6'. Review -# every bot PR that touches an upper bound here — a bound is a decision, not -# drift. +# and its idea of maintenance includes WIDENING the bound across majors — PR #94 +# proposed 'brace-expansion@2': '>=2.1.3 <3' -> '<6'. +# ENFORCED, not just documented: ci.yml's "Verify bounded floor-pins stay +# in-major" step fails the security job if any bound below moves, disappears, or +# is rewritten into a shape it cannot read. A bound is a decision — crossing one +# means removing it in a hand-written commit that says why, and updating the +# expected list in that step. Why two layers: CONTRIBUTING.md. overrides: protobufjs: '>=7.6.5 <8' # GHSA-j3f2-48v5-ccww vite: '>=8.0.5' diff --git a/renovate.json b/renovate.json index eb7ac3b..ee8c3b1 100644 --- a/renovate.json +++ b/renovate.json @@ -1,4 +1,12 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>cachekit-io/renovate-config"] + "extends": ["local>cachekit-io/renovate-config"], + "packageRules": [ + { + "description": "Upper bounds on pnpm-workspace.yaml overrides are decisions (LAB-1020), not drift, so cross-major proposals wait under Pending Approval instead of opening a branch. Routine majors only — the vulnerability-alert path overrides this and gets through, which is why ci.yml's 'Verify bounded floor-pins stay in-major' step is the real gate. Rationale: CONTRIBUTING.md.", + "matchDepTypes": ["pnpm-workspace.overrides"], + "matchUpdateTypes": ["major"], + "dependencyDashboardApproval": true + } + ] } From efd0d5d930512d2219bbe102814a736d9e438aae Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 20:06:11 +1000 Subject: [PATCH 2/2] ci(deps): let the floor-pin guard explain a shape-moved failure (LAB-1513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `found=$(...)` pipeline runs under `set -o pipefail`. Its `grep -v` stage exits 1 whenever it selects no lines — which is precisely the "file shape moved" case the guard exists to catch (overrides header renamed, block entirely comments, file unreadable). Under pipefail plus `set -e` that aborted the step at the grep stage, before the set comparison and its `::error::` remediation text could run, so the guard failed closed but silently: a bare exit-1 with nothing telling the reader which pin moved or what to do about it. `|| true` on the substitution, mirroring the sibling "Verify brace-expansion advisory-ignore invariant" step, which already solves this and documents why. It cannot mask real drift: a broken pipeline yields empty or partial output, which never equals `expected`, so the explicit branch still fails — now with its explanation intact. Chosen over folding the comment filter into the awk program, which would drop the grep stage but leave a missing/unreadable pnpm-workspace.yaml aborting bare — the same contract violation by another door. Mutation matrix re-run against the step body extracted from the YAML, 15/15: the four shape-moved cases now emit the diagnostic (3 did not before), and every drift case still fails while every legitimate edit still passes. Reported by Kody, PR #101 review 2026-08-05. --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff89489..ca3c5d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,10 +168,16 @@ jobs: # deliberately NOT matched, so it drops out of `found` and fails the # set comparison below. Unrecognised shape must fail closed: a parser # that quietly skips what it cannot read is not a guard. + # `|| true`, as in the sibling step below: the `grep -v` exits 1 when + # it selects nothing — exactly the shape-moved case this guard exists + # to catch — and under pipefail that would abort here with no message. + # Let the empty result reach the explicit branch so the failure + # explains itself. It cannot mask a real drift: a broken pipeline + # yields empty or partial output, which never equals `expected`. found=$(awk '/^overrides:[[:space:]]*$/{inblk=1;next} inblk && /^[^[:space:]#]/{inblk=0} inblk' pnpm-workspace.yaml \ | grep -v '^[[:space:]]*#' \ | sed -nE "s/^ ['\"]?([^'\":]+)['\"]?:[[:space:]]*['\"]>=[0-9][0-9.]*[[:space:]]+<([0-9]+)(\.[0-9.]*)?['\"].*/\1 \2/p" \ - | sort) + | sort || true) if [ "${found}" != "${expected}" ]; then echo "::error::Bounded floor-pins in pnpm-workspace.yaml no longer match the set this check declares. A bound is a deliberate constraint, not drift (see the notes beside each pin, and CONTRIBUTING.md). If crossing a major is genuinely required, remove the bound in a hand-written commit that says why and update the expected list in this step."