Skip to content

tools/pr-land.py: gate, wait and merge a PR only on a fully green CI - #1053

Merged
GLinnik21 merged 4 commits into
mainfrom
tools/pr-land-gate-wait-merge
Oct 26, 2026
Merged

GLinnik21 merged 4 commits into
mainfrom
tools/pr-land-gate-wait-merge

Conversation

@GLinnik21

Copy link
Copy Markdown
Owner

What

Merging has been done with ad-hoc gh calls, and twice it went wrong: #387 merged RED because gh pr merge was chained after a watcher that read "run completed" mid-run, and #924 read gh run list as success while checks were still running. tools/pr-land.py is the one place that judges a PR's checks and merges it.

  • pr-land.py gate N [--sha S]: one verdict, one exit code. 0 green, 1 red, 3 refused (not OPEN, wrong base, BEHIND/DIRTY/HAS_HOOKS, a required check skipping or unknown, an unlisted merge state), 4 not yet (running, missing required check, empty or unstable list), 5 the head moved off S. Required checks come from the base branch's ruleset (gh api .../rules/branches/<base>); a base that requires nothing needs a stable check list over two polls. Green requires mergeStateStatus == CLEAN.
  • pr-land.py wait N --sha S [--timeout]: the same judgment, polled.
  • pr-land.py merge N --sha S --subject T (--body-file F | --body-from-pr): waits, then gh pr merge N --squash --match-head-commit S --subject T --body-file -, with the SHA that was gated (never re-read), no --admin, --auto or branch deletion; a head race re-gates and retries only with the same SHA, otherwise exit 5. The squash message is scanned against the repo's private-value list through outbound-guard.py (fails closed) and what was scanned is byte-identical to what is sent. Afterwards it fetches main and checks that the squash commit is an ancestor, and a verify failure says the merge went through.
  • UNSTABLE (required checks pass, a non-required one such as CodeQL is still running or failed) is judged by its rows: a failed row is red and named, a pending row is "not yet", never green until CLEAN.

--body-file and --body-from-pr are mutually exclusive and exactly one is required; the plan's --body-file F [--body-from-pr] cannot be meant literally (it would require a file that the second flag then ignores), and its "a body is required; --body-from-pr is opt-in" is what this implements.

Scope

Stage 1a of the automation series. NOT in this PR: restack, the land-pr skill, and the outbound-guard refusal of a raw gh pr merge (and its learning this script's text flags, plan line 25); those land after this one has merged something for real.

Review

One independent Opus review of the combined diff. The safety trace found no path to gh pr merge without green checks and the gated head. It found 1 MAJOR (UNSTABLE refused by name, which would have blocked the first live use for about ten minutes after the required checks pass and mislabelled a red non-required check as unknown) and 4 MINOR (verify failure wording, a head-race exit code, the documented evaluation order, a traceback after a merge); all five are fixed in the last commit, the new cases red first.

Verification

Not verified: merge and its verify step against the real repo (no PR has been merged through it yet; its first live use is the docs-only fleet-plan PR), the real --match-head-commit race wording, a real UNSTABLE PR, and that gh pr merge --body-file - reads stdin (help text only). Needs a checkout to run (gh api fills in the repo; verify fetches origin).

No tracking issue (owner: none), so no Closes #N.

🤖 Generated with Claude Code

https://claude.ai/code/session_014bHMduhNfrC8w3qjNhEWdB

GLinnik21 and others added 4 commits October 26, 2026 04:46
…o not allow

gate is the read-only verdict: the PR open, not a draft, on main or release/v*,
at the asserted head, every required context of the base's rules reported and
passing, nothing pending, failed or cancelled, mergeStateStatus CLEAN (UNKNOWN
and an all-green BLOCKED are re-polled; BEHIND and DIRTY say to merge the base
into the branch). wait polls it with a 30-60 s backoff, never gh run list.
merge is wait, then gh pr merge --squash --match-head-commit (never --auto)
with a required body, retrying only the head-oid race, then a verify against
origin/<base>. The subject and body go through outbound-guard in-process before
any gh call. Exit codes: 0 green, 1 red, 2 usage, 3 refused, 4 timeout, 5 head
moved.

tools/test_pr_land.py runs it against a fake gh on PATH with an injected
sleeper, real gh JSON as fixtures, and a temporary git origin for the verify.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bHMduhNfrC8w3qjNhEWdB
…s the merge went through

UNSTABLE is GitHub's "mergeable with a non-passing commit status": the required checks pass while
a non-required one (CodeQL) still runs or failed. It was refused by name as an unknown state, so a
merge started in that window exited 3 at once and never waited, and a failing non-required check
gave exit 3 instead of 1 naming the check. It now goes to the rows like BLOCKED: a failed row is
exit 1 naming it, a running row is exit 4, all rows passing is lag (exit 4 after the bounded
re-poll), and green still needs CLEAN.

The verify step after a successful merge: every VERIFY FAILED line starts "gh pr merge succeeded;"
(exit 1 also means a red check with nothing merged); a MERGED state with no merge commit is worded
on its own; and odd JSON (a check-run without a name, a non-object mergeCommit) is noted, never a
traceback. A required_status_checks entry without a context is refused instead of a KeyError. A
head race that never clears while the head stays at S is exit 3, not 5. The module docstring states
the order judge() actually applies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bHMduhNfrC8w3qjNhEWdB
…the credential scan passes

ci/source_bundle.py refuses any file that holds the Plex token key next to a 20+ character
value. The squash-message shape-rule test deliberately writes such a value, so the literal
tripped the repository tree scan (PR #1053). The value is now a module constant formatted
into the body, as .claude/hooks/outbound-guard-test.py does; the runtime string is unchanged,
so the shape rule is still what refuses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bHMduhNfrC8w3qjNhEWdB
@GLinnik21
GLinnik21 merged commit 5e44b2e into main Oct 26, 2026
15 checks passed
@GLinnik21
GLinnik21 deleted the tools/pr-land-gate-wait-merge branch October 26, 2026 09:47
GLinnik21 added a commit that referenced this pull request Oct 26, 2026
GLinnik21 added a commit that referenced this pull request Oct 26, 2026
GLinnik21 added a commit that referenced this pull request Oct 26, 2026
…merges (#1055)

`tools/pr-land.py restack --worktree W --upper BRANCH --lower PR [--dry-run] [--push]`

After the PR below a stacked PR squash-merges as commit S, the upper branch is brought current by
merges, never a rebase:

1. merge the lower PR's final head
2. merge `S^`
3. `merge --no-ff -s ours S` (marks S merged without changing the upper tree, which already has the
   lower PR's content)
4. merge `origin/<base>`

Result: `git diff origin/<base>..BRANCH` is only the upper PR's own change. The manual procedure lived
in AGENTS.md prose and was done by hand; step 3 alone (`-s ours` on the squash) drops whatever `main`
gained before it, so the four-step order is the part that is easy to get wrong.

Safety, all before any fetch or merge: the lower PR must be MERGED with a merge commit; S must exist
locally, be an ancestor of `origin/<base>` and have exactly one parent; W must be clean, on BRANCH, and
have no merge, cherry-pick or revert in progress; `--upper` must be a plain branch name and not the base
branch. `--dry-run` fetches nothing and changes nothing. On a conflict it stops at the failing step and
leaves the merge in progress for the human; it never aborts, resets, rebases or forces. `--push` runs
only after all four merges, is a plain push, follows the branch's upstream (`origin/<name>` only; any
other remote is refused before the merges), and reports the git rejection reason.

`python3 tools/test_pr_land.py`: 88 tests OK (hermetic: temp dirs, a local bare origin, a fake `gh` on
PATH, no network); the core steps were mutation-checked (drop step 3, swap steps 1 and 2, a fetching
dry run and a `--force` push each turn the suite red). One independent review found no blocking
findings. This PR was itself restacked onto the #1053 squash with the tool it adds.
Dr-Amp pushed a commit to Dr-Amp/plx-native that referenced this pull request Oct 26, 2026
Brings in main's GLinnik21#1054 (one capability value per playback request), GLinnik21#1060
(OpenRecovery state machine for refusals before the first picture) and GLinnik21#1053
(tools/pr-land.py).

Live TV adapts to the capability value: request_live takes the Arc<Caps> the
way request_play does, and the app passes it at play start. ResolveEnv no
longer derives Default, so the channel tests build their environment from
ResolveEnv::snapshot and set the live channel on it. The LiveTune type moves
above the ResolveEnv doc comment, which it had split.

Signed-off-by: Dr-Amp <dr_amp@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant