무슨 일이 있었나
우리가 관리하는 CLI 도구의 릴리스 PR에, 해결되지 않은 리뷰 스레드 네 개가 열려 있었다. 나는 그것들이 정리될 때까지 릴리스를 멈추려고 PR을 드래프트로 되돌렸다. 그날 밤, 릴리스를 담당하던 자동화 작업이 그 PR을 리뷰 준비 상태로 바꿨고, 7초 뒤에 병합했다. 태그가 찍히고, GitHub 릴리스가 올라가고, 패키지 레지스트리에도 배포됐다. 열린 지적 네 개는 그대로였다.
왜 막히지 않았나
드래프트는 '아직 리뷰를 요청하지 않는다'는 상태 표시다. 드래프트인 동안에는 병합 버튼이 비활성화되지만, 드래프트를 푸는 것 자체가 gh pr ready 한 번이면 된다. 쓰기 권한이 있는 사람이나 봇이면 누구든 할 수 있다. 자동화 작업 입장에서는 '릴리스 PR을 병합하라'는 목표가 있었고, 드래프트는 그 목표 앞에 놓인 작은 단계 하나였을 뿐이다. 내 '보류'는 나에게만 의미가 있었다.
꼬리표와 자물쇠의 차이
꼬리표는 사람이 읽고 존중해 주기를 기대하는 표시다. 자물쇠는 병합 경로 자체가 확인하고, 조건이 맞지 않으면 거부하는 장치다. 드래프트, '보류' 라벨, PR 본문의 '병합 금지' 문구는 모두 꼬리표다. 브랜치 보호 규칙의 필수 검사, 필수 리뷰, 대화 해결 필수는 자물쇠다. 사람과 자동화가 같은 저장소에서 일한다면, 지켜야 하는 보류는 자물쇠로 걸어야 한다.
자물쇠 1: 대화 해결 필수
브랜치 보호(또는 룰셋)에 '병합 전에 대화 해결 필수(Require conversation resolution before merging)'를 켠다. 그러면 해결되지 않은 리뷰 스레드가 하나라도 남아 있는 PR은 병합되지 않는다. 이번 사례처럼 '열린 지적이 있으면 병합하지 않는다'가 규칙이라면, 그 규칙을 사람의 기억이 아니라 저장소 설정에 두는 것이다.
자물쇠 2: 보류 라벨을 읽는 필수 검사
리뷰 스레드 말고 다른 이유로 멈추고 싶을 때도 있다. 그럴 때는 hold 같은 라벨이 붙어 있으면 실패하는 작은 워크플로를 만든다. pull_request 이벤트의 labeled, unlabeled, synchronize, opened, reopened에서 돌게 하고, 라벨 목록에 hold가 있으면 종료 코드 1로 끝낸다. 이 검사를 브랜치 보호의 필수 검사로 지정하면, 라벨을 떼기 전까지 병합할 수 없다. 라벨을 떼는 것도 한 번의 호출이지만, 적어도 '보류를 풀었다'는 행위가 기록에 남고 병합과 분리된다.
관리자 우회도 확인한다
브랜치 보호에는 관리자에게 예외를 주는 설정이 있다. 자동화가 관리자 권한의 토큰으로 돈다면, 위의 자물쇠도 우회될 수 있다. 관리자에게도 규칙을 적용하도록 설정했는지, 그리고 자동화 토큰의 권한이 필요 이상으로 크지 않은지 함께 본다.
자동화 쪽에서 할 일
자동화 작업이 PR을 드래프트에서 꺼낼 때는, 그 PR이 왜 드래프트였는지 먼저 읽게 한다. 누군가 일부러 되돌린 드래프트라면, 꺼내는 것이 아니라 멈추고 보고해야 한다. 그리고 병합 직전에는 열린 리뷰 스레드 수를 다시 센다. 상태를 바꾼 뒤 7초 만에 병합한다는 것은, 그 사이에 아무것도 다시 확인하지 않았다는 뜻이다.
정리
멈추게 하고 싶은 것이 사람이 아니라 시스템이라면, 시스템이 읽는 곳에 멈춤을 건다. 드래프트는 '아직 아니다'라는 말이고, 자물쇠는 '안 된다'라는 말이다.
What happened
A release PR for one of the CLI tools we maintain still had four unresolved review threads. I wanted to stop the release until they were handled, so I converted the PR back to a draft. That night, the automated job responsible for releases marked the PR ready for review and merged it seven seconds later. A tag was cut, a GitHub release went up, and the package was published to the registry. The four threads were still unresolved.
Why nothing stopped it
Draft is a status that means "not requesting review yet". While a PR is a draft the merge button is disabled, but leaving draft takes a single gh pr ready. Any person or bot with write access can do it. From the automated job's point of view, its goal was to merge the release PR, and draft was just one small step in front of that goal. My "hold" meant something only to me.
Label versus lock
A label is a marker you hope people will read and respect. A lock is something the merge path itself checks, and it refuses when the condition isn't met. A draft, a "hold" label, and "do not merge" in the PR body are all labels. Required checks, required reviews, and required conversation resolution in branch protection are locks. When people and automation work in the same repository, any hold that matters has to be a lock.
Lock 1: require conversation resolution
Turn on "Require conversation resolution before merging" in branch protection or a ruleset. Then any PR with an unresolved review thread cannot be merged. If your rule is "don't merge with open findings", as it was here, this moves that rule out of someone's memory and into the repository settings.
Lock 2: a required check that reads a hold label
Sometimes you want to stop for a reason that isn't a review thread. For that, add a small workflow that fails while a label such as hold is present. Run it on the pull_request events labeled, unlabeled, synchronize, opened, and reopened, and exit 1 if hold is in the label list. Make it a required check in branch protection, and the PR cannot merge until the label comes off. Removing the label is also one call, but at least lifting the hold becomes its own recorded action, separate from the merge.
Check the admin bypass too
Branch protection has a setting that exempts administrators. If your automation runs with an admin token, it may bypass the locks above. Check that the rules apply to administrators as well, and that the automation's token has no more permission than it needs.
What the automation should do
Before an automated job takes a PR out of draft, have it read why the PR was a draft. If someone deliberately converted it back, the job should stop and report, not undraft it. And right before merging, it should recount the open review threads. Merging seven seconds after changing the state means nothing was re-checked in between.
Takeaway
If the thing you need to stop is a system rather than a person, put the stop where the system reads. A draft says "not yet". A lock says "no".
发生了什么
我们维护的一个 CLI 工具的发布 PR 上,还有四个未解决的审查线程。我想在它们处理完之前停住发布,于是把 PR 转回了草稿。当天晚上,负责发布的自动化任务把这个 PR 标记为可审查,7 秒后就合并了。标签打上了,GitHub release 发出去了,包也发布到了注册表。那四个线程仍然没有解决。
为什么没有被拦住
草稿是一种状态,意思是“还没请求审查”。PR 处于草稿时合并按钮是禁用的,但退出草稿只需要一次 gh pr ready,任何有写权限的人或机器人都能做。对那个自动化任务来说,目标是合并发布 PR,草稿只是挡在目标前面的一个小步骤。我的“挂起”只对我自己有意义。
标签和锁的区别
标签是一种指望别人读到并尊重的标记。锁是合并路径本身会检查、条件不满足就拒绝的机制。草稿、“hold” 标签、PR 正文里的“请勿合并”,都是标签。分支保护里的必需检查、必需审查、必须解决会话,才是锁。当人和自动化在同一个仓库里工作时,真正要守住的挂起必须用锁来实现。
锁一:要求会话全部解决
在分支保护或规则集里打开 “Require conversation resolution before merging”(合并前必须解决会话)。这样只要还有一个未解决的审查线程,PR 就无法合并。如果规则是“有未处理意见就不合并”,就像这次一样,那这一步就是把规则从人的记忆里搬进仓库设置。
锁二:读取挂起标签的必需检查
有时你想因为审查线程以外的原因停下来。这时可以写一个小工作流:只要带着 hold 之类的标签就失败。让它在 pull_request 事件的 labeled、unlabeled、synchronize、opened、reopened 上运行,标签列表里有 hold 就以退出码 1 结束。把它设为分支保护的必需检查,在标签被移除之前 PR 就无法合并。移除标签同样只需一次调用,但至少“解除挂起”会成为一个单独留下记录的动作,与合并分开。
也要检查管理员绕过
分支保护里有一个给管理员豁免的设置。如果自动化用的是管理员权限的令牌,上面的锁也可能被绕过。要确认规则对管理员同样生效,以及自动化令牌的权限没有超出需要。
自动化这边该做什么
自动化任务把 PR 从草稿中取出之前,先让它读一读这个 PR 为什么是草稿。如果是有人特意转回去的,任务应该停下来报告,而不是取消草稿。合并之前,再重新数一遍未解决的审查线程。改完状态 7 秒就合并,说明这中间什么都没有重新确认。
小结
如果要拦住的是系统而不是人,就把拦截放在系统会读取的地方。草稿说的是“还不行”,锁说的是“不可以”。
何が起きたか
私たちが保守している CLI ツールのリリース PR に、未解決のレビュースレッドが四つ残っていた。それが片づくまでリリースを止めたかったので、PR をドラフトに戻した。その夜、リリースを担当していた自動化ジョブがその PR をレビュー可能な状態に切り替え、7 秒後にマージした。タグが打たれ、GitHub のリリースが作られ、パッケージもレジストリに公開された。四つのスレッドは未解決のままだった。
なぜ止まらなかったか
ドラフトは「まだレビューを依頼していない」という状態の表示だ。ドラフトの間はマージボタンが無効になるが、ドラフトを外すのは gh pr ready 一回で済む。書き込み権限のある人やボットなら誰でもできる。自動化ジョブにとって目標はリリース PR をマージすることで、ドラフトはその手前にある小さな一手順にすぎなかった。私の「保留」は、私にとってしか意味がなかった。
ラベルと鍵の違い
ラベルは、人が読んで尊重してくれることを期待する印だ。鍵は、マージの経路そのものが確認し、条件を満たさなければ拒否する仕組みだ。ドラフト、「hold」ラベル、PR 本文の「マージ禁止」はどれもラベルだ。ブランチ保護の必須チェック、必須レビュー、会話の解決必須が鍵にあたる。人と自動化が同じリポジトリで働くなら、守らせたい保留は鍵でかける。
鍵 1:会話の解決を必須にする
ブランチ保護(またはルールセット)で「Require conversation resolution before merging」(マージ前の会話解決を必須にする)をオンにする。すると未解決のレビュースレッドが一つでも残っている PR はマージできなくなる。今回のように「開いた指摘があるならマージしない」がルールなら、そのルールを人の記憶からリポジトリの設定へ移すことになる。
鍵 2:保留ラベルを読む必須チェック
レビュースレッド以外の理由で止めたいこともある。その場合は、hold のようなラベルが付いている間は失敗する小さなワークフローを作る。pull_request イベントの labeled、unlabeled、synchronize、opened、reopened で動かし、ラベル一覧に hold があれば終了コード 1 で終える。これをブランチ保護の必須チェックに指定すれば、ラベルを外すまでマージできない。ラベルを外すのも一回の呼び出しだが、少なくとも「保留を解いた」という操作が記録に残り、マージとは別の行為になる。
管理者による迂回も確認する
ブランチ保護には、管理者を例外にする設定がある。自動化が管理者権限のトークンで動いていると、上の鍵も迂回されうる。ルールが管理者にも適用される設定になっているか、自動化のトークンが必要以上の権限を持っていないかを合わせて見る。
自動化の側でやること
自動化ジョブが PR をドラフトから出す前に、その PR がなぜドラフトだったのかを読ませる。誰かが意図して戻したドラフトなら、外すのではなく止まって報告するべきだ。そしてマージの直前に、開いているレビュースレッドの数を数え直す。状態を変えて 7 秒でマージするということは、その間に何も確認し直していないということだ。
まとめ
止めたい相手が人ではなくシステムなら、システムが読む場所に止めを置く。ドラフトは「まだ」と言い、鍵は「だめだ」と言う。