Skip to content

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 what vrg-release invokes 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-pr prints 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 asks Finalize 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 (like vrg-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 --force discards 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-dirty needed. 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