Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@

## Proposed

- Reject Draft pull requests again at both direct-merge and auto-merge
mutation functions. This defense-in-depth boundary prevents a stale caller
decision from reaching guarded GitHub mutations after PR lifecycle changes.

- Skip target-repository old-head Actions inventory when review execution is
centralized. Same-repository stale-run cleanup remains enabled; central
review lifecycle is handled in the configured dispatch repository, avoiding
Expand Down
33 changes: 33 additions & 0 deletions docs/doctoring/draft-merge-mutation-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Draft merge mutation boundary

Decision date: **2026-09-07**

## Problem

The scheduler normally excludes Draft pull requests during inspection. A PR can
change lifecycle state after that decision, or another caller can invoke the
mutation helper directly. Without a second guard, direct merge or auto-merge
could proceed from stale Ready-state authority.

## Decision

`enable_auto_merge` and `merge_pr` each reject `isDraft` before actor
validation, head-SHA processing, or any GitHub command. The upstream decision
filter remains in place; this is a minimal defense-in-depth invariant at the
irreversible boundary.

## Failure scenes

- A Ready PR becomes Draft after inspection: mutation is refused.
- A direct helper call supplies a Draft PR: no GitHub command is executed.
- A non-Draft PR follows the existing guarded expected-head flow unchanged.

## Evidence and follow-up

RED commit: `2d140a84203a0df0cb86cd6b6ab31fc37bbdbda2`.
Fresh exact-head hosted checks and independent review remain required.

## Reference

GitHub. (2026). *Pull requests and draft pull requests*.
https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
13 changes: 13 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -3416,3 +3416,16 @@ same name in another file can carry the opposite safety property.**
- **Evidence:** RED commit
`08a16caa4fdb0d0d86c44bb8cd7aed611beaab7b`; fresh exact-head hosted checks
remain required before integration.


### Draft merge mutation boundary

- **Status:** Proposed
- **Owner:** `ContextualWisdomLab/.github`
- **Problem:** Scheduler decision code filtered Draft PRs, but the direct-merge
and auto-merge mutation functions did not revalidate lifecycle state.
- **Action:** Reject Draft PRs at both mutation entrypoints before actor, SHA,
or GitHub mutation processing.
- **Evidence:** RED commit
`2d140a84203a0df0cb86cd6b6ab31fc37bbdbda2`; fresh exact-head hosted checks
remain required before integration.
4 changes: 4 additions & 0 deletions scripts/ci/pr_review_merge_scheduler_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2640,6 +2640,8 @@ def run_head_guarded_merge(

def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:
"""Enable auto-merge for a PR at its current head using an allowed method."""
if pr.get("isDraft"):
raise RuntimeError("enable-auto-merge refused for draft PR")
number = str(pr["number"])
if dry_run:
return
Expand All @@ -2650,6 +2652,8 @@ def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:

def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:
"""Merge a current-head-approved PR immediately with a head guard."""
if pr.get("isDraft"):
raise RuntimeError("direct-merge refused for draft PR")
number = str(pr["number"])
if dry_run:
return
Expand Down
15 changes: 15 additions & 0 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10887,3 +10887,18 @@ def test_central_dispatch_skips_non_authoritative_target_actions_inventory(
decision = inspect(make_pr(baseRefName="feature-base"), trigger_reviews=False)

assert decision.action == "skip"


def test_draft_pr_cannot_reach_merge_mutations(monkeypatch):
"""Defense in depth rejects drafts at both guarded merge boundaries."""
calls = []
monkeypatch.setattr(sched, "run", lambda args: calls.append(args) or "")
monkeypatch.setenv("GITHUB_ACTIONS", "true")
monkeypatch.setenv("GH_TOKEN", "workflow-token")
draft_pr = make_pr(isDraft=True, headRefOid="a" * 40)

for mutation in (sched.enable_auto_merge, sched.merge_pr):
with pytest.raises(RuntimeError, match="draft PR"):
mutation("owner/repo", draft_pr, dry_run=False)

assert calls == []
Loading