Skip to content
Merged
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
22 changes: 21 additions & 1 deletion .github/workflows/merge-gate-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -15,6 +17,7 @@
"group:allNonMajor"
],
"timezone": "America/Los_Angeles",
"minimumReleaseAge": "3 days",
"labels": ["deps"],
"prHourlyLimit": 4,
"prConcurrentLimit": 8,
Expand Down Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions scripts/check-renovate-floor.py
Original file line number Diff line number Diff line change
@@ -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())