The pull request your bot opened last release can no longer pass

Press · No. 095

Shipped

0.7.2 taught a fan-out job to close its own predecessors. Every release opened a fresh pull request in each consumer repository, and closed nothing, so a repository that had not merged for two releases collected four of them. Three of the four consumers ended up in that state across a single pair of releases.

The clutter is the visible half. The half that matters is that the older pull requests could not pass. Each one pins the release that opened it, and a newer release declares targets that release did not know about, so the older pull request’s check fails against a definition of “correct” that did not exist when it was created. The bot was manufacturing red checks in other people’s repositories.

This guide builds the fan-out branch naming, shows why the stale pull request fails rather than just sits there, and adds the twelve lines that retire it.

Setup: one pull request per consumer, per base branch

A fan-out that pushes generated content into several repositories needs branch names that cannot collide. Version and base branch both belong in the name, because the same release may land on more than one long-lived branch:

#!/usr/bin/env bash
set -euo pipefail

open_fanout_pr() {
  local repo="$1" base="$2" version="$3" work="$4"
  local branch="fanout/brand-v${version}-${base}"

  git -C "$work" checkout -q -b "$branch"
  git -C "$work" commit -qam "chore: adopt brand v${version}"
  git -C "$work" push -q origin "$branch"

  gh pr create --repo "$repo" --base "$base" --head "$branch" \
    --title "adopt brand v${version}" \
    --body "Generated by the brand fan-out. Nothing here is hand-edited."
}

Two properties come from that name and are worth keeping. It is unique per release and per base, so nothing collides. And it lives in a namespace, fanout/, that no human uses, which is what will make automatic closing safe in a minute.

Why the stale pull request is not merely redundant

Picture a repository that ignored the v0.6.0 pull request. v0.7.0 ships and adds a new target, a generated block in a file that was not covered before. The consumer’s check now verifies every declared target.

The v0.6.0 pull request still pins v0.6.0. Run its check today and it fails: it does not contain the new block, because the release it pins does not know that block exists. Nothing is wrong with the branch, and rebasing does not help; the pull request is asking for a state that is no longer valid.

So the repository ends up with several open bot pull requests, most of them red, all of them opened by the same author. Two well-known bots take this seriously enough to build machinery for it. Dependabot “automatically rebases pull requests to resolve any conflicts”, and gives up after 30 days without a merge (managing pull requests for dependency updates). It also caps how many it will have open at once: with the default limit, “If five pull requests with version updates are open, no further pull requests are raised until some of those open requests are merged or closed” (Dependabot options reference). Stacked bot pull requests are not free; they consume a repository’s real attention budget, and in that case they block the next update outright.

Retire the predecessors when you open the successor

The rule is narrow on purpose: same base branch, same branch namespace, not the branch just created.

# supersede <base> <current-branch> <version>
# Close this base's earlier fan-out pull requests, with a reason.
supersede() {
  local base="$1" branch="$2" version="$3"
  gh pr list --state open --base "$base" --json number,headRefName \
    --jq ".[] | select(.headRefName | startswith(\"fanout/brand-v\")) | select(.headRefName != \"$branch\") | .number" \
    | while read -r old_pr; do
        [ -n "$old_pr" ] || continue
        echo "  superseding #$old_pr"
        gh pr close "$old_pr" --delete-branch \
          --comment "Superseded by v$version. An earlier fan-out PR pins a release older than the targets the current one declares, so its check cannot pass." \
          || echo "::warning::could not close #$old_pr"
      done
}

gh pr list does the base filtering server-side with --base, and --json number,headRefName with --jq gets you numbers with no output parsing. gh pr close takes both flags this needs: --comment to “Leave a closing comment” and --delete-branch to “Delete the local and remote branch after close”.

Call it right after the pull request is created, inside the same per-repository loop:

open_fanout_pr "$repo" "$base" "$version" "$work"
supersede "$base" "fanout/brand-v${version}-${base}" "$version"

Use it, then verify it

The dangerous part of this function is what it closes, so test it against a fixture instead of a live repository. Save five open pull requests, four of them from previous fan-outs across two base branches, one of them a human’s:

[
  { "number": 41, "baseRefName": "dev",  "headRefName": "fanout/brand-v0.6.0-dev" },
  { "number": 42, "baseRefName": "main", "headRefName": "fanout/brand-v0.6.0-main" },
  { "number": 57, "baseRefName": "dev",  "headRefName": "fanout/brand-v0.7.0-dev" },
  { "number": 58, "baseRefName": "main", "headRefName": "fanout/brand-v0.7.0-main" },
  { "number": 60, "baseRefName": "dev",  "headRefName": "feature/unrelated-work" }
]

Then stub gh so the real --jq expression still runs, and nothing touches the network:

#!/usr/bin/env bash
set -euo pipefail
. ./supersede.sh

# stub gh: `pr list --base` is filtered here the way the real command filters it
# server-side; the --jq expression is the real one.
gh() {
  if [ "$1 $2" = "pr list" ]; then
    local expr="" base=""
    while [ $# -gt 0 ]; do
      [ "$1" = "--jq" ] && expr="$2"
      [ "$1" = "--base" ] && base="$2"
      shift
    done
    jq -c "[ .[] | select(.baseRefName == \"$base\") ]" open-prs.json | jq -r "$expr"
  elif [ "$1 $2" = "pr close" ]; then
    echo "  closed #$3"
  fi
}

echo "opening fanout/brand-v0.8.0-dev against dev"
supersede dev "fanout/brand-v0.8.0-dev" 0.8.0
echo "opening fanout/brand-v0.8.0-main against main"
supersede main "fanout/brand-v0.8.0-main" 0.8.0
opening fanout/brand-v0.8.0-dev against dev
  superseding #41
  closed #41
  superseding #57
  closed #57
opening fanout/brand-v0.8.0-main against main
  superseding #42
  closed #42
  superseding #58
  closed #58

Read that output for what is missing as much as for what is there. #60, the human’s branch, is never touched. The dev pass leaves the main pull requests alone, and the other way round, because base filtering happens before the prefix match. And the branch just opened is excluded by name, which is the check that keeps this function from closing its own pull request the moment you call it.

Gotchas

Forgetting to exclude the current branch closes the pull request you just opened. The select(.headRefName != "$branch") clause is the entire safety of this function. Symptom: the fan-out reports success and the repository has no open pull request. Escape: the fixture test above, with the new branch present in the fixture.

Namespace your bot’s branches before you automate closing anything. A prefix match is only safe when the prefix is yours alone. Symptom: a bot closing work someone was in the middle of. Escape: one reserved prefix, matched with startswith, never a substring or a title match.

Closing without a comment reads as a malfunction. Someone who was mid-review on that pull request gets a closed tab and no reason. Escape: --comment with the actual reason, naming the release that superseded it.

--delete-branch and deterministic branch names interact. If you close without deleting the remote branch and later regenerate the same branch name, gh pr create fails because a pull request for that head already exists. Escape: delete on close, which is what --delete-branch is for, and keep the version in the branch name so reuse is rare anyway.

Shipping a fix mid-rollout is how the pile got built. The stacking here came from releasing again while consumers were still adopting the previous release. This fix was deliberately held back until every consumer was verified current with no open pull requests, which is the same discipline that would have prevented the problem. Symptom: each release adds pull requests faster than the consumers merge them. Escape: treat “every consumer is current” as a release precondition for a tool whose releases fan out.

Sources

Changelog

  • fix(press): propagate retires its own superseded PRs (0.7.2) (#137) (9876899)