Pull requests

The pull request steps and how to write a changelog fragment are in CONTRIBUTING.md. This page holds why fragments exist, what CI checks about them, and the script that watches a pull request to merge.

Changelog fragments: why, and what CI checks (#993)

CHANGELOG.md is assembled from one changelog.d/ fragment per pull request and is never edited by hand.

This is not a style preference. Every entry used to be prepended to the same anchor, so two pull requests open at once conflicted on that file every time, whatever they said — and entries here are essays, averaging 23 lines, so resolving one was never a two-line merge. Two fragments are two different files, and git only conflicts on the same region of the same file.

CI's Changelog fragment job requires one on any pull request that changes code or a binding (anything under bindings/), fails one that edits CHANGELOG.md by hand, and renders the draft into its job summary — docs-only pull requests included — so reviewers see the assembled section without checking the branch out. Dependabot's pull requests are exempt — a lockfile bump has nothing to say in a changelog, and its auto-merge lane has to stay hands-off green. For anything else, the escape hatch is a deliberate, visible no changelog label. Adding a label does not start a CI run: add it, then re-run the failed Changelog fragment job, which reads the labels as they are when it runs.

Watching a PR to merge

scripts/watch_pr.py polls a PR and stops the moment a human has to act:

python scripts/watch_pr.py 912              # poll, then squash when mergeable
python scripts/watch_pr.py 912 --no-merge   # report only

It exits 0 merged, 1 closed without merging (or a merge it could not confirm), 2 when you are needed — an unresolved review thread, printed with its file, line and body; a failed check; a stale branch; a structural block; a thread listing too long to read in one page; or a merge GitHub refused twice, with GitHub's reason — and 3 if it gave up. The merge names the head it evaluated (--match-head-commit), so a push that lands after the last read is refused rather than merged unseen.

On a repo whose branch protection does not require conversation resolution, pass --await-review (#987). A green PR is mergeable there before its reviewer has said anything, and a thread not yet written cannot be unresolved, so the flag holds the merge while a review request is pending and until someone other than the author has reviewed, and stops (2) while a reviewer's latest review requests changes. Unresolved threads, failed checks and a needed rebase still come first, and a review that never arrives waits out --max-polls and exits 3. This repo requires resolution, so it does not need the flag.

What the flag cannot do, and a TLA+ model of the watcher (formal/tla/WatchPR) shows no client can: a review, thread or change request that arrives between the watcher's last read and its merge still gets through, so on a repo where that matters, require conversation resolution on the server. A review of an earlier head still counts after a push; GitHub's "dismiss stale pull request approvals" setting is the way to require one per head. And a check that is not required does not hold the merge, so make the checks that matter required.

Two stop conditions need confirming before they are reported, because for a few seconds after a push GitHub still describes the previous run. A structural block must hold for STUCK_POLLS polls (currently 3). A failed check must show the same check names for FAILURE_POLLS polls (currently 2), since the rollup can still carry conclusions from the SHA before the fix. For a few seconds after a push GitHub reports the PR as blocked with the previous run's checks complete and the new ones not yet created, which in a single snapshot is indistinguishable from a required review that will never arrive. A poll whose read failed — the PR view or the review-thread listing — does not count towards the streak.

Two behaviours are worth knowing, because hand-written loops keep getting them wrong: review threads rank above checks in decide(), so a comment never waits out a CI run (the reads themselves go the other way, PR view first, which is what lets a review that lands between them bring its threads along), and UNSTABLE counts as mergeable, so a still-running non-required job does not hold the merge. tests/test_watch_pr.py pins both. tests/test_watch_pr_protocol.py replays the model's counterexamples against a fake GitHub, through the real I/O layer.