vrg-finalize-pr¶
Installed as: vrg-finalize-pr (Python console script)
Source: src/vergil_tooling/bin/vrg_finalize_pr.py
Finalizes a pull request and reconciles local state afterwards. Has three modes:
vrg-finalize-pr <PR>— runs the pre-merge provenance check, waits for checks to go green, merges the PR (or confirms it is already merged), then runs the cleanup below. This replaces a manual web merge followed by a separate cleanup step.vrg-finalize-pr(no PR) — interactive: infers the PR from the.worktrees/worktrees and always confirms before acting (see Choosing the PR below). With no candidates, cleanup-only after confirmation: switch to the target branch, fast-forward pull, delete merged local branches, and prune stale remote-tracking references.vrg-finalize-pr --cleanup-only— non-interactive release path: skips inference and merge entirely, never reads stdin, and runs only the cleanup. This is whatvrg-releaseinvokes during its close-finalize phase (issue #1448). Mutually exclusive with a PR argument.
Must be run from the main worktree — the cleanup removes worktrees, which is unsafe when the calling shell's CWD is inside one.
Choosing the PR¶
vrg-finalize-pr <pr-url-or-number>— no prompts; the explicit argument is the confirmation. This is the scriptable path (vrg-submit-prprints the URL to pass here).vrg-finalize-pr(no arguments) — infers candidates by mapping each.worktrees/worktree's branch to its open PR, and always confirms before acting: one candidate asksFinalize PR #N?; several present a menu; none asks before running cleanup-only. Worktrees without an open PR are listed with the reason they were skipped. Inference mode requires an interactive terminal and fails fast unless both stdin and stdout are TTYs — a captured stdout would make the prompts invisible (issue #1448).vrg-finalize-pr --cleanup-only— no prompts and no merge; the scriptable path for callers (likevrg-release) that only want the cleanup.
Waiting for green¶
When the PR's checks are not finished, vrg-finalize-pr waits for
them and merges automatically once everything is green and current.
The wait is bounded by a deadline — it never blocks forever — and
it never merges past a non-terminal check. Doomed outcomes abort
immediately rather than after the wait: a draft PR, merge conflicts, a
failed check (named in the error), or a branch still behind after five
update attempts. A branch that is merely behind the target is updated
automatically and the wait restarts.
Orphaned check-runs. GitHub occasionally leaves a check-run
non-terminal (queued/in_progress) even though the workflow run that
owns it has already completed — an orphan that would never go green on
its own. The waiter detects this while waiting, not only at the
deadline (issue #3358). About every 30 seconds it checks the backing run
(gh run view) of each pending Actions check. Once a run has completed,
its check gets a one-minute grace period to turn terminal. If the check is
still pending after that, the backing job's own conclusion decides
(GET /repos/{owner}/{repo}/actions/jobs/{job_id}):
- The job succeeded and the check is not required: the check is
treated as passed and a warning names it (
'test / matrix' check-run stale; backing job succeeded), so the merge goes ahead instead of waiting out the deadline. Checks that really failed are still caught by the failed-checks gate. - The job succeeded but the check is one of the target branch's required status checks: the check is a suspected orphan (issue #3379). GitHub keeps blocking the merge on a required check it reports as pending, so passing it would only move the wait to the merge step. If it is still pending when the grace period below runs out, the error names the check, says GitHub will block the merge until it settles, and gives the same close-and-reopen guidance.
- The job succeeded but the required checks could not be read (see Required checks below): a suspected orphan for the same reason, because finalize cannot tell whether GitHub will block on the stale check. The error, if the grace runs out, says the required-check lookup failed. Without a stale check, a failed lookup still only warns and waits on registered checks.
- The job did not succeed, has no conclusion, or its conclusion cannot
be read: a suspected orphan. If the grace runs out, finalize aborts
with recovery guidance: close and reopen the PR to re-run the gate, then
re-run
vrg-finalize-pr. The error names each such check, its job's outcome and its job link.
Grace period for suspected orphans. During a GitHub Actions incident,
GitHub can write job results back minutes late, and at the moment of
checking that looks exactly like an orphan (issue #3431). Closing and
reopening the PR then would throw away results that are on their way. So a
suspected orphan is not declared at once. The waiter prints a warning naming
the suspects (… have a completed run but no result yet … GitHub may be
delayed; re-checking for up to 5m00s …) and keeps polling, re-reading each
suspect's job about every 30 seconds. A result that arrives in time lets the
wait finish normally. Only a check still pending when the grace runs out is
declared orphaned, and the error says how long each check waited. The grace
defaults to 5 minutes from when the backing run was first seen completed.
Set VRG_ORPHAN_GRACE (seconds, a positive whole number) to change it; a
malformed value is an error. vrg-pr-await applies the same grace before it
settles with orphaned_check.
The deadline is the backstop. When it elapses with checks still pending,
each one is checked against its backing run again, and a non-terminal check
over a completed run aborts with the same guidance. Genuinely
still-running checks are waited on as normal; if the deadline elapses
with nothing orphaned (for example an app-posted status that is legitimately
slow), finalize fails with a timeout error that names every still-pending
check and its run/job link, with the recovery path: re-run the stuck job,
then re-run the waiting command (vrg-finalize-pr, or vrg-release --resume).
Deadline. The deadline defaults to 30 minutes (about four times the
longest legitimate CI run, 5–8 minutes) and is shared by every merge wait:
vrg-finalize-pr, vrg-release (merge-release, back-merge-bump),
vrg-update-deps and vrg-wait-until-green. Set VRG_CHECKS_TIMEOUT
(seconds, a positive whole number) to change it; a malformed value is an
error, and the deadline cannot be disabled. While waiting, the waiter prints
each pending check with how long it has been pending, re-reporting whenever
the pending set changes and at least once a minute.
Stale check-runs. GitHub occasionally sets a check-run's conclusion
and completedAt but leaves its status at IN_PROGRESS. gh pr checks
derives its state from status, so it reports such a check as pending
indefinitely. The waiter checks each pending check's conclusion in the
PR's statusCheckRollup. A check that has a conclusion is terminal and is
classified by that conclusion, so a stale success passes and a stale failure
fails. A warning names the inconsistent check (issue #3170).
Required checks. "Green" includes the target branch's required
status checks, read from both classic branch protection
(GET /repos/{owner}/{repo}/branches/{branch}) and repository rulesets
(GET /repos/{owner}/{repo}/rules/branches/{branch}). CI aggregator jobs
such as test / evidence only register as checks once their dependencies
finish, so every registered check can be done while a required one does
not exist yet. Finalize keeps waiting until each required check is
registered and terminal, printing what it awaits (for example
Awaiting required checks: test / evidence (not registered)). If the
merge is still refused with "the base branch policy prohibits the merge"
and no check has failed, finalize treats it as not ready: it polls and
re-attempts within the same deadline, then fails naming the outstanding
required checks. A policy block that waiting cannot clear, such as a
required review (reviewDecision=REVIEW_REQUIRED), aborts at once. If the
required checks cannot be read (permissions or an API error), finalize
prints a warning and falls back to waiting on registered checks only
(issue #3061).
After the merge, the PR's own branch and worktree are cleaned up
explicitly (a squash merge hides them from git branch --merged),
followed by the usual sweep, pull, and prune.
Usage¶
vrg-finalize-pr [PR | --cleanup-only] [--target-branch BRANCH]
[--strategy {merge,squash,rebase}]
[--allow-provenance-violation] [--clean-dirty] [--dry-run]
[--no-wait-cd]
Arguments¶
| Argument | Description |
|---|---|
PR |
PR number or URL to merge and finalize. Omit to infer interactively. |
--cleanup-only |
Skip PR inference and merge; run cleanup without prompting or reading stdin (non-interactive release path). Mutually exclusive with PR. |
--target-branch |
Branch to switch to (default: develop) |
--strategy |
Merge strategy when a PR is given (default: squash) |
--allow-provenance-violation |
Proceed despite provenance violations (conscious human override) |
--clean-dirty |
Opt-in: clear a merged/closed worktree whose only dirt is untracked build/validation output, after showing what will be deleted and confirming. Never touches modified tracked files or a reused-branch straggler with unmerged commits (issue #2348). |
--dry-run |
Show what would be done without making changes |
--no-wait-cd |
Do not block on the CD workflow run the merge triggers. Blocking (and failing on a non-success run) is the default (issue #2753); this is the conscious opt-out. |
Behavior¶
1. Provenance Check, Wait, and Merge (PR mode only)¶
Runs the pre-merge provenance check first, so violations surface
before any waiting. Advisories are printed but do not block.
Violations abort the merge unless --allow-provenance-violation
is given. If the PR is already merged, the merge step is skipped and
finalize proceeds straight to cleanup. Otherwise the wait-for-green
loop runs (see Waiting for green) and the PR is merged with the
selected strategy. --dry-run skips the wait and prints what it
would do.
1a. Explicit-Target Cleanup (PR mode only)¶
The just-merged PR branch and its .worktrees/ worktree are deleted
by name. The default squash strategy rewrites history onto the
target, so the branch is never an ancestor and the merged-branch
sweep below cannot see it. The branch's PR-workflow relay ref
(refs/vergil/pr-workflow/<branch>, pushed by report-ready) is
deleted alongside it, so a cloud-handoff ref never outlives its
branch (issue #2369). Deleting a relay ref that was never pushed is a
no-op.
2. Switch to Target Branch¶
Checks out the target branch (default: develop).
3. Fast-Forward Pull¶
Fetches (--tags --force) and fast-forward merges origin/{target}.
4. Delete Merged Branches and Worktrees¶
Deletes local branches merged into the target branch. If a merged
branch is still checked out in a canonical .worktrees/ worktree, that
worktree is removed first (skipped if it has uncommitted changes).
Cached container images for the branch are cleaned.
Two guards keep this sweep from racing parallel agent sessions in
their creation-to-first-commit window (issue #1445), since
git branch --merged classifies a branch just created from the
target's tip as merged:
- Zero-commit branches are skipped. A branch whose tip equals the target's tip carries no merged work, so deleting it saves nothing — and it is exactly what an in-flight issue branch looks like before its first commit.
- Merge evidence is required. A branch is only swept when a closed or merged PR exists for its head; ancestry alone cannot distinguish a merged branch from one created off an older target tip. Branches skipped for lack of evidence are listed with the reason and left for manual cleanup.
Both guards gate the worktree removal as strictly as the branch deletion. The explicit-target cleanup (step 1a) is unaffected — the just-merged PR branch has merge evidence by construction.
Stuck merged worktrees are surfaced, not buried¶
A merged/closed worktree the sweep cannot remove — its tree is dirty,
or its branch name was reused after a same-named PR merged
(issue #1719) — is neither live work nor removable cruft. Rather than leaving
the skip reason inside the collapsed progress-stage log (where a clean
✓ cleanup summary hides it), finalize reports every such worktree
prominently after the pipeline, with the reason, so finishing with
stuck worktrees is impossible to miss (issue #2348).
--clean-dirty: guarded clear of untracked-only dirt¶
The common Mac case is a merged worktree whose only "dirt" is
un-gitignored build or validation output. --clean-dirty offers an
opt-in path to clear exactly that, after the pipeline, on real stdio:
- Only a merged/closed worktree whose every uncommitted path is
untracked is a candidate. A reused-branch straggler classifies as
no-pr/draft(its merged verdict withheld, issue #1719), so it is never in a clearable state and its unmerged commits are never at risk. - A worktree with modified tracked files is refused — those are never discarded — and left surfaced as needing attention.
- Each removal shows exactly what will be deleted (the untracked
paths) and requires confirmation before
git worktree remove --forcediscards the output and deletes the branch.
The default guards are unchanged: without --clean-dirty, anything with
tracked modifications or unmerged commits is protected, as before.
Tip: the cleaner fix is to gitignore your validation output so the worktree is never dirtied in the first place — then it sweeps automatically with no
--clean-dirtyneeded. That is repo-specific configuration, out of scope for finalize itself.
Eternal branches are protected based on the branching_model:
| Branching Model | Protected Branches |
|---|---|
docs-single-branch |
develop, gh-pages |
library-release |
develop, main, gh-pages |
application-promotion |
develop, release, main, gh-pages |
5. Prune Remote References¶
Runs git remote prune origin to clean up stale remote-tracking
branches. As a swept safety net, any PR-workflow relay ref
(refs/vergil/pr-workflow/*) whose branch no longer exists on origin
is pruned too, so a relay ref stranded by a branch deleted outside
finalize (or by an earlier failed run) is never left behind
(issue #2369).
6. Working-Tree Cleanliness Gate¶
Fails if the target branch's working tree is not clean, so leftover files are cleaned up before the next issue.
7. Post-Finalization Validation¶
Runs canonical validation via vrg-container-run to catch problems on
the target branch before the next PR is created.
8. CD Workflow Wait¶
Blocks on the CD workflow run this merge triggered — identified by
the merge commit SHA, not "the latest run on the branch" — and fails if
that run does not succeed (issue #2753). The base-branch push a merge
produces dispatches CD asynchronously (docker build/publish, docs,
release), so finalize polls for the run to register (bounded by a
dispatch timeout), watches it to completion (bounded by a watch
timeout), and surfaces the failing run's URL so a post-merge publish
failure is caught here rather than discovered later. --no-wait-cd is
the conscious opt-out from blocking. When the repo has no CD workflow,
or no run is dispatched for the merge within the dispatch window (a
change CD legitimately skips), finalize does not block.
Watch deadline. The watch defaults to 90 minutes (5400 seconds),
about twice the longest known CD: vergil-containers' CD routinely takes
around 43 minutes, because its rust image jobs alone take about 40. Set
VRG_CD_TIMEOUT (seconds, a positive whole number) to change it. It
mirrors VRG_CHECKS_TIMEOUT: a malformed value is an error rather than a
silent fallback, and the deadline cannot be disabled.
Timeout is not failure. A run that concludes non-success fails the
stage with "the CD workflow run triggered by this merge did not
succeed". A run still going at the deadline gets a different headline:
"CD run still in progress after Ns; result unknown". It comes with the
run URL, a gh run view <id> command to re-check it, and a pointer to
vrg-finalize-pr --cleanup-only to re-confirm once the run has
finished. It still fails the stage, because an unknown result is not a
pass, but it never claims the run failed (issue #3316).
Batch mode (comma-list / --all)¶
A comma-separated PR argument (vrg-finalize-pr 123,124,125) or --all
(every open PR in .worktrees/) finalizes several PRs as a single
serialized batch (issue #1673). Each item runs
vrg-finalize-pr <pr> --skip-post-checks (merge + cleanup, deferring
the post-merge checks); after every item merges, one end-of-batch
vrg-finalize-pr --cleanup-only runs validation and the CD check, then
— with --release/--install — a single vrg-release.
The batch summary reports that end-of-batch step as post-checks. Its
exit code cannot say which of the two checks failed, so a failure reads
"end-of-batch post-checks (validation + CD check) exited N" and never
blames validation alone. The --cleanup-only stage summary printed just
above it names the failing stage (issue #3316).
Unlike vrg-submit-pr's batch, these PRs are already open, so each one
that lands behind another will update-branch and re-run its gate
(the normal serialized-merge cost). For zero-waste CI, submit the batch
with vrg-submit-pr --all --finalize instead, which rebases each branch
before opening its PR.
The batch asks one confirmation up front (skipped with --yes) and
is fail-fast: the first failure stops it and prints a
merged / failed / not started summary. The single-PR modes are
unchanged.
The --skip-post-checks flag (used internally by the batch) finalizes a
PR but skips the validation and CD-check stages and never chains a
release — the batch runs those once at the end.
Exit Codes¶
| Code | Meaning |
|---|---|
| 0 | Finalization complete, or declined at a confirmation prompt |
| 1 | Provenance violation, unmergeable PR (draft, conflicts, failed checks, stuck behind, orphaned check-run, or wait timeout), not run from main worktree, non-TTY stdin or stdout in inference mode, dirty working tree, failed validation, failed CD run, CD watch timeout (result unknown), or a batch item failed |