Tooling · Automation

자기 자신을
기다리고 있던 자동화

"체크가 전부 초록이 되면 머지"를 기다리는 워크플로가 그 체크 중 하나라면, 성공은 우연으로만 일어난다. Dependabot auto-merge 워크플로가 몇 주째 돌고 있었다. 그동안 머지된 PR은 하나같이 겨우 빠져나간 것들이었다. 워크플로가 제대로 동작해서 머지된 게 아니고, 경쟁 조건이 매번 운 좋게 유리한 쪽으로 풀렸을 뿐이다. 버그는 구조에 있었다. 워크플로의 스텝 하나가, 그 스텝이 끝나야만 끝날 수 있는 체크를 기다리고 있었다.

의존성 업데이트 PR이 잔뜩 쌓여 있었다. 이걸 치워야 할 auto-merge 워크플로는 겉으로는 제 할 일을 하는 듯 보였다. 이력을 보면 혼자 머지된 PR도 몇 개 있었다. 그런데 애초에 PR이 왜 쌓였는지 파고들어 보니, 가끔 실패하는 워크플로보다 더 나쁜 게 나왔다. 원래 의도한 방식으로는 한 번도 성공한 적이 없는 워크플로였다.

자기 자신을 기다리던 잡

머지 스텝은 gh pr checks --watch를 호출했다. PR의 체크가 전부 초록이 될 때까지 기다렸다가 승인하고 머지하는 방식이다. 문제는 auto-merge 잡 자신도 그 PR의 체크 중 하나라는 점이다. 스텝이 지켜보는 체크 목록에 아직 돌고 있는 자기 자신이 들어 있었다. 그 조건은 지켜보는 스텝이 끝나야만 참이 될 수 있다. 참가자가 하나뿐인 데드락이었다. 이걸 끝낸 건 GitHub Actions의 6시간 잡 타임아웃뿐이었고, 매번 아무 알림 없이 그렇게 끝났다.

그때까지 "자동 머지"된 PR은 모두 이 6시간짜리 시계와의 타이밍 경쟁에서 이긴 것이다. 데드락에 걸린 잡이 알아채기 전에 다른 이벤트가 PR을 먼저 닫아 준 경우들이다. 워크플로가 동작한 게 아니었다. 워크플로가 경쟁에서 졌는데, 마침 아무도 신경 쓰지 않는 방향으로 졌을 뿐이다.

자기 자신을 기다리는 대기는 폴링으로 바꿨다. gh pr checks --json name,bucket으로 체크 목록을 받아 오고, 잡 자신의 이름인 auto-merge 체크는 기다릴 목록에서 명시적으로 뺐다. 잡 전체에는 45분 제한을 걸었다. 정말로 멈춘 경우라면 6시간을 날린 뒤에야 아는 대신 바로 요란하게 실패한다.

첫 번째 버그 바로 뒤에 있던 두 번째 버그

고친 워크플로를 처음 끝까지 돌려 보니 마지막 스텝까지 가서 죽었다. gh pr review --approve가 실패한 것이다. GitHub Actions에는 pull request를 승인할 권한이 없었다. 호출이 잘못된 게 아니고 저장소 설정이 그랬다. 스크립트는 strict mode 셸로 돌고 있어서 이 실패를 치명적인 오류로 보고 멈췄다. 이 실행의 목적인 머지를 딱 한 줄 남겨 두고서다.

사실 이 승인 호출은 처음부터 하는 일이 없었다. 브랜치에 필수 리뷰 규칙이 걸려 있지 않으니 승인이 있든 없든 달라지는 게 없다. 우회할 방법을 찾지 않고 그냥 지웠다.

502가 남기고 간 것

쌓인 PR을 치우려면 GitHub이 502를 연달아 내는 와중에 gh pr merge --squash를 계속 재시도해야 했다. 그러다 몇몇 PR이 명령의 종료 코드만 봐서는 알 수 없는 상태로 남았다. squash 커밋은 main에 들어갔는데 PR은 열린 채였다. 한 PR은 첫 요청이 이미 성공했는데 재시도가 또 돌아서, 같은 squash 커밋이 하나 더 생기기도 했다. 머지 명령이 보고한 실패와 저장소에 일어난 일이 어느새 서로 다른 얘기가 돼 있었다.

복구는 영향받은 PR마다 @dependabot recreate 댓글 하나로 끝냈다. Dependabot은 변경이 이미 main에 있는 PR은 닫고, 아직 머지가 필요한 PR은 브랜치를 새로 만들어 force-push한다. PR마다 어느 쪽인지 하나하나 따져 보는 것보다 싸고 훨씬 믿을 만했다.

실패하던 PR은 저마다 이유가 따로 있었다

워크플로 버그와는 별개로, 몇몇 PR은 자동화와 상관없는 자기 사정으로 실패하고 있었다. 하나는 Go e2e 테스트였다. 정규화하지 않은 날짜로 명세서 기간을 계산하는 바람에, CI가 하필 31일에 돌 때만 깨졌다. ruff 0.16 업그레이드도 있었다. 새 포매터가 소스 파일만이 아니라 문서에 들어 있는 파이썬 코드 블록까지 전부 손댔다. Kotlin Gradle 플러그인 3개는 한꺼번에 2.4.10으로 올려야 했다. 하나만 먼저 올라가면 빌드가 깨졌기 때문이다.

셋 다 자동화 탓이 아니었다. 자동화가 제대로 돌았어도 고쳐지지 않았을 문제들이다. 사람이 직접 들여다봐야 했다.

"이제 된다"는 어떤 모습이었나

제대로 고쳐졌는지 확인한 건 실시간으로 지켜본 초록불이 아니었다. 한참 뒤 전혀 다른 작업을 하다가, 평범한 의존성 업데이트 하나가 열리고 체크를 통과하고 아무도 보지 않는 사이에 혼자 머지된 걸 발견했을 때였다.

이 버그의 일반적인 모양

"체크가 전부 초록이면 머지"를 조건으로 거는 워크플로가 그 체크 목록에 자기 자신도 들어 있다면, 어디서든 이 실패가 숨어 있다. 이런 데드락은 로직이 끝나서 풀리는 일이 없다. 외부 타임아웃이나 상관없는 이벤트 덕분에 우연히 풀릴 뿐이다. Dependabot 자동화만이 아니고, 스스로 승인하거나 머지하는 CI 설정이라면 한 번쯤 점검해 볼 만하다.

더 볼 자료

고친 워크플로 예시(같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트 backend-service-playbook의 워크플로. 자기 자신의 체크는 빼고, 승인 스텝은 없앴다)