From 5ce54105a53470918eebd6b0ed0a6159c2bc4171 Mon Sep 17 00:00:00 2001 From: Ray Morris Date: Thu, 2 Jul 2026 00:06:10 -0500 Subject: [PATCH] Auto-clean stale pr-test-builds releases iNavFlight/pr-test-builds accumulated releases forever since nothing deleted them once a PR closed (157 at time of writing). Adds a merge-triggered workflow for immediate cleanup plus a scheduled sweep and manual script as a safety net / backlog cleaner. --- .github/scripts/cleanup-old-pr-test-builds.py | 135 ++++++++++++++++++ .../cleanup-pr-test-builds-scheduled.yml | 28 ++++ .github/workflows/cleanup-pr-test-builds.yml | 58 ++++++++ docs/development/cleanup-pr-test-builds.md | 100 +++++++++++++ 4 files changed, 321 insertions(+) create mode 100755 .github/scripts/cleanup-old-pr-test-builds.py create mode 100644 .github/workflows/cleanup-pr-test-builds-scheduled.yml create mode 100644 .github/workflows/cleanup-pr-test-builds.yml create mode 100644 docs/development/cleanup-pr-test-builds.md diff --git a/.github/scripts/cleanup-old-pr-test-builds.py b/.github/scripts/cleanup-old-pr-test-builds.py new file mode 100755 index 00000000000..ed9e9e0727b --- /dev/null +++ b/.github/scripts/cleanup-old-pr-test-builds.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Bulk-delete iNavFlight/pr-test-builds releases for merged or stale PRs. + +See docs/development/cleanup-pr-test-builds.md for full usage, auth setup, and +troubleshooting. Complements .github/workflows/cleanup-pr-test-builds.yml, +which deletes a release immediately when its PR merges; this script is the +manual/scheduled sweep for anything that workflow missed plus backlog +cleanup. + +Usage: + python3 .github/scripts/cleanup-old-pr-test-builds.py [--dry-run] [--older-than DAYS] + +Auth: requires the `gh` CLI, authenticated via the GITHUB_TOKEN (or GH_TOKEN) +environment variable, or a prior `gh auth login`. The token needs Contents: +write access to iNavFlight/pr-test-builds to delete releases (read access is +enough for --dry-run). +""" +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timezone + +PR_TEST_BUILDS_REPO = "iNavFlight/pr-test-builds" +SOURCE_REPO = "iNavFlight/inav" +TAG_RE = re.compile(r"^pr-(\d+)$") + + +def gh_json(*args): + result = subprocess.run(["gh", *args], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"gh {' '.join(args)} failed") + return json.loads(result.stdout) + + +def list_releases(): + return gh_json( + "release", "list", "--repo", PR_TEST_BUILDS_REPO, + "--json", "tagName,publishedAt", "--limit", "1000", + ) + + +def get_pr_status(pr_number): + return gh_json( + "pr", "view", str(pr_number), "--repo", SOURCE_REPO, + "--json", "state", + ) + + +def delete_release(tag): + result = subprocess.run( + ["gh", "release", "delete", tag, "--repo", PR_TEST_BUILDS_REPO, + "--cleanup-tag", "--yes"], + capture_output=True, text=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "delete failed") + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dry-run", action="store_true", + help="Print what would be deleted without deleting anything") + parser.add_argument("--older-than", type=int, metavar="DAYS", + help="Also delete releases published DAYS+ ago, regardless of PR merge status") + return parser.parse_args() + + +def main(): + args = parse_args() + + if subprocess.run(["gh", "auth", "status"], capture_output=True).returncode != 0: + sys.exit("gh is not authenticated - set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`") + + try: + releases = list_releases() + except RuntimeError as exc: + sys.exit(f"could not list releases: {exc}") + + now = datetime.now(timezone.utc) + deleted, skipped, errors = [], [], [] + + for release in releases: + tag = release["tagName"] + match = TAG_RE.match(tag) + if not match: + skipped.append((tag, "tag doesn't match pr-, leaving alone")) + continue + + pr_number = match.group(1) + published_at = datetime.fromisoformat(release["publishedAt"].replace("Z", "+00:00")) + age_days = (now - published_at).days + + try: + pr = get_pr_status(pr_number) + except RuntimeError as exc: + errors.append((tag, f"could not look up PR #{pr_number}: {exc}")) + continue + + if pr["state"] == "MERGED": + reason = "PR merged" + elif args.older_than is not None and age_days >= args.older_than: + reason = f"release is {age_days}d old (>= {args.older_than}d), PR state={pr['state']}" + else: + skipped.append((tag, f"PR #{pr_number} state={pr['state']}, release is {age_days}d old")) + continue + + if not args.dry_run: + try: + delete_release(tag) + except RuntimeError as exc: + errors.append((tag, f"delete failed: {exc}")) + continue + + deleted.append((tag, reason)) + + verb = "Would delete" if args.dry_run else "Deleted" + for tag, reason in deleted: + print(f"{verb}: {tag} ({reason})") + for tag, reason in skipped: + print(f"Skipped: {tag} ({reason})") + for tag, reason in errors: + print(f"ERROR: {tag} ({reason})", file=sys.stderr) + + print() + print(f"Summary: {len(deleted)} {'to delete' if args.dry_run else 'deleted'}, " + f"{len(skipped)} skipped, {len(errors)} errors") + + if errors: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/cleanup-pr-test-builds-scheduled.yml b/.github/workflows/cleanup-pr-test-builds-scheduled.yml new file mode 100644 index 00000000000..084289f586f --- /dev/null +++ b/.github/workflows/cleanup-pr-test-builds-scheduled.yml @@ -0,0 +1,28 @@ +name: Cleanup PR Test Builds (scheduled sweep) + +# Safety net for cleanup-pr-test-builds.yml: runs .github/scripts/cleanup-old-pr-test-builds.py +# on a schedule to catch anything the merge-triggered workflow missed (a failed API +# call, a PR merged before that workflow existed, etc.) and to gradually clear the +# pre-existing backlog. --older-than 14 also deletes releases for PRs that are still +# open but haven't been touched in 14 days, not just merged ones. +# +# Requires the same repository secret PR_BUILDS_TOKEN used by pr-test-builds.yml +# and cleanup-pr-test-builds.yml (Contents: write access to iNavFlight/pr-test-builds). +on: + schedule: + - cron: '17 4 * * *' # daily, off-the-hour to avoid GitHub's top-of-hour load spike + workflow_dispatch: {} # allow manual runs + +jobs: + sweep: + runs-on: ubuntu-latest + permissions: {} + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + - name: Run cleanup sweep + env: + GH_TOKEN: ${{ secrets.PR_BUILDS_TOKEN }} + run: python3 .github/scripts/cleanup-old-pr-test-builds.py --older-than 14 diff --git a/.github/workflows/cleanup-pr-test-builds.yml b/.github/workflows/cleanup-pr-test-builds.yml new file mode 100644 index 00000000000..12c3cfa06e6 --- /dev/null +++ b/.github/workflows/cleanup-pr-test-builds.yml @@ -0,0 +1,58 @@ +name: Cleanup PR Test Build + +# Deletes the pr-test-builds release for a PR as soon as it merges, so +# iNavFlight/pr-test-builds doesn't accumulate stale test firmware forever. +# See pr-test-builds.yml for the workflow that creates these releases. +# +# Uses pull_request_target (not pull_request) so secrets are available even +# for PRs from forks -- GitHub withholds repo secrets from plain +# pull_request-triggered workflows on fork PRs as a safety measure. +# pull_request_target runs the base repo's trusted workflow file instead of +# the fork's, which is normally risky if you check out and execute the PR's +# code with privileged secrets -- but this workflow never checks out or runs +# any PR code, it only reads github.event.pull_request.number/.merged from +# the trusted event payload and calls the GitHub API. +# +# Requires the same repository secret PR_BUILDS_TOKEN used by +# pr-test-builds.yml (Contents: write access to iNavFlight/pr-test-builds). +# +# A "not found" release (e.g. the PR never had a successful test build) is +# treated as an expected no-op, not a failure. Any other API failure (auth, +# rate limit, etc.) fails the job loudly so it doesn't go unnoticed -- see +# also cleanup-pr-test-builds-scheduled.yml, which sweeps up anything missed +# here. +# +# workflow_dispatch is a manual escape hatch: retry cleanup for a specific PR +# if the automatic run failed, or test this workflow without waiting for a +# live merge event. +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to clean up' + required: true + type: number + +jobs: + cleanup: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + permissions: {} + steps: + - name: Delete pr-test-builds release + env: + GH_TOKEN: ${{ secrets.PR_BUILDS_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }} + run: | + if err=$(gh release view "pr-${PR_NUMBER}" --repo iNavFlight/pr-test-builds 2>&1 >/dev/null); then + gh release delete "pr-${PR_NUMBER}" \ + --repo iNavFlight/pr-test-builds --cleanup-tag --yes + echo "Deleted pr-test-builds release pr-${PR_NUMBER}" + elif [[ "$err" == *"release not found"* ]]; then + echo "No pr-test-builds release found for PR #${PR_NUMBER} - nothing to clean up." + else + echo "::error::gh release view failed: $err" + exit 1 + fi diff --git a/docs/development/cleanup-pr-test-builds.md b/docs/development/cleanup-pr-test-builds.md new file mode 100644 index 00000000000..acbeb66e0cc --- /dev/null +++ b/docs/development/cleanup-pr-test-builds.md @@ -0,0 +1,100 @@ +# Cleaning up pr-test-builds releases + +`.github/workflows/pr-test-builds.yml` publishes a test firmware build to +[iNavFlight/pr-test-builds](https://github.com/iNavFlight/pr-test-builds) for every open +PR (tag `pr-`, e.g. `pr-11675`). Nothing used to delete these once the PR closed, +so the repo accumulated releases forever (157 at the time this was written). Three +mechanisms now keep it clean: + +1. **`cleanup-pr-test-builds.yml`** — deletes a PR's release immediately when the PR + merges. Primary mechanism, handles the common case. +2. **`cleanup-pr-test-builds-scheduled.yml`** — a daily cron sweep that runs the script + below with `--older-than 14`. Safety net for anything (1) missed, and gradually clears + the pre-existing backlog. +3. **`.github/scripts/cleanup-old-pr-test-builds.py`** — the script the scheduled sweep uses + for its logic, also runnable by hand. The merge-triggered workflow has its own simpler + inline `gh` logic instead, since it only ever handles the single-release, definitely-merged + case — the two independent delete-a-release implementations should be kept in sync by hand + if either changes. + +Only `inav` needs this — `inav-configurator`'s PR test builds are plain GitHub Actions +artifacts linked from a PR comment, not `pr-test-builds` releases, so they expire under +GitHub's own artifact retention policy with no cleanup needed. + +## Token setup + +Both workflows use the existing `PR_BUILDS_TOKEN` repository secret (the same one +`pr-test-builds.yml` uses to publish releases) — no new secret is required. It must be a +PAT (fine-grained or classic with `repo` scope) with Contents: write access to +`iNavFlight/pr-test-builds`; the default `GITHUB_TOKEN` can't write to a different repo. + +For the local script, export the same kind of token as `GITHUB_TOKEN` or `GH_TOKEN`, or +just use an already-authenticated `gh` CLI (`gh auth login`) — the script shells out to +`gh` rather than talking to the API directly, so it picks up whatever `gh` is configured +to use. + +## Running the script manually + +```bash +# Preview only, deletes releases for merged PRs +python3 .github/scripts/cleanup-old-pr-test-builds.py --dry-run + +# Actually delete releases for merged PRs +python3 .github/scripts/cleanup-old-pr-test-builds.py + +# Also sweep releases 14+ days old regardless of PR merge status (open or closed) +python3 .github/scripts/cleanup-old-pr-test-builds.py --dry-run --older-than 14 +``` + +Output looks like: + +``` +Would delete: pr-11614 (PR merged) +Would delete: pr-11631 (release is 21d old (>= 14d), PR state=OPEN) +Skipped: pr-11675 (PR #11675 state=OPEN, release is 1d old) + +Summary: 2 to delete, 1 skipped, 0 errors +``` + +Any lookup or delete failure is reported as `ERROR:` on stderr and makes the script exit +non-zero — it does not silently skip failures. + +## Manually retrying/testing the merge-triggered workflow + +`cleanup-pr-test-builds.yml` also accepts `workflow_dispatch`, so you can retry cleanup +for one specific PR (e.g. if the automatic run failed) without waiting for another merge: + +```bash +gh workflow run cleanup-pr-test-builds.yml --repo iNavFlight/inav -f pr_number=12345 +``` + +## Troubleshooting + +- **"No pr-test-builds release found for PR #N"** in the workflow log is expected and not + an error — it means that PR never had a successful test build (e.g. all CI runs failed), + so there's nothing to delete. +- **Auth failures** (workflow): confirm `PR_BUILDS_TOKEN` hasn't expired — it's a PAT, so + it has an expiry date set when it was created, unlike the auto-rotating `GITHUB_TOKEN`. +- **Auth failures** (local script): run `gh auth status` to check; `gh auth login` or set + `GITHUB_TOKEN`/`GH_TOKEN`. +- **Rate limiting**: the script makes two `gh` API calls per release (list release info, + then look up the source PR). For very large backlogs this can approach GitHub's 5,000 + requests/hour authenticated limit; `gh` will report rate-limit errors as they occur — if + you see them, wait for the reset time shown and re-run. It's idempotent: already-deleted + releases simply won't appear in the next `gh release list`, so a re-run picks up where it + left off. + +## Limitations + +- Only handles `iNavFlight/pr-test-builds` releases tagged `pr-`; anything else in + that repo is left alone. +- The script assumes every `pr-` tag corresponds to an `iNavFlight/inav` PR. If + `pr-test-builds` is ever shared with another source repo, the script's hardcoded + `SOURCE_REPO` constant will need to become configurable. +- `--older-than` deletes based on the release's `published_at` timestamp, not + `created_at` — the two differ in this repo (see git history / commit message for this + change if you want the details), and `created_at` is not a reliable age signal here. +- The script lists at most 1000 releases per run (`gh release list --limit 1000`, newest + first). If the backlog ever exceeds that, the oldest — most overdue — releases would + silently fall off the list. Not a concern at current scale (~150 releases), but worth + revisiting (pagination, or `--order asc`) if the repo grows much larger.