diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e572845..ca3c5d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,52 @@ jobs: with: node-version: '22' + # renovate.json's approval gate covers routine majors only; a + # vulnerability-alert update overrides repo config and gets through + # (PR #94 did), so this step is the layer that actually holds — for bots + # and humans alike. Rationale for both layers: CONTRIBUTING.md. + # Deliberately ahead of `pnpm install`: it needs no node_modules, and an + # install script must not get to rewrite the file before the check reads + # it. (The brace-expansion step below reads pnpm-lock.yaml after install + # and has the same exposure — pre-existing, untouched here.) + - name: Verify bounded floor-pins stay in-major + run: | + set -euo pipefail + # Declared bounds, " ", 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 || 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." + 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 + } + ] }