Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ updates:

# Maintain the pinned dependencies of the Python tools
- package-ecosystem: "pip"
directory: "/"
directory: "/tools"
schedule:
interval: "monthly"
groups:
Expand Down
29 changes: 27 additions & 2 deletions .github/workflows/stale-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
--site site ${{ 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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
*.egg-info
__pycache__
.pytest_cache
site/
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 <https://scientific-python.github.io/upload-nightly-action/> 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.
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ reachable.

[bot]: https://github.com/scientific-python-bot

The current state of every package on the channel is published after each run at
<https://scientific-python.github.io/upload-nightly-action/status.html>, 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
follow, we have no way to reach you: the run records that in an issue here instead, and the fix is
Expand Down
24 changes: 24 additions & 0 deletions tests/test_check_stale_wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest
Expand Down Expand Up @@ -116,5 +117,28 @@ 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="<b>stale</b>",
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.status_page(check_stale_wheels.summary_rows([fresh, stale], now), now)
assert page.count('<tr class="stale">') == 1
assert "&lt;b&gt;stale&lt;/b&gt;" in page and "<b>stale</b>" not in page
assert '<a href="https://github.com/o/r/issues/1">opened</a>' 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 "<h2>Stale wheel reminders</h2>" in page
# The README's own H1 becomes the page title rather than appearing twice
assert page.count("Nightly upload") == 2 and "<h1>Nightly upload</h1>" not in page


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
42 changes: 42 additions & 0 deletions tools/_static/site.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* 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
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); }

/* 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; }
Loading
Loading