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..fba6e645 --- /dev/null +++ b/.github/scripts/outdated_gate.py @@ -0,0 +1,956 @@ +#!/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 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 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 +-------------------- +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 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" + +# 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 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 + +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.-]+)?$") + +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 + + +def one_line(text): + """Text for a table cell or a log line: whitespace collapsed, newlines included.""" + return " ".join(text.split()) + + +# ---- 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 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, 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: + request = urllib.request.Request(url, headers={ + "Accept-Encoding": "gzip", + "User-Agent": "BetterWindowsServices-outdated-gate", + }) + 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: + 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: + # 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 = 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: + 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] + # 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 + + +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 = 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) + + +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 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(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(pages): + where = "page %d of %s" % (number, package) + 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)) + 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 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] + + +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 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 " 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 += " - " + one_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: + 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: + 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, 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") + 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)]) + 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 + (" - " + note if 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 = 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 = [] + 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 ------------------------------------------------------------------------------------ + + +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 = [] + + 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")) + 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" + " 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", ""))) + + 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): + 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"))]} + 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, tracked, own, {})) + raises("a tracked project missing from the listing is refused", Unreadable, + 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, [], {}, {})) + raises("a listing of another format version is refused", Malformed, + 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, [], {}, {})) + + host = "https://" + NUGET_HOST + pages = { + SERVICE_INDEX: {"resources": [{"@type": "Other", "@id": "x"}, + {"@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]) + 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[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[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))) + + 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. 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() + if args.command == "canary": + return canary() + return verdict(args.listing, args.held, args.summary) + except Exception: # deliberately broad - see above + import traceback + 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 + + +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"