Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .github/scripts/check_stale_pins.py
Original file line number Diff line number Diff line change
@@ -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-<mod> @ git+https://.../refs/pull/<N>/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-<mod>`` 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(
"<tr>"
f"<td><a href='{html.escape(s['url'])}'>"
f"{html.escape(s['repo'])}#{s['number']}</a></td>"
f"<td>{html.escape(s['title'])}</td>"
f"<td><code>{html.escape(s['module'])}</code></td>"
f"<td>{s['series']} wheel on PyPI &mdash; strip the pin</td>"
"</tr>"
for s in stale
)
body = (
f"<h2>Stale dependency pins ({n})</h2>"
"<p>These open PRs pin a <code>git+https</code> dependency whose OCA "
"wheel is now published on PyPI. Strip the pin line from "
"<code>test-requirements.txt</code> so the &ldquo;Detect unreleased "
"dependencies&rdquo; check can go green.</p>"
"<table border='1' cellpadding='6' cellspacing='0'>"
"<tr><th>PR</th><th>Title</th><th>Module</th><th>Action</th></tr>"
f"{rows}</table>"
)
else:
body = (
"<h2>No stale dependency pins</h2>"
"<p>Every open-PR pin still points at an unreleased dependency.</p>"
)
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())
93 changes: 93 additions & 0 deletions .github/workflows/check-stale-pins.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
cat pins-digest.subject
echo
echo "EOF"
} >> "$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
Loading