From a6d230a9b2338d2802d8876f9e8b5377489dcdb6 Mon Sep 17 00:00:00 2001 From: seabbs-bot Date: Mon, 24 Aug 2026 19:52:31 +0100 Subject: [PATCH] feat: add a version-pr-reaper reusable An auto-increment PR goes stale when a release lands by another route, or when a later bump overtakes a pending one. Nothing closed those, so they accumulated along with their branches. The selection logic belongs here rather than in each package: it is the same semver comparison, Project.toml-only check and merge-base orphan sweep everywhere, and a copy per package means a fix has to be re-synced across the org. The caller supplies the triggers and passes `dry_run` through. The reusable does not second-guess the caller's event: a caller that wants a preview from any trigger gets one. Co-authored-by: Sam Abbott --- .github/workflows/version-pr-reaper.yml | 224 ++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 .github/workflows/version-pr-reaper.yml diff --git a/.github/workflows/version-pr-reaper.yml b/.github/workflows/version-pr-reaper.yml new file mode 100644 index 0000000..c131470 --- /dev/null +++ b/.github/workflows/version-pr-reaper.yml @@ -0,0 +1,224 @@ +# Closes an auto-increment PR, and deletes its branch, once the package version +# on main has reached or passed the version the PR proposes. An auto-increment +# PR goes stale when a release lands by another route, or when a later bump +# overtakes a pending one: the PR proposing the older version has nothing left +# to do. +# +# It also sweeps orphaned auto-increment branches carrying no open PR (closed +# by hand, or never created), which the PR-list step cannot reach and which +# would otherwise pile up. A branch already merged into main is safe to delete +# outright. Otherwise safety is judged against the branch's own merge-base with +# main rather than main's current tip: main moves on and touches files the +# branch never did, so diffing against the tip makes an old, harmless branch +# look larger over time. Diffing from the merge-base isolates what the branch +# itself added, which is what a PR's file list would have shown. +# +# The caller supplies the triggers. A push to main reaps what a landed release +# supersedes straight away; a schedule catches a version that moved by a route +# that does not push to main; `workflow_dispatch` with `dry_run` previews +# without closing or deleting anything. + +name: Version PR Reaper + +on: + workflow_call: + inputs: + dry_run: + description: >- + Report what would be closed and deleted without doing either. The + caller passes its own `workflow_dispatch` input through; anything + other than the string `true` acts for real. + required: false + type: string + default: 'false' + +jobs: + reap: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + # Full history, so a merge-base against a branch many commits + # behind main can be computed below. + fetch-depth: 0 + + - name: Read current version + id: current + run: | + set -euo pipefail + + # A pre-release/build suffix should never reach main, but strip + # one defensively rather than let it break the numeric compare + # below. + CURRENT_VERSION=$(grep '^version = ' Project.toml \ + | sed 's/version = "\(.*\)"/\1/') + CURRENT_VERSION="${CURRENT_VERSION%%[-+]*}" + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "Current version: $CURRENT_VERSION" + + - name: Reap stale auto-increment PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT_VERSION: ${{ steps.current.outputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + # A single sortable integer so 0.10.0 compares above 0.9.0, + # which plain string or float comparison gets wrong. + semver_key() { + local major minor patch + IFS='.' read -r major minor patch <<< "$1" + printf '%d%06d%06d' "$major" "$minor" "$patch" + } + CURRENT_KEY=$(semver_key "$CURRENT_VERSION") + + # Branches this step disposes of are recorded here, so the + # orphan sweep below does not also try to act on them. + : > handled_branches.txt + + # Only the exact prefix the increment-version action writes. + PRS=$(gh pr list --state open --json number,headRefName \ + --jq '.[] | select(.headRefName + | startswith("auto/version-increment-"))') + + if [ -z "$PRS" ]; then + echo "No open auto-increment PRs." + exit 0 + fi + + echo "$PRS" | jq -c '.' | while read -r pr; do + NUMBER=$(echo "$pr" | jq -r '.number') + BRANCH=$(echo "$pr" | jq -r '.headRefName') + echo "$BRANCH" >> handled_branches.txt + PROPOSED="${BRANCH#auto/version-increment-}" + + if ! [[ "$PROPOSED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "PR #$NUMBER's branch $BRANCH doesn't carry a plain" \ + "semver suffix. Leaving it alone." + continue + fi + + PROPOSED_KEY=$(semver_key "$PROPOSED") + if [ "$PROPOSED_KEY" -gt "$CURRENT_KEY" ]; then + echo "PR #$NUMBER proposes $PROPOSED, ahead of current" \ + "$CURRENT_VERSION. Leaving it open." + continue + fi + + # Only a throwaway version bump is safe to close: if someone + # has built on the branch, closing it would discard real + # work. + FILES=$(gh pr view "$NUMBER" --json files --jq '.files[].path') + if [ "$FILES" != "Project.toml" ]; then + echo "PR #$NUMBER proposes $PROPOSED (<= $CURRENT_VERSION)" \ + "but its diff touches more than Project.toml. Someone" \ + "has built on $BRANCH: skipping." + continue + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] would close PR #$NUMBER ($PROPOSED <=" \ + "$CURRENT_VERSION, Project.toml only) and delete $BRANCH." + continue + fi + + echo "Closing PR #$NUMBER ($PROPOSED <= $CURRENT_VERSION," \ + "Project.toml only) and deleting $BRANCH." + # A human may have closed the PR or deleted the branch since + # the listing above, and either call can fail transiently. + # Under `set -e` an unguarded failure would abandon every + # remaining PR in this run, so a failure only skips its own. + if ! gh pr close "$NUMBER" --comment \ + "Superseded: the package is already at $CURRENT_VERSION."; then + echo "Could not close PR #$NUMBER. Leaving $BRANCH in place." + continue + fi + git push origin --delete "$BRANCH" \ + || echo "Closed PR #$NUMBER but could not delete $BRANCH." + done + + - name: Reap orphaned auto-increment branches + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT_VERSION: ${{ steps.current.outputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + semver_key() { + local major minor patch + IFS='.' read -r major minor patch <<< "$1" + printf '%d%06d%06d' "$major" "$minor" "$patch" + } + CURRENT_KEY=$(semver_key "$CURRENT_VERSION") + + [ -f handled_branches.txt ] || : > handled_branches.txt + + # Scoped to the exact prefix, same as the PR-list step above, + # rather than fetching every branch in the repository. + REF_GLOB='auto/version-increment-*' + git fetch origin "+refs/heads/$REF_GLOB:refs/remotes/origin/$REF_GLOB" + + git branch -r --list 'origin/auto/version-increment-*' \ + | sed 's/^[[:space:]]*origin\///' \ + | while read -r BRANCH; do + if grep -qxF "$BRANCH" handled_branches.txt; then + # Already disposed of, or left alone, by the PR-driven + # step above. + continue + fi + + PROPOSED="${BRANCH#auto/version-increment-}" + if ! [[ "$PROPOSED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Branch $BRANCH doesn't carry a plain semver suffix." \ + "Leaving it alone." + continue + fi + + PROPOSED_KEY=$(semver_key "$PROPOSED") + if [ "$PROPOSED_KEY" -gt "$CURRENT_KEY" ]; then + echo "Branch $BRANCH proposes $PROPOSED, ahead of current" \ + "$CURRENT_VERSION. Leaving it alone." + continue + fi + + if git merge-base --is-ancestor "origin/$BRANCH" HEAD; then + REASON="already merged into main" + else + # What this branch itself added, not what main has since + # gone on to touch: diffing straight against main's + # current tip would make an old, harmless branch look + # larger every time main moves, never smaller. + MERGE_BASE=$(git merge-base HEAD "origin/$BRANCH") + FILES=$(git diff --name-only "$MERGE_BASE" "origin/$BRANCH") + if [ "$FILES" != "Project.toml" ]; then + echo "Branch $BRANCH proposes $PROPOSED (<=" \ + "$CURRENT_VERSION) but has commits beyond a version" \ + "bump since it diverged from main. Someone has built" \ + "on it: skipping." + continue + fi + # "Unmerged" is not asserted here: a squash-merge lands + # as a brand new commit on main, so the ancestor check + # above never fires for it even though its content did + # land. This diff can't tell squashed-in from genuinely + # rejected apart, so the log only claims what it knows. + REASON="Project.toml only since it diverged from main" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "[dry run] would delete orphaned branch $BRANCH" \ + "($PROPOSED <= $CURRENT_VERSION, $REASON)." + continue + fi + + echo "Deleting orphaned branch $BRANCH ($PROPOSED <=" \ + "$CURRENT_VERSION, $REASON)." + git push origin --delete "$BRANCH" \ + || echo "Could not delete orphaned branch $BRANCH." + done