From d58b9fc87fc650916fadeb0d591e770a5432513a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 20 Aug 2026 18:23:35 -0400 Subject: [PATCH 1/4] Minor tweaks to issue-opening bot --- tools/check_stale_wheels.py | 51 ++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/tools/check_stale_wheels.py b/tools/check_stale_wheels.py index d7bdcf2..ecf937b 100755 --- a/tools/check_stale_wheels.py +++ b/tools/check_stale_wheels.py @@ -32,6 +32,7 @@ ANACONDA_API = "https://api.anaconda.org" CHANNEL_URL = f"https://anaconda.org/{ANACONDA_USER}" ACTION_URL = "https://github.com/scientific-python/upload-nightly-action" +BOT_URL = "https://github.com/scientific-python-bot" POLICY_URL = f"{ACTION_URL}#artifact-cleanup-policy-at-the-scientific-python-nightly-wheels-channel" # Hidden markers let us find our own issues and comments again without relying on @@ -62,6 +63,10 @@ SESSION = requests.Session() SESSION.headers["User-Agent"] = "scientific-python-upload-nightly-action" +# What this run did, surfaced as an annotation on the job page at the end so the +# issues are one click away rather than buried in the summary table. +NOTICES = [] + # Ambient run configuration, set once by main(). GH: Github = None LOGIN = "" @@ -230,6 +235,9 @@ def issue_body(stale, age, removal_date): {uploads} +The *Updated* date shown on anaconda.org can be newer than these: it tracks any change to the \ +package, including our own removal of versions that have expired. + Wheels are [removed after {RETENTION_DAYS} days]({POLICY_URL}), so unless a new nightly is \ uploaded before **{removal_date:%Y-%m-%d}** there will be no wheels left on the channel at all, \ and downstream projects that test against nightlies will fail to install them. @@ -243,11 +251,17 @@ def issue_body(stale, age, removal_date): - Has there simply been nothing to build? Consider uploading on a fixed cadence even when \ nothing has changed, to keep the channel populated for downstream users. -This issue was opened automatically from [scientific-python/upload-nightly-action]({ACTION_URL}), \ -and will be closed automatically once a new wheel is uploaded. +*🤖 Opened automatically by [scientific-python-bot]({BOT_URL}) from \ +[scientific-python/upload-nightly-action]({ACTION_URL}), and closed again on its own once a new \ +wheel is uploaded.* """ +def action(done, would): + """Word a summary status as an action taken, or one --dry-run only considered.""" + return would if DRY_RUN else done + + def create_issue(repo, title, body): if DRY_RUN: print(f" [dry run] would open an issue on {repo.full_name}: {title}") @@ -280,8 +294,11 @@ def handle_fresh(packages, issues): newest = max(package.last_upload for package in packages) for issue in open_issues: for package in packages: - package.status = "resolved" + package.status = action("resolved", "would close") package.issue_url = issue.html_url + NOTICES.append( + f"{action('Closed', 'Would close')} for {describe(packages)}: {issue.html_url}" + ) close_issue( issue, f"New nightly wheels were uploaded on {newest:%Y-%m-%d}. Thanks! Closing." ) @@ -310,7 +327,11 @@ def mark(status, url): issue_title(stale, age), issue_body(stale, age, removal_date), ) - mark("opened", url) + mark(action("opened", "would open"), url) + NOTICES.append( + f"{action('Opened', 'Would open')} for {describe(stale)}: " + f"{url or repo.html_url + '/issues'}" + ) return issue = open_issues[0] @@ -319,7 +340,11 @@ def mark(status, url): return if any(FINAL_WARNING_MARKER in (item.body or "") for item in issue.get_comments()): return - mark("final warning", issue.html_url) + mark(action("final warning", "would comment"), issue.html_url) + NOTICES.append( + f"{action('Commented', 'Would comment')} on {describe(stale)} at {age} days: " + f"{issue.html_url}" + ) comment( issue, f"{FINAL_WARNING_MARKER}\nStill no new nightly wheels for {describe(stale)}. The " @@ -348,7 +373,7 @@ def handle_repo(repo, packages, now): def write_summary(packages, now): lines = [ - f"## Nightly wheel freshness ({now:%Y-%m-%d})", + f"## Nightly wheel freshness ({now:%Y-%m-%d}){' — dry run' if DRY_RUN else ''}", "", "| Package | Last upload | Age (days) | Repository | Status |", "| --- | --- | --- | --- | --- |", @@ -376,6 +401,19 @@ def write_summary(packages, now): fid.write(summary + "\n") +def annotate(lines): + """Surface what the run did as an annotation on the GitHub Actions job page.""" + if not lines: + return + body = "\n".join(lines) + if os.environ.get("GITHUB_ACTIONS"): + # Workflow commands are one line, so the newlines have to be encoded + body = body.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::notice title=Stale wheel check::{body}") + else: + print(f"\n{body}") + + def report_errors(errors): """Open (or update) an issue here about projects we could not reach. @@ -456,6 +494,7 @@ def main(argv=None): handle_repo(group[0].repo, group, now) write_summary(packages, now) + annotate(NOTICES) errors = [package.error for package in packages if package.error] if errors: report_errors(errors) From 5c151180a5748548a6e74ebf05ee84396afbb3d9 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Sep 2026 14:06:19 -0400 Subject: [PATCH 2/4] FIX: Page --- .github/dependabot.yml | 2 +- .github/workflows/stale-wheels.yml | 29 +++++++- .gitignore | 1 + AGENTS.md | 6 +- README.md | 3 + tests/test_check_stale_wheels.py | 18 +++++ tools/check_stale_wheels.py | 103 ++++++++++++++++++++++------- tools/requirements.txt | 3 +- 8 files changed, 135 insertions(+), 30 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5085408..932b1a2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,7 +15,7 @@ updates: # Maintain the pinned dependencies of the Python tools - package-ecosystem: "pip" - directory: "/" + directory: "/tools" schedule: interval: "monthly" groups: diff --git a/.github/workflows/stale-wheels.yml b/.github/workflows/stale-wheels.yml index 59ebfae..5e0dcca 100644 --- a/.github/workflows/stale-wheels.yml +++ b/.github/workflows/stale-wheels.yml @@ -37,10 +37,35 @@ jobs: ISSUE_OPENER_TOKEN: ${{ secrets.ISSUE_OPENER_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - uv run --with-requirements tools/requirements.txt tools/check_stale_wheels.py ${{ inputs.dry_run && '--dry-run' || '' }} + uv run --with-requirements tools/requirements.txt tools/check_stale_wheels.py \ + --html site/index.html ${{ inputs.dry_run && '--dry-run' || '' }} - report-failure: + - name: Upload the status page + if: inputs.dry_run != true + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + # https://scientific-python.github.io/upload-nightly-action/ — from real runs only, + # so the page never shows "would open" needs: [report] + if: inputs.dry_run != true + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy the status page + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + + report-failure: + needs: [report, deploy] if: failure() && github.event_name == 'schedule' permissions: issues: write diff --git a/.gitignore b/.gitignore index 3cf3cb8..ab42f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.egg-info __pycache__ .pytest_cache +site/ diff --git a/AGENTS.md b/AGENTS.md index 14711c0..8782df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ The hour between the two is deliberate: a package must be flagged before it can This repository nags other projects about silently broken automation, so its own cron jobs must not fail quietly. It is called as a job rather than used as a composite action because `ci.yml` checks out to `_action_path` and `remove-wheels.yml` does not check out at all. +The stale wheel check also renders its table through `tools/status.html` and `tools/status.css` (Jinja, autoescaped) into `site/index.html`, which `stale-wheels.yml` publishes to with `actions/deploy-pages`. +Pages is deployed from the workflow artifact, not a `gh-pages` branch, by request; the repository's Pages source must be set to "GitHub Actions" for the deploy job to work. +Dry runs skip the deploy so the public page never shows "would open". + ## Conventions Pin third-party actions to a full commit SHA with a `# vX.Y.Z` comment; Dependabot updates them monthly as a single group. @@ -53,7 +57,7 @@ Dry runs still authenticate and still read issues; they only skip writes. The thresholds are constants at the top of the script rather than command line options, by request: add an option only when something actually needs to vary. `RETENTION_DAYS` must stay in step with the 30 days in `remove-wheels.yml` and the policy section of `README.md`. -Tests live in `tests/test_check_stale_wheels.py` and run with `uv run --frozen tests/test_check_stale_wheels.py`. +Tests live in `tests/test_check_stale_wheels.py` and run with `uv run --with-requirements tools/requirements.txt tests/test_check_stale_wheels.py`. They stub the network, so they are fast and safe to run anywhere. Every case in them is a real package whose metadata would break a naive implementation; add to that table rather than replacing it when the resolution logic changes. diff --git a/README.md b/README.md index c8bbe4d..08c679d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ reachable. [bot]: https://github.com/scientific-python-bot +The current state of every package on the channel is published after each run at +. + The check runs daily from `tools/check_stale_wheels.py` in this repository, an hour before the cleanup job that does the deleting. If your project's PyPI metadata carries no GitHub URL we can follow, we have no way to reach you: the run records that in an issue here instead, and the fix is diff --git a/tests/test_check_stale_wheels.py b/tests/test_check_stale_wheels.py index 1f8fcc2..ad3e9ea 100755 --- a/tests/test_check_stale_wheels.py +++ b/tests/test_check_stale_wheels.py @@ -3,6 +3,7 @@ import re import sys +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -116,5 +117,22 @@ def test_policy_url_anchor_exists(): assert check_stale_wheels.POLICY_URL.split("#", 1)[1] in anchors +def test_html_summary_marks_stale_rows_and_escapes(): + now = datetime(2026, 9, 3, tzinfo=timezone.utc) + fresh = check_stale_wheels.Package(name="fresh", last_upload=now, status="ok") + stale = check_stale_wheels.Package( + name="stale", + last_upload=now - timedelta(days=check_stale_wheels.WARN_DAYS), + status="opened", + issue_url="https://github.com/o/r/issues/1", + ) + page = check_stale_wheels.html_summary( + check_stale_wheels.summary_rows([fresh, stale], now), now + ) + assert page.count('') == 1 + assert "<b>stale</b>" in page and "stale" not in page + assert 'opened' in page + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) diff --git a/tools/check_stale_wheels.py b/tools/check_stale_wheels.py index ecf937b..3970e73 100755 --- a/tools/check_stale_wheels.py +++ b/tools/check_stale_wheels.py @@ -17,6 +17,7 @@ import argparse import os import re +import shutil import sys import urllib.parse from collections import defaultdict @@ -25,6 +26,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path +import jinja2 import requests from github import Auth, Github, GithubException @@ -58,8 +60,9 @@ "icechunk": "earth-mover/icechunk", } +HERE = Path(__file__).resolve().parent SCRIPT = Path(__file__).name -IGNORE_FILE = Path(__file__).resolve().parent.parent / "packages-ignore-from-cleanup.txt" +IGNORE_FILE = HERE.parent / "packages-ignore-from-cleanup.txt" SESSION = requests.Session() SESSION.headers["User-Agent"] = "scientific-python-upload-nightly-action" @@ -371,34 +374,81 @@ def handle_repo(repo, packages, now): package.error = f"`{package.name}` ({repo.full_name}): {exc}" -def write_summary(packages, now): +def run_url(): + """Link to the current GitHub Actions run, or None outside of one.""" + run_id = os.environ.get("GITHUB_RUN_ID") + if not run_id: + return None + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + return f"{server}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{run_id}" + + +def summary_rows(packages, now): + """One row per package, oldest upload first, with what both renderers need.""" + epoch = datetime.min.replace(tzinfo=timezone.utc) + rows = [] + for package in sorted(packages, key=lambda p: p.last_upload or epoch): + known = package.last_upload is not None + age = package.age_days(now) if known else None + status = package.status + if package.error: + # The full text, including how to fix it, goes in the issue report_errors opens + status = f"error: {package.error.split(': ', 1)[-1].split(' — ')[0]}" + rows.append( + { + "name": package.name, + "upload": f"{package.last_upload:%Y-%m-%d}" if known else "?", + "age": str(age) if known else "?", + "stale": known and age >= WARN_DAYS, + "repo": package.repo.full_name if package.repo else None, + "status": status, + "url": None if package.error else package.issue_url, + } + ) + return rows + + +def markdown_summary(rows, now): lines = [ f"## Nightly wheel freshness ({now:%Y-%m-%d}){' — dry run' if DRY_RUN else ''}", "", "| Package | Last upload | Age (days) | Repository | Status |", "| --- | --- | --- | --- | --- |", ] - epoch = datetime.min.replace(tzinfo=timezone.utc) - for package in sorted(packages, key=lambda p: p.last_upload or epoch): - age = "?" if package.last_upload is None else package.age_days(now) - upload = "?" if package.last_upload is None else f"{package.last_upload:%Y-%m-%d}" - name = package.repo.full_name if package.repo else None - repo = f"[{name}](https://github.com/{name})" if name else "—" - status = package.status - if package.issue_url: - status = f"[{status}]({package.issue_url})" - if package.error: - # Keep the table readable; the full text, including how to fix it, goes - # in the issue report_errors opens. - status = f"error: {package.error.split(': ', 1)[-1].split(' — ')[0]}" - elif age != "?" and age >= WARN_DAYS: - age = f"**{age}**" - lines.append(f"| {package.name} | {upload} | {age} | {repo} | {status} |") - summary = "\n".join(lines) + for row in rows: + age = f"**{row['age']}**" if row["stale"] else row["age"] + repo = f"[{row['repo']}](https://github.com/{row['repo']})" if row["repo"] else "—" + status = f"[{row['status']}]({row['url']})" if row["url"] else row["status"] + lines.append(f"| {row['name']} | {row['upload']} | {age} | {repo} | {status} |") + return "\n".join(lines) + + +def html_summary(rows, now): + env = jinja2.Environment(loader=jinja2.FileSystemLoader(HERE), autoescape=True) + return env.get_template("status.html").render( + rows=rows, + channel=ANACONDA_USER, + channel_url=CHANNEL_URL, + policy_url=POLICY_URL, + action_url=ACTION_URL, + retention_days=RETENTION_DAYS, + warn_days=WARN_DAYS, + when=f"{now:%Y-%m-%d %H:%M} UTC", + run_url=run_url(), + ) + + +def write_summary(packages, now, html_path=None): + rows = summary_rows(packages, now) + summary = markdown_summary(rows, now) print(summary) if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as fid: fid.write(summary + "\n") + if html_path: + html_path.parent.mkdir(parents=True, exist_ok=True) + html_path.write_text(html_summary(rows, now)) + shutil.copy(HERE / "status.css", html_path.parent) def annotate(lines): @@ -420,11 +470,8 @@ def report_errors(errors): This uses GITHUB_TOKEN rather than the bot's token: the thing that failed may well be the bot's token itself. """ - run_id = os.environ.get("GITHUB_RUN_ID") - check = "stale wheel check" - if run_id: - server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") - check = f"[{check}]({server}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{run_id})" + url = run_url() + check = f"[stale wheel check]({url})" if url else "stale wheel check" body = ( f"{REPORT_MARKER}\nThese packages are on the " f"[`{ANACONDA_USER}`]({CHANNEL_URL}) channel, but the " @@ -461,6 +508,12 @@ def main(argv=None): action="store_true", help="report what would happen without opening, commenting on, or closing issues", ) + parser.add_argument( + "--html", + type=Path, + metavar="PATH", + help="also write the table as a standalone web page, for the status site", + ) args = parser.parse_args(argv) DRY_RUN = args.dry_run @@ -493,7 +546,7 @@ def main(argv=None): for group in by_repo.values(): handle_repo(group[0].repo, group, now) - write_summary(packages, now) + write_summary(packages, now, args.html) annotate(NOTICES) errors = [package.error for package in packages if package.error] if errors: diff --git a/tools/requirements.txt b/tools/requirements.txt index 6a869ab..b3cfda1 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -6,6 +6,7 @@ # Pin exactly rather than with a range: Dependabot leaves a range alone while the # newest release still satisfies it, so a range would give neither pinning nor # update pull requests. -PyGithub==2.10.0 # check_stale_wheels.py: reading, opening and closing issues +PyGithub==2.10.0 # check_stale_wheels.py: reading, opening and closing issues +jinja2==3.1.6 # check_stale_wheels.py: rendering status.html pytest==9.1.1 # tests/ requests==2.34.2 # check_stale_wheels.py: the anaconda.org and PyPI APIs From df90bc5311cf878b699580cc04ad5b87566e767c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Sep 2026 14:08:44 -0400 Subject: [PATCH 3/4] FIX: Missing --- tools/status.css | 34 ++++++++++++++++++++++++++++++++++ tools/status.html | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tools/status.css create mode 100644 tools/status.html diff --git a/tools/status.css b/tools/status.css new file mode 100644 index 0000000..e05ddfd --- /dev/null +++ b/tools/status.css @@ -0,0 +1,34 @@ +/* Colors are the PyData Sphinx Theme tokens that https://scientific-python.org is + built on, copied from the --pst-color-* custom properties in the compiled + stylesheet it serves (the filename is content-hashed, so it cannot be linked): + https://github.com/scientific-python/scientific-python-hugo-theme/blob/main/assets/theme-css/pst/variables/_color.scss + The heading font is the one the site loads from Google Fonts. */ +:root { + color-scheme: light dark; + --primary: #0a7d91; --secondary: #8045e5; --text: #222832; --muted: #48566b; + --border: #d1d5da; --surface: #f3f4f5; --background: #ffffff; --stale: #f0a03026; +} +@media (prefers-color-scheme: dark) { + :root { + --primary: #3fb1c5; --secondary: #9c5ffd; --text: #ced6dd; --muted: #9ca4af; + --border: #48566b; --surface: #29313d; --background: #14181e; --stale: #f0a03033; + } +} +body { + font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color: var(--text); background: var(--background); + max-width: 64rem; margin: 0 auto; padding: 2rem 1.5rem; +} +header { display: flex; align-items: center; gap: 1.25rem; margin-bottom: 1.5rem; } +header img { height: 4.5rem; width: auto; } +h1 { font-family: Lato, sans-serif; font-weight: 900; font-size: 2rem; margin: 0; line-height: 1.2; } +h1 small { display: block; font-weight: 400; font-size: 1rem; color: var(--muted); margin-top: 0.25rem; } +a { color: var(--primary); text-decoration: none; } +a:hover { color: var(--secondary); text-decoration: underline; } +code { font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; } +table { border-collapse: collapse; width: 100%; margin-top: 1.5rem; } +th { background: var(--surface); font-weight: 700; } +th, td { text-align: left; padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); } +th:nth-child(3), td:nth-child(3) { text-align: right; } +tr.stale td { background: var(--stale); font-weight: 600; } +footer { margin-top: 2rem; font-size: 0.9rem; color: var(--muted); } diff --git a/tools/status.html b/tools/status.html new file mode 100644 index 0000000..6d518ea --- /dev/null +++ b/tools/status.html @@ -0,0 +1,41 @@ + + + + + +Scientific Python nightly wheel freshness + + + + + + +
+Scientific Python logo +

Nightly wheel freshness +the {{ channel }} channel

+
+

How recently each project last uploaded a nightly wheel. +Wheels are removed after {{ retention_days }} days; projects whose +newest wheel is more than {{ warn_days }} days old are highlighted, and have an issue opened on +their tracker.

+ + + +{% for row in rows -%} + + + + + + + +{% endfor -%} + +
PackageLast uploadAge (days)RepositoryStatus
{{ row.name }}{{ row.upload }}{{ row.age }}{% if row.repo %}{{ row.repo }}{% else %}—{% endif %}{% if row.url %}{{ row.status }}{% else %}{{ row.status }}{% endif %}
+ + + From 2b5278450291fecf972519df9cf1d031c966ed64 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Sep 2026 15:57:35 -0400 Subject: [PATCH 4/4] Render README --- .github/workflows/stale-wheels.yml | 2 +- AGENTS.md | 2 +- README.md | 3 +- tests/test_check_stale_wheels.py | 12 ++++++-- tools/{status.css => _static/site.css} | 10 ++++++- tools/check_stale_wheels.py | 41 ++++++++++++++++++-------- tools/index.html | 9 ++++++ tools/layout.html | 24 +++++++++++++++ tools/requirements.txt | 9 +++--- tools/status.html | 29 +++++------------- 10 files changed, 96 insertions(+), 45 deletions(-) rename tools/{status.css => _static/site.css} (76%) create mode 100644 tools/index.html create mode 100644 tools/layout.html diff --git a/.github/workflows/stale-wheels.yml b/.github/workflows/stale-wheels.yml index 5e0dcca..d5280d5 100644 --- a/.github/workflows/stale-wheels.yml +++ b/.github/workflows/stale-wheels.yml @@ -38,7 +38,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | uv run --with-requirements tools/requirements.txt tools/check_stale_wheels.py \ - --html site/index.html ${{ inputs.dry_run && '--dry-run' || '' }} + --site site ${{ inputs.dry_run && '--dry-run' || '' }} - name: Upload the status page if: inputs.dry_run != true diff --git a/AGENTS.md b/AGENTS.md index 8782df2..0334206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ The hour between the two is deliberate: a package must be flagged before it can This repository nags other projects about silently broken automation, so its own cron jobs must not fail quietly. It is called as a job rather than used as a composite action because `ci.yml` checks out to `_action_path` and `remove-wheels.yml` does not check out at all. -The stale wheel check also renders its table through `tools/status.html` and `tools/status.css` (Jinja, autoescaped) into `site/index.html`, which `stale-wheels.yml` publishes to with `actions/deploy-pages`. +The stale wheel check also renders a small site with Jinja: the table through `tools/status.html` into `site/status.html`, and the README through `tools/index.html` into `site/index.html`, both extending `tools/layout.html` and sharing `tools/_static/site.css`; `stale-wheels.yml` then publishes the directory to with `actions/deploy-pages`. Pages is deployed from the workflow artifact, not a `gh-pages` branch, by request; the repository's Pages source must be set to "GitHub Actions" for the deploy job to work. Dry runs skip the deploy so the public page never shows "would open". diff --git a/README.md b/README.md index 08c679d..6ecc093 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ reachable. [bot]: https://github.com/scientific-python-bot The current state of every package on the channel is published after each run at -. +, alongside a rendered copy of +this README. The check runs daily from `tools/check_stale_wheels.py` in this repository, an hour before the cleanup job that does the deleting. If your project's PyPI metadata carries no GitHub URL we can diff --git a/tests/test_check_stale_wheels.py b/tests/test_check_stale_wheels.py index ad3e9ea..f5db3e4 100755 --- a/tests/test_check_stale_wheels.py +++ b/tests/test_check_stale_wheels.py @@ -126,13 +126,19 @@ def test_html_summary_marks_stale_rows_and_escapes(): status="opened", issue_url="https://github.com/o/r/issues/1", ) - page = check_stale_wheels.html_summary( - check_stale_wheels.summary_rows([fresh, stale], now), now - ) + page = check_stale_wheels.status_page(check_stale_wheels.summary_rows([fresh, stale], now), now) assert page.count('') == 1 assert "<b>stale</b>" in page and "stale" not in page assert 'opened' in page +def test_landing_page_is_the_readme_routing_to_the_status_page(): + page = check_stale_wheels.landing_page(datetime(2026, 9, 3, tzinfo=timezone.utc)) + assert 'href="status.html"' in page + assert "

Stale wheel reminders

" in page + # The README's own H1 becomes the page title rather than appearing twice + assert page.count("Nightly upload") == 2 and "

Nightly upload

" not in page + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) diff --git a/tools/status.css b/tools/_static/site.css similarity index 76% rename from tools/status.css rename to tools/_static/site.css index e05ddfd..c64cebb 100644 --- a/tools/status.css +++ b/tools/_static/site.css @@ -1,4 +1,5 @@ -/* Colors are the PyData Sphinx Theme tokens that https://scientific-python.org is +/* Deployed as _static/site.css beside the rendered pages. + Colors are the PyData Sphinx Theme tokens that https://scientific-python.org is built on, copied from the --pst-color-* custom properties in the compiled stylesheet it serves (the filename is content-hashed, so it cannot be linked): https://github.com/scientific-python/scientific-python-hugo-theme/blob/main/assets/theme-css/pst/variables/_color.scss @@ -32,3 +33,10 @@ th, td { text-align: left; padding: 0.45rem 0.75rem; border-bottom: 1px solid va th:nth-child(3), td:nth-child(3) { text-align: right; } tr.stale td { background: var(--stale); font-weight: 600; } footer { margin-top: 2rem; font-size: 0.9rem; color: var(--muted); } + +/* The landing page is the README, rendered */ +main h1, main h2 { font-family: Lato, sans-serif; font-weight: 900; font-size: 1.4rem; margin: 2rem 0 0.5rem; } +pre { background: var(--surface); padding: 0.75rem 1rem; overflow-x: auto; border-radius: 4px; } +pre code { font-size: 0.85em; } +:not(pre) > code { background: var(--surface); padding: 0.1em 0.3em; border-radius: 3px; } +.callout { background: var(--surface); border-left: 4px solid var(--primary); padding: 0.75rem 1rem; } diff --git a/tools/check_stale_wheels.py b/tools/check_stale_wheels.py index 3970e73..97b9549 100755 --- a/tools/check_stale_wheels.py +++ b/tools/check_stale_wheels.py @@ -27,6 +27,7 @@ from pathlib import Path import jinja2 +import markdown_it import requests from github import Auth, Github, GithubException @@ -61,6 +62,7 @@ } HERE = Path(__file__).resolve().parent +TEMPLATES = jinja2.Environment(loader=jinja2.FileSystemLoader(HERE), autoescape=True) SCRIPT = Path(__file__).name IGNORE_FILE = HERE.parent / "packages-ignore-from-cleanup.txt" SESSION = requests.Session() @@ -423,9 +425,9 @@ def markdown_summary(rows, now): return "\n".join(lines) -def html_summary(rows, now): - env = jinja2.Environment(loader=jinja2.FileSystemLoader(HERE), autoescape=True) - return env.get_template("status.html").render( +def status_page(rows, now): + return TEMPLATES.get_template("status.html").render( + title="Nightly wheel freshness", rows=rows, channel=ANACONDA_USER, channel_url=CHANNEL_URL, @@ -438,17 +440,32 @@ def html_summary(rows, now): ) -def write_summary(packages, now, html_path=None): +def landing_page(now): + """The README, rendered, so the site's front page never goes stale.""" + heading, _, body = (HERE.parent / "README.md").read_text().partition("\n") + return TEMPLATES.get_template("index.html").render( + # The layout's header is the page title, so the README's own is dropped + title=heading.removeprefix("# ").strip(), + readme=markdown_it.MarkdownIt("commonmark").render(body), + action_url=ACTION_URL, + when=f"{now:%Y-%m-%d}", + ) + + +def write_summary(packages, now, site_dir=None): rows = summary_rows(packages, now) summary = markdown_summary(rows, now) print(summary) if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as fid: fid.write(summary + "\n") - if html_path: - html_path.parent.mkdir(parents=True, exist_ok=True) - html_path.write_text(html_summary(rows, now)) - shutil.copy(HERE / "status.css", html_path.parent) + if site_dir: + site_dir.mkdir(parents=True, exist_ok=True) + (site_dir / "status.html").write_text(status_page(rows, now)) + (site_dir / "index.html").write_text(landing_page(now)) + shutil.copytree(HERE / "_static", site_dir / "_static", dirs_exist_ok=True) + # Harmless with the Actions deploy; keeps _static/ alive if Pages ever moves to a branch + (site_dir / ".nojekyll").touch() def annotate(lines): @@ -509,10 +526,10 @@ def main(argv=None): help="report what would happen without opening, commenting on, or closing issues", ) parser.add_argument( - "--html", + "--site", type=Path, - metavar="PATH", - help="also write the table as a standalone web page, for the status site", + metavar="DIR", + help="also write the status site there: the table, the README, and the stylesheet", ) args = parser.parse_args(argv) @@ -546,7 +563,7 @@ def main(argv=None): for group in by_repo.values(): handle_repo(group[0].repo, group, now) - write_summary(packages, now, args.html) + write_summary(packages, now, args.site) annotate(NOTICES) errors = [package.error for package in packages if package.error] if errors: diff --git a/tools/index.html b/tools/index.html new file mode 100644 index 0000000..a1e0acd --- /dev/null +++ b/tools/index.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} +{% block subtitle %}nightly wheels for the Scientific Python ecosystem{% endblock %} +{% block content %} +

How fresh is every project's nightly wheel right now? +— the daily check's latest table.

+{{ readme | safe }} +{% endblock %} +{% block footer %}This is the README of +scientific-python/upload-nightly-action, rendered {{ when }}.{% endblock %} diff --git a/tools/layout.html b/tools/layout.html new file mode 100644 index 0000000..58f7e48 --- /dev/null +++ b/tools/layout.html @@ -0,0 +1,24 @@ + + + + + +{{ title }} — Scientific Python nightly wheels + + + + + + +
+Scientific Python logo +

{{ title }} +{% block subtitle %}{% endblock %}

+
+
+{% block content %}{% endblock %} +
+
{% block footer %}{% endblock %}
+ + diff --git a/tools/requirements.txt b/tools/requirements.txt index b3cfda1..2e86e6f 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -6,7 +6,8 @@ # Pin exactly rather than with a range: Dependabot leaves a range alone while the # newest release still satisfies it, so a range would give neither pinning nor # update pull requests. -PyGithub==2.10.0 # check_stale_wheels.py: reading, opening and closing issues -jinja2==3.1.6 # check_stale_wheels.py: rendering status.html -pytest==9.1.1 # tests/ -requests==2.34.2 # check_stale_wheels.py: the anaconda.org and PyPI APIs +PyGithub==2.10.0 # check_stale_wheels.py: reading, opening and closing issues +jinja2==3.1.6 # check_stale_wheels.py: rendering the status site +markdown-it-py==4.2.0 # check_stale_wheels.py: the README as the site's front page +pytest==9.1.1 # tests/ +requests==2.34.2 # check_stale_wheels.py: the anaconda.org and PyPI APIs diff --git a/tools/status.html b/tools/status.html index 6d518ea..ca394ad 100644 --- a/tools/status.html +++ b/tools/status.html @@ -1,21 +1,6 @@ - - - - - -Scientific Python nightly wheel freshness - - - - - - -
-Scientific Python logo -

Nightly wheel freshness -the {{ channel }} channel

-
+{% extends "layout.html" %} +{% block subtitle %}the {{ channel }} channel{% endblock %} +{% block content %}

How recently each project last uploaded a nightly wheel. Wheels are removed after {{ retention_days }} days; projects whose newest wheel is more than {{ warn_days }} days old are highlighted, and have an issue opened on @@ -34,8 +19,8 @@

Nightly wheel freshness {% endfor -%} - - - +scientific-python/upload-nightly-action. +About the channel and this check.{% endblock %}