From eb637e68f36d41d85d3ff086ee923398cdff7cdf Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 22 Sep 2026 20:36:20 +0200 Subject: [PATCH 1/2] Enforce 72-hour update quarantine --- .github/scripts/cargo-release-quarantine.py | 168 ++++++++++++++++++++ .github/workflows/maintenance-queue.yml | 60 ++++++- 2 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/cargo-release-quarantine.py diff --git a/.github/scripts/cargo-release-quarantine.py b/.github/scripts/cargo-release-quarantine.py new file mode 100644 index 0000000..61cdea0 --- /dev/null +++ b/.github/scripts/cargo-release-quarantine.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Enforce a minimum crates.io release age on Cargo update proposals.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import subprocess +import sys +import tomllib +import urllib.parse +import urllib.request + +DEP_TABLES = {"dependencies", "dev-dependencies", "build-dependencies"} +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +_cache: dict[str, dict] = {} + + +def git_show(path: str) -> bytes: + return subprocess.check_output(["git", "show", f"HEAD:{path}"]) + + +def load_lock(raw: bytes) -> list[dict]: + return tomllib.loads(raw.decode()).get("package", []) + + +def package_key(pkg: dict) -> tuple[str, str, str | None]: + return pkg["name"], pkg["version"], pkg.get("source") + + +def crate_versions(name: str) -> dict: + if name in _cache: + return _cache[name] + url = "https://crates.io/api/v1/crates/" + urllib.parse.quote(name, safe="") + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "codeandsolder-maintenance-detector/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=30) as response: + payload = json.load(response) + versions = {v["num"]: v for v in payload.get("versions", [])} + _cache[name] = versions + return versions + + +def created_at(pkg: dict) -> dt.datetime | None: + if pkg.get("source") != CRATES_IO_SOURCE: + return None + info = crate_versions(pkg["name"]).get(pkg["version"]) + if not info: + return None + value = info.get("created_at") + if not value: + return None + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def ineligible(pkg: dict, cutoff: dt.datetime) -> str | None: + when = created_at(pkg) + if when is None: + return ( + f"{pkg['name']} {pkg['version']}: no authoritative crates.io " + f"publication timestamp for source {pkg.get('source')!r}" + ) + if when > cutoff: + return ( + f"{pkg['name']} {pkg['version']}: published {when.isoformat()} " + f"(cutoff {cutoff.isoformat()})" + ) + return None + + +def declaration_name(alias: str, spec) -> str: + if isinstance(spec, dict): + return spec.get("package", alias) + return alias + + +def collect_dependencies(obj, prefix=()) -> dict[tuple[str, str], object]: + out = {} + if not isinstance(obj, dict): + return out + for key, value in obj.items(): + if key in DEP_TABLES and isinstance(value, dict): + table = ".".join((*prefix, key)) + for alias, spec in value.items(): + out[(table, alias)] = spec + if isinstance(value, dict): + out.update(collect_dependencies(value, (*prefix, key))) + return out + + +def changed_direct_crates() -> tuple[set[str], set[str]]: + files = subprocess.check_output( + ["git", "ls-files", "*Cargo.toml"], text=True + ).splitlines() + names: set[str] = set() + unknown_source: set[str] = set() + for path in files: + with open(path, "rb") as f: + after = collect_dependencies(tomllib.load(f)) + before = collect_dependencies(tomllib.loads(git_show(path).decode())) + for key in set(before) | set(after): + old = before.get(key) + new = after.get(key) + if old == new: + continue + alias = key[1] + spec = new if new is not None else old + name = declaration_name(alias, spec) + names.add(name) + if isinstance(spec, dict) and any(k in spec for k in ("git", "path")): + unknown_source.add(name) + return names, unknown_source + + +def changed_lock_packages() -> list[dict]: + before = {package_key(p) for p in load_lock(git_show("Cargo.lock"))} + with open("Cargo.lock", "rb") as f: + after = load_lock(f.read()) + return [p for p in after if package_key(p) not in before and p.get("source")] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("manifest", "lockfile")) + parser.add_argument("--hours", type=int, default=72) + args = parser.parse_args() + + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=args.hours) + changed = changed_lock_packages() + + if args.mode == "lockfile": + bad = [] + for pkg in changed: + reason = ineligible(pkg, cutoff) + if reason: + bad.append(reason) + for reason in bad: + print(f"quarantined: {reason}", file=sys.stderr) + return 1 if bad else 0 + + direct, unknown_source = changed_direct_crates() + blocked = set(unknown_source) + reasons = [] + for pkg in changed: + if pkg["name"] not in direct: + continue + reason = ineligible(pkg, cutoff) + if reason: + blocked.add(pkg["name"]) + reasons.append(reason) + + for name in sorted(unknown_source): + reasons.append(f"{name}: changed git/path dependency has no release timestamp") + for reason in reasons: + print(f"quarantined: {reason}", file=sys.stderr) + for name in sorted(blocked): + print(name) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/maintenance-queue.yml b/.github/workflows/maintenance-queue.yml index 743fe96..7f518b2 100644 --- a/.github/workflows/maintenance-queue.yml +++ b/.github/workflows/maintenance-queue.yml @@ -47,6 +47,27 @@ jobs: exit 0 fi + mapfile -t quarantined < <(python3 .github/scripts/cargo-release-quarantine.py manifest --hours 72) + if (( ${#quarantined[@]} )); then + printf 'Quarantining fresh direct dependencies: %s\\n' "${quarantined[*]}" + git reset --hard HEAD + exclude_args=() + for dep in "${quarantined[@]}"; do + exclude_args+=(--exclude "$dep") + done + if ! cargo upgrade --incompatible allow --pinned allow "${exclude_args[@]}" >> /tmp/cargo-upgrade.log 2>&1; then + cat /tmp/cargo-upgrade.log + echo "failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + if ! python3 .github/scripts/cargo-release-quarantine.py lockfile --hours 72; then + echo "quarantined=true" >> "$GITHUB_OUTPUT" + git reset --hard HEAD + exit 0 + fi + python3 <<'PY' import json import subprocess @@ -117,7 +138,7 @@ jobs: git reset --hard HEAD - name: Queue manifest dependency issues - if: steps.manifest.outputs.failed != 'true' + if: steps.manifest.outputs.failed != 'true' && steps.manifest.outputs.quarantined != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -208,6 +229,12 @@ jobs: echo "failed=true" >> "$GITHUB_OUTPUT" exit 0 fi + if ! python3 .github/scripts/cargo-release-quarantine.py lockfile --hours 72; then + echo "quarantined=true" >> "$GITHUB_OUTPUT" + echo "changed=false" >> "$GITHUB_OUTPUT" + git reset --hard HEAD + exit 0 + fi if git diff --quiet -- Cargo.lock; then echo "changed=false" >> "$GITHUB_OUTPUT" else @@ -217,7 +244,7 @@ jobs: git reset --hard HEAD - name: Queue or close lockfile refresh issue - if: steps.manifest.outputs.failed != 'true' && steps.lockfile.outputs.failed != 'true' + if: steps.manifest.outputs.failed != 'true' && steps.lockfile.outputs.failed != 'true' && steps.lockfile.outputs.quarantined != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -299,12 +326,33 @@ jobs: run: | set -euo pipefail - VERSION="$({ - curl -fsSL https://static.rust-lang.org/dist/channel-rust-stable.toml - } | python3 -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer)["pkg"]["rust"]["version"].split()[0])')" + curl -fsSL https://static.rust-lang.org/dist/channel-rust-stable.toml -o /tmp/rust-stable.toml + read -r VERSION RELEASE_DATE ELIGIBLE < <(python3 - <<'PY' + import datetime as dt + import tomllib + with open("/tmp/rust-stable.toml", "rb") as f: + data = tomllib.load(f) + version = data["pkg"]["rust"]["version"].split()[0] + release_date = dt.date.fromisoformat(data["date"]) + # The manifest has date precision only. Waiting until UTC midnight + # four dates later guarantees at least 72 hours even if publication + # happened at the very end of the release date. + eligible_at = dt.datetime.combine( + release_date + dt.timedelta(days=4), + dt.time.min, + tzinfo=dt.timezone.utc, + ) + eligible = dt.datetime.now(dt.timezone.utc) >= eligible_at + print(version, release_date.isoformat(), "yes" if eligible else "no") + PY + ) test -n "$VERSION" - echo "Current stable Rust: $VERSION" + echo "Current stable Rust: $VERSION (release date $RELEASE_DATE)" + if [[ "$ELIGIBLE" != "yes" ]]; then + echo "Rust $VERSION is still inside the 72-hour release quarantine" + exit 0 + fi gh label create "automation:rust-release" --repo "$REPO" --color "8250df" --description "Queued automatically when stable Rust changes" --force From 1f959d89abbaf3aec0c8e6ef974613554f6dbcb1 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 22 Sep 2026 20:45:41 +0200 Subject: [PATCH 2/2] Resolve Cargo candidates before quarantine checks --- .github/workflows/maintenance-queue.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maintenance-queue.yml b/.github/workflows/maintenance-queue.yml index 7f518b2..1e0f565 100644 --- a/.github/workflows/maintenance-queue.yml +++ b/.github/workflows/maintenance-queue.yml @@ -41,7 +41,8 @@ jobs: run: | set -euo pipefail - if ! cargo upgrade --incompatible allow --pinned allow > /tmp/cargo-upgrade.log 2>&1; then + if ! cargo upgrade --incompatible allow --pinned allow > /tmp/cargo-upgrade.log 2>&1 || + ! cargo update >> /tmp/cargo-upgrade.log 2>&1; then cat /tmp/cargo-upgrade.log echo "failed=true" >> "$GITHUB_OUTPUT" exit 0 @@ -55,7 +56,8 @@ jobs: for dep in "${quarantined[@]}"; do exclude_args+=(--exclude "$dep") done - if ! cargo upgrade --incompatible allow --pinned allow "${exclude_args[@]}" >> /tmp/cargo-upgrade.log 2>&1; then + if ! cargo upgrade --incompatible allow --pinned allow "${exclude_args[@]}" >> /tmp/cargo-upgrade.log 2>&1 || + ! cargo update >> /tmp/cargo-upgrade.log 2>&1; then cat /tmp/cargo-upgrade.log echo "failed=true" >> "$GITHUB_OUTPUT" exit 0