Skip to content

ci: keep open PR branches up to date with main automatically - #675

Merged
cuttlefisch merged 2 commits into
mainfrom
ci/auto-update-pr-branches
Aug 6, 2026
Merged

ci: keep open PR branches up to date with main automatically#675
cuttlefisch merged 2 commits into
mainfrom
ci/auto-update-pr-branches

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

Four PRs went stale against main simultaneously today, each then needing a
manual back-merge plus a full CI re-run before it could land. This removes the
manual step.

Why they go stale

main's protection sets required_status_checks.strict = true — a PR must be up
to date with the base to merge. That is the right setting; it is what stops two
independently-green PRs from combining into a broken main. But it means every
merge to main invalidates every other open PR
.

Done by hand the cost is paid twice: once in CI minutes, and once in the latency
between "CI went green" and "somebody noticed and pushed the back-merge". With
several PRs open the second cost dominates, and a PR discovers it is stale at
merge time — the worst moment to find out.

What this does

On every push to main, bring every open non-draft PR forward. Combined with
auto-merge (now enabled repo-wide), a PR that is green and approved lands without
anyone watching it.

Two bugs the obvious version would have shipped

Both were found by testing against the live API rather than reasoning about it.

1. mergeStateStatus is computed lazily. The first query for a PR kicks off
the computation and returns UNKNOWN; only a later query sees the truth. A
workflow keyed on state == "BEHIND" would therefore skip exactly the PRs it
exists to fix, on precisely the run where main just moved and nothing has been
recomputed — silently, looking like a successful no-op run. Observed live on this
repo minutes before writing it: two PRs reported mergeable: UNKNOWN alongside a
fully-populated check list. Fixed by reading behind_by from the compare API,
which is derived from the commit graph and correct on first read.

2. gh api writes its error body to stdout. On a 404 — deleted branch,
fork-side head — $behind holds {"message":"Not Found",...}, not an empty
string. An emptiness check alone passes that straight to [ "$behind" -eq 0 ],
which errors and, being false, falls through to attempting an update on a branch
that does not exist. Fixed with a case guard requiring an actual integer; all
four input shapes (5, 0, empty, error-JSON) unit-tested.

Deliberate non-behaviours

  • Never force-pushes. gh pr update-branch merges, so nobody's local clone
    has history rewritten under it.
  • Skips drafts. A draft is not racing to merge; updating it would burn a CI
    run per push to main for nothing.
  • One conflict does not fail the run. A merge conflict is the author's to
    resolve, and must not hide the outcome for every other PR. Each result goes in
    a per-PR summary table with a totals line.
  • Concurrency group cancels older runs. Several merges in quick succession
    would otherwise start overlapping runs racing on the same branches, doubling
    the CI they trigger.

Also enabled allow_auto_merge on the repository, which was off — that is what
lets a green PR land without a manual merge command.

🤖 Generated with Claude Code

`main`'s protection sets `required_status_checks.strict = true`, so a PR must
be up to date with the base to merge. That is the right setting — it is what
stops two independently-green PRs from combining into a broken main — but it
means every merge to main invalidates every other open PR.

Paid by hand, that costs twice: the CI re-run, and the latency between "CI went
green" and "somebody noticed and pushed the back-merge". With several PRs open
the second cost dominates, and a PR discovers it is stale at merge time, which
is the worst moment to find out. Four PRs went stale together this way.

This workflow brings every open, non-draft PR forward whenever main moves. With
auto-merge (now enabled repo-wide), a green and approved PR lands unattended.

Two things it gets right that the obvious version does not:

  * It reads `behind_by` from the compare API rather than `mergeStateStatus`.
    Mergeability is computed LAZILY — the first query kicks off the computation
    and returns UNKNOWN, and only a later query sees the truth. A workflow keyed
    on it would skip exactly the PRs it exists to fix, on precisely the run where
    main just moved. Observed live on this repo: two PRs reported
    `mergeable: UNKNOWN` alongside a fully-populated check list.

  * It requires `behind_by` to actually be an integer before using it as one.
    `gh api` writes its error body to STDOUT, so on a 404 (deleted or fork-side
    branch) the variable holds `{"message":"Not Found",...}` rather than being
    empty; an emptiness check alone passes that to `[ "$x" -eq 0 ]`, which errors
    and — being false — falls through to attempting an update on a branch that
    does not exist. Both paths verified against the live API.

It never force-pushes (`update-branch` merges), skips drafts so a push to main
does not burn a CI run per draft, and reports a per-PR outcome table rather than
failing the run when one branch conflicts — a conflict is the author's to
resolve and must not hide the result for the others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cuttlefisch cuttlefisch added the release:none Skip version bump on merge label Aug 6, 2026
@cuttlefisch
cuttlefisch enabled auto-merge August 6, 2026 11:43
@cuttlefisch
cuttlefisch merged commit 7b1d43b into main Aug 6, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none Skip version bump on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant