diff --git a/CHANGELOG.md b/CHANGELOG.md index c47c4bda6b..515e3ed893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/doctoring/draft-merge-mutation-boundary.md b/docs/doctoring/draft-merge-mutation-boundary.md new file mode 100644 index 0000000000..f0a9a327fa --- /dev/null +++ b/docs/doctoring/draft-merge-mutation-boundary.md @@ -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 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ded5f53046..7dbae89a11 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -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. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 5034ebd29e..cd8550f211 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -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 @@ -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 diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 7e10cf555c..cb3421a1dd 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -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 == []