Shipping

Open it as a draft and let a human merge

A pull request that opens and merges in the same run is a push with extra steps. Draft is the one control that works without depending on permissions.

MobileByteSensei 10 min read
A lock whose upper paddle is barred shut because the lower gates stand open
gitworkflowguardrails

Letting automation open pull requests is one of the better things you can do with it. A fix gets found, a branch gets pushed, a PR appears with a description of what changed, and a person reads it when they are next at a keyboard.

Letting that same automation merge is a different decision wearing the same clothes, and the difference does not show up in the diff.

The case this post is about

One line, in the tool that opens upstream pull requests:

$ ./open-upstream-pr.sh
scripts/open-upstream-pr.sh:41:  pr merge --auto "$pr_url"
refusing: a merge directive exists in the PR-opening path
  this tool opens DRAFTS; merging is a human decision
$ echo $?
2

Line 41 was added as a convenience. The tool already opened a correct PR; auto-merging it saved somebody a click, and the change is one line that reads like tidying.

What it removed is the only place a person was ever going to look at the change. This post follows that one line, because the gate above does something unusual — it scans its own source — and the reason is that a draft-by-default policy enforced only at the call site is one convenience commit from gone.

The symptom

A pull request opens. Checks run. Checks pass. It merges. Elapsed time: eleven minutes, none of it with a human in it.

Nothing here is wrong in any way you can point at. The change was correct. The tests passed. The branch was clean. And the pull request — the artifact whose entire purpose is to exist in an unmerged state long enough for someone to look at it — existed in that state for eleven minutes at three in the morning.

A PR that opens and merges in the same run is not a review. It is a push with a paper trail.

The second shape is the one people actually ship, because it feels more careful: auto-merge enabled at open time. Now the merge is deferred, conditional on checks, which sounds like restraint. What it really does is make the review window exactly as long as CI takes, and then close it automatically. Nobody declined to review. The window shut on its own, which is the part that makes it invisible — there is no moment where a person was asked and said nothing.

A window whose closing is an outcome of CI, not of anyone's decision

The third shape is what happens to the humans. You open a PR for review, somebody sits down to look at it, and it merged twenty minutes earlier. Their comment lands on a closed thread. Do that three or four times and people stop opening them, not out of protest but because reviewing has stopped having an effect. Review is a habit, and habits need their actions to matter.

And the fourth is that none of this is visible afterwards. A merged PR with a green check and an approval looks the same in every report you could build, whether that approval came from a person reading a diff or from the same system that wrote it. If you went looking for "changes that reached the default branch without human review", the query would come back empty, because the shape of the record is identical.

Why the obvious fix failed

Require an approval before merge. The first and most natural answer. It fails because approval is an API call, and the system that opens the PR can generally make it. If you separate the identities so it cannot, you have moved the problem to credential management — and the moment somebody adds a second token to unblock a different task, the control quietly stops existing.

Branch protection. Better, because it lives on the repository rather than in the automation, and it survives the automation being rewritten. It still has the same hole in a different place: administrative permission bypasses it, automation frequently holds administrative permission, and --admin is one flag. Protection that an actor can override is a reminder, not a control, for exactly that actor.

Only auto-merge the safe changes. This is the attempt that deserves the most attention because it sounds the most reasonable, and it is circular. The thing deciding a change is low-risk is the same thing that made the change. If its judgement about the change were reliable, you would not need the review; the review exists precisely for the cases where that judgement is wrong, and those are exactly the cases it will classify as safe.

One shaft carrying both the pawl that holds and the pinion that drives

Take write access away entirely. The maximalist version: let the automation report what it would have done and let a person do it. This is genuinely safe and it is worth saying why it is still a bad trade, because the reasoning is not obvious. An automation that can only describe its findings produces another queue of prose that someone has to read, interpret, and then re-derive the change from. The expensive part of a small upstream fix is not deciding to make it — it is reconstructing enough context to write it. Throwing away the branch and keeping only the description discards the part that was actually worth automating, and in practice what happens is that nobody re-derives anything and the findings rot.

The first three share a shape: they are rules, evaluated by a system, that something with enough permission can decline to be bound by. They control the action by forbidding it, and forbidding is only as strong as the thing doing the forbidding. The fourth escapes that by removing the capability altogether, and pays for it by removing the useful work along with the risk.

The actual mechanism

Open it as a draft.

A draft pull request is not merged-but-discouraged. It is structurally unmergeable: the platform refuses the merge, and no permission level changes that, because it is not a permission check. It is a property of the object.

That is the whole mechanism and it is worth being explicit about why it is categorically different from the three above. Those are rules about who may do a thing. This is a state in which the thing cannot be done. Rules are evaluated against an actor and can be satisfied by becoming a different actor. A state is evaluated against the object and does not care who is asking.

A rack held by a drop-pin with its puller to hand, beside a rack welded solid

The second half matters just as much: the transition out of draft is a positive human act. Not an absence of objection, not a timeout, not a quorum that passes by default — somebody has to click the thing. That turns consent from a negative into a positive, which is the real difference between review and the appearance of review. "Nobody stopped it" and "someone released it" produce the same merge and mean completely different things.

Then the third part, and this is the one that is easy to leave out: the automation must not be able to undraft its own PR. Otherwise the draft is a speed bump with an API call next to it. That refusal cannot live in platform config, because config is one setting away from gone. It has to live in the automation's own code, and it has to be checked — a gate that reads the runtime and refuses to ship it if it contains a merge directive at all.

The sub-case worth planning for is what you do when the platform offers no such state. Plenty of irreversible operations have no draft: publishing a package, sending a notification, pushing to a registry. The move there is to manufacture one — publish to a pre-release channel nobody consumes by default, write to a staging location the real consumer does not read, emit to a dead-letter queue rather than the live topic.

The test for whether your manufactured state is real is simple and unforgiving: can the automation promote it by itself? If promotion is one more API call with the same credentials, you have built a speed bump and labelled it a gate. A pre-release channel that the automation can also mark stable is not a draft; it is the same action with a longer path.

The fix

Three pieces, and the third is the one that keeps the first two true:

open the PR as a draft, always
surface it to a human at the next touchpoint
gate the automation's own source for merge directives

The third line is the one people leave out, and it is what stops the first two being advisory. An automation that can open a draft can also be edited, later, to merge it — so it checks itself before it runs:

set -euo pipefail

# GATE FIRST — scan this tool's OWN source for merge directives. A draft-by-default
# policy enforced only at the call site is one convenience commit from gone.
if grep -rnE '(pr merge|--auto-merge|--merge\b)' "$SELF_DIR" \
     --include='*.sh' --include='*.ts' | grep -v '# forbidden:'; then
  echo "refusing: a merge directive exists in the PR-opening path" >&2
  echo "  this tool opens DRAFTS; merging is a human decision" >&2
  exit 2
fi

pr_url=$(open_draft_pr --repo "$UPSTREAM" --head "$FORK:$BRANCH" --base "$BASE" \
                       --title "$TITLE" --body-file "$BODY")

# SURFACE IT — an unseen draft is the same as no PR at all.
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$UPSTREAM" "$pr_url" >> "$QUEUE"
echo "opened DRAFT: $pr_url  (queued for the next human touchpoint)"

The interesting case is the gate firing on its own source, after somebody adds a convenience flag:

$ ./open-upstream-pr.sh
scripts/open-upstream-pr.sh:41:  pr merge --auto "$pr_url"
refusing: a merge directive exists in the PR-opening path
  this tool opens DRAFTS; merging is a human decision
$ echo $?
2

That last line is a grep, essentially — the automation's runtime must not contain pr merge, --auto-merge, --admin, or an approve-then-merge sequence, and if it does, the gate refuses. It costs nothing and it is the difference between a policy you wrote down and a policy that is true, because the failure mode here is not malice, it is somebody adding a convenience in six months without knowing the draft was load-bearing.

The surfacing step needs a little care too. An autonomous run that finds upstream work should not drop it and should not open it unsupervised either — so it goes into a queue, and the queue is read out the next time a human is in the loop. The queue is what keeps "do not act alone" from turning into "lose the finding".

How we proved it

The gate refuses. A canary containing an auto-merge directive in the automation's runtime makes the check fail, and removing it makes the check pass. That is the actual evidence that the control is a control rather than a sentence in a document — it has a demonstrated red state.

The drafts accumulate. Upstream fixes found during unattended runs sit as draft pull requests with their reasoning attached, waiting. None of them have merged on their own, because none of them can.

What this does not do is make the review good. A draft PR marked ready by a human who did not read it merges exactly like one that was reviewed carefully, and this mechanism cannot tell the difference — it only guarantees that a person took an action, not that the action was informed. I have moved the failure from "no human was involved" to "a human rubber-stamped it", which is a real improvement and is not the same as solving it.

And the honest problem with the queue: work that is safely enqueued and never drained is indistinguishable from work that was dropped, except that it feels handled. I have a queue. I do not have anything that notices when an item has been sitting in it for a month. That is the next thing to build and I have not built it, so for now the queue's reliability is a person remembering to read it — which is precisely the kind of dependency this whole post is arguing against.

A holding basin of moored craft under an unbroken mat of weed, and a feeder still running

What this generalizes to

For any action an automation takes that you cannot undo — merging, publishing, sending, deleting, deploying — the useful question is not what rule forbids this, it is what state makes this impossible.

Rules are checked against an actor, and an actor with sufficient permission is a rule's exception clause. States are properties of the object, so they hold regardless of who is asking, and they survive the permission model being changed by somebody who did not know what depended on it.

Where such a state exists — draft, unpublished, disabled, unsigned, quarantined — use it as the default and make the exit a positive human act. Where none exists, the next best thing is to write the refusal into the automation itself and put a gate on it, because the one thing you can control about future convenience is whether it fails loudly.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS