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
135 changes: 135 additions & 0 deletions .github/scripts/cleanup-old-pr-test-builds.py
Original file line number Diff line number Diff line change
@@ -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-<number>, 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()
28 changes: 28 additions & 0 deletions .github/workflows/cleanup-pr-test-builds-scheduled.yml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions .github/workflows/cleanup-pr-test-builds.yml
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions docs/development/cleanup-pr-test-builds.md
Original file line number Diff line number Diff line change
@@ -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-<number>`, 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-<number>`; anything else in
that repo is left alone.
- The script assumes every `pr-<number>` 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.
Loading