From 6b058b77140eab8374e52f1503fec26454071570 Mon Sep 17 00:00:00 2001 From: stxkxs <139715017+stxkxs@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:26:11 -0700 Subject: [PATCH] feat: hold the update floor above what the package managers accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared preset set no minimumReleaseAge, so Renovate proposed a dependency the moment it was published. pnpm 11 refuses one that new. The two settings had never been compared, and they disagree in a way that surfaces far from either. ─── Why it matters more than it looks ─── pnpm 11 defaults minimumReleaseAge to 1440 minutes, and — the part that makes this sharp — it re-verifies EVERY entry in the lockfile on every install, not just the entries being added. A single package younger than the floor anywhere in the tree fails `pnpm install --frozen-lockfile`, so the red lands on whatever pull request happens to run next, having changed nothing related to it. That means the disagreement does not fail where it is caused. It fails somewhere else, later, on someone else's change. ─── The floor ─── minimumReleaseAge is three days: above pnpm's one, with headroom, and free against the weekly schedule the preset already runs on. It is also worth having on its own terms — a compromised publish is usually caught within hours, and nothing here needs to be the first consumer of a release. vulnerabilityAlerts states minimumReleaseAge: null rather than inheriting it. Renovate already defaults that way; writing it down means a later edit to the top-level floor cannot quietly start delaying CVE fixes. A security PR whose fix is itself hours old may still be refused by pnpm until it ages out — opening it immediately is the point, and it goes green on a re-run. ─── A check, because the validator cannot do this one ─── renovate-config-validator checks shape. It accepts `minimumReleaseAge: "3 bananas"` without complaint, because the schema says "string" and stops there, and it has no opinion on whether the value agrees with the tools that consume the lockfiles Renovate writes. scripts/check-renovate-floor.py asserts what the schema cannot: that the floor parses as a duration, that it is at or above pnpm's, and that the security path is explicitly exempt. `--self-test` breaks the config nine ways and fails unless every break is rejected, with the shipped config as the control — a gate nobody has watched fail is not known to be a gate. Both run in CI, alongside the validator in --strict mode. default.json is consumed by every repository in the org and until now nothing read it before it shipped. The new job joins the merge gate's needs, so it cannot be added and then quietly ignored. --- .github/workflows/merge-gate-action.yml | 22 ++- default.json | 7 +- scripts/check-renovate-floor.py | 177 ++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 scripts/check-renovate-floor.py diff --git a/.github/workflows/merge-gate-action.yml b/.github/workflows/merge-gate-action.yml index 83d4602..aa73d47 100644 --- a/.github/workflows/merge-gate-action.yml +++ b/.github/workflows/merge-gate-action.yml @@ -27,6 +27,26 @@ jobs: - name: it parses run: python3 -c "import yaml,sys; yaml.safe_load(open('actions/merge-gate/action.yml'))" + # default.json is consumed by every repository in the org, and until now + # nothing looked at it before it shipped. + renovate-preset: + name: renovate preset is valid and holds its floor + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: the schema + run: npx --yes --package renovate renovate-config-validator --strict default.json + # The validator only knows the schema. It accepts `minimumReleaseAge: + # "3 bananas"` because the schema says "string" and stops there, and it has + # no opinion at all on whether the value agrees with the package managers + # that consume the lockfiles Renovate writes. That agreement is the actual + # invariant, so it gets its own check — and a self-test, because a gate + # nobody has watched fail is not known to be a gate. + - name: the floor, and proof the check can fail + run: | + python3 scripts/check-renovate-floor.py + python3 scripts/check-renovate-floor.py --self-test + # This job is three things at once, deliberately. # # It is this repository's own required check. It is also the only place the @@ -43,7 +63,7 @@ jobs: merge-gate: name: merge gate runs-on: ubuntu-latest - needs: [test, manifest] + needs: [test, manifest, renovate-preset] if: always() steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/default.json b/default.json index 2572e82..835bd66 100644 --- a/default.json +++ b/default.json @@ -4,7 +4,9 @@ "Shared Renovate posture for every nanohype repo. Consume it with a one-line renovate.json: {\"extends\": [\"github>nanohype/.github\"]}.", "This exists because the posture was previously copy-pasted into six repos and had already drifted: five carried automerge rules and eks-gitops carried none, and one copy needed a config migration the others did not. A tooling preset belongs in one consumed place, not in per-repo copies.", "ORDERING HAZARD, read before adding a repo-local packageRule: a consumer's packageRules are appended AFTER the ones here, and later rules win. The security rule at the end of this array is what keeps CVE-triggered updates out of automerge, so a local rule that sets automerge:true would land after it and defeat it. Any local rule enabling automerge must re-exclude isVulnerabilityAlert itself.", - "TITLE CONTRACT: 'deps' is the semantic SCOPE, never the semantic TYPE. Renovate's PR title is the first thing a conventional-commit gate reads, and a type outside that gate's allowed list makes every PR this preset opens unmergeable in that repo — with no signal anywhere else, because a repo without such a gate merges the same title happily. Keep the type inside the conventional set (chore); express what is being updated in the scope." + "TITLE CONTRACT: 'deps' is the semantic SCOPE, never the semantic TYPE. Renovate's PR title is the first thing a conventional-commit gate reads, and a type outside that gate's allowed list makes every PR this preset opens unmergeable in that repo — with no signal anywhere else, because a repo without such a gate merges the same title happily. Keep the type inside the conventional set (chore); express what is being updated in the scope.", + "AGE FLOOR: minimumReleaseAge holds routine updates for three days. Partly that is supply-chain posture — a compromised publish is typically caught and unpublished within hours, and no dependency here is urgent enough to be worth being the first consumer of. Mostly it is so this preset stops proposing work the package managers will refuse. pnpm 11 defaults minimumReleaseAge to 1440 minutes AND re-verifies every entry in the lockfile on every install, not just the ones being added, so a single too-young package anywhere in the tree fails `pnpm install --frozen-lockfile` in repos that never touched it. Renovate's floor has to stay at or above the package manager's, or Renovate writes lockfiles that its own CI rejects. Three days leaves headroom over pnpm's one, and costs nothing against the weekly schedule already in force.", + "The security path opts out on purpose: vulnerabilityAlerts sets minimumReleaseAge to null so a CVE fix is proposed the moment it exists. When the fix itself is hours old that can still produce a PR pnpm declines to install until it ages out. Opening it immediately is the point — the PR is the signal a human needs — and it goes green on a re-run without any config change." ], "extends": [ "config:recommended", @@ -15,6 +17,7 @@ "group:allNonMajor" ], "timezone": "America/Los_Angeles", + "minimumReleaseAge": "3 days", "labels": ["deps"], "prHourlyLimit": 4, "prConcurrentLimit": 8, @@ -129,8 +132,10 @@ } ], "vulnerabilityAlerts": { + "description": "minimumReleaseAge is null here on purpose, stated rather than inherited: the top-level three-day floor must not delay a CVE fix. Renovate already defaults this block that way, and writing it down keeps a future edit to the top-level floor from silently acquiring a delay on the security path.", "labels": ["security", "deps"], "automerge": false, + "minimumReleaseAge": null, "schedule": ["at any time"] }, "osvVulnerabilityAlerts": true diff --git a/scripts/check-renovate-floor.py b/scripts/check-renovate-floor.py new file mode 100644 index 0000000..12e7779 --- /dev/null +++ b/scripts/check-renovate-floor.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""The shared preset's age floor stays at or above the package managers'. + +`renovate-config-validator` checks that the config is *shaped* correctly. It does +not check that it is *coherent with the tools that consume its output* — it +accepts `minimumReleaseAge: "3 bananas"` without complaint, because the schema +says "string" and stops there. + +The invariant that matters is not in any schema. pnpm 11 defaults +`minimumReleaseAge` to 1440 minutes and re-verifies every entry of the lockfile +on each install, not only the entries being added. So if Renovate's floor sits +below pnpm's, Renovate will eventually author a lockfile that pnpm then refuses +to install — and the failure lands in whichever repository happens to run CI +next, on a pull request that changed nothing related. + +Renovate's floor must therefore be >= pnpm's. That is what this asserts. + +Run with --self-test to break the inputs and confirm each break is rejected. A +gate that has never been seen to fail is not known to be a gate. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import sys + +# pnpm 11's default `minimumReleaseAge`, in minutes. If pnpm's default moves, or +# a repo sets its own lower floor, this constant is the thing to revisit — the +# comparison below is only as honest as this number. +PNPM_FLOOR_MINUTES = 1440 + +# Renovate accepts duration strings such as "3 days" / "6 months" / "1 year". +UNITS = { + "minute": 1, + "hour": 60, + "day": 1440, + "week": 10080, + "month": 43800, # Renovate treats a month as ~30.4 days + "year": 525600, +} + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +class Rejected(Exception): + """A checked invariant does not hold.""" + + +def parse_duration(text: object) -> int: + """Duration string -> minutes. Raises Rejected on anything unparseable.""" + if not isinstance(text, str): + raise Rejected(f"minimumReleaseAge must be a duration string, got {text!r}") + m = re.fullmatch(r"\s*(\d+)\s+(minute|hour|day|week|month|year)s?\s*", text) + if not m: + raise Rejected( + f"minimumReleaseAge {text!r} is not a duration Renovate parses. " + f"Expected e.g. '3 days'. The config validator accepts this string " + f"because the schema only says 'string', so nothing else catches it." + ) + return int(m.group(1)) * UNITS[m.group(2)] + + +def check(cfg: dict) -> list[str]: + """Returns the list of things checked. Raises Rejected on the first failure.""" + checked = [] + + if "minimumReleaseAge" not in cfg: + raise Rejected( + "the preset sets no minimumReleaseAge. Renovate would then propose " + "packages published minutes ago, and pnpm would refuse the lockfile " + "they land in." + ) + minutes = parse_duration(cfg["minimumReleaseAge"]) + checked.append(f"minimumReleaseAge {cfg['minimumReleaseAge']!r} parses to {minutes} minutes") + + if minutes < PNPM_FLOOR_MINUTES: + raise Rejected( + f"minimumReleaseAge is {minutes} minutes, below pnpm's " + f"{PNPM_FLOOR_MINUTES}. Renovate would open PRs whose lockfiles pnpm " + f"rejects, and the failure would surface in unrelated pull requests." + ) + checked.append(f"{minutes} >= pnpm's floor of {PNPM_FLOOR_MINUTES} minutes") + + va = cfg.get("vulnerabilityAlerts") + if not isinstance(va, dict): + raise Rejected("vulnerabilityAlerts is missing, so the security path is unstated.") + if "minimumReleaseAge" not in va: + raise Rejected( + "vulnerabilityAlerts does not state minimumReleaseAge. Renovate's own " + "default is null, but leaving it implicit means raising the top-level " + "floor would silently start delaying CVE fixes." + ) + if va["minimumReleaseAge"] is not None: + raise Rejected( + f"vulnerabilityAlerts.minimumReleaseAge is {va['minimumReleaseAge']!r}, " + f"not null. A security fix must not wait behind the routine floor." + ) + checked.append("vulnerabilityAlerts.minimumReleaseAge is explicitly null") + + return checked + + +def self_test(cfg: dict) -> int: + """Break the config every way the checks claim to catch. Each must be rejected.""" + def without(key): + d = json.loads(json.dumps(cfg)) + d.pop(key, None) + return d + + def with_top(value): + d = json.loads(json.dumps(cfg)) + d["minimumReleaseAge"] = value + return d + + def with_va(value, present=True): + d = json.loads(json.dumps(cfg)) + if present: + d["vulnerabilityAlerts"]["minimumReleaseAge"] = value + else: + d["vulnerabilityAlerts"].pop("minimumReleaseAge", None) + return d + + breaks = [ + ("no floor at all", without("minimumReleaseAge")), + ("floor below pnpm's", with_top("6 hours")), + ("floor exactly one minute short", with_top("1439 minutes")), + ("unparseable duration", with_top("3 bananas")), + ("numeric instead of duration string", with_top(4320)), + ("null floor", with_top(None)), + ("security path delayed", with_va("3 days")), + ("security path left implicit", with_va(None, present=False)), + ("vulnerabilityAlerts removed", without("vulnerabilityAlerts")), + ] + + failures = [] + for label, broken in breaks: + try: + check(broken) + except Rejected: + print(f" rejected {label}") + else: + failures.append(label) + print(f" ACCEPTED {label} <-- the check does not catch this") + + # The self-test is itself worthless if the unbroken config does not pass. + try: + check(cfg) + except Rejected as e: + failures.append(f"the real config does not pass: {e}") + print(f" ACCEPTED (control) the shipped config is rejected: {e}") + else: + print(" passed (control) the shipped config") + + if failures: + print(f"\nFAIL {len(failures)} break(s) were not caught.") + return 1 + print(f"\nOK all {len(breaks)} breaks rejected, and the shipped config passes.") + return 0 + + +def main() -> int: + cfg = json.loads((ROOT / "default.json").read_text()) + if "--self-test" in sys.argv: + return self_test(cfg) + try: + for line in check(cfg): + print(f"OK {line}") + except Rejected as e: + print(f"FAIL {e}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())