From 546eba01186198562054783f545e8f4c412afdf8 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Wed, 23 Sep 2026 01:38:26 +0200 Subject: [PATCH 1/5] chore: configure Renovate dependency updates --- renovate.json | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..18fa4f1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "dependencyDashboard": true, + "minimumReleaseAge": "3 days", + "minimumReleaseAgeBehaviour": "timestamp-required", + "internalChecksFilter": "strict", + "separateMultipleMajor": true, + "prConcurrentLimit": 0, + "prHourlyLimit": 0, + "rebaseWhen": "behind-base-branch", + "packageRules": [ + { + "description": "Automerge ordinary compatible updates after CI", + "matchUpdateTypes": ["minor", "patch", "pin", "pinDigest", "digest", "lockFileMaintenance"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "pr" + }, + { + "description": "Potentially breaking pre-1.0 updates stay open for the maintenance agent", + "matchCurrentVersion": "/^0\\./", + "automerge": false + }, + { + "description": "Major updates are always attempted but stay open for the maintenance agent", + "matchUpdateTypes": ["major"], + "automerge": false + }, + { + "description": "Package replacements stay open for the maintenance agent", + "matchUpdateTypes": ["replacement"], + "automerge": false + } + ] +} From cf09ca5a13f6b8454e64442a5ee8a44f18a20caf Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Wed, 23 Sep 2026 01:41:17 +0200 Subject: [PATCH 2/5] chore: enable Renovate lockfile maintenance --- renovate.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index 18fa4f1..e3afa7e 100644 --- a/renovate.json +++ b/renovate.json @@ -9,14 +9,27 @@ "prConcurrentLimit": 0, "prHourlyLimit": 0, "rebaseWhen": "behind-base-branch", + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 3am"], + "automerge": true, + "automergeType": "pr" + }, "packageRules": [ { "description": "Automerge ordinary compatible updates after CI", - "matchUpdateTypes": ["minor", "patch", "pin", "pinDigest", "digest", "lockFileMaintenance"], + "matchUpdateTypes": ["minor", "patch", "pin", "pinDigest", "digest"], "matchCurrentVersion": "!/^0/", "automerge": true, "automergeType": "pr" }, + { + "description": "Cargo lockfile maintenance is age-gated by repository CI", + "matchUpdateTypes": ["lockFileMaintenance"], + "minimumReleaseAge": null, + "automerge": true, + "automergeType": "pr" + }, { "description": "Potentially breaking pre-1.0 updates stay open for the maintenance agent", "matchCurrentVersion": "/^0\\./", @@ -30,6 +43,7 @@ { "description": "Package replacements stay open for the maintenance agent", "matchUpdateTypes": ["replacement"], + "minimumReleaseAge": null, "automerge": false } ] From 28daa8c01ea8273d14452132b7843aa27ec0648e Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Wed, 23 Sep 2026 01:42:02 +0200 Subject: [PATCH 3/5] chore: let Renovate own dependency discovery --- .github/workflows/maintenance-queue.yml | 306 ------------------------ 1 file changed, 306 deletions(-) diff --git a/.github/workflows/maintenance-queue.yml b/.github/workflows/maintenance-queue.yml index 1e0f565..43e79b8 100644 --- a/.github/workflows/maintenance-queue.yml +++ b/.github/workflows/maintenance-queue.yml @@ -10,312 +10,6 @@ permissions: issues: write jobs: - dependency-queue: - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - name: Prepare labels - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: | - gh label create "automation:dependency" --repo "$REPO" --color "1d76db" --description "Cargo.toml dependency upgrade queued automatically" --force - gh label create "automation:lockfile" --repo "$REPO" --color "0e8a16" --description "Cargo.lock refresh queued automatically" --force - gh label create "automation:maintenance-error" --repo "$REPO" --color "d73a4a" --description "The passive maintenance detector itself needs repair" --force - - - name: Install current stable Rust and cargo-upgrade - shell: bash - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal - rustup default stable - cargo install cargo-edit --version 0.13.13 --locked --no-default-features --features upgrade - - - name: Detect manifest dependency upgrades - id: manifest - shell: bash - run: | - set -euo pipefail - - 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 - 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 || - ! cargo update >> /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 - import tomllib - from pathlib import Path - - DEP_TABLES = {"dependencies", "dev-dependencies", "build-dependencies"} - - def load_before(path: str): - raw = subprocess.check_output(["git", "show", f"HEAD:{path}"]) - return tomllib.loads(raw.decode()) - - def load_after(path: str): - with open(path, "rb") as f: - return tomllib.load(f) - - def declaration(value): - if isinstance(value, str): - return value - if isinstance(value, dict): - return json.dumps(value, sort_keys=True, separators=(",", ":")) - return json.dumps(value, sort_keys=True) - - def collect(obj, prefix=()): - 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 dep, spec in value.items(): - out[(table, dep)] = declaration(spec) - if isinstance(value, dict): - out.update(collect(value, (*prefix, key))) - return out - - files = subprocess.check_output( - ["git", "ls-files", "*Cargo.toml"], text=True - ).splitlines() - - grouped = {} - for path in files: - if not Path(path).exists(): - continue - before = collect(load_before(path)) - after = collect(load_after(path)) - for key in sorted(set(before) | set(after)): - old = before.get(key) - new = after.get(key) - if old == new: - continue - table, dep = key - grouped.setdefault(dep, []).append({ - "manifest": path, - "table": table, - "old": old, - "new": new, - }) - - payload = [ - {"dependency": dep, "changes": changes} - for dep, changes in sorted(grouped.items()) - ] - Path("/tmp/manifest-upgrades.json").write_text(json.dumps(payload, indent=2)) - print(json.dumps(payload, indent=2)) - PY - - git reset --hard HEAD - - - name: Queue manifest dependency issues - if: steps.manifest.outputs.failed != 'true' && steps.manifest.outputs.quarantined != 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - shell: bash - run: | - set -euo pipefail - - while IFS= read -r item; do - dep="$(jq -r '.dependency' <<<"$item")" - marker="automation-dependency-${dep}" - existing="$(gh issue list --repo "$REPO" --state open --label "automation:dependency" --limit 200 --search "${marker} in:body" --json number --jq '.[0].number // empty')" - - details="$(jq -r '.changes[] | "- `\(.manifest)` / `\(.table)`: `\(.old)` → `\(.new)`"' <<<"$item")" - export marker dep details log stat update_log VERSION - body="$(python3 .github/scripts/render-maintenance-template.py <<'EOF' - - - - `cargo upgrade` reports a newer manifest dependency requirement for **${dep}**. - - ${details} - - Update this dependency everywhere appropriate, including incompatible/major changes. Read the crate's changelog/release notes where useful, adapt code to current APIs and best practices, refresh `Cargo.lock`, and run the full relevant CI/test suite. Do not skip an update merely because it is transitive, low-risk, or already SemVer-compatible. - - This issue is generated and refreshed automatically. A ChatGPT maintenance automation is expected to implement it. - EOF - )" - - if [[ -n "$existing" ]]; then - gh issue edit "$existing" --repo "$REPO" --title "deps: upgrade ${dep}" --body "$body" - else - gh issue create --repo "$REPO" --title "deps: upgrade ${dep}" --label "automation:dependency" --body "$body" - fi - done < <(jq -c '.[]' /tmp/manifest-upgrades.json) - - # Close stale generated dependency issues when cargo-upgrade no longer - # reports that dependency. A future upgrade will open a fresh issue. - gh issue list --repo "$REPO" --state open --label "automation:dependency" --limit 200 --json number,body --jq '.[] | @base64' | - while IFS= read -r encoded; do - issue="$(base64 -d <<<"$encoded")" - number="$(jq -r '.number' <<<"$issue")" - body="$(jq -r '.body' <<<"$issue")" - dep="$(sed -n 's/.*.*/\1/p' <<<"$body" | head -n1)" - [[ -n "$dep" ]] || continue - if ! jq -e --arg dep "$dep" 'any(.[]; .dependency == $dep)' /tmp/manifest-upgrades.json >/dev/null; then - gh issue close "$number" --repo "$REPO" --reason completed --comment "Closed automatically: cargo-upgrade no longer reports this manifest dependency as needing an upgrade." - fi - done - - - name: Report cargo-upgrade detector failure - if: steps.manifest.outputs.failed == 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - shell: bash - run: | - set -euo pipefail - marker="automation-maintenance-error-cargo-upgrade" - existing="$(gh issue list --repo "$REPO" --state open --label "automation:maintenance-error" --limit 50 --search "${marker} in:body" --json number --jq '.[0].number // empty')" - log="$(tail -n 200 /tmp/cargo-upgrade.log)" - export marker dep details log stat update_log VERSION - body="$(python3 .github/scripts/render-maintenance-template.py <<'EOF' - - The passive dependency detector failed while running `cargo upgrade`. - - ```text - ${log} - ``` - - Repair the detector or repository state so passive dependency issue creation resumes. - EOF - )" - if [[ -n "$existing" ]]; then - gh issue edit "$existing" --repo "$REPO" --body "$body" - else - gh issue create --repo "$REPO" --title "maintenance detector: cargo-upgrade failed" --label "automation:maintenance-error" --body "$body" - fi - - - name: Detect lockfile-only updates - id: lockfile - if: steps.manifest.outputs.failed != 'true' - shell: bash - run: | - set -euo pipefail - git reset --hard HEAD - if ! cargo update > /tmp/cargo-update.log 2>&1; then - cat /tmp/cargo-update.log - 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 - echo "changed=true" >> "$GITHUB_OUTPUT" - git diff --stat -- Cargo.lock > /tmp/lockfile-stat.txt - fi - git reset --hard HEAD - - - name: Queue or close lockfile refresh issue - if: steps.manifest.outputs.failed != 'true' && steps.lockfile.outputs.failed != 'true' && steps.lockfile.outputs.quarantined != 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - CHANGED: ${{ steps.lockfile.outputs.changed }} - shell: bash - run: | - set -euo pipefail - marker="automation-lockfile-refresh" - existing="$(gh issue list --repo "$REPO" --state open --label "automation:lockfile" --limit 50 --search "${marker} in:body" --json number --jq '.[0].number // empty')" - - if [[ "$CHANGED" == "true" ]]; then - update_log="$(tail -n 250 /tmp/cargo-update.log)" - stat="$(cat /tmp/lockfile-stat.txt)" - export marker dep details log stat update_log VERSION - body="$(python3 .github/scripts/render-maintenance-template.py <<'EOF' - - - - A plain `cargo update` changes `Cargo.lock`, so there are compatible and/or transitive dependency updates available even if no manifest requirement needs changing. - - ```text - ${stat} - - ${update_log} - ``` - - Refresh the entire lockfile, inspect noteworthy transitive changes where appropriate, run the full relevant CI/test suite, and fix any fallout. There is intentionally no "too trivial to bother" threshold. - - This issue is generated and refreshed automatically. A ChatGPT maintenance automation is expected to implement it. - EOF - )" - if [[ -n "$existing" ]]; then - gh issue edit "$existing" --repo "$REPO" --body "$body" - else - gh issue create --repo "$REPO" --title "deps: refresh Cargo.lock" --label "automation:lockfile" --body "$body" - fi - elif [[ -n "$existing" ]]; then - gh issue close "$existing" --repo "$REPO" --reason completed --comment 'Closed automatically: `cargo update` no longer changes Cargo.lock.' - fi - - - name: Report cargo-update detector failure - if: steps.lockfile.outputs.failed == 'true' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - shell: bash - run: | - set -euo pipefail - marker="automation-maintenance-error-cargo-update" - existing="$(gh issue list --repo "$REPO" --state open --label "automation:maintenance-error" --limit 50 --search "${marker} in:body" --json number --jq '.[0].number // empty')" - log="$(tail -n 200 /tmp/cargo-update.log)" - export marker dep details log stat update_log VERSION - body="$(python3 .github/scripts/render-maintenance-template.py <<'EOF' - - The passive dependency detector failed while running `cargo update`. - - ```text - ${log} - ``` - - Repair the detector or repository state so passive lockfile issue creation resumes. - EOF - )" - if [[ -n "$existing" ]]; then - gh issue edit "$existing" --repo "$REPO" --body "$body" - else - gh issue create --repo "$REPO" --title "maintenance detector: cargo-update failed" --label "automation:maintenance-error" --body "$body" - fi - rust-release-queue: runs-on: ubuntu-24.04 timeout-minutes: 10 From 32ce725493691d8e2259f12bb6e45ce08de69552 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Wed, 23 Sep 2026 01:42:32 +0200 Subject: [PATCH 4/5] chore: make Cargo quarantine a CI guard --- .github/scripts/cargo-release-quarantine.py | 164 ++++++-------------- 1 file changed, 45 insertions(+), 119 deletions(-) diff --git a/.github/scripts/cargo-release-quarantine.py b/.github/scripts/cargo-release-quarantine.py index 61cdea0..267fbce 100644 --- a/.github/scripts/cargo-release-quarantine.py +++ b/.github/scripts/cargo-release-quarantine.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Enforce a minimum crates.io release age on Cargo update proposals.""" +"""Fail if a Cargo.lock change introduces crates.io releases younger than the quarantine.""" from __future__ import annotations @@ -11,156 +11,82 @@ import tomllib import urllib.parse import urllib.request +from pathlib import Path -DEP_TABLES = {"dependencies", "dev-dependencies", "build-dependencies"} -CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" -_cache: dict[str, dict] = {} +CRATES_IO_MARKERS = ("crates.io-index", "index.crates.io") +_cache: dict[str, dict[str, dict]] = {} -def git_show(path: str) -> bytes: - return subprocess.check_output(["git", "show", f"HEAD:{path}"]) +def git_show(ref: str, path: str) -> bytes: + return subprocess.check_output(["git", "show", f"{ref}:{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]: +def key(pkg: dict) -> tuple[str, str, str | None]: return pkg["name"], pkg["version"], pkg.get("source") -def crate_versions(name: str) -> dict: +def is_crates_io(pkg: dict) -> bool: + source = pkg.get("source") or "" + return source.startswith("registry+") and any(marker in source for marker in CRATES_IO_MARKERS) + + +def crate_versions(name: str) -> dict[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", - }, + "https://crates.io/api/v1/crates/" + urllib.parse.quote(name, safe=""), + headers={"Accept": "application/json", "User-Agent": "codeandsolder-renovate-quarantine/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", [])} + versions = {item["num"]: item for item 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 +def publication_time(pkg: dict) -> dt.datetime | 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")] + value = info.get("created_at") if info else None + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("mode", choices=("manifest", "lockfile")) + parser.add_argument("--base-ref", required=True) parser.add_argument("--hours", type=int, default=72) args = parser.parse_args() + try: + before = {key(pkg) for pkg in load_lock(git_show(args.base_ref, "Cargo.lock"))} + except subprocess.CalledProcessError: + before = set() + + after = load_lock(Path("Cargo.lock").read_bytes()) + changed = [pkg for pkg in after if key(pkg) not in before and is_crates_io(pkg)] 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 = [] + + blocked: list[str] = [] 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) + when = publication_time(pkg) + if when is None: + blocked.append(f"{pkg['name']} {pkg['version']}: missing authoritative crates.io publication time") + elif when > cutoff: + blocked.append( + f"{pkg['name']} {pkg['version']}: published {when.isoformat()}, " + f"younger than {args.hours}h" + ) + + if blocked: + print("Cargo release-age quarantine is still active:", file=sys.stderr) + for item in blocked: + print(f"- {item}", file=sys.stderr) + return 1 + + print(f"Cargo release-age quarantine passed for {len(changed)} newly introduced crates.io package versions.") return 0 From f50c0e2682f1df144bbb2599d1efecadff6ada62 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Wed, 23 Sep 2026 01:42:34 +0200 Subject: [PATCH 5/5] ci: enforce Cargo release-age quarantine --- .github/workflows/dependency-quarantine.yml | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/dependency-quarantine.yml diff --git a/.github/workflows/dependency-quarantine.yml b/.github/workflows/dependency-quarantine.yml new file mode 100644 index 0000000..6014a15 --- /dev/null +++ b/.github/workflows/dependency-quarantine.yml @@ -0,0 +1,22 @@ +name: Cargo release-age quarantine + +on: + pull_request: + paths: + - "**/Cargo.lock" + - "Cargo.lock" + +permissions: + contents: read + +jobs: + quarantine: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + + - name: Enforce 72-hour crates.io quarantine + run: python3 .github/scripts/cargo-release-quarantine.py --base-ref "${{ github.event.pull_request.base.sha }}" --hours 72