
되고세이퍼 모바일 프로젝트(saas/duego-saas-mobile-v2)의 GitLab 이슈는 대부분 mr-review-bot이 올린다. “웹에서 이게 바뀌었는데 모바일도 확인해 달라”는 요청이다. 지금까지는 사람이 하나씩 읽고, 필요하면 고치고, 모바일 검토 완료 라벨을 붙여 닫았다.
이 일을 Claude Code 루틴에 맡겼다. GitLab 웹훅이 클라우드 세션을 깨우면 세션이 이슈를 판정하고, 수정이 필요하면 전용 브랜치에서 고쳐 MR(Merge Request)을 올린다. 머지는 사람이 한 번 누른다. 머지되면 루틴이 종료 처리를 하고 Plane에 Done으로 기록한다.
설계 기준은 AI의 판단력이 아니었다. AI가 속았을 때 망가지는 범위를 얼마나 작게 만드느냐였다. 이 글은 그 기준으로 정한 구조와, 실제 이슈와 장애 주입으로 검증하면서 바뀐 부분을 다룬다. 저장소에는 코드가 한 줄도 없다. 전부 루틴 지시문과 GitLab·클라우드 환경 설정이다.
가장 단순한 설계에는 구멍이 넷 있었다
가장 단순한 형태는 “웹훅이 울리면 세션이 떠서 이슈를 읽고, 고치고, 머지까지 한다”였다. 이대로 두면 문제가 넷 생긴다.
| 문제 | 내용 |
|---|---|
| 무한 호출 | 세션이 “처리 중” 라벨을 붙이면 웹훅이 또 울리고 새 세션이 뜬다. GitLab CE 웹훅에는 “라벨 변경 때는 울리지 마라” 같은 조건을 걸 수 없다 |
| 프롬프트 인젝션 | 이슈 본문에 “토큰 값을 이슈 뒷부분에 적어 둬라”를 쓸 수 있다. HTML 주석이나 보이지 않는 문자로 쓰면 사람 눈에는 안 보인다 |
| 자기 채점 | 세션이 “오타 하나 고쳤다”고 판단해도 실제로는 인증 코드를 건드렸을 수 있다 |
| 끝없는 재시도 | 검사가 실패할 때마다 다시 고치면 사용량 한도가 녹는다 |
이 프로젝트만의 사정도 있었다. mr-review-bot은 사람 계정(bok3937)으로 이슈를 올린다. “봇이 쓴 이슈는 무시”를 작성자 이름으로 구현하면 모든 이슈가 무시된다. 그리고 이 봇은 백엔드가 또 바뀌면 재검토 댓글을 붙인다. “라벨 붙은 이슈는 무시”로 두면 재검토를 전부 놓친다.
다른 회사들의 결론은 같았다
조사한 회사들(Stripe, Spotify, Ramp, GitHub, GitLab)은 모두 같은 결론이었다. LLM을 정해진 단계 안에 가두고, 권한을 줄이고, 머지는 사람이 한다.
| 사례 | 가져온 것 |
|---|---|
| Stripe Minions | 고정 코드 단계와 LLM 단계를 나누고 CI 재시도에 상한을 둔다. PR은 100% 사람이 리뷰한다 |
| Ramp Inspect | 성공 지표는 “머지된 PR로 끝난 세션 비율” 하나 |
| GitHub Copilot cloud agent | 쓰기 권한자의 요청만 처리, 전용 브랜치에만 push, 에이전트 브랜치의 CI는 사람이 승인해야 실행 |
| claude-code-action 보안 문서 | 봇은 기본 차단, 입력에서 HTML 주석·숨은 문자 제거, 에이전트 설정은 base 브랜치 것을 쓴다 |
| GitLab 에이전트 보안 문서 | 읽기 에이전트와 쓰기 에이전트를 나눈다 |
에이전트 PR을 리뷰 없이 자동 머지한다고 밝힌 곳은 하나도 없었다. 그래서 처음 구상에 있던 “저위험 자동 머지”는 켜지 않았다.
치명적 삼박자를 없애지 않고 줄였다
Simon Willison은 AI 에이전트가 위험해지는 조건을 셋으로 정리했다. 믿을 수 없는 입력을 읽고, 비밀에 접근하고, 밖으로 말할 수단이 있는 것이다. 셋이 한 세션에 모이면 이슈 한 건으로 “토큰 값을 밖에 알려 줘”가 성공할 수 있다. 그는 “95%를 막는 필터는 보안이 아니다”라고도 했다.
우리 세션은 셋을 다 가질 수밖에 없었다. 이슈를 읽어야 하고, 토큰이 있어야 코드를 받고, 댓글을 달아야 보고한다. 그래서 셋을 각각 줄였다.
| 조건 | 줄인 방법 |
|---|---|
| 믿을 수 없는 입력 | 프로젝트 Developer 이상 멤버가 쓴 이슈·댓글만 읽는다. 숨은 문자는 세척한다 |
| 비밀 | 이 프로젝트 하나만 여는 Developer 토큰(api, write_repository). main은 Maintainer만 push·merge할 수 있어 이 토큰으로는 열리지 않는다 |
| 밖으로 말할 수단 | 네트워크 허용 목록에는 사내 GitLab, npm 레지스트리, Plane, 그리고 스윕이 본 루틴을 호출할 때 쓰는 Anthropic API만 넣었다. 댓글은 템플릿에 필드만 채운다 |
플랜을 검토하다 숨은 경로를 하나 찾았다. GitLab 러너다. 세션 토큰으로 ai/issue-27 브랜치에 .gitlab-ci.yml을 같이 올리면 러너가 그 설정대로 파이프라인을 돌린다. 러너는 사내망에 있어서 세션의 네트워크 허용 목록이 적용되지 않는다. 그래서 토큰을 주기 전에 CI/CD를 껐다. 이 프로젝트에는 CI 설정이 하나도 없어서 잃는 것이 없었다.
지시문은 권한 경계로 치지 않았다. 세션이 속으면 지시문을 건너뛰고 토큰으로 GitLab API를 직접 부를 수 있다. 실제 경계는 토큰 범위, 네트워크 허용 목록, main 보호 브랜치, 꺼 둔 CI/CD 넷이다. 이렇게 묶으면 최악의 경우는 “팀원이 악의적 이슈를 써서 토큰이 팀원만 보는 댓글에 찍히고, 그 토큰으로는 main에 못 들어간다”가 된다. 토큰을 한 번 폐기하면 복구된다.
Plane 키는 예외였다. 회사가 발급하는 계정이라 전용 계정을 만들 수 없어서 담당자 개인 키를 썼다. 지시문으로 “이 프로젝트 이슈 생성·수정만”이라고 묶었지만 경계는 아니다. 그래서 루틴 환경을 다른 사람과 공유하지 않기로 했다.
전체 흐름
루틴은 셋을 만들었다. 이슈를 처리하는 본 루틴, 웹훅이 놓친 이슈를 찾는 스윕 루틴, 읽기 전용 검증에 쓰는 프로브 루틴이다.

그림에서 GitLab 쪽으로 가는 화살표는 토큰 범위 안에서만 동작한다. 본 루틴은 Developer 토큰으로 댓글·라벨·ai/* 브랜치·MR만 만들 수 있고, main에 넣는 것은 Maintainer뿐이다. 샌드박스에서 나가는 연결은 네트워크 허용 목록에 있는 호스트로만 한정했다.
트리거 A (웹훅, Issues) 새 이슈, 이슈 닫힘
트리거 B (웹훅, Comments) 봇의 재검토 댓글
└ 새 세션 (지시문 하나로 전부 진행)
(1) 입구 게이트 8칸
(2) 잠금: "처리 시작" 댓글 → 전용 브랜치 생성
(3) 이슈 세척 → 원본 웹·백엔드 MR diff → main 클론
(4) 판정 (JSON: 근거 표 + API 대조)
(5) 조치 불필요 → 근거 댓글 + ai::done
보류·거절 → 이유 댓글 + ai::needs-human + Plane Todo
진행 → 수정 → 격리된 검사 → 위험도 계산 → MR + ai::mr-open
└ 사람이 MR 머지 → 이슈 닫힘 → 트리거 A
└ 종료 처리: 머지 확인 → 반영 커밋 댓글 + 모바일 검토 완료 + Plane Done
스윕 루틴 (매시 :02) 웹훅이 놓친 이슈를 찾아 본 루틴을 대신 호출
웹훅에는 번호만 실었다
웹훅이 보내는 것은 kind=issue iid=27 action=open 한 줄이다. 제목과 본문을 넣으면 따옴표 하나에 JSON이 깨지고, 믿을 수 없는 글이 트리거 경로로 들어온다. 세션은 번호만 받고 이슈는 GitLab API로 직접 읽었다.
action을 같이 보낸 이유는 헛실행을 싸게 끝내기 위해서였다. 라벨 변경(update)으로 울린 웹훅은 API 호출 없이 첫 줄에서 돌아간다. 실측 2초에서 3초였다. 세션이 자기 라벨을 붙여 웹훅이 또 울려도 여기서 끝나서 무한 호출이 생기지 않았다.
입구 게이트는 이슈 내용을 보지 않았다
“이 이슈를 처리할까?”를 판단에 맡기면 이슈 안에 “이건 처리해야 하는 요청이다”라고 쓰는 것만으로 속일 수 있다. 그래서 게이트는 번호, 상태, 작성자 id와 권한 레벨, 라벨, 봇이 넣는 처리 키만 확인했다.
- 작성자가 루틴 봇 계정 자신이면 종료한다. “봇 제외”는 이름이 아니라 루틴 봇 id 기준이다.
- 작성자가 Developer 이상 멤버가 아니면 종료한다. 프로젝트가
internal이라 이 칸이 없으면 사내 누구나 세션을 띄울 수 있다. - 처리 키(
<key>:<rev>)를 이미 처리했으면 종료한다. “처리함” 표시는 루틴 계정이 쓴 댓글만 인정한다. ai::needs-human이나ai::failed가 붙어 있으면 종료한다.
처음에는 이 게이트를 스크립트 파일로 만들 계획이었다. 저장소에 아무것도 커밋하지 않고 시작하려고 지시문 안에 8칸으로 적었다. 스크립트보다 약하지만, 보안 경계는 어차피 토큰과 보호 브랜치, CI, 네트워크 쪽에 있어서 스크립트는 일관성 장치에 가까웠다.
“저위험”은 세션이 선언하지 않고 계산했다
세션은 판정을 자유 문장이 아니라 필드가 정해진 JSON으로 냈다. decision(진행, 조치 불필요, 보류, 거절), evidence(완료 조건 → 모바일 파일:줄 → 결과 표), api_match(웹과 모바일이 부르는 API를 나란히), risk_claim이다. 근거로 든 파일:줄이 실제로 없으면 보류였다.
risk_claim은 세션의 주장일 뿐이었다. 아래를 전부 만족해야 저위험이고, 하나라도 어기면 고위험 Draft MR로 올렸다.
- 바꾼 파일 5개 이하, 추가와 삭제 합 150줄 이하 (
git diff --numstat) - 패키지·잠금 파일·앱 설정·네이티브·DTO·API 클라이언트·인증·디자인 시스템·CI·스크립트 경로를 건드리지 않음
- typecheck·lint·(로직 변경 시) test 통과
검사 자체도 위험했다. typecheck·lint·test는 브랜치 코드를 실행하는 일이라, 이슈가 유도한 테스트 한 줄이면 세션 셸의 비밀을 읽을 수 있다. 계획은 npm ci 후 그대로 돌리는 것이었는데, 실제로는 세 겹을 씌웠다. 검사가 읽는 설정 파일(package.json, jest, babel, eslint, tsconfig, __mocks__)이 main과 다르면 검사를 돌리지 않는다. 설치는 npm ci --ignore-scripts로 한다. 실행은 토큰이 하나도 없는 빈 환경(env -i)에서 한다.
처음에는 우리 토큰 4개만 env -u로 지웠다. 프로브로 확인해 보니 샌드박스에 GITHUB_TOKEN, AWS_* 같은 플랫폼 자격 증명이 더 남아 있었다. 그래서 빈 환경으로 바꿨다. 바꾼 뒤 프로브에서 셸 환경 변수는 5개뿐이었고, typecheck 23초, lint 45초, test 29초로 통과했다.
동시에 뜬 두 세션은 댓글로 갈랐다
같은 이슈로 웹훅이 몇 초 사이 두 번 울리면 두 세션이 둘 다 “라벨 없네?”를 볼 수 있다. 라벨에는 “먼저 붙인 쪽만 이긴다”는 기능이 없다.
그래서 잠금은 브랜치로 잡았다. ai/issue-<iid>-<키 뒤 8자> 브랜치를 만드는 API는 이미 있으면 400으로 실패한다. 처음 계획은 수정 직전에 브랜치를 만드는 것이었다. 그러면 판정만 하고 끝나는 조치 불필요·보류 경로는 잠금을 안 잡아서 댓글이 두 번 달린다. 그래서 입구 게이트 직후로 옮겼다.
문제는 “브랜치가 이미 있다”가 두 가지 뜻이라는 점이었다. 다른 세션이 지금 작업 중이거나, 앞 세션이 죽어서 남긴 고아 브랜치거나. 처음에는 ai::in-progress 라벨로 둘을 가르려 했다. 장애 주입에서 웹훅 두 번을 1초 간격으로 울렸더니 두 세션이 둘 다 처리했다(지시문 v6). 라벨은 같은 것을 두 번 붙여도 이벤트가 하나만 남아서 “내가 방금 붙인 것”과 “남이 붙인 것”이 구별되지 않았다.
v7에서 증거를 댓글로 바꿨다. 세션은 브랜치를 만들기 전에 <!-- ai-issue-routine:lock=<키> --> 댓글을 남긴다. 댓글은 매번 번호와 서버 시각이 남아 내 것과 남의 것이 갈린다. 브랜치 생성에 실패한 세션은 400을 받은 뒤에 사실을 읽고 정했다.
- 그 브랜치로 만든 MR이 있으면 앞 세션이 MR까지 냈다. 댓글과 라벨만 마무리한다.
- 내 것이 아닌 잠금 댓글이 30분 안에 있으면 살아 있는 세션이 작업 중이다. LOCKED로 돌아간다.
- 둘 다 아니면 고아다. 브랜치를 지우고 다시 만들어 이어받는다.
순서는 “A의 댓글 → A의 브랜치 → B의 실패 → B의 읽기”로 고정되어서 B는 반드시 A의 댓글을 본다. ai::in-progress 라벨은 표시용으로만 남겼다. 라벨을 잠금으로 쓰면 세션이 죽었을 때 영구 잠금이 되기 때문에, 30분이 지나면 고아로 봤다.
장애 주입 결과는 다음과 같았다.
| 주입 | 기대 | 결과 |
|---|---|---|
| 고아 브랜치를 미리 만들어 둠 | 지우고 다시 만들어 이어받음 | 통과 (65초) |
| 브랜치와 MR이 이미 있음 | 판단 없이 댓글·라벨만 마무리 | 통과 (55초) |
| 웹훅 두 번을 1초 간격으로 | 한 세션만 처리 | v6 실패(둘 다 처리) → v7 잠금 댓글 → v9 통과(한쪽 LOCKED, 판정 댓글 1개) |
GitLab 권한을 넘는 쓰기가 막히는지는 행동 시험 대신 설정 조회로 확인했다. 무인 세션에 파괴적 시도를 시키면 모델 안전장치가 거부해서 시험이 성립하지 않았다. 대신 봇 권한(Developer), main 보호(push·merge Maintainer, force 금지), CI/CD 꺼짐을 조회해 거절이 구조적으로 일어난다는 것을 확인했다.
지난 이슈 24건에 대 보니 막히는 곳이 셋 있었다
설계를 사람이 수동 처리한 이슈 24건(#2부터 #27)에 대 봤다. 그대로는 닫힘까지 못 가는 곳이 셋 있었다.
| 막힌 곳 | 원인 | 대응 |
|---|---|---|
| 원본 MR을 못 봄 | 이슈가 가리키는 웹 MR, 백엔드 MR은 다른 프로젝트(133, 132)에 있다 | 두 프로젝트의 읽기 전용 토큰(read_api, Reporter)을 따로 줬다. 같은 GitLab 서버라 허용 목록은 늘지 않는다 |
| 재검토를 못 들음 | 봇은 열린 이슈에도 재검토를 댓글로만 붙인다(#21, #24, #27) | 댓글 웹훅을 하나 더 달고 봇의 재검토 댓글만 골랐다. <key>:<rev>마다 한 번만 처리한다 |
| 종료 처리 없음 | Closes #로 닫히기만 하고 모바일 검토 완료 라벨과 반영 댓글이 없다 | 이슈 닫힘 웹훅을 종료 처리 세션으로 썼다. MR이 실제로 머지됐을 때만 라벨을 붙인다 |
같은 원본 MR에서 나온 이슈는 MR 하나로 묶었다. #18·#19, #20·#21·#22는 수정 하나로 닫혔던 이슈들이다. 이슈마다 세션이 따로 뜨면 같은 MR이 여러 개 생긴다.
판정 값에 “조치 불필요”를 넣은 것도 이 대조에서 나왔다. 처음 구상에는 없던 값이다. 이 프로젝트 이슈는 대부분 “모바일 확인해 보니 바꿀 것이 없음”으로 끝나서, 이 값이 없으면 전부 보류로 몰린다. 대신 틀린 “조치 불필요”는 필요한 수정을 조용히 묻기 때문에, 처음에는 사람이 확인하고 닫게 했다.
판정은 실행마다 흔들렸다
수동 처리한 23건을 정답지로 두고 세션에게 판정만 시켰다. 틀린 것을 보고 지시문에 규칙을 더했다.
| 헷갈린 지점 | 사례 | 더한 규칙 |
|---|---|---|
| “안 깨진다”와 “똑같다” | 웹이 버튼·입력 칸·필터를 새로 넣었는데 “모바일 저장은 안 깨진다”고 봄(#18, #19) | 새 동작이 생기고 모바일에 같은 화면이 있으면 수정 필요 |
| 이름이 비슷한 화면 | 웹 “교육·회의”와 모바일 “회의록”을 같은 것으로 봄. 부르는 API가 달랐다(#8) | 웹·모바일 API 경로를 나란히 적고 같은 리소스일 때만 연결 |
| 서버의 새 거절 조건 | 백엔드가 “이 상태에선 수정 불가”를 추가했는데 “오류 알림이 뜨니 됐다”고 봄. 사람은 버튼 자체를 숨겼다(#7) | 새 거부 조건은 진입을 막는지 확인 |
| 묶음 MR | 450파일짜리 dev→staging 병합 MR에서 이슈와 상관없는 변경을 격차로 잡음(#12) | 병합 MR이면 완료 조건에 적힌 화면·API만 본다 |
채점 결과는 진행 13건 중 13건, 조치 불필요 10건 중 8건이 맞았고 치명적 누락은 없었다. 그런데 같은 지시문, 같은 입력인데 #7은 1차에 맞고 3차에 틀렸다. 한 번 맞혔다고 다음에도 맞는다는 보장이 없었다.
그래서 합격 기준을 “0건 틀림”이 아니라 “틀려도 사람에게서 멈춘다”로 잡았다. 조치 불필요는 사람이 닫고, 수정은 사람이 머지하고, 나머지는 ai::needs-human으로 간다. 범위 심사를 하는 LLM 서브에이전트를 하나 더 붙이는 안도 있었지만 넣지 않았다. 복잡도만 늘고 판정 흔들림을 하나 더 보태는 쪽이라고 봤다.
첫 실전 #27은 4분 43초 걸렸다
웹훅을 붙인 뒤 열려 있던 #27을 처음 맡겼다. 원본 MR 두 개가 모두 dev→staging 병합이라 이슈 범위만 판단했고, TBM 공정 변경 동작이 웹과 다르다고 판정했다. 1파일 +17/−2줄을 고치고 typecheck·lint·test·depcruise를 통과해 저위험 MR !2를 올렸다. 4분 43초였다. 사람이 머지하자 45초 뒤 종료 처리 세션이 반영 커밋 댓글, 모바일 검토 완료 라벨, Plane Done을 남겼다.
막힌 곳이 둘 있었다.
첫째, 다 못 고쳤는데 MR에 Closes #27을 넣었다. 세션은 안전점검 결과보고서를 “사람이 정할 일”로 남겼는데, 머지하자 남은 일이 있는데도 이슈가 닫혔다. 그 뒤로는 전부 처리했을 때만 Closes를 쓰고, 일부만 고쳤으면 Refs # + ai::needs-human + Plane Todo로 바꿨다.
둘째, 루틴 편집기가 지시문을 망가뜨렸다. 편집 화면에 붙여 넣으면 $A … $B 사이가 수식으로, 1.이 목록으로 바뀌어 API 주소와 단계 번호가 사라졌다. 겉보기에는 멀쩡해서 조회하기 전에는 몰랐다. 지시문은 {변수}, (1) 표기로 쓰고, 붙여 넣은 뒤 반드시 다시 조회하게 했다.
Plane에서도 같은 일이 두 장으로 갈렸다. 사람이 결과보고서를 만들면서 한 장을 올렸는데, 세션은 자기가 남긴 표시만 찾아서 그것을 못 보고 새로 한 장을 만들었다. 그래서 세션은 새로 만들기 전에 Plane 최근 이슈에서 “GitLab #번호”가 들어간 것을 찾아 거기에 덧붙이게 했다.
웹훅 한도 때문에 스윕 루틴이 필요했다
루틴에는 시간당 30회 호출 한도가 있다. 이슈 하나를 처리하면 웹훅이 6번에서 7번 울린다. 트리거 1번, 세션이 붙이는 라벨 2번에서 3번, 세션이 쓰는 댓글 3번이다. 시간당 4건에서 5건이 상한인 셈이다. 넘친 웹훅은 GitLab 훅 로그에만 429로 남고 우리 쪽에는 흔적이 없다. 장애 주입 중에 실제로 이 한도에 닿았다.
그래서 별도 스윕 루틴을 매시 :02에 돌렸다. 계획은 15분 간격이었지만 플랫폼 cron 최소 간격이 1시간이었다. 스윕은 GitLab에 아무것도 쓰지 않고 GET과 본 루틴 호출만 한다. 처리 라벨 없이 처리 키만 있는 이슈, ai::in-progress가 30분 넘게 붙은 이슈, “처리함” 표시가 없는 봇 재검토 댓글을 찾아 한 번에 최대 5건까지 본 루틴을 대신 깨운다. 훑을 때마다 라벨별 이슈 수를 JSON 한 줄로 남겨서 별도 대시보드 없이 세션 로그가 실행 기록이 됐다.
첫 유실 사고: 429와 잘못 넣은 루틴 토큰
설계 문서에 적어 둔 유실 시나리오가 실전 이슈 #30에서 그대로 일어났다. #30은 동시 실행 장애 주입 직후, 한도가 꽉 찬 순간(2026-09-30 17:24 KST)에 열렸다. 웹훅은 429를 받았고 세션은 뜨지 않았다.
스윕은 매시 정확히 돌며 #30을 미처리 후보로 잡았다. 그런데 본 루틴 호출이 401로 실패했다. ROUTINE_FIRE_TOKEN에 스윕 루틴 자신의 토큰이 들어가 있었다. 루틴 토큰은 루틴마다 다르고 그 루틴만 깨운다. 401은 8시간 동안 스윕 세션 로그에만 남아서 아무도 보지 못했다.
다음 날 09:01에 사람이 이슈를 닫았다 다시 열어 깨웠고, 루틴은 95초 만에 조치 불필요로 판정했다. 본 루틴 토큰을 재발급해 웹훅 두 개의 헤더와 ROUTINE_FIRE_TOKEN을 함께 바꿨다. 이 사고로 드러난 것은 스윕이 호출 실패를 로그 밖으로 알릴 길이 없다는 점이었다. GitLab 댓글은 스윕에게 쓰기 금지라 그 경로도 쓸 수 없었다.
택하지 않은 방식
| 방식 | 택하지 않은 이유 |
|---|---|
| 판단 세션과 수정 세션을 루틴 둘로 분리 | GitLab 권고의 정석이지만, 루틴끼리 일을 넘기려면 세션이 Anthropic API를 부르고 루틴 토큰을 가져야 한다. 네트워크가 하나 더 열려 삼박자가 오히려 굵어진다 |
| 인젝션 탐지 모델 추가 | 95%를 막는 필터는 보안이 아니다. 권한을 줄이는 쪽을 본 방어로 뒀다 |
| 라벨로 잠금 | 같은 라벨을 두 번 붙여도 이벤트가 하나라 내 것과 남의 것을 못 가르고, 세션이 죽으면 영구 잠금이 된다 |
| 범위 심사 LLM 서브에이전트 | 복잡도만 늘고 판정 흔들림을 보탠다. 규칙 계산과 사람 리뷰로 대신했다 |
| 게이트·게시를 스크립트 파일로 | 저장소에 커밋 없이 시작하려고 지시문 안에 뒀다 |
| 자동 머지 즉시 활성화 | 공개 사례에 선례가 없었다 |
지시문보다 권한 쪽에 무게를 뒀다
판정은 같은 입력에서도 흔들렸고, 지시문은 편집기가 조용히 바꿔 놓을 수 있었고, 장애 주입에서는 지시문으로 정한 잠금이 처음에 실패했다. 그래서 경계는 지시문이 아니라 토큰 범위, 네트워크 허용 목록, main 보호 브랜치, 꺼 둔 CI/CD에 뒀다. 합격 기준도 “틀리지 않는다”가 아니라 “틀려도 사람에게서 멈춘다”였다.