From 083698d15c52fc656073cca2ee51fa2e43ad3b53 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Fri, 19 Jun 2026 16:55:26 -0400 Subject: [PATCH] ci: nightly check-stale-pins job to flag removable dependency pins Scan every open PR we author for test-requirements.txt git+https pins whose OCA wheel is now published on PyPI (so the pin is removable and the PR's "Detect unreleased dependencies" check can go green). Emails a digest only when there is something to act on. Mirrors fork-sync-and-digest's conventions (SHA-pinned actions, LEDOENT_FORK_SYNC_TOKEN, AWS SES SMTP). Closes the gap that left a stale mis_builder pin on OCA/l10n-usa#180 after its 19.0 wheel shipped. Claude-Session: https://claude.ai/code/session_01XJAVdFhdMCcs3VhkgtWGDu --- .github/scripts/check_stale_pins.py | 177 +++++++++++++++++++++++++ .github/workflows/check-stale-pins.yml | 93 +++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 .github/scripts/check_stale_pins.py create mode 100644 .github/workflows/check-stale-pins.yml diff --git a/.github/scripts/check_stale_pins.py b/.github/scripts/check_stale_pins.py new file mode 100644 index 0000000..21bdcdd --- /dev/null +++ b/.github/scripts/check_stale_pins.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Flag test-requirements.txt git+https dependency pins that are now removable. + +The OCA dep-chain-in-flight pattern pins an *unmerged* dependency PR via a +``odoo-addon- @ git+https://.../refs/pull//head`` line in +``test-requirements.txt``. Once that dependency's OCA wheel is published to +PyPI, the pin is stale: it must be stripped or the PR's "Detect unreleased +dependencies" check stays red forever. Nothing told us when that happened (a +stale ``mis_builder`` pin sat on OCA/l10n-usa#180 after its wheel shipped), so +this nightly job closes the loop. + +For every open PR authored by us (across the org's forks + the upstream OCA +repos we contribute to), it reads the head branch's ``test-requirements.txt``, +extracts each git+https pin, and checks whether ``odoo-addon-`` has a +release on PyPI for the PR's series (``18.0`` / ``19.0`` taken from the branch +name). Matches are "removable". Writes ``stale-pins.json`` plus an HTML digest +(``pins-digest.html`` / ``pins-digest.subject``) for the email step. + +Env: + GH_TOKEN token that can search/read PRs across ledoent/* + OCA. + PIN_CHECK_AUTHOR GitHub login to scan (default: dnplkndll). +""" + +import base64 +import html +import json +import os +import re +import subprocess +import sys +import urllib.request + +AUTHOR = os.environ.get("PIN_CHECK_AUTHOR", "dnplkndll") +PIN_RE = re.compile(r"odoo-addon-([a-z0-9_]+)\s*@\s*git\+https", re.IGNORECASE) +SERIES_RE = re.compile(r"^(\d+\.\d+)") + + +def gh(*args): + return subprocess.run(["gh", *args], capture_output=True, text=True) + + +def our_open_prs(): + """URLs of every open PR authored by AUTHOR, org-wide.""" + r = gh( + "search", "prs", + "--author", AUTHOR, "--state", "open", "--limit", "300", + "--json", "url", + ) + if r.returncode != 0: + print("pr search failed:", r.stderr.strip(), file=sys.stderr) + return [] + return [p["url"] for p in json.loads(r.stdout or "[]")] + + +def pr_head(url): + r = gh( + "pr", "view", url, + "--json", "number,title,headRefName,headRepository,repository,url", + ) + if r.returncode != 0: + return None + return json.loads(r.stdout) + + +def fetch_test_requirements(repo, ref): + """test-requirements.txt content from a repo branch, or '' if absent.""" + r = gh( + "api", f"repos/{repo}/contents/test-requirements.txt", + "-X", "GET", "-f", f"ref={ref}", "--jq", ".content", + ) + if r.returncode != 0: + return "" + try: + return base64.b64decode(r.stdout).decode() + except Exception: # noqa: BLE001 - malformed/binary content is just "no pins" + return "" + + +_pypi_cache = {} + + +def pypi_has_series(pkg, series): + key = (pkg, series) + if key in _pypi_cache: + return _pypi_cache[key] + ok = False + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{pkg}/json", timeout=20 + ) as resp: + data = json.load(resp) + ok = any(v.startswith(series) for v in data.get("releases", {})) + except Exception: # noqa: BLE001 - a PyPI hiccup must not crash the digest + ok = False + _pypi_cache[key] = ok + return ok + + +def collect_stale(): + stale = [] + for url in our_open_prs(): + pr = pr_head(url) + if not pr: + continue + head_repo = (pr.get("headRepository") or {}).get("nameWithOwner") + ref = pr.get("headRefName") or "" + if not head_repo: + continue + series_match = SERIES_RE.match(ref) + if not series_match: + continue # can't tell the series -> can't check the right wheel + series = series_match.group(1) + content = fetch_test_requirements(head_repo, ref) + if not content: + continue + for pin in PIN_RE.finditer(content): + module = pin.group(1) + pkg = "odoo-addon-" + module.replace("_", "-") + if pypi_has_series(pkg, series): + stale.append( + { + "repo": pr["repository"]["nameWithOwner"], + "number": pr["number"], + "title": pr["title"], + "url": pr["url"], + "module": module, + "pkg": pkg, + "series": series, + } + ) + return stale + + +def render(stale): + n = len(stale) + subject = ( + f"[ledoent] {n} stale dependency pin(s) to remove" + if n + else "[ledoent] no stale dependency pins" + ) + if n: + rows = "".join( + "" + f"" + f"{html.escape(s['repo'])}#{s['number']}" + f"{html.escape(s['title'])}" + f"{html.escape(s['module'])}" + f"{s['series']} wheel on PyPI — strip the pin" + "" + for s in stale + ) + body = ( + f"

Stale dependency pins ({n})

" + "

These open PRs pin a git+https dependency whose OCA " + "wheel is now published on PyPI. Strip the pin line from " + "test-requirements.txt so the “Detect unreleased " + "dependencies” check can go green.

" + "" + "" + f"{rows}
PRTitleModuleAction
" + ) + else: + body = ( + "

No stale dependency pins

" + "

Every open-PR pin still points at an unreleased dependency.

" + ) + with open("stale-pins.json", "w") as f: + json.dump(stale, f, indent=2) + with open("pins-digest.subject", "w") as f: + f.write(subject) + with open("pins-digest.html", "w") as f: + f.write(body) + print(f"stale pins: {n}") + + +if __name__ == "__main__": + render(collect_stale()) diff --git a/.github/workflows/check-stale-pins.yml b/.github/workflows/check-stale-pins.yml new file mode 100644 index 0000000..fecf7ec --- /dev/null +++ b/.github/workflows/check-stale-pins.yml @@ -0,0 +1,93 @@ +name: check-stale-pins + +# Nightly org-level workflow: +# +# Scan every open PR we author (forks + upstream OCA) for +# test-requirements.txt git+https dependency pins whose OCA wheel is now +# published on PyPI, i.e. the pin is removable. Emails a digest ONLY when +# there is something to act on, so a clean night is silent. +# +# Why this exists: the dep-chain-in-flight pattern pins an unmerged dependency +# PR; once that dep's wheel ships, the pin is stale and the PR's "Detect +# unreleased dependencies" check stays red until the pin is stripped. Nothing +# surfaced that transition (a stale mis_builder pin sat on OCA/l10n-usa#180), +# so this closes the loop. See .github/scripts/check_stale_pins.py. +# +# Secrets (org-level or on ledoent/.github): +# LEDOENT_FORK_SYNC_TOKEN Fine-grained PAT, ledoent/* + public_repo (read +# PRs across our forks and search upstream OCA). +# SMTP_SERVER / SMTP_USERNAME / SMTP_PASSWORD / SMTP_TO / SMTP_FROM +# same AWS SES setup as fork-sync-and-digest. + +on: + schedule: + - cron: "30 13 * * *" # 13:30 UTC, just after fork-sync-and-digest + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + check-pins: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + # Third-party Actions pinned to commit SHAs (same versions as + # fork-sync-and-digest.yml — bump both together when reviewing). + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.10" + + - name: Scan open PRs for removable pins + env: + GH_TOKEN: ${{ secrets.LEDOENT_FORK_SYNC_TOKEN }} + run: python3 .github/scripts/check_stale_pins.py + + - name: Write job summary + if: always() + run: cat pins-digest.html >> "$GITHUB_STEP_SUMMARY" + + - name: Read subject + count + id: digest + run: | + { + echo "subject<> "$GITHUB_OUTPUT" + echo "count=$(python3 -c 'import json;print(len(json.load(open("stale-pins.json"))))')" >> "$GITHUB_OUTPUT" + + - name: Email digest (only when there are stale pins) + if: steps.digest.outputs.count != '0' + uses: dawidd6/action-send-mail@4226df7daafa6fc901a43789c49bf7ab309066e7 # v3 + with: + server_address: ${{ secrets.SMTP_SERVER }} + server_port: 587 + secure: false + username: ${{ secrets.SMTP_USERNAME }} + password: ${{ secrets.SMTP_PASSWORD }} + subject: ${{ steps.digest.outputs.subject }} + to: ${{ secrets.SMTP_TO }} + from: ${{ secrets.SMTP_FROM }} + html_body: file://pins-digest.html + ignore_cert: false + + - name: Upload digest artifact (kept 7 days for debugging) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: stale-pins-${{ github.run_id }} + path: | + stale-pins.json + pins-digest.html + pins-digest.subject + retention-days: 7