Skip to content
Open
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
224 changes: 224 additions & 0 deletions .github/workflows/version-pr-reaper.yml
Original file line number Diff line number Diff line change
@@ -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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion semver_key and CURRENT_KEY=$(semver_key "$CURRENT_VERSION") are duplicated verbatim in the orphan-branch step at lines 153-160. Both steps already read CURRENT_VERSION from steps.current, so compute CURRENT_KEY once in the "Read current version" step and expose it as an extra output for both later steps to consume, instead of re-deriving it twice.

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 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue gh pr list defaults to --limit 30 for the whole open-PR list before the jq filter for the auto/version-increment- prefix runs. On a repo with more than 30 open PRs (plausible on a first run, since accumulated stale auto-increment PRs are exactly what this workflow exists to clear), older matching PRs beyond page 1 are silently skipped. Such a PR is then absent from handled_branches.txt, so the orphan-branch step below has no way to know it isn't orphaned and can delete its branch while the PR is still open. Add --limit 200 (or paginate) to the gh pr list call.

--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