-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add a version-pr-reaper reusable #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() { | ||
| 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 \ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue |
||
| --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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion
semver_keyandCURRENT_KEY=$(semver_key "$CURRENT_VERSION")are duplicated verbatim in the orphan-branch step at lines 153-160. Both steps already readCURRENT_VERSIONfromsteps.current, so computeCURRENT_KEYonce 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.