From 7cac780aaad8a1eb2872c80ca84e93912ae349bd Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 2 Nov 2026 18:42:12 +0100 Subject: [PATCH 1/2] Ask nuget.org every week what Dependabot did not propose Dependabot's NuGet updater reports every package that carries an assembly as not compatible with net10.0-windows10.0.17763, logs that no version can be updated, and finishes green. Every NuGet pull request it opened here moved one of the two packages that carry no assembly, so the window's control library, the test framework and the property-based testing library only ever moved when somebody happened to look. outdated.yml runs every Tuesday, the day after Dependabot, and on pull requests that touch it. It restores, lists what NuGet resolved, and asks the registration of each package for its versions and their dates. It is red when the tree is behind a listed version older than the cooldown in dependabot.yml, which it reads rather than repeats. A younger version is listed and is not red. A newer version the tree deliberately does not take goes into .github/outdated/held.toml with the highest version covered, a date and a reason. coverlet.collector 10 is held until 2026-12-01, because the coverage floors need a pull request of their own to measure it. The gate refuses a listing that is missing a tracked project or a referenced package, and a package whose own version is not among the versions it read. Before every run it tests its decisions on known cases, and a package years behind has to come out behind. The comment on the NuGet entry of dependabot.yml now says what that entry does and does not do, measured on the same run. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 16 +- .github/outdated/held.toml | 20 + .github/scripts/outdated_gate.py | 737 +++++++++++++++++++++++++++++++ .github/workflows/outdated.yml | 90 ++++ 4 files changed, 858 insertions(+), 5 deletions(-) create mode 100644 .github/outdated/held.toml create mode 100644 .github/scripts/outdated_gate.py create mode 100644 .github/workflows/outdated.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 928beb18..43d3a8b4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -88,11 +88,17 @@ updates: # traps paid for one at a time, and a major version of it is an afternoon of looking at the # window rather than a tick on a green check. # - # NOT VERIFIED YET, and written down rather than assumed: whether Meziantou.Analyzer, which - # is declared in Directory.Build.props at the root and in no .csproj, is proposed through the - # projects that import that file. It should be - Dependabot evaluates a project together with - # the props files above it - but nothing here proves it, and the way to find out is to look - # at what arrives in the first weeks rather than to argue about it. Backlog row 403. + # Meziantou.Analyzer, which is declared in Directory.Build.props at the root and in no .csproj, + # IS proposed through the projects that import that file. That was written here as not + # verified until the run of 2026-11-02 logged "Updated Meziantou.Analyzer from 3.0.313 to + # 3.0.317 in /Directory.Build.props" once for each of the ten directories. Backlog row 403. + # + # WHAT THIS ENTRY DOES NOT DO, measured in the same run: propose any package that carries an + # assembly. The updater answers "is not compatible. Incompatible project frameworks: + # net10.0-windows10.0.17763" for each of them - CsCheck and WPF-UI among them - and then "No + # updatable version found". Every NuGet pull request it opened here moved Meziantou.Analyzer + # or CsWin32, the two that carry none. .github/workflows/outdated.yml asks nuget.org itself + # every Tuesday, with the cooldown below, and is red for what this entry should have proposed. - package-ecosystem: nuget directories: - "/src/*" diff --git a/.github/outdated/held.toml b/.github/outdated/held.toml new file mode 100644 index 00000000..4751b75e --- /dev/null +++ b/.github/outdated/held.toml @@ -0,0 +1,20 @@ +# Newer NuGet versions the owner decided not to take yet, read by .github/scripts/outdated_gate.py. +# +# One table per package: +# +# [[held]] +# package = "Some.Package" +# through = "2.0.0" # the highest version this decision covers +# until = 2026-12-31 # a date, after which the package is red again +# reason = "Why, in a sentence somebody can check." +# +# A newer version past `through`, or the day after `until`, makes the package red again. So does +# an entry that holds nothing any more, because the package moved or left the tree - a hold +# nobody needs still reads as a decision somebody made. A key the gate does not know is refused +# rather than ignored, so a misspelt `until` cannot hold a version for ever. + +[[held]] +package = "coverlet.collector" +through = "10.1.0" +until = 2026-12-01 +reason = "The coverage floors in tests/coverage-floor.json are held to a tenth of a point, and coverlet 10 counts auto-properties and records differently, so the move needs a pull request of its own whose numbers CI measures (backlog 615)." diff --git a/.github/scripts/outdated_gate.py b/.github/scripts/outdated_gate.py new file mode 100644 index 00000000..fb5b65cb --- /dev/null +++ b/.github/scripts/outdated_gate.py @@ -0,0 +1,737 @@ +#!/usr/bin/env python3 +"""Say which NuGet packages of this repository have a newer version nobody proposed. + + python .github/scripts/outdated_gate.py selftest + python .github/scripts/outdated_gate.py canary + python .github/scripts/outdated_gate.py verdict packages.json --held .github/outdated/held.toml \ + [--summary FILE] + +.github/workflows/outdated.yml runs the three in that order. Each one exits 0 when +it is satisfied, 1 when it found what it exists to find, and 2 when it could not +read what it was given - and 2 is never a pass. + +Why this exists +--------------- +Dependabot is configured to watch every NuGet package here, and for most of them +it never will. Its NuGet updater decides whether a newer version is compatible +with the project before proposing it, and it answers "not compatible" for every +package that carries an assembly when the project targets +net10.0-windows10.0.17763. The run of 2026-11-02 logged exactly that for CsCheck +4.9.1, CsCheck 5.0.0 and WPF-UI 4.3.0, then "No updatable version found", and +went green. Every NuGet pull request it ever opened here moved one of two +packages that carry no assembly at all, an analyser and a source generator. +CsCheck 5.0.0 was moved by hand in pull request 105 because nothing said it +existed. So this check asks NuGet itself, once a week, and is red when the tree +is behind a version old enough that Dependabot would have proposed it. + +What is read, and why none of it is trusted to be complete +----------------------------------------------------------- +The packages come from `dotnet list package --format json` after a restore, +which is what NuGet resolved rather than what a file seems to say. Two checks +keep that list honest. Every project git tracks has to appear in it, so a +project the solution forgot is a refusal rather than a shorter list. And every +package a tracked project or props file names in a PackageReference has to +appear in it, so a listing that lost a package is a refusal too. + +The versions come from NuGet's registration resource, found through the service +index rather than written here. A package with many versions is split into pages +that have to be fetched one by one, and a page that was not read would make a +package look current. So the version in the tree has to be among the versions +read, and a package whose own version is missing from them is a refusal. + +What counts as newer +-------------------- +A listed version that sorts above the one in the tree, in NuGet's order: up to +four numbers, then a release above any prerelease of it, prerelease labels +compared part by part, numbers below words. Prereleases count only when the tree +is on a prerelease of that package itself, which is how `dotnet list package +--outdated` decides it too. + +The cooldown, and where its number lives +----------------------------------------- +A version younger than the cooldown is reported as cooling and is not red, so +that this check asks for exactly what Dependabot would have proposed and nothing +earlier. The number of days is read from the nuget entry of +.github/dependabot.yml rather than repeated here, because two copies of one +number drift, and the day they disagree one of them is wrong without saying so. + +Held versions +------------- +A newer version that the owner decided not to take yet goes into +.github/outdated/held.toml with the highest version the decision covers, a date +it expires and the reason. A newer version past that, or the date passing, makes +the package red again. A hold that covers nothing any more - the package moved, +or left the tree - is red as well, because a hold nobody needs still reads as a +decision somebody made. + +The canary +---------- +Before the verdict trusts its own reading, Newtonsoft.Json 12.0.3 goes through +the same path and has to come out behind, past the cooldown, on a major step. +Version 13 has been on NuGet since 2021. Without that, a green verdict cannot +tell a current tree from a registration that answered nothing, or a comparison +that never says newer. +""" +import argparse +import datetime +import http.client +import json +import os +import re +import subprocess +import sys +import time +import tomllib +import urllib.error +import urllib.request +import zlib + +SERVICE_INDEX = "https://api.nuget.org/v3/index.json" + +# The registration resource that lists every version with its publication date, compressed, +# SemVer 2 versions included. Found by this type in the service index on every run. +REGISTRATION_TYPE = "RegistrationsBaseUrl/3.6.0" + +# Old on purpose, for the reason osv_gate.py gives about its own canaries: an answer years old +# cannot be withdrawn by a registry changing its mind about something recent. +CANARIES = (("Newtonsoft.Json", "12.0.3", "major"),) + +# A ceiling on any one answer, far above anything real. The largest registration page this +# check reads is in the hundreds of kilobytes. The ceiling applies after decompression too, +# so a small compressed answer cannot unpack into something that fills the runner. +MOST_BYTES = 32 * 1024 * 1024 + +ATTEMPTS = 3 + +PACKAGE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$") +VERSION = re.compile( + r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$") +REFERENCE = re.compile(r"]*?\bInclude\s*=\s*\"([^\"]+)\"", re.DOTALL) + +HOLD_KEYS = {"package", "through", "until", "reason"} + +# The order a table is read in: what needs doing first. +STATE_ORDER = {"behind": 0, "held": 1, "cooling": 2, "current": 3} + + +class Malformed(Exception): + """An input whose shape is not the one this gate reads. Always a 2, never a pass.""" + + +class Unreadable(Exception): + """An answer that could not be fetched or read. Always a 2, never a pass.""" + + +def refused(exc): + print("REFUSED: %s" % exc) + return 2 + + +# ---- versions ----------------------------------------------------------------------------------- + + +class Version: + """A NuGet version, ordered the way NuGet orders them.""" + + def __init__(self, text): + match = VERSION.match(text.strip()) if isinstance(text, str) else None + if match is None: + raise Malformed("%r is not a NuGet version" % (text,)) + self.text = text.strip() + self.release = tuple(int(part or 0) for part in match.groups()[:4]) + labels = match.group(5) + self.pre = tuple(labels.split(".")) if labels else () + self.key = (self.release, 0 if self.pre else 1, + tuple((0, int(label), "") if label.isdigit() else (1, 0, label.lower()) + for label in self.pre)) + + def __eq__(self, other): + return self.key == other.key + + def __lt__(self, other): + return self.key < other.key + + def __le__(self, other): + return self.key <= other.key + + def __hash__(self): + return hash(self.key) + + def __repr__(self): + return self.text + + +def step(old, new): + """How far apart two versions are, by the first number that differs.""" + for name, a, b in zip(("major", "minor", "patch", "revision"), old.release, new.release): + if a != b: + return name + return "prerelease" + + +# ---- answers from outside ------------------------------------------------------------------------- + + +def decompress(body): + inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) + out = inflater.decompress(body, MOST_BYTES + 1) + if len(out) > MOST_BYTES or inflater.unconsumed_tail: + raise Unreadable("an answer unpacks to more than %d bytes" % MOST_BYTES) + return out + + +def fetch_json(url): + """One JSON answer from NuGet, retried on a transient failure, refused on anything else.""" + last = None + for attempt in range(ATTEMPTS): + try: + request = urllib.request.Request(url, headers={ + "Accept-Encoding": "gzip", + "User-Agent": "BetterWindowsServices-outdated-gate", + }) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(MOST_BYTES + 1) + encoding = (response.headers.get("Content-Encoding") or "").lower() + if len(body) > MOST_BYTES: + raise Unreadable("%s: more than %d bytes" % (url, MOST_BYTES)) + if encoding == "gzip": + body = decompress(body) + return json.loads(body.decode("utf-8")) + except urllib.error.HTTPError as exc: + if exc.code < 500 and exc.code != 429: + raise Unreadable("%s: HTTP %d" % (url, exc.code)) from exc + last = exc + except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException) as exc: + last = exc + except (UnicodeDecodeError, json.JSONDecodeError, zlib.error) as exc: + raise Unreadable("%s: %s" % (url, exc)) from exc + if attempt + 1 < ATTEMPTS: + time.sleep(2 ** attempt * 5) + raise Unreadable("%s: %s after %d attempts" % (url, last, ATTEMPTS)) + + +def field(record, key, kind, where): + if not isinstance(record, dict) or key not in record: + raise Malformed("%s has no %r" % (where, key)) + value = record[key] + if not isinstance(value, kind): + raise Malformed("%s: %r is %s, not %s" % (where, key, type(value).__name__, kind.__name__)) + return value + + +def items(record, key, where, required=False): + if not isinstance(record, dict): + raise Malformed("%s is not an object" % where) + if key not in record: + if required: + raise Malformed("%s has no %r" % (where, key)) + return [] + value = record[key] + if not isinstance(value, list): + raise Malformed("%s: %r is not a list" % (where, key)) + return value + + +def registration_base(fetch): + index = fetch(SERVICE_INDEX) + for resource in items(index, "resources", "the service index", required=True): + if isinstance(resource, dict) and resource.get("@type") == REGISTRATION_TYPE: + base = field(resource, "@id", str, "the registration resource") + return base if base.endswith("/") else base + "/" + raise Malformed("the service index names no %s" % REGISTRATION_TYPE) + + +class Release: + def __init__(self, version, listed, published): + self.version = version + self.listed = listed + self.published = published + + +def published_at(text, where): + try: + moment = datetime.datetime.fromisoformat(text) + except (TypeError, ValueError) as exc: + raise Malformed("%s: %r is not a time" % (where, text)) from exc + if moment.tzinfo is None: + moment = moment.replace(tzinfo=datetime.timezone.utc) + return moment + + +def releases_of(package, base, fetch): + """Every version NuGet has of one package, each page fetched when it is not inline.""" + if not PACKAGE_ID.match(package): + raise Malformed("%r is not a package id" % package) + index = fetch("%s%s/index.json" % (base, package.lower())) + found = [] + for number, page in enumerate(items(index, "items", "the registration of " + package, required=True)): + where = "page %d of %s" % (number, package) + leaves = items(page, "items", where) + if "items" not in page: + page = fetch(field(page, "@id", str, where)) + leaves = items(page, "items", where, required=True) + for leaf in leaves: + entry = field(leaf, "catalogEntry", dict, where) + version = Version(field(entry, "version", str, where)) + listed = entry.get("listed", True) + if not isinstance(listed, bool): + raise Malformed("%s: listed is not true or false" % where) + found.append(Release(version, listed, published_at(entry.get("published"), where))) + if not found: + raise Unreadable("NuGet listed no version of %s at all" % package) + return found + + +# ---- what the repository says ----------------------------------------------------------------------- + + +def git_files(*patterns): + out = subprocess.run(["git", "ls-files", "--", *patterns], check=True, capture_output=True, text=True) + return [line for line in out.stdout.splitlines() if line] + + +def cooldown_days(text): + """The default-days of the one nuget entry in a dependabot.yml, refused if not exactly one.""" + blocks = re.split(r"(?m)^\s*-\s*package-ecosystem:\s*", text) + nuget = [block for block in blocks[1:] if re.match(r"[\"']?nuget[\"']?\s*$", block.splitlines()[0])] + if len(nuget) != 1: + raise Malformed("dependabot.yml has %d nuget entries, not one" % len(nuget)) + days = re.findall(r"(?m)^\s*default-days:\s*(\d+)\s*$", nuget[0]) + if len(days) != 1: + raise Malformed("the nuget entry of dependabot.yml has %d default-days, not one" % len(days)) + return int(days[0]) + + +def declared(texts): + """Every package a PackageReference names, by lower-case id, from project and props files.""" + return {name.lower(): name for text in texts for name in REFERENCE.findall(text)} + + +def tree(listing, root, tracked, named): + """{(id, version): projects} from a dotnet list package listing, refused when incomplete.""" + if not isinstance(listing, dict) or listing.get("version") != 1: + raise Malformed("the listing is not version 1 of the dotnet list package format") + problems = list(items(listing, "problems", "the listing")) + for project in items(listing, "projects", "the listing", required=True): + problems += items(project, "problems", "a project") + if problems: + raise Unreadable("dotnet list reported a problem: %s" % json.dumps(problems[0])) + found = {} + seen = set() + for project in items(listing, "projects", "the listing", required=True): + path = field(project, "path", str, "a project") + name = os.path.relpath(path, root).replace(os.sep, "/") + seen.add(name) + for framework in items(project, "frameworks", name): + for package in items(framework, "topLevelPackages", name): + package_id = field(package, "id", str, name) + resolved = field(package, "resolvedVersion", str, name + " " + package_id) + found.setdefault((package_id, resolved), set()).add(name) + missing = sorted(set(tracked) - seen) + if missing: + raise Unreadable("the listing has no entry for %s, which git tracks" % ", ".join(missing)) + listed_ids = {package_id.lower() for package_id, _ in found} + lost = sorted(original for lower, original in named.items() if lower not in listed_ids) + if lost: + raise Unreadable("a PackageReference names %s and the listing does not have it" % ", ".join(lost)) + if not found: + raise Unreadable("the listing has no package at all") + return found + + +class Hold: + def __init__(self, entry, where): + unknown = set(entry) - HOLD_KEYS + if unknown: + raise Malformed("%s has a key this gate does not know: %s" % (where, ", ".join(sorted(unknown)))) + self.package = field(entry, "package", str, where) + if not PACKAGE_ID.match(self.package): + raise Malformed("%s: %r is not a package id" % (where, self.package)) + self.through = Version(field(entry, "through", str, where)) + self.until = field(entry, "until", datetime.date, where) + if isinstance(self.until, datetime.datetime): + raise Malformed("%s: until is a date, without a time" % where) + self.reason = field(entry, "reason", str, where).strip() + if len(self.reason) < 20: + raise Malformed("%s: the reason has to say why, in a sentence" % where) + + +def holds(text): + """The held versions, one per package, refused on anything it does not understand.""" + try: + document = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise Malformed("held.toml: %s" % exc) from exc + unknown = set(document) - {"held"} + if unknown: + raise Malformed("held.toml has a table this gate does not know: %s" % ", ".join(sorted(unknown))) + entries = document.get("held", []) + if not isinstance(entries, list): + raise Malformed("held.toml: held is not a list of tables") + found = {} + for number, entry in enumerate(entries): + hold = Hold(entry, "held entry %d" % (number + 1)) + if hold.package.lower() in found: + raise Malformed("held.toml holds %s twice" % hold.package) + found[hold.package.lower()] = hold + return found + + +# ---- the decision --------------------------------------------------------------------------------- + + +class Judgement: + def __init__(self, package, version, state, target=None, note=""): + self.package = package + self.version = version + self.state = state + self.target = target + self.note = note + + +def judge(package, version_text, releases, now, days, hold=None): + """What one package in the tree is, against every version NuGet has of it.""" + version = Version(version_text) + if not any(release.version == version for release in releases): + raise Unreadable("%s %s is not among the %d versions NuGet lists for it - a page was not read" + % (package, version_text, len(releases))) + newer = [r for r in releases if r.listed and r.version > version and (version.pre or not r.version.pre)] + if not newer: + return Judgement(package, version, "current") + cutoff = now - datetime.timedelta(days=days) + ripe = [r for r in newer if r.published <= cutoff] + if not ripe: + first = min(newer, key=lambda r: r.published) + newest = max(newer, key=lambda r: r.version.key) + return Judgement(package, version, "cooling", newest, + "past the cooldown on %s" % (first.published + datetime.timedelta(days=days)).date()) + target = max(ripe, key=lambda r: r.version.key) + if hold is not None: + if target.version <= hold.through and now.date() <= hold.until: + return Judgement(package, version, "held", target, + "held through %s until %s: %s" % (hold.through, hold.until, hold.reason)) + if now.date() > hold.until: + return Judgement(package, version, "behind", target, "the hold expired on %s" % hold.until) + return Judgement(package, version, "behind", target, "newer than the hold through %s" % hold.through) + return Judgement(package, version, "behind", target) + + +# ---- the commands ----------------------------------------------------------------------------------- + + +def describe(judgement, projects=()): + target = judgement.target + line = "%-8s %s %s" % (judgement.state, judgement.package, judgement.version) + if target is not None: + line += " -> %s (%s, published %s)" % (target.version, step(judgement.version, target.version), + target.published.date()) + if judgement.note: + line += " - " + judgement.note + if projects: + line += " [%s]" % ", ".join(sorted(projects)) + return line + + +def canary(fetch=fetch_json, now=None, days=None): + """Refuse a run in which a package years behind does not come out behind.""" + now = now or datetime.datetime.now(datetime.timezone.utc) + try: + if days is None: + with open(".github/dependabot.yml", encoding="utf-8") as stream: + days = cooldown_days(stream.read()) + base = registration_base(fetch) + missed = 0 + for package, version, expected in CANARIES: + judgement = judge(package, version, releases_of(package, base, fetch), now, days) + caught = (judgement.state == "behind" + and step(judgement.version, judgement.target.version) == expected) + print("canary: %s - %s" % (describe(judgement), "caught" if caught else "NOT CAUGHT")) + missed += 0 if caught else 1 + except (Malformed, Unreadable, OSError) as exc: + return refused(exc) + if missed: + print("A package years behind did not come out behind. Until it does, no verdict of this run") + print(" means anything - a current tree and a registry that answered nothing read the same.") + return 1 + return 0 + + +def summary_table(judgements, found, hold_problems): + lines = ["## NuGet packages against nuget.org", "", + "| Package | In the tree | Newer version | Published | Step | State | Projects |", + "|---|---|---|---|---|---|---|"] + for judgement in judgements: + target = judgement.target + projects = sorted(found[(judgement.package, judgement.version.text)]) + lines.append("| %s | %s | %s | %s | %s | %s | %s |" % ( + judgement.package, judgement.version, + target.version if target else "", + target.published.date() if target else "", + step(judgement.version, target.version) if target else "", + judgement.state + (" - " + judgement.note.replace("|", "/") if judgement.note else ""), + ", ".join(projects) if len(projects) < 4 else "%d projects" % len(projects))) + for problem in hold_problems: + lines.append("") + lines.append("**%s**" % problem) + return "\n".join(lines) + "\n" + + +def verdict(listing_path, held_path, summary_path, fetch=fetch_json, now=None): + now = now or datetime.datetime.now(datetime.timezone.utc) + try: + root = subprocess.run(["git", "rev-parse", "--show-toplevel"], check=True, + capture_output=True, text=True).stdout.strip() + with open(listing_path, encoding="utf-8-sig") as stream: + listing = json.load(stream) + texts = [] + for path in git_files("*.csproj", "*.props", "*.targets"): + with open(os.path.join(root, path), encoding="utf-8-sig") as stream: + texts.append(stream.read()) + found = tree(listing, root, git_files("*.csproj"), declared(texts)) + with open(os.path.join(root, ".github", "dependabot.yml"), encoding="utf-8") as stream: + days = cooldown_days(stream.read()) + with open(held_path, encoding="utf-8") as stream: + held = holds(stream.read()) + base = registration_base(fetch) + cache = {} + judgements = [] + for package, version in sorted(found, key=lambda key: (key[0].lower(), Version(key[1]).key)): + if package.lower() not in cache: + cache[package.lower()] = releases_of(package, base, fetch) + judgements.append(judge(package, version, cache[package.lower()], now, days, + held.get(package.lower()))) + except (Malformed, Unreadable, OSError, json.JSONDecodeError, subprocess.CalledProcessError) as exc: + return refused(exc) + + in_tree = {package.lower() for package, _ in found} + hold_problems = [] + for lower, hold in sorted(held.items()): + # A hold a newer version went past is not listed here - that package is already behind, + # with the reason in its note. These two are the holds that cover nothing at all. + states = {j.state for j in judgements if j.package.lower() == lower} + if lower not in in_tree: + hold_problems.append("held.toml holds %s, which no project uses - remove the entry" % hold.package) + elif states <= {"current", "cooling"}: + hold_problems.append("held.toml holds %s through %s and nothing is held by it any more - " + "remove the entry" % (hold.package, hold.through)) + + judgements.sort(key=lambda j: (STATE_ORDER[j.state], j.package.lower())) + print("cooldown: %d days, read from .github/dependabot.yml" % days) + for judgement in judgements: + print(describe(judgement, found[(judgement.package, judgement.version.text)])) + for problem in hold_problems: + print("HOLD: " + problem) + if summary_path: + with open(summary_path, "a", encoding="utf-8") as stream: + stream.write(summary_table(judgements, found, hold_problems)) + + behind = [j for j in judgements if j.state == "behind"] + print("%d package versions read, %d behind, %d held, %d cooling, %d current, %d hold problem(s)." % ( + len(judgements), len(behind), sum(j.state == "held" for j in judgements), + sum(j.state == "cooling" for j in judgements), sum(j.state == "current" for j in judgements), + len(hold_problems))) + if behind or hold_problems: + print("Move the package, or hold the version in .github/outdated/held.toml with a reason and a date.") + return 1 + return 0 + + +# ---- the self-test ------------------------------------------------------------------------------------ + + +def selftest(): + """Every decision on cases whose answer is known, the red ones included. Reads nothing outside.""" + checks = [] + + def same(name, expected, actual): + checks.append((name, expected == actual, expected, actual)) + + def raises(name, kind, action): + try: + action() + except kind: + checks.append((name, True, kind.__name__, kind.__name__)) + return + except Exception as exc: # the wrong refusal is a failed check, not a crash of the test + checks.append((name, False, kind.__name__, type(exc).__name__)) + return + checks.append((name, False, kind.__name__, "nothing raised")) + + ordered = ["1.0.0", "1.0.1", "1.1.0-alpha", "1.1.0-alpha.1", "1.1.0-alpha.beta", "1.1.0-beta", + "1.1.0-beta.2", "1.1.0-beta.11", "1.1.0-rc.1", "1.1.0", "1.1.0.1", "2.0.0"] + same("NuGet order, twelve versions", ordered, + [v.text for v in sorted((Version(t) for t in reversed(ordered)), key=lambda v: v.key)]) + same("1.0 is 1.0.0.0", True, Version("1.0") == Version("1.0.0.0")) + same("build metadata does not order", True, Version("2.0.0+abc") == Version("2.0.0")) + same("labels compare without case", True, Version("1.0.0-RC.1") == Version("1.0.0-rc.1")) + raises("a version with a space is refused", Malformed, lambda: Version("1.0 .0")) + raises("a version that is not a string is refused", Malformed, lambda: Version(5)) + same("step major", "major", step(Version("12.0.3"), Version("13.0.1"))) + same("step patch", "patch", step(Version("4.0.1"), Version("4.0.2"))) + + now = datetime.datetime(2026, 11, 2, 12, tzinfo=datetime.timezone.utc) + + def at(days_ago): + return now - datetime.timedelta(days=days_ago) + + def rel(text, days_ago, listed=True): + return Release(Version(text), listed, at(days_ago)) + + base = [rel("1.0.0", 400), rel("1.1.0", 200)] + same("on the newest", "current", judge("P", "1.1.0", base, now, 7).state) + judged = judge("P", "1.0.0", base, now, 7) + same("behind a ripe minor", ("behind", "1.1.0"), (judged.state, judged.target.version.text)) + same("younger than the cooldown", "cooling", judge("P", "1.1.0", base + [rel("1.2.0", 3)], now, 7).state) + judged = judge("P", "1.0.0", base + [rel("1.2.0", 3)], now, 7) + same("newest cooling, an older newer one ripe", ("behind", "1.1.0"), + (judged.state, judged.target.version.text)) + same("exactly the cooldown is ripe", "behind", judge("P", "1.1.0", base + [rel("1.2.0", 7)], now, 7).state) + same("a prerelease does not count for a stable tree", "current", + judge("P", "1.1.0", base + [rel("2.0.0-rc.1", 30)], now, 7).state) + same("a prerelease counts for a prerelease tree", "behind", + judge("P", "2.0.0-beta.1", base + [rel("2.0.0-beta.1", 60), rel("2.0.0-rc.1", 30)], now, 7).state) + same("an unlisted newer version does not count", "current", + judge("P", "1.1.0", base + [rel("1.3.0", 30, listed=False)], now, 7).state) + same("the tree on an unlisted version is still found", "behind", + judge("P", "1.0.0", [rel("1.0.0", 400, listed=False), rel("1.1.0", 200)], now, 7).state) + raises("a tree version NuGet does not list is refused", Unreadable, + lambda: judge("P", "1.0.5", base, now, 7)) + + hold_text = ('[[held]]\npackage = "P"\nthrough = "1.1.0"\nuntil = 2026-12-01\n' + 'reason = "A reason long enough to say why."\n') + hold = holds(hold_text)["p"] + same("held through the target, before the date", "held", judge("P", "1.0.0", base, now, 7, hold).state) + judged = judge("P", "1.0.0", base + [rel("1.2.0", 30)], now, 7, hold) + same("a version past the hold is behind", ("behind", "1.2.0"), (judged.state, judged.target.version.text)) + late = datetime.datetime(2026, 12, 2, 12, tzinfo=datetime.timezone.utc) + same("an expired hold is behind", "behind", judge("P", "1.0.0", base, late, 7, hold).state) + same("the last day of a hold still holds", "held", + judge("P", "1.0.0", base, datetime.datetime(2026, 12, 1, 23, tzinfo=datetime.timezone.utc), 7, hold).state) + raises("a hold key with a typo is refused", Malformed, lambda: holds(hold_text.replace("until", "untill"))) + raises("a hold without a reason is refused", Malformed, + lambda: holds('[[held]]\npackage = "P"\nthrough = "1.1.0"\nuntil = 2026-12-01\nreason = "x"\n')) + raises("a hold with a string for a date is refused", Malformed, + lambda: holds(hold_text.replace("2026-12-01", '"2026-12-01"'))) + raises("a hold with a time is refused", Malformed, + lambda: holds(hold_text.replace("2026-12-01", "2026-12-01T10:00:00"))) + raises("a package held twice is refused", Malformed, lambda: holds(hold_text + hold_text)) + same("no holds at all", {}, holds("# nothing held\n")) + + yml = ("updates:\n - package-ecosystem: github-actions\n cooldown:\n default-days: 3\n" + " - package-ecosystem: nuget\n directories:\n - \"/src/*\"\n cooldown:\n" + " default-days: 7\n - package-ecosystem: pip\n cooldown:\n default-days: 9\n") + same("the nuget cooldown, among three entries", 7, cooldown_days(yml)) + raises("no nuget entry is refused", Malformed, lambda: cooldown_days(yml.replace("nuget", "docker"))) + raises("a nuget entry without the days is refused", Malformed, + lambda: cooldown_days(yml.replace(" default-days: 7\n", ""))) + + named = declared(['', + '']) + same("references read across lines", {"a.b", "c"}, set(named)) + root = os.path.abspath(os.sep + "repo") + + def project(name, *packages): + return {"path": os.path.join(root, name), + "frameworks": [{"framework": "net10.0", "topLevelPackages": [ + {"id": p, "requestedVersion": v, "resolvedVersion": v} for p, v in packages]}]} + + listing = {"version": 1, "projects": [project("a/a.csproj", ("A.B", "1.0.0")), + project("c/c.csproj", ("C", "2.0.0"), ("A.B", "1.0.0"))]} + same("the tree, by package and version", {("A.B", "1.0.0"): {"a/a.csproj", "c/c.csproj"}, + ("C", "2.0.0"): {"c/c.csproj"}}, + tree(listing, root, ["a/a.csproj", "c/c.csproj"], named)) + raises("a tracked project missing from the listing is refused", Unreadable, + lambda: tree(listing, root, ["a/a.csproj", "c/c.csproj", "d/d.csproj"], named)) + raises("a referenced package missing from the listing is refused", Unreadable, + lambda: tree(listing, root, ["a/a.csproj", "c/c.csproj"], dict(named, e="E"))) + raises("a listing reporting a problem is refused", Unreadable, + lambda: tree(dict(listing, problems=[{"level": "error", "text": "no assets"}]), root, [], {})) + raises("a listing of another format version is refused", Malformed, + lambda: tree(dict(listing, version=2), root, [], {})) + raises("a package without a resolved version is refused", Malformed, + lambda: tree({"version": 1, "projects": [{"path": os.path.join(root, "x.csproj"), "frameworks": [ + {"framework": "net10.0", "topLevelPackages": [{"id": "A.B"}]}]}]}, root, [], {})) + + pages = { + SERVICE_INDEX: {"resources": [{"@type": "Other", "@id": "x"}, + {"@type": REGISTRATION_TYPE, "@id": "https://r/"}]}, + "https://r/p/index.json": {"items": [ + {"items": [{"catalogEntry": {"version": "1.0.0", "published": "2025-01-01T00:00:00+00:00"}}]}, + {"@id": "https://r/p/page/2.json"}]}, + "https://r/p/page/2.json": {"items": [ + {"catalogEntry": {"version": "1.1.0", "published": "2025-06-01T00:00:00+00:00", "listed": True}}]}, + } + fetch = pages.__getitem__ + found = releases_of("P", registration_base(fetch), fetch) + same("an inline page and a fetched one", ["1.0.0", "1.1.0"], [r.version.text for r in found]) + raises("a package id with a slash is refused", Malformed, lambda: releases_of("a/../b", "https://r/", fetch)) + raises("a service index without registrations is refused", Malformed, + lambda: registration_base(lambda url: {"resources": []})) + raises("an answer that unpacks past the ceiling is refused", Unreadable, + lambda: decompress(gzip_of(b"0" * (MOST_BYTES + 2)))) + same("an answer under the ceiling unpacks", b"{}", decompress(gzip_of(b"{}"))) + + canary_pages = dict(pages) + canary_pages["https://r/newtonsoft.json/index.json"] = {"items": [{"items": [ + {"catalogEntry": {"version": "12.0.3", "published": "2019-11-09T00:00:00+00:00"}}, + {"catalogEntry": {"version": "13.0.1", "published": "2021-03-22T00:00:00+00:00"}}]}]} + same("the canary is caught on a registry that answers", 0, + quiet(lambda: canary(canary_pages.__getitem__, now, 7))) + current = dict(canary_pages) + current["https://r/newtonsoft.json/index.json"] = {"items": [{"items": [ + {"catalogEntry": {"version": "12.0.3", "published": "2019-11-09T00:00:00+00:00"}}]}]} + same("the canary on a registry with nothing newer is NOT caught", 1, + quiet(lambda: canary(current.__getitem__, now, 7))) + + failed = [check for check in checks if not check[1]] + for name, passed, expected, actual in checks: + if not passed: + print("FAILED %s: expected %r, got %r" % (name, expected, actual)) + print("self-test: %d checks, %s" % (len(checks), "every one as expected" if not failed + else "%d NOT as expected" % len(failed))) + return 1 if failed else 0 + + +def gzip_of(data): + compressor = zlib.compressobj(9, zlib.DEFLATED, 16 + zlib.MAX_WBITS) + return compressor.compress(data) + compressor.flush() + + +def quiet(action): + """Run a command of this script with its printing kept out of the self-test's own output.""" + saved = sys.stdout + sys.stdout = open(os.devnull, "w", encoding="utf-8") + try: + return action() + finally: + sys.stdout.close() + sys.stdout = saved + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("selftest", help="every decision on cases whose answer is known") + commands.add_parser("canary", help="refuse a run in which a package years behind is not behind") + judging = commands.add_parser("verdict", help="red when the tree is behind a version past the cooldown") + judging.add_argument("listing", help="dotnet list package --format json, after a restore") + judging.add_argument("--held", required=True, help="the held versions") + judging.add_argument("--summary", default="", help="a Markdown file to append the table to") + args = parser.parse_args(argv) + # An exception nobody expected would end Python with 1, which reads as "found something". + # Whatever this script did not foresee is a 2, with the traceback kept for whoever reads it. + try: + if args.command == "selftest": + return selftest() + if args.command == "canary": + return canary() + return verdict(args.listing, args.held, args.summary) + except Exception: # deliberately broad - see above + import traceback + traceback.print_exc() + print("REFUSED: the gate stopped on something it does not handle, so this run says nothing") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/outdated.yml b/.github/workflows/outdated.yml new file mode 100644 index 00000000..a3f33c75 --- /dev/null +++ b/.github/workflows/outdated.yml @@ -0,0 +1,90 @@ +# Outdated packages - every NuGet package of this repository, asked against nuget.org once a week. +# +# This exists because Dependabot cannot do it here, which was measured rather than suspected. On +# 2026-11-02 its NuGet updater logged "The package CsCheck.5.0.0 is not compatible. Incompatible +# project frameworks: net10.0-windows10.0.17763", the same for CsCheck 4.9.1 and WPF-UI 4.3.0, +# then "No updatable version found", and finished green. Every NuGet pull request it ever opened +# here moved one of the two packages that carry no assembly. Everything else - the window's +# control library, the test framework, the property-based testing library - was moved by hand, +# when somebody happened to look. .github/scripts/outdated_gate.py says what it reads and decides. +# +# Red means the tree is behind a version that has been on NuGet for longer than the cooldown in +# .github/dependabot.yml, and that nobody held in .github/outdated/held.toml with a reason. A +# version younger than the cooldown is listed and is not red. The table goes to the job summary. +# +# NOT A REQUIRED CHECK, and it should not become one: it answers a question about the world, not +# about the pull request, and a package released on Monday would block every pull request that +# touches these files until somebody moved it. +name: outdated packages + +on: + # Tuesday morning, the day after Dependabot's Monday run, so that a package it can propose has + # had its chance and what is red here is what it did not propose. + schedule: + - cron: '0 6 * * 2' + workflow_dispatch: + # Everything this check stands on, so that a change to it is proven before it is merged. + pull_request: + paths: + - '.github/workflows/outdated.yml' + - '.github/scripts/outdated_gate.py' + - '.github/outdated/**' + - '.github/dependabot.yml' + +permissions: + contents: read # checkout only - nothing in this workflow pushes, comments or labels + +concurrency: + group: outdated-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + name: outdated packages + runs-on: ubuntu-latest + timeout-minutes: 15 + + # MSBuild reads environment variables as properties, so these two reach both the restore and + # the listing - dotnet list takes no property switches. EnableWindowsTargeting because this + # runner is Linux and every project targets Windows. NuGetAudit off because a package with an + # advisory stops the restore, and that question belongs to restore in build.yml and to + # osv-scanner.yml, not to this one. + env: + EnableWindowsTargeting: 'true' + NuGetAudit: 'false' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.14' + + # Reads nothing outside. Every decision on cases whose answer is known, the red ones too, + # before anything below trusts them. + - name: The gate's own cases + run: python .github/scripts/outdated_gate.py selftest + + # A package years behind has to come out behind, through the same registry and the same + # comparison the verdict uses. Otherwise a green verdict could be a registry that answered + # nothing. + - name: Canary + run: python .github/scripts/outdated_gate.py canary + + - name: Restore + run: dotnet restore BetterWindowsServices.slnx + + - name: What NuGet resolved + run: dotnet list BetterWindowsServices.slnx package --format json > "$RUNNER_TEMP/packages.json" + + - name: Verdict + run: >- + python .github/scripts/outdated_gate.py verdict "$RUNNER_TEMP/packages.json" + --held .github/outdated/held.toml + --summary "$GITHUB_STEP_SUMMARY" From f0763d5de7bb1def94074e6af0b56835f027ba7c Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 2 Nov 2026 19:00:42 +0100 Subject: [PATCH 2/2] Open only NuGet over HTTPS, and read references as XML The review of the outdated-package gate found that every address it opened came from NuGet's own answers and went straight to urllib, which also opens file:// URLs. Every address is now checked before it is opened, redirects included: HTTPS, on the host of the service index, with no credentials and no other port. Project files are read with the XML parser instead of a pattern, so a reference inside a comment is no longer a reference, and a document type declaration is refused before parsing. A reference written in a project file now has to be in that project's own listing, not only somewhere in the listing. The page counts NuGet gives for a registration and for each page have to match what was read. Smaller things from the same review: an HTTPError is closed on every path, a compressed answer has to reach its end, every repository file is read under the size ceiling, a true is not taken for a number, the canary finds dependabot.yml from the repository root, a reason written over several lines stays in one table row, and an unexpected stop prints its verdict before the traceback. The self-test holds on to the errors it raises, because CPython closes an error's body by itself once nothing refers to it, and without that the check could not tell a gate that closes from one that does not. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/outdated_gate.py | 375 ++++++++++++++++++++++++------- 1 file changed, 297 insertions(+), 78 deletions(-) diff --git a/.github/scripts/outdated_gate.py b/.github/scripts/outdated_gate.py index fb5b65cb..fba6e645 100644 --- a/.github/scripts/outdated_gate.py +++ b/.github/scripts/outdated_gate.py @@ -29,15 +29,30 @@ The packages come from `dotnet list package --format json` after a restore, which is what NuGet resolved rather than what a file seems to say. Two checks keep that list honest. Every project git tracks has to appear in it, so a -project the solution forgot is a refusal rather than a shorter list. And every -package a tracked project or props file names in a PackageReference has to -appear in it, so a listing that lost a package is a refusal too. +project the solution forgot is a refusal rather than a shorter list. And the +PackageReference items the repository's own files name have to appear in it: + + - one written in a project file, in the listing of THAT project, so a package + one project lost is a refusal even while another project still lists it. + - one written in a props or targets file, somewhere in the listing. Which + projects import such a file is MSBuild's evaluation, and this gate does not + repeat it. + +The files are read as XML, so a reference inside a comment is not a reference. +A reference whose Condition is false for every framework is read as one all the +same, and comes back as a refusal naming it - loud, and the fix is in the file. The versions come from NuGet's registration resource, found through the service index rather than written here. A package with many versions is split into pages that have to be fetched one by one, and a page that was not read would make a -package look current. So the version in the tree has to be among the versions -read, and a package whose own version is missing from them is a refusal. +package look current. So the count of pages the index names, and the count of +versions each page names, have to match what was read, and the version in the +tree has to be among the versions read. + +Only NuGet is asked. Every address this gate opens - the service index, the +registration, every page the registration names, every redirect - has to be +HTTPS on the host of the service index, so a crafted answer cannot point the job +at a file on the runner or at another host. What counts as newer -------------------- @@ -75,19 +90,26 @@ import argparse import datetime import http.client +import io import json import os import re import subprocess import sys +import tempfile import time import tomllib import urllib.error +import urllib.parse import urllib.request +import xml.etree.ElementTree as ElementTree import zlib SERVICE_INDEX = "https://api.nuget.org/v3/index.json" +# The one host this gate talks to, taken from the address above rather than written twice. +NUGET_HOST = urllib.parse.urlsplit(SERVICE_INDEX).hostname + # The registration resource that lists every version with its publication date, compressed, # SemVer 2 versions included. Found by this type in the service index on every run. REGISTRATION_TYPE = "RegistrationsBaseUrl/3.6.0" @@ -96,9 +118,10 @@ # cannot be withdrawn by a registry changing its mind about something recent. CANARIES = (("Newtonsoft.Json", "12.0.3", "major"),) -# A ceiling on any one answer, far above anything real. The largest registration page this -# check reads is in the hundreds of kilobytes. The ceiling applies after decompression too, -# so a small compressed answer cannot unpack into something that fills the runner. +# A ceiling on any one answer and on any one file, far above anything real. The largest +# registration page this check reads is in the hundreds of kilobytes, the listing in the tens. +# The ceiling applies after decompression too, so a small compressed answer cannot unpack +# into something that fills the runner. MOST_BYTES = 32 * 1024 * 1024 ATTEMPTS = 3 @@ -106,7 +129,6 @@ PACKAGE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$") VERSION = re.compile( r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$") -REFERENCE = re.compile(r"]*?\bInclude\s*=\s*\"([^\"]+)\"", re.DOTALL) HOLD_KEYS = {"package", "through", "until", "reason"} @@ -127,6 +149,11 @@ def refused(exc): return 2 +def one_line(text): + """Text for a table cell or a log line: whitespace collapsed, newlines included.""" + return " ".join(text.split()) + + # ---- versions ----------------------------------------------------------------------------------- @@ -172,16 +199,45 @@ def step(old, new): # ---- answers from outside ------------------------------------------------------------------------- +def checked_url(url): + """The URL itself, when this gate may open it: HTTPS, on NuGet's host, nothing else in it.""" + try: + parts = urllib.parse.urlsplit(url) if isinstance(url, str) else None + port = parts.port if parts is not None else None + except ValueError: + parts = None + port = None + if (parts is None or parts.scheme != "https" or parts.hostname != NUGET_HOST + or parts.username or parts.password or port not in (None, 443)): + raise Unreadable("%r is not an address on %s over HTTPS, so it is not opened" % (url, NUGET_HOST)) + return url + + +class CheckedRedirects(urllib.request.HTTPRedirectHandler): + """Follows a redirect only to an address checked_url accepts.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + checked_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +OPENER = urllib.request.build_opener(CheckedRedirects) + + def decompress(body): inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) out = inflater.decompress(body, MOST_BYTES + 1) if len(out) > MOST_BYTES or inflater.unconsumed_tail: raise Unreadable("an answer unpacks to more than %d bytes" % MOST_BYTES) + if not inflater.eof: + raise Unreadable("an answer ends before its compressed stream does") return out -def fetch_json(url): +def fetch_json(url, open_url=None, sleep=time.sleep): """One JSON answer from NuGet, retried on a transient failure, refused on anything else.""" + checked_url(url) + open_url = open_url or OPENER.open last = None for attempt in range(ATTEMPTS): try: @@ -189,7 +245,7 @@ def fetch_json(url): "Accept-Encoding": "gzip", "User-Agent": "BetterWindowsServices-outdated-gate", }) - with urllib.request.urlopen(request, timeout=30) as response: + with open_url(request, timeout=30) as response: body = response.read(MOST_BYTES + 1) encoding = (response.headers.get("Content-Encoding") or "").lower() if len(body) > MOST_BYTES: @@ -198,15 +254,19 @@ def fetch_json(url): body = decompress(body) return json.loads(body.decode("utf-8")) except urllib.error.HTTPError as exc: - if exc.code < 500 and exc.code != 429: - raise Unreadable("%s: HTTP %d" % (url, exc.code)) from exc - last = exc + # An HTTPError is a response as well and holds its connection until it is closed. + # Closed here on every path, and only its code is kept for the message. + code = exc.code + exc.close() + if code < 500 and code != 429: + raise Unreadable("%s: HTTP %d" % (url, code)) from None + last = "HTTP %d" % code except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException) as exc: - last = exc + last = str(exc) or type(exc).__name__ except (UnicodeDecodeError, json.JSONDecodeError, zlib.error) as exc: raise Unreadable("%s: %s" % (url, exc)) from exc if attempt + 1 < ATTEMPTS: - time.sleep(2 ** attempt * 5) + sleep(2 ** attempt * 5) raise Unreadable("%s: %s after %d attempts" % (url, last, ATTEMPTS)) @@ -214,7 +274,8 @@ def field(record, key, kind, where): if not isinstance(record, dict) or key not in record: raise Malformed("%s has no %r" % (where, key)) value = record[key] - if not isinstance(value, kind): + # True is an int to Python. Nothing this gate reads as a number is ever written as true. + if not isinstance(value, kind) or (kind is int and isinstance(value, bool)): raise Malformed("%s: %r is %s, not %s" % (where, key, type(value).__name__, kind.__name__)) return value @@ -236,7 +297,7 @@ def registration_base(fetch): index = fetch(SERVICE_INDEX) for resource in items(index, "resources", "the service index", required=True): if isinstance(resource, dict) and resource.get("@type") == REGISTRATION_TYPE: - base = field(resource, "@id", str, "the registration resource") + base = checked_url(field(resource, "@id", str, "the registration resource")) return base if base.endswith("/") else base + "/" raise Malformed("the service index names no %s" % REGISTRATION_TYPE) @@ -258,18 +319,29 @@ def published_at(text, where): return moment +def counted(record, leaves, where): + """Refuse a list shorter or longer than the count the same answer gives for it.""" + advertised = field(record, "count", int, where) + if advertised != len(leaves): + raise Unreadable("%s names %d entries and %d were read" % (where, advertised, len(leaves))) + + def releases_of(package, base, fetch): """Every version NuGet has of one package, each page fetched when it is not inline.""" if not PACKAGE_ID.match(package): raise Malformed("%r is not a package id" % package) - index = fetch("%s%s/index.json" % (base, package.lower())) + index = fetch(checked_url("%s%s/index.json" % (base, package.lower()))) + pages = items(index, "items", "the registration of " + package, required=True) + counted(index, pages, "the registration of " + package) found = [] - for number, page in enumerate(items(index, "items", "the registration of " + package, required=True)): + for number, page in enumerate(pages): where = "page %d of %s" % (number, package) - leaves = items(page, "items", where) - if "items" not in page: - page = fetch(field(page, "@id", str, where)) - leaves = items(page, "items", where, required=True) + if "items" in page: + leaves = items(page, "items", where) + else: + fetched = fetch(checked_url(field(page, "@id", str, where))) + leaves = items(fetched, "items", where, required=True) + counted(page, leaves, where) for leaf in leaves: entry = field(leaf, "catalogEntry", dict, where) version = Version(field(entry, "version", str, where)) @@ -285,8 +357,26 @@ def releases_of(package, base, fetch): # ---- what the repository says ----------------------------------------------------------------------- -def git_files(*patterns): - out = subprocess.run(["git", "ls-files", "--", *patterns], check=True, capture_output=True, text=True) +def read_text(path, limit=MOST_BYTES): + """A file of the repository as text, refused past the ceiling rather than read whole.""" + with open(path, "rb") as stream: + data = stream.read(limit + 1) + if len(data) > limit: + raise Unreadable("%s is larger than %d bytes" % (path, limit)) + try: + return data.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise Unreadable("%s is not UTF-8: %s" % (path, exc)) from exc + + +def repository_root(): + out = subprocess.run(["git", "rev-parse", "--show-toplevel"], check=True, capture_output=True, text=True) + return out.stdout.strip() + + +def git_files(root, *patterns): + out = subprocess.run(["git", "ls-files", "--", *patterns], check=True, capture_output=True, text=True, + cwd=root) return [line for line in out.stdout.splitlines() if line] @@ -302,14 +392,34 @@ def cooldown_days(text): return int(days[0]) -def declared(texts): - """Every package a PackageReference names, by lower-case id, from project and props files.""" - return {name.lower(): name for text in texts for name in REFERENCE.findall(text)} +def references(text, where): + """The packages one MSBuild file names in a PackageReference, by lower-case id. + + Read as XML, so a reference inside a comment is not one. A document type declaration is + refused before parsing - no project file needs one, and it is the only way to make the + parser expand entities.""" + if " %s (%s, published %s)" % (target.version, step(judgement.version, target.version), target.published.date()) if judgement.note: - line += " - " + judgement.note + line += " - " + one_line(judgement.note) if projects: line += " [%s]" % ", ".join(sorted(projects)) return line @@ -437,8 +552,7 @@ def canary(fetch=fetch_json, now=None, days=None): now = now or datetime.datetime.now(datetime.timezone.utc) try: if days is None: - with open(".github/dependabot.yml", encoding="utf-8") as stream: - days = cooldown_days(stream.read()) + days = cooldown_days(read_text(os.path.join(repository_root(), ".github", "dependabot.yml"))) base = registration_base(fetch) missed = 0 for package, version, expected in CANARIES: @@ -447,7 +561,7 @@ def canary(fetch=fetch_json, now=None, days=None): and step(judgement.version, judgement.target.version) == expected) print("canary: %s - %s" % (describe(judgement), "caught" if caught else "NOT CAUGHT")) missed += 0 if caught else 1 - except (Malformed, Unreadable, OSError) as exc: + except (Malformed, Unreadable, OSError, subprocess.CalledProcessError) as exc: return refused(exc) if missed: print("A package years behind did not come out behind. Until it does, no verdict of this run") @@ -463,12 +577,13 @@ def summary_table(judgements, found, hold_problems): for judgement in judgements: target = judgement.target projects = sorted(found[(judgement.package, judgement.version.text)]) + note = one_line(judgement.note).replace("|", "/") lines.append("| %s | %s | %s | %s | %s | %s | %s |" % ( judgement.package, judgement.version, target.version if target else "", target.published.date() if target else "", step(judgement.version, target.version) if target else "", - judgement.state + (" - " + judgement.note.replace("|", "/") if judgement.note else ""), + judgement.state + (" - " + note if note else ""), ", ".join(projects) if len(projects) < 4 else "%d projects" % len(projects))) for problem in hold_problems: lines.append("") @@ -479,19 +594,19 @@ def summary_table(judgements, found, hold_problems): def verdict(listing_path, held_path, summary_path, fetch=fetch_json, now=None): now = now or datetime.datetime.now(datetime.timezone.utc) try: - root = subprocess.run(["git", "rev-parse", "--show-toplevel"], check=True, - capture_output=True, text=True).stdout.strip() - with open(listing_path, encoding="utf-8-sig") as stream: - listing = json.load(stream) - texts = [] - for path in git_files("*.csproj", "*.props", "*.targets"): - with open(os.path.join(root, path), encoding="utf-8-sig") as stream: - texts.append(stream.read()) - found = tree(listing, root, git_files("*.csproj"), declared(texts)) - with open(os.path.join(root, ".github", "dependabot.yml"), encoding="utf-8") as stream: - days = cooldown_days(stream.read()) - with open(held_path, encoding="utf-8") as stream: - held = holds(stream.read()) + root = repository_root() + listing = json.loads(read_text(listing_path)) + own = {} + shared = {} + for path in git_files(root, "*.csproj", "*.props", "*.targets"): + named = references(read_text(os.path.join(root, path)), path) + if path.endswith(".csproj"): + own[path] = named + else: + shared.update(named) + found = tree(listing, root, git_files(root, "*.csproj"), own, shared) + days = cooldown_days(read_text(os.path.join(root, ".github", "dependabot.yml"))) + held = holds(read_text(held_path)) base = registration_base(fetch) cache = {} judgements = [] @@ -539,6 +654,18 @@ def verdict(listing_path, held_path, summary_path, fetch=fetch_json, now=None): # ---- the self-test ------------------------------------------------------------------------------------ +class Closing(io.BytesIO): + """A response body that remembers being closed.""" + + def __init__(self): + super().__init__(b"") + self.was_closed = False + + def close(self): + self.was_closed = True + super().close() + + def selftest(): """Every decision on cases whose answer is known, the red ones included. Reads nothing outside.""" checks = [] @@ -616,6 +743,15 @@ def rel(text, days_ago, listed=True): lambda: holds(hold_text.replace("2026-12-01", "2026-12-01T10:00:00"))) raises("a package held twice is refused", Malformed, lambda: holds(hold_text + hold_text)) same("no holds at all", {}, holds("# nothing held\n")) + spread = holds(hold_text.replace('"A reason long enough to say why."', + '"""A reason long enough\nto say why, on two lines."""'))["p"] + same("a reason over two lines reads as one", "A reason long enough to say why, on two lines.", spread.reason) + row = summary_table([Judgement("P", Version("1.0.0"), "held", Release(Version("1.1.0"), True, at(200)), + "held: one\nline | two")], {("P", "1.0.0"): {"a.csproj"}}, []) + same("a note with a newline and a bar stays in one table row", 1, + sum(1 for line in row.splitlines() if line.startswith("| P |"))) + same("the rest of that note is in the same row", True, + any(line.startswith("| P |") and "held: one line / two" in line for line in row.splitlines())) yml = ("updates:\n - package-ecosystem: github-actions\n cooldown:\n default-days: 3\n" " - package-ecosystem: nuget\n directories:\n - \"/src/*\"\n cooldown:\n" @@ -625,9 +761,22 @@ def rel(text, days_ago, listed=True): raises("a nuget entry without the days is refused", Malformed, lambda: cooldown_days(yml.replace(" default-days: 7\n", ""))) - named = declared(['', - '']) - same("references read across lines", {"a.b", "c"}, set(named)) + same("references read across lines", {"a.b": "A.B", "c": "C"}, + references('' + '', + "x.csproj")) + same("a reference inside a comment is not one", {"a.b": "A.B"}, + references('' + '', "x.csproj")) + same("an old-style project in the MSBuild namespace", {"a.b": "A.B"}, + references('' + '' + '', "x.csproj")) + raises("a document type declaration is refused", Malformed, + lambda: references(']>&a;', "x.csproj")) + raises("a file that is not XML is refused", Malformed, + lambda: references('', "x.csproj")) + root = os.path.abspath(os.sep + "repo") def project(name, *packages): @@ -637,48 +786,116 @@ def project(name, *packages): listing = {"version": 1, "projects": [project("a/a.csproj", ("A.B", "1.0.0")), project("c/c.csproj", ("C", "2.0.0"), ("A.B", "1.0.0"))]} + tracked = ["a/a.csproj", "c/c.csproj"] + own = {"a/a.csproj": {"a.b": "A.B"}, "c/c.csproj": {"c": "C", "a.b": "A.B"}} same("the tree, by package and version", {("A.B", "1.0.0"): {"a/a.csproj", "c/c.csproj"}, ("C", "2.0.0"): {"c/c.csproj"}}, - tree(listing, root, ["a/a.csproj", "c/c.csproj"], named)) + tree(listing, root, tracked, own, {})) raises("a tracked project missing from the listing is refused", Unreadable, - lambda: tree(listing, root, ["a/a.csproj", "c/c.csproj", "d/d.csproj"], named)) - raises("a referenced package missing from the listing is refused", Unreadable, - lambda: tree(listing, root, ["a/a.csproj", "c/c.csproj"], dict(named, e="E"))) + lambda: tree(listing, root, tracked + ["d/d.csproj"], own, {})) + raises("a package one project lost is refused while another lists it", Unreadable, + lambda: tree({"version": 1, "projects": [project("a/a.csproj", ("A.B", "1.0.0")), + project("c/c.csproj", ("C", "2.0.0"))]}, + root, tracked, own, {})) + raises("a package a props file names, missing everywhere, is refused", Unreadable, + lambda: tree(listing, root, tracked, own, {"e": "E"})) + same("a package a props file names, listed by one project, is enough", 2, + len(tree(listing, root, tracked, own, {"c": "C"}))) raises("a listing reporting a problem is refused", Unreadable, - lambda: tree(dict(listing, problems=[{"level": "error", "text": "no assets"}]), root, [], {})) + lambda: tree(dict(listing, problems=[{"level": "error", "text": "no assets"}]), root, [], {}, {})) raises("a listing of another format version is refused", Malformed, - lambda: tree(dict(listing, version=2), root, [], {})) + lambda: tree(dict(listing, version=2), root, [], {}, {})) + raises("a listing whose version is true is refused", Malformed, + lambda: tree(dict(listing, version=True), root, [], {}, {})) raises("a package without a resolved version is refused", Malformed, lambda: tree({"version": 1, "projects": [{"path": os.path.join(root, "x.csproj"), "frameworks": [ - {"framework": "net10.0", "topLevelPackages": [{"id": "A.B"}]}]}]}, root, [], {})) + {"framework": "net10.0", "topLevelPackages": [{"id": "A.B"}]}]}]}, root, [], {}, {})) + host = "https://" + NUGET_HOST pages = { SERVICE_INDEX: {"resources": [{"@type": "Other", "@id": "x"}, - {"@type": REGISTRATION_TYPE, "@id": "https://r/"}]}, - "https://r/p/index.json": {"items": [ - {"items": [{"catalogEntry": {"version": "1.0.0", "published": "2025-01-01T00:00:00+00:00"}}]}, - {"@id": "https://r/p/page/2.json"}]}, - "https://r/p/page/2.json": {"items": [ + {"@type": REGISTRATION_TYPE, "@id": host + "/r/"}]}, + host + "/r/p/index.json": {"count": 2, "items": [ + {"count": 1, "items": [{"catalogEntry": {"version": "1.0.0", "published": "2025-01-01T00:00:00+00:00"}}]}, + {"count": 1, "@id": host + "/r/p/page/2.json"}]}, + host + "/r/p/page/2.json": {"count": 1, "items": [ {"catalogEntry": {"version": "1.1.0", "published": "2025-06-01T00:00:00+00:00", "listed": True}}]}, } fetch = pages.__getitem__ found = releases_of("P", registration_base(fetch), fetch) same("an inline page and a fetched one", ["1.0.0", "1.1.0"], [r.version.text for r in found]) - raises("a package id with a slash is refused", Malformed, lambda: releases_of("a/../b", "https://r/", fetch)) + short = dict(pages) + short[host + "/r/p/page/2.json"] = {"count": 1, "items": []} + raises("a page with fewer versions than its count is refused", Unreadable, + lambda: releases_of("P", host + "/r/", short.__getitem__)) + fewer = dict(pages) + fewer[host + "/r/p/index.json"] = dict(pages[host + "/r/p/index.json"], count=3) + raises("an index with fewer pages than its count is refused", Unreadable, + lambda: releases_of("P", host + "/r/", fewer.__getitem__)) + boolean = dict(pages) + boolean[host + "/r/p/index.json"] = dict(pages[host + "/r/p/index.json"], count=True) + raises("a count that is true is refused", Malformed, + lambda: releases_of("P", host + "/r/", boolean.__getitem__)) + elsewhere = dict(pages) + elsewhere[host + "/r/p/index.json"] = {"count": 1, "items": [{"count": 1, "@id": "file:///etc/hosts"}]} + raises("a page on file:// is not opened", Unreadable, + lambda: releases_of("P", host + "/r/", elsewhere.__getitem__)) + raises("a package id with a slash is refused", Malformed, lambda: releases_of("a/../b", host + "/r/", fetch)) raises("a service index without registrations is refused", Malformed, lambda: registration_base(lambda url: {"resources": []})) + raises("a registration on another host is refused", Unreadable, + lambda: registration_base(lambda url: {"resources": [{"@type": REGISTRATION_TYPE, + "@id": "https://example.org/r/"}]})) + same("NuGet over HTTPS is opened", host + "/v3/x.json", checked_url(host + "/v3/x.json")) + for name, url in (("plain HTTP", "http://%s/v3/x.json" % NUGET_HOST), + ("another host", "https://example.org/v3/x.json"), + ("a host that only starts like NuGet's", "https://%s.example.org/x.json" % NUGET_HOST), + ("credentials", "https://user:secret@%s/x.json" % NUGET_HOST), + ("another port", "https://%s:8443/x.json" % NUGET_HOST), + ("a file", "file:///etc/hosts")): + raises("%s is not opened" % name, Unreadable, lambda url=url: checked_url(url)) + raises("a redirect to another host is not followed", Unreadable, + lambda: CheckedRedirects().redirect_request(None, None, 302, "Found", {}, "https://example.org/x")) + + # The errors are kept alive here on purpose. CPython closes an HTTPError's body by itself when + # the last reference to it goes, at the end of the except block, so without these references + # this check passes whether the gate closes it or not - measured, the mutation that removed + # the close went unnoticed until they were added. + for code, kind in ((404, "refused at once"), (503, "retried, then refused")): + bodies = [] + errors = [] + + def failing(request, timeout, code=code, bodies=bodies, errors=errors): + body = Closing() + bodies.append(body) + errors.append(urllib.error.HTTPError(request.full_url, code, "no", {}, body)) + raise errors[-1] + + raises("HTTP %d is %s" % (code, kind), Unreadable, + lambda: fetch_json(host + "/x.json", open_url=failing, sleep=lambda seconds: None)) + same("every HTTP %d answer is closed" % code, (ATTEMPTS if code == 503 else 1, True), + (len(bodies), all(body.was_closed for body in bodies))) + raises("an answer that unpacks past the ceiling is refused", Unreadable, lambda: decompress(gzip_of(b"0" * (MOST_BYTES + 2)))) + raises("an answer cut short is refused", Unreadable, lambda: decompress(gzip_of(b"{}" * 500)[:-12])) same("an answer under the ceiling unpacks", b"{}", decompress(gzip_of(b"{}"))) + with tempfile.TemporaryDirectory() as folder: + big = os.path.join(folder, "big.json") + with open(big, "wb") as stream: + stream.write(b"0" * 11) + raises("a file past the ceiling is refused", Unreadable, lambda: read_text(big, limit=10)) + same("a file under the ceiling is read", "0" * 11, read_text(big, limit=11)) + canary_pages = dict(pages) - canary_pages["https://r/newtonsoft.json/index.json"] = {"items": [{"items": [ + canary_pages[host + "/r/newtonsoft.json/index.json"] = {"count": 1, "items": [{"count": 2, "items": [ {"catalogEntry": {"version": "12.0.3", "published": "2019-11-09T00:00:00+00:00"}}, {"catalogEntry": {"version": "13.0.1", "published": "2021-03-22T00:00:00+00:00"}}]}]} same("the canary is caught on a registry that answers", 0, quiet(lambda: canary(canary_pages.__getitem__, now, 7))) current = dict(canary_pages) - current["https://r/newtonsoft.json/index.json"] = {"items": [{"items": [ + current[host + "/r/newtonsoft.json/index.json"] = {"count": 1, "items": [{"count": 1, "items": [ {"catalogEntry": {"version": "12.0.3", "published": "2019-11-09T00:00:00+00:00"}}]}]} same("the canary on a registry with nothing newer is NOT caught", 1, quiet(lambda: canary(current.__getitem__, now, 7))) @@ -719,7 +936,8 @@ def main(argv=None): judging.add_argument("--summary", default="", help="a Markdown file to append the table to") args = parser.parse_args(argv) # An exception nobody expected would end Python with 1, which reads as "found something". - # Whatever this script did not foresee is a 2, with the traceback kept for whoever reads it. + # Whatever this script did not foresee is a 2. The verdict comes first, in a sentence, and the + # traceback after it, because in a CI log it is the only record of where the gate stopped. try: if args.command == "selftest": return selftest() @@ -728,8 +946,9 @@ def main(argv=None): return verdict(args.listing, args.held, args.summary) except Exception: # deliberately broad - see above import traceback - traceback.print_exc() - print("REFUSED: the gate stopped on something it does not handle, so this run says nothing") + print("REFUSED: the gate stopped on something it does not handle, so this run gives no verdict.") + print("Where it stopped, for whoever fixes the gate:") + traceback.print_exc(file=sys.stdout) return 2