diff --git a/.github/scripts/monitor_nightly.py b/.github/scripts/monitor_nightly.py new file mode 100644 index 0000000..781980b --- /dev/null +++ b/.github/scripts/monitor_nightly.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +"""Monitor the scientific-python-nightly-wheels channel and file issues on this repo. + +Run by ``.github/workflows/monitor-nightly.yml``. Uses ``PyGithub`` for the +GitHub API and ``requests`` for the anaconda.org API (installed from +``.github/scripts/requirements.txt``). + +For every package on the channel, issues are filed on this coordination repo: + * > 30 days without an upload -> open a "stale" issue (auto-closed on recovery) + * > 60 days without an upload -> additionally open a "purge candidate" issue. + +Optionally, if PRODUCER_GITHUB_TOKEN (a PAT) is set and the wheel appears in the +hand-maintained mapping ``packages-source-repos.yaml`` (wheel-name -> owner/repo): + * > 15 days without an upload -> open a tracking issue on the wheel's own source + repo, escalated with a comment at the 30- and 60-day marks and auto-closed on + recovery. +If that PAT is missing, expired, or unauthorized, a "nightly-pat-invalid" issue is +opened on this repo instead (and auto-closed once the token works again). + +Never deletes anything (that is remove-wheels.yml's job). Packages listed in +``packages-ignore-from-cleanup.txt`` are intentionally exempt and are skipped. + +Environment: + GITHUB_TOKEN token with ``issues: write`` on this repo + GITHUB_REPOSITORY ``owner/name`` of this repo + GITHUB_WORKSPACE checkout root (holds the mapping + ignore list) + GITHUB_API_URL API base (set on GitHub Enterprise); optional + GITHUB_STEP_SUMMARY optional path to append the freshness table to + PRODUCER_GITHUB_TOKEN optional PAT with ``issues: write`` on the source repos, + enabling notifications on each wheel's own repository +""" + +from __future__ import annotations + +import datetime as dt +import os +import sys +from pathlib import Path + +import requests +import yaml +from github import Auth, Github, GithubException, UnknownObjectException + +ANACONDA_ORG = "scientific-python-nightly-wheels" +NOTIFY_DAYS = 15 +STALE_DAYS = 30 +PURGE_DAYS = 60 +STALE_LABEL = "stale-nightly" +PURGE_LABEL = "nightly-purge-candidate" +PRODUCER_LABEL = "nightly-upload-stalled" +PAT_INVALID_LABEL = "nightly-pat-invalid" +ANACONDA_API = "https://api.anaconda.org" + + +def marker(name, kind) -> str: + return f"" + + +def load_ignore_list(): + """Reuse the same exemption list that remove-wheels.yml honors.""" + ignore = set() + path = ( + Path(os.environ.get("GITHUB_WORKSPACE", ".")) + / "packages-ignore-from-cleanup.txt" + ) + try: + for line in path.read_text(encoding="utf-8").splitlines(): + name = line.strip() + if name and not name.startswith("#"): + ignore.add(name) + print(f"Ignoring {len(ignore)} exempt package(s): {', '.join(sorted(ignore))}") + except OSError as exc: + print( + f"Could not read {path}: {exc}; proceeding without an ignore list.", + file=sys.stderr, + ) + return ignore + + +def latest_upload_age(name): + """Return (age_in_days, YYYY-MM-DD) for the most recent file, or None.""" + detail = requests.get( + f"{ANACONDA_API}/package/{ANACONDA_ORG}/{name}", timeout=30 + ).json() + times = [] + for f in detail.get("files", []): + raw = (f.get("upload_time") or "").strip() + try: + times.append(dt.datetime.fromisoformat(raw)) + except ValueError: + continue + if not times: + return None + latest = max(times) + age_days = (dt.datetime.now(dt.timezone.utc) - latest).days + return age_days, latest.date().isoformat() + + +def ensure_label(repo, name, color, description): + try: + return repo.get_label(name) + except UnknownObjectException: + return repo.create_label(name=name, color=color, description=description) + + +def open_issues(repo, label): + return [ + i + for i in repo.get_issues(state="open", labels=[label]) + if i.pull_request is None + ] + + +def ensure_open(issues, repo, label, name, kind, title, body): + m = marker(name, kind) + if any(m in (i.body or "") for i in issues): + print(f"Issue already open for {name} ({kind}).") + return + created = repo.create_issue(title=title, body=f"{m}\n\n{body}", labels=[label]) + print(f"Opened {kind} issue #{created.number} for {name}.") + + +def close_if_open(issues, name, kind, comment): + m = marker(name, kind) + for issue in (i for i in issues if m in (i.body or "")): + issue.create_comment(comment) + issue.edit(state="closed", state_reason="completed") + print(f"Closed {kind} issue #{issue.number} for {name}.") + + +def load_source_repos(): + """Hand-maintained wheel-name -> owner/repo mapping (packages-source-repos.yaml). + + Each ``packages`` entry may be a bare ``owner/repo`` string or a mapping with a + ``repo`` key (leaving room for per-repo config such as labels/assignees later). + """ + mapping = {} + path = Path(os.environ.get("GITHUB_WORKSPACE", ".")) / "packages-source-repos.yaml" + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except OSError as exc: + print( + f"Could not read {path}: {exc}; no producer notifications.", file=sys.stderr + ) + return mapping + except yaml.YAMLError as exc: + print( + f"Could not parse {path}: {exc}; no producer notifications.", + file=sys.stderr, + ) + return mapping + + for name, entry in (data.get("packages") or {}).items(): + repo = entry.get("repo") if isinstance(entry, dict) else entry + if isinstance(repo, str) and "/" in repo: + mapping[name] = repo + print(f"Loaded {len(mapping)} wheel->repo mapping(s).") + return mapping + + +def add_comment_once(issue, mark, body): + """Add a comment, unless one carrying ``mark`` already exists (idempotent).""" + if any(mark in (c.body or "") for c in issue.get_comments()): + return + issue.create_comment(f"{mark}\n\n{body}") + + +def check_producer_token(self_repo, producer_gh): + """Validate the PAT; if it is invalid/expired, open an issue on this repo. + + Returns the client if the token authenticates (and closes any previously + opened "PAT invalid" issue), or None if producer notifications should be + skipped this run. + """ + label = ensure_label( + self_repo, + PAT_INVALID_LABEL, + "b60205", + "The PAT used to notify producing repos is missing, expired, or unauthorized.", + ) + issues = open_issues(self_repo, label) + try: + login = producer_gh.get_user().login + except GithubException as exc: + title = ( + "Cross-repo notification token (NIGHTLY_UPLOAD_ISSUE_PAT) is not working" + ) + body = "\n".join( + [ + "The nightly channel monitor is configured to notify producing " + "repositories when their nightly wheels stop uploading, but the " + "Personal Access Token it uses could not authenticate:", + "", + f"```\n{exc.status}: {exc.data}\n```", + "", + "Producer-repo notifications are skipped until this is fixed. Please " + "renew or rotate the `NIGHTLY_UPLOAD_ISSUE_PAT` secret with a token " + "that has `issues: write` on the target repositories.", + "", + "This issue will be closed automatically once the token works again.", + ] + ) + ensure_open(issues, self_repo, label, "config", "pat-invalid", title, body) + return None + except Exception as exc: # noqa: BLE001 - transient error: skip, but do not file + print( + f"Could not validate producer PAT ({exc}); skipping producer notifications this run.", + file=sys.stderr, + ) + return None + print(f"Producer PAT authenticated as {login}.") + close_if_open( + issues, + "config", + "pat-invalid", + "The cross-repo notification token is working again; closing automatically.", + ) + return producer_gh + + +def handle_producer(producer_gh, cache, repo_full, name, age_days, last_upload): + """Open/escalate/close a tracking issue on the wheel's own source repository.""" + if repo_full not in cache: + prepo = producer_gh.get_repo(repo_full) + label = ensure_label( + prepo, + PRODUCER_LABEL, + "b60205", + "Nightly wheels have stopped being uploaded to the scientific-python nightly channel.", + ) + cache[repo_full] = (prepo, label, open_issues(prepo, label)) + prepo, label, issues = cache[repo_full] + + m = marker(name, "producer") + existing = next((i for i in issues if m in (i.body or "")), None) + + # Recovered: close the producer issue if one is open. + if age_days <= NOTIFY_DAYS: + if existing is not None: + existing.create_comment( + f"`{name}` is being uploaded to the nightly channel again " + f"({last_upload}); closing automatically." + ) + existing.edit(state="closed", state_reason="completed") + print( + f"Closed producer issue #{existing.number} on {repo_full} for {name}." + ) + return + + # Open the tracking issue at the 15-day mark. + if existing is None: + body = "\n".join( + [ + f"The nightly wheels for `{name}` have not been uploaded to the " + f"[scientific-python nightly channel](https://anaconda.org/{ANACONDA_ORG}/{name}) " + f"in **{age_days} days** (last upload: {last_upload}).", + "", + "This usually means the nightly build/upload job in this repository has " + "started failing. Please check your CI and restore the nightly upload.", + "", + "Escalation policy on the nightly channel:", + f"- after {STALE_DAYS} days a tracking issue is opened on " + "`scientific-python/upload-nightly-action`;", + f"- after {PURGE_DAYS} days the package may be purged from the channel.", + "", + "This issue was opened automatically and will be closed automatically " + "once fresh nightly wheels are uploaded again.", + ] + ) + existing = prepo.create_issue( + title=f"Nightly wheels for `{name}` have not been uploaded in {age_days}+ days", + body=f"{m}\n\n{body}", + labels=[label], + ) + print(f"Opened producer issue #{existing.number} on {repo_full} for {name}.") + + # Escalate (one comment each) as later thresholds are crossed. + if age_days > STALE_DAYS: + add_comment_once( + existing, + marker(name, "producer-30"), + f"Still no nightly upload after {STALE_DAYS}+ days (last upload: {last_upload}). " + "A tracking issue has been opened on `scientific-python/upload-nightly-action`.", + ) + if age_days > PURGE_DAYS: + add_comment_once( + existing, + marker(name, "producer-60"), + f"Still no nightly upload after {PURGE_DAYS}+ days (last upload: {last_upload}). " + "This package may be purged from the nightly channel until uploads resume.", + ) + + +def write_summary(rows): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + return + lines = [ + f"## Nightly channel freshness ({ANACONDA_ORG})", + "", + "| Package | Age (days) | Last upload |", + "| --- | ---: | --- |", + *[f"| {name} | {age} | {last} |" for name, age, last in rows], + ] + with Path(path).open("a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +def main() -> int: + token = os.environ.get("GITHUB_TOKEN", "").strip() + repo_name = os.environ.get("GITHUB_REPOSITORY", "").strip() + if not token or "/" not in repo_name: + print("GITHUB_TOKEN and GITHUB_REPOSITORY are required.", file=sys.stderr) + return 1 + + gh = Github( + auth=Auth.Token(token), + base_url=os.environ.get("GITHUB_API_URL", Github.DEFAULT_BASE_URL), + ) + repo = gh.get_repo(repo_name) + ignore = load_ignore_list() + + # Optional: notify each wheel's own source repo, using a PAT that has + # issues: write on those repos and a hand-maintained wheel -> repo mapping. + producer_token = os.environ.get("PRODUCER_GITHUB_TOKEN", "").strip() + producer_gh = Github(auth=Auth.Token(producer_token)) if producer_token else None + if producer_gh: + producer_gh = check_producer_token(repo, producer_gh) + source_repos = load_source_repos() if producer_gh else {} + producer_cache = {} + if not producer_token: + print("PRODUCER_GITHUB_TOKEN not set; skipping producer-repo notifications.") + elif producer_gh and not source_repos: + print("Mapping is empty; no producer-repo notifications will be sent.") + + stale_label = ensure_label( + repo, + STALE_LABEL, + "fbca04", + "A nightly package has not received an upload in over 30 days.", + ) + purge_label = ensure_label( + repo, + PURGE_LABEL, + "d93f0b", + "A nightly package has not received an upload in over 60 days.", + ) + + packages = requests.get( + f"{ANACONDA_API}/packages/{ANACONDA_ORG}", timeout=30 + ).json() + print(f"Found {len(packages)} packages in {ANACONDA_ORG}.") + + stale_open = open_issues(repo, stale_label) + purge_open = open_issues(repo, purge_label) + summary = [] + + for pkg in packages: + name = pkg["name"] + if name in ignore: + print(f"Skipping exempt package {name}.") + continue + + try: + result = latest_upload_age(name) + except (requests.RequestException, ValueError, KeyError) as exc: + print(f"Could not fetch details for {name}: {exc}", file=sys.stderr) + continue + if result is None: + print(f"No dated files for {name}; skipping.", file=sys.stderr) + continue + + age_days, last_upload = result + summary.append((name, age_days, last_upload)) + + index_hint = ( + f"python -m pip install {name} --pre --upgrade " + f"--index-url https://pypi.anaconda.org/{ANACONDA_ORG}/simple " + f"--extra-index-url https://pypi.org/simple" + ) + + # --- 30 day stale issue --------------------------------------------- + if age_days > STALE_DAYS: + body = "\n".join( + [ + f"The package `{name}` has not received a nightly wheel upload in " + f"**{age_days} days** (last upload: {last_upload}).", + "", + "The producing project's nightly build is most likely failing. " + "Please check its CI and restore the nightly upload.", + "", + f"Latest files: https://anaconda.org/{ANACONDA_ORG}/{name}/files", + "", + "This issue was opened automatically and will be closed " + "automatically once a fresh upload lands.", + ] + ) + ensure_open( + stale_open, + repo, + stale_label, + name, + "stale", + f"`{name}`: no nightly upload in over {STALE_DAYS} days", + body, + ) + else: + close_if_open( + stale_open, + name, + "stale", + f"`{name}` received a fresh nightly upload ({last_upload}); closing automatically.", + ) + + # --- 60 day purge issue --------------------------------------------- + if age_days > PURGE_DAYS: + body = "\n".join( + [ + f"The package `{name}` has not received a nightly wheel upload in " + f"**{age_days} days** (last upload: {last_upload}), which is beyond " + f"the {PURGE_DAYS}-day threshold.", + "", + "Maintainers: please decide whether to **purge this package from " + "the nightly channel** until its build is fixed. Long-stale wheels " + 'are no longer "nightly" and can silently mask upstream breakage ' + "for downstream users who install with:", + "", + "```", + index_hint, + "```", + "", + "If this package is intentionally kept despite being stale, add it " + "to `packages-ignore-from-cleanup.txt` to silence this monitor.", + "", + "If the nightly build is restored, this issue will be closed " + "automatically.", + ] + ) + ensure_open( + purge_open, + repo, + purge_label, + name, + "purge", + f"`{name}`: consider purging from the nightly channel ({age_days} days stale)", + body, + ) + else: + close_if_open( + purge_open, + name, + "purge", + f"`{name}` received a fresh nightly upload ({last_upload}); closing automatically.", + ) + + # --- producer repo notification (opt-in, needs PAT + mapping) -------- + if producer_gh and name in source_repos: + try: + handle_producer( + producer_gh, + producer_cache, + source_repos[name], + name, + age_days, + last_upload, + ) + except Exception as exc: # noqa: BLE001 - one bad repo must not abort the run + print( + f"Producer notification for {name} ({source_repos[name]}) failed: {exc}", + file=sys.stderr, + ) + + summary.sort(key=lambda row: row[1], reverse=True) + write_summary(summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..68c33de --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,4 @@ +# Dependencies for monitor_nightly.py (used by monitor-nightly.yml). +PyGithub==2.9.1 +PyYAML==6.0.2 +requests==2.32.5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a978b32 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,37 @@ +name: Lint scripts + +# Lints and format-checks the Python helpers under scripts/ and .github/scripts/ +# that are run by the action and the monitor workflow. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + # ruff is pinned inline so a new release cannot change the lint outcome + # unexpectedly (the rule selection is also pinned in ruff.toml). Bump + # deliberately. + - name: Lint (ruff check) + run: uvx ruff@0.16.0 check scripts .github/scripts + + - name: Format check (ruff format) + run: uvx ruff@0.16.0 format --check scripts .github/scripts diff --git a/.github/workflows/monitor-nightly.yml b/.github/workflows/monitor-nightly.yml new file mode 100644 index 0000000..310f623 --- /dev/null +++ b/.github/workflows/monitor-nightly.yml @@ -0,0 +1,70 @@ +name: Monitor nightly channel + +# Watches the scientific-python-nightly-wheels channel and files issues on this +# repository when a package stops receiving uploads: +# * > 30 days without an upload -> open a "stale" issue (auto-closed on recovery) +# * > 60 days without an upload -> additionally open a "purge candidate" issue +# asking maintainers to remove the package from the nightly channel. +# +# This complements remove-wheels.yml (which prunes old *versions*); here we only +# open/close tracking issues and never delete anything. Packages listed in +# packages-ignore-from-cleanup.txt are intentionally exempt and are skipped. +# +# The logic lives in .github/scripts/monitor_nightly.py (dependencies pinned in +# .github/scripts/requirements.txt). + +on: + schedule: + # Every day at 07:00 UTC, after the nightly uploads have had time to land. + - cron: '0 7 * * *' + workflow_dispatch: + +# Least-privilege GITHUB_TOKEN. This job only checks out the repo (to read the +# monitor script and the ignore list) and opens/closes/comments on issues; +# package data comes from the public anaconda.org API. Any scope omitted from a +# `permissions:` block already defaults to `none`; the `none` entries below are +# spelled out for readability so the intent is explicit. +permissions: + issues: write # create / comment on / close the tracking issues + contents: read # checkout: read the script + packages-ignore-from-cleanup.txt + actions: none + attestations: none + checks: none + deployments: none + discussions: none + id-token: none + packages: none + pages: none + pull-requests: none + repository-projects: none + security-events: none + statuses: none + +concurrency: + group: monitor-nightly + cancel-in-progress: false + +jobs: + monitor: + runs-on: ubuntu-latest + if: github.repository_owner == 'scientific-python' + steps: + - name: Check out the action + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.x' + + - name: Install dependencies + run: python -m pip install -r "${GITHUB_WORKSPACE}/.github/scripts/requirements.txt" + + - name: Check nightly upload freshness + env: + GITHUB_TOKEN: ${{ github.token }} + # Optional PAT with `issues: write` on the producing repos. When set + # (together with entries in packages-source-repos.yaml), the monitor + # also opens a tracking issue on each stalled wheel's own repository. + PRODUCER_GITHUB_TOKEN: ${{ secrets.NIGHTLY_UPLOAD_ISSUE_PAT }} + run: python3 "${GITHUB_WORKSPACE}/.github/scripts/monitor_nightly.py" diff --git a/.gitignore b/.gitignore index 096b5eb..b2834a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ # pixi environments .pixi *.egg-info + +# python +__pycache__/ +.ruff_cache/ diff --git a/README.md b/README.md index cb3cf00..7bd2b5a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,43 @@ updates: interval: "weekly" ``` +## Getting notified when an upload fails + +If a nightly upload silently starts failing, downstream projects can go weeks +without fresh wheels. To catch this, set `report_failures: true`. When the +upload fails the action opens (or reuses) an issue on the repository running the +action, and closes it automatically on the next successful upload. + +This uses the automatically-provided `github.token`, so no extra secret is +needed — but the calling workflow must grant `issues: write`: + +```yml +jobs: + upload: + permissions: + # `report_failures` needs `issues: write`; that is the only scope the + # action itself requires. Any scope you do not list defaults to `none`. + # `contents: read` is only needed if the job also checks out the repo. + issues: write + contents: read + steps: + ... + - name: Upload wheel + uses: scientific-python/upload-nightly-action@main + with: + artifacts_path: dist + anaconda_nightly_upload_token: ${{ secrets.UPLOAD_TOKEN }} + report_failures: true +``` + +Additional inputs: + +| Input | Default | Description | +| --- | --- | --- | +| `report_failures` | `false` | Open/close a tracking issue on the calling repo when the upload fails/recovers. | +| `github_token` | `${{ github.token }}` | Token used to manage the tracking issue. Override to open the issue on another repo. | +| `issue_repository` | current repo | `owner/name` where the tracking issue should be opened. | + ## Access to the ``scientific-python-nightly-wheels`` channel To request access to the wheel channel, please open an issue on [the upload action's @@ -77,6 +114,46 @@ Any versions beyond these are automatically removed as part of a daily cron job Projects may have reasons to request to be added to the list exempt from this automated cleanup, however in that case the responsibility of cleaning-up old, unused versions fall back on the individual project. +## Monitoring channel freshness (maintainers) + +In addition to pruning old *versions* (see above), a scheduled workflow +([`.github/workflows/monitor-nightly.yml`](.github/workflows/monitor-nightly.yml)) +watches for packages that have stopped receiving uploads entirely and files +issues on this repository: + +- **> 30 days** without an upload → opens a `stale-nightly` issue for the package + (automatically closed once a fresh upload lands). +- **> 60 days** without an upload → additionally opens a `nightly-purge-candidate` + issue asking maintainers to decide whether to purge the package from the + channel (also auto-closed on recovery). + +It never deletes anything, runs a small Python script +([`.github/scripts/monitor_nightly.py`](.github/scripts/monitor_nightly.py), using +PyGithub) with the built-in `github.token`, and skips packages listed in +[`packages-ignore-from-cleanup.txt`](packages-ignore-from-cleanup.txt) so that +intentionally-exempt packages are not flagged. + +### Notifying the projects directly (optional) + +The monitor can also open a tracking issue on **each wheel's own repository** so +the people who can fix the build hear about it early: + +- **> 15 days** without an upload → opens a `nightly-upload-stalled` issue on the + producing repo, escalated with a comment at the 30- and 60-day marks and + auto-closed on recovery. + +Because the default `github.token` cannot write issues on other repositories, +this requires a Personal Access Token with `issues: write` on those repos, stored +as the `NIGHTLY_UPLOAD_ISSUE_PAT` secret, plus a hand-maintained wheel → repo +mapping in +[`packages-source-repos.yaml`](packages-source-repos.yaml). It is fully opt-in: +with no PAT (or an empty mapping) this behaviour is skipped entirely. + +If the PAT is set but invalid, expired, or unauthorized, the monitor opens a +`nightly-pat-invalid` issue on *this* repository (using the built-in token) so +maintainers know to rotate the secret, and closes it automatically once the token +works again. + # Using nightly builds in CI To test against nightly builds, you can use the following command to install from diff --git a/action.yml b/action.yml index 478453a..8a676fe 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,26 @@ inputs: description: 'List of labels assigned to the uploaded artifacts' required: false default: main + report_failures: + description: >- + When "true", open an issue on the repository running this action if the + upload fails, and close it automatically on the next successful upload. + The calling workflow must grant `permissions: issues: write`. + required: false + default: 'false' + github_token: + description: >- + Token used to open/close the failure-reporting issue. Defaults to the + automatically provided `github.token`; override only if you need to open + the issue on a different repository with a token that has access to it. + required: false + default: ${{ github.token }} + issue_repository: + description: >- + Repository (in "owner/name" form) where the failure issue should be opened. + Defaults to the repository running the action. + required: false + default: '' runs: using: "composite" @@ -34,7 +54,11 @@ runs: manifest-path: ${{ github.action_path }}/pixi.toml - name: Upload wheels + id: upload shell: bash + # When failure reporting is enabled we must not abort here, so that the + # reporting step below can run. The final step re-raises the failure. + continue-on-error: ${{ inputs.report_failures == 'true' }} env: INPUT_ARTIFACTS_PATH: ${{ inputs.artifacts_path }} INPUT_ANACONDA_NIGHTLY_UPLOAD_ORGANIZATION: ${{ inputs.anaconda_nightly_upload_organization }} @@ -42,3 +66,25 @@ runs: INPUT_ANACONDA_NIGHTLY_UPLOAD_LABELS: ${{ inputs.anaconda_nightly_upload_labels }} run: | pixi run --manifest-path ${{ github.action_path }}/pixi.toml ${{ github.action_path }}/upload_wheels.sh + + - name: Report upload status + if: ${{ inputs.report_failures == 'true' }} + shell: bash + # Issue reporting is best-effort: on pull requests / forks the token is + # read-only and cannot open issues. Never fail the build over reporting; + # a genuine upload failure is still surfaced by the next step. + continue-on-error: true + env: + GITHUB_TOKEN: ${{ inputs.github_token }} + UPLOAD_OUTCOME: ${{ steps.upload.outcome }} + ISSUE_REPOSITORY: ${{ inputs.issue_repository }} + run: | + pixi run --manifest-path ${{ github.action_path }}/pixi.toml \ + python ${{ github.action_path }}/scripts/report_failure.py + + - name: Propagate upload failure + if: ${{ steps.upload.outcome == 'failure' }} + shell: bash + run: | + echo "::error::Nightly wheel upload failed." >&2 + exit 1 diff --git a/packages-source-repos.yaml b/packages-source-repos.yaml new file mode 100644 index 0000000..3a14d10 --- /dev/null +++ b/packages-source-repos.yaml @@ -0,0 +1,31 @@ +# Hand-maintained mapping: nightly wheel name -> source GitHub repository. +# +# Used by .github/scripts/monitor_nightly.py to open a tracking issue on a +# project's OWN repository when its nightly wheels stop being uploaded (see the +# "Monitoring channel freshness" section of the README). +# +# This only takes effect when the monitor workflow is given a Personal Access +# Token via the secrets.NIGHTLY_UPLOAD_ISSUE_PAT secret (exposed to the script +# as PRODUCER_GITHUB_TOKEN) that has `issues: write` on the listed repositories. +# Without that token this file is ignored. +# +# Each entry maps the wheel name (as published on +# https://anaconda.org/scientific-python-nightly-wheels ) to its source repo. +# An entry may be a bare "owner/repo" string, or a mapping with a `repo:` key so +# that per-repo configuration (e.g. custom labels or assignees) can be added +# later without changing the file format. + +packages: + # Dummy entry, kept so the file is always valid YAML and the schema is + # exercised in CI. It is inert in practice: no wheel named + # "example-nightly-package" exists on the channel, so it never triggers. + # Replace the entries below with real projects. + example-nightly-package: + repo: scientific-python/upload-nightly-action + + # Real examples (uncomment and verify before enabling): + # contourpy: contourpy/contourpy + # scikit-image: + # repo: scikit-image/scikit-image + # sunpy: + # repo: sunpy/sunpy diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..501673a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,6 @@ +# Pin the rule selection so `ruff check` stays stable across ruff releases +# (ruff's default rule set changes between versions). Change this deliberately. +line-length = 88 + +[lint] +select = ["E4", "E7", "E9", "F", "W", "I"] diff --git a/scripts/report_failure.py b/scripts/report_failure.py new file mode 100644 index 0000000..61c1722 --- /dev/null +++ b/scripts/report_failure.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Open/close a tracking issue on the calling repository when a nightly upload fails. + +Run by the "Report upload status" step in ``action.yml`` via the action's pixi +Python. Uses ``requests``, which is already part of the pixi environment (a +dependency of ``anaconda-client``), so nothing extra is installed. + +Environment: + GITHUB_TOKEN token with ``issues: write`` on the target repo + UPLOAD_OUTCOME ``failure`` or ``success`` + ISSUE_REPOSITORY optional ``owner/name`` override for where to open the issue + GITHUB_REPOSITORY ``owner/name`` of the repo running the action (fallback target) + GITHUB_API_URL API base (set on GitHub Enterprise); optional + GITHUB_SERVER_URL / GITHUB_RUN_ID used to link to the failing run +""" + +from __future__ import annotations + +import os +import sys + +import requests + +LABEL = "nightly-upload-failure" +TITLE = "Nightly wheel upload is failing" +API = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + + +def session(token: str) -> requests.Session: + s = requests.Session() + s.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "upload-nightly-action", + } + ) + return s + + +def run_url() -> str | None: + server = os.environ.get("GITHUB_SERVER_URL") + repo = os.environ.get("GITHUB_REPOSITORY") + run_id = os.environ.get("GITHUB_RUN_ID") + return ( + f"{server}/{repo}/actions/runs/{run_id}" if server and repo and run_id else None + ) + + +def find_open_issues(s, repo): + r = s.get( + f"{API}/repos/{repo}/issues", + params={"state": "open", "labels": LABEL, "per_page": 100}, + ) + r.raise_for_status() + # The issues endpoint also returns pull requests; filter them out. + return [i for i in r.json() if "pull_request" not in i] + + +def ensure_label(s, repo): + r = s.post( + f"{API}/repos/{repo}/labels", + json={ + "name": LABEL, + "color": "b60205", + "description": "Automatically opened when a nightly wheel upload fails.", + }, + ) + if r.status_code not in (201, 422): # 422 == label already exists + r.raise_for_status() + + +def open_failure_issue(s, repo): + if find_open_issues(s, repo): + print("A failure issue is already open; leaving it as-is.") + return + + lines = [ + "The nightly wheel upload performed by " + "[`scientific-python/upload-nightly-action`]" + "(https://github.com/scientific-python/upload-nightly-action) failed.", + "", + "No fresh nightly wheels were published for this project. Downstream " + "projects that test against these nightly wheels may start failing " + "until the upload succeeds again.", + ] + if link := run_url(): + lines += ["", f"Failing workflow run: {link}"] + lines += [ + "", + "This issue was opened automatically and will be closed automatically " + "on the next successful upload.", + ] + + ensure_label(s, repo) + r = s.post( + f"{API}/repos/{repo}/issues", + json={"title": TITLE, "body": "\n".join(lines), "labels": [LABEL]}, + ) + r.raise_for_status() + print(f"Opened failure issue #{r.json()['number']} on {repo}.") + + +def close_failure_issues(s, repo): + issues = find_open_issues(s, repo) + if not issues: + return + + comment = "Nightly wheel upload succeeded again; closing automatically." + if link := run_url(): + comment += f"\n\nSuccessful run: {link}" + + for issue in issues: + number = issue["number"] + s.post( + f"{API}/repos/{repo}/issues/{number}/comments", json={"body": comment} + ).raise_for_status() + s.patch( + f"{API}/repos/{repo}/issues/{number}", + json={"state": "closed", "state_reason": "completed"}, + ).raise_for_status() + print(f"Closed failure issue #{number} on {repo}.") + + +def main() -> int: + outcome = os.environ.get("UPLOAD_OUTCOME", "").strip() + if outcome not in {"failure", "success"}: + print(f"Unexpected UPLOAD_OUTCOME={outcome!r}; nothing to do.", file=sys.stderr) + return 0 + + token = os.environ.get("GITHUB_TOKEN", "").strip() + if not token: + print("No GITHUB_TOKEN provided; skipping issue reporting.", file=sys.stderr) + return 0 + + repo = ( + os.environ.get("ISSUE_REPOSITORY", "").strip() + or os.environ.get("GITHUB_REPOSITORY", "").strip() + ) + if "/" not in repo: + print("Could not determine target repository; skipping.", file=sys.stderr) + return 0 + + s = session(token) + try: + if outcome == "failure": + open_failure_issue(s, repo) + else: + close_failure_issues(s, repo) + except requests.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status in (401, 403): + print( + f"Skipping issue reporting: the token cannot write issues on {repo} " + f"(HTTP {status}). This is expected on pull requests and forks.", + file=sys.stderr, + ) + return 0 + body = exc.response.text if exc.response is not None else "" + print(f"GitHub API error: {exc}\n{body}", file=sys.stderr) + return 1 + except requests.RequestException as exc: + print(f"Network error talking to GitHub: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())