Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<package> <upper-bound major>", 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 <bound` value.
# Anything else — a `||` union, a missing bound, reindented YAML — is
# 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 || 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

Expand Down
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 7 additions & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 9 additions & 1 deletion renovate.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Loading