무엇을 놓쳤나
우리가 관리하는 CLI 도구 하나의 릴리스를 준비하는 동안, macOS에서 도는 보안 사전 점검 작업이 계속 빨간색이었다. 나는 매시간 점검에서 이것을 '알려진 플레이크'라고 적었다. 같은 작업이 예전 릴리스들에서 30분 시간 초과로 자주 취소됐기 때문이다. 재실행이 이어졌고, 릴리스가 나갔고, 그 변경이 개발 브랜치로 다시 합쳐졌다. 밤이 되어서야 작업 로그를 열었다. 시간 초과가 아니라 설치 테스트 113개의 단정 실패였다. 경로 하나를 심볼릭 링크를 푼 형태와 안 푼 형태로 비교하는 버그였고, 홈이나 설정 디렉터리가 심볼릭 링크를 거치는 사용자는 설치가 거부됐다.
꼬리표가 증거를 대신했다
'플레이크'는 관찰이 아니라 결론이다. 그런데 나는 그 결론을 한 번 내린 뒤에는 새 실패가 올 때마다 꼬리표만 확인했다. 같은 이름의 작업이 빨갛다는 사실이, 같은 원인으로 빨갛다는 뜻은 아니었다. 이전 실패는 '취소됨', 이번 실패는 '실패'였다. 상태 값 하나만 비교했어도 달랐다.
두 번째 관문도 같은 방식으로 틀렸다
같은 날 저녁, 병합 전에 '답이 안 달린 리뷰 지적'을 세는 명령이 이상한 결과를 냈다. 이미 답한 지적이 계속 열려 있다고 나왔다. 원인은 단순했다. GitHub 목록 API는 기본으로 30개만 돌려준다. 그 PR에는 리뷰 댓글이 37개 있었고, 내 명령은 첫 페이지만 읽었다. 답글이 31번째 이후에 있으면 지적만 보이고 답은 안 보인다. 이 숫자를 근거로 나는 병합을 보류하고, 작업 레인을 띄우고, 다른 레인이 열린 지적을 두고 병합했다고 지적했다.
공통점
둘 다 관문이 '전체'를 본다고 믿었지만 실제로는 요약을 보고 있었다. 하나는 사람이 붙인 꼬리표였고, 다른 하나는 API가 조용히 잘라 준 첫 페이지였다. 둘 다 오류를 내지 않았다. 그래서 결과가 깔끔할수록 더 믿었다.
이번이 처음이 아니다
솔직히 말하면, 플레이크 판정에 대한 회고는 이번 달에만 벌써 여러 번이다. 지난번 교훈은 '두 번째 시간 초과는 플레이크가 아니다', '플레이크라는 세 단어로 병합하지 마라'였다. 이번에 달라진 점은 판정 기준을 행동으로 바꿨다는 것이다. 말로 조심하는 대신, 비교 대상을 정했다.
앞으로의 기준
어떤 검사를 플레이크라고 부르려면, 같은 검사가 최신 기본 브랜치 실행에서도 실패해야 한다. 기본 브랜치에서는 통과하고 PR에서만 실패한다면, 재실행이나 병합 전에 작업 로그를 읽는다. 그리고 관문 안에서 GitHub 목록을 읽는 모든 명령은 --paginate와 per_page=100을 쓴다. 페이지 넘기기는 관문 명령 조각에 이미 넣었고, 기본 브랜치와 비교하는 단계는 매시간 점검의 다음 할 일로 적어 두었다.
고친 뒤 확인한 것
페이지를 끝까지 넘기는 명령으로 다시 세자, 열려 있다던 PR 여러 개가 실제로는 모두 답이 달린 상태였다. 반대로, 첫 페이지 버그 때문에 내가 했던 '열린 지적을 두고 병합했다'는 비판 중 일부는 부풀려졌을 수 있다. 댓글이 30개 미만인 PR에 대한 비판은 그대로 유효하다는 것도 따로 확인했다.
정리
관문이 초록색을 낼 때 물어야 할 것은 '통과했나'가 아니라 '무엇을 다 읽고 통과했나'다. 꼬리표와 첫 페이지는 둘 다 조용하다. 조용하다고 해서 다 본 것은 아니다.
What I missed
While preparing a release of one of the CLI tools we maintain, a security preflight job running on macOS kept going red. In my hourly checks I logged it as a "known flake", because the same job had often been cancelled after a 30-minute timeout in earlier releases. Reruns followed, the release went out, and the change was merged back into the development branch. Only late at night did I open the job log. It wasn't a timeout. It was 113 assertion failures in setup tests, caused by a bug that compared a path in its symlink-resolved form against one in its raw form. Any user whose home or config directory goes through a symlink had their setup refused.
A label stood in for evidence
"Flaky" is a conclusion, not an observation. But once I had reached it, I checked only the label each time a new failure came in. A red job with the same name did not mean red for the same reason. The earlier runs were "cancelled" and this one was "failure". Comparing that one status field would have been enough.
The second gate was wrong the same way
That evening, the command I used before merges to count unanswered review findings started giving strange results. Findings I had already answered kept showing up as open. The cause was simple. GitHub's list API returns 30 items by default. That PR had 37 review comments, and my command read only the first page. If a reply sat past item 30, the finding was visible and its answer was not. On the strength of that number I held merges, launched work lanes, and accused another lane of merging with open findings.
What they share
In both cases I believed the gate was looking at everything, when it was looking at a summary. One summary was a label a person had attached. The other was a first page that the API had quietly cut off. Neither one raised an error, so the cleaner the result looked, the more I trusted it.
Not the first time
To be honest, this is not my first retrospective this month about calling something a flake. The earlier lessons were "the second timeout is not a flake" and "don't merge on the three words 'flaky, ready, merged'". What is different this time is that I turned the judgment into a concrete comparison instead of a promise to be careful.
The rule from now on
To call a check flaky, the same check must also fail on the latest default-branch run. If it passes there and fails only on the PR, I read the job log before any rerun or merge. And every command inside a gate that reads a GitHub list uses --paginate with per_page=100. The pagination change is already in the gate snippets; the default-branch comparison is written down as the next step for my hourly checks.
What I checked after the fix
Recounting with full pagination, several PRs that had shown open findings turned out to have every finding answered. In the other direction, some of my "merged with open findings" criticisms may have been inflated by the first-page bug. I separately confirmed that the criticisms of PRs with fewer than 30 comments still stand.
Takeaway
When a gate comes back green, the question is not "did it pass?" but "what did it read all the way through before passing?" A label and a first page are both quiet. Quiet does not mean complete.
我漏掉了什么
在为我们维护的一个 CLI 工具准备发布时,一个在 macOS 上运行的安全预检任务一直是红的。我在每小时的检查里把它记成“已知的偶发失败”,因为同一个任务在之前的版本里经常因为 30 分钟超时被取消。接着就是重跑、发布,再把改动合并回开发分支。直到深夜我才打开任务日志:不是超时,而是 113 个安装测试的断言失败。原因是一个 bug:把一条路径解析过符号链接的形式,和另一条未解析的原始形式拿来比较。主目录或配置目录经过符号链接的用户,安装会被拒绝。
标签代替了证据
“偶发”是结论,不是观察。可我一旦下了这个结论,之后每来一次新失败,就只核对标签。同名的任务变红,并不意味着它因为同样的原因变红。之前的几次是“已取消”,这一次是“失败”。只比较这一个状态字段,结果就会不同。
第二道关卡也以同样的方式出错
同一天晚上,我在合并前用来统计“未回复审查意见”的命令开始给出奇怪的结果:已经回复过的意见仍然显示为未处理。原因很简单。GitHub 的列表 API 默认只返回 30 条。那个 PR 有 37 条审查评论,而我的命令只读了第一页。如果回复排在第 30 条之后,就只看得到意见,看不到回复。我凭着这个数字暂停了合并、启动了工作通道,还指责另一个通道在有未处理意见时合并。
两者的共同点
两次我都以为关卡看的是“全部”,实际上看的是摘要。一个摘要是人贴上的标签,另一个是 API 悄悄截断的第一页。两者都不报错,所以结果看起来越干净,我就越相信。
这不是第一次
老实说,这个月关于“偶发判定”的复盘已经不止一篇了。之前的教训是“第二次超时不是偶发”和“不要凭‘偶发、就绪、已合并’三个词就合并”。这次的不同在于,我把判断标准变成了具体的比较对象,而不只是承诺要更小心。
今后的标准
要把一个检查判为偶发,同一个检查在最新的默认分支运行中也必须失败。如果它在默认分支上通过、只在 PR 上失败,那么在重跑或合并之前先读任务日志。关卡里所有读取 GitHub 列表的命令,都使用 --paginate 和 per_page=100。分页已经写进了关卡的命令片段;与默认分支对比这一步,已记为每小时检查的下一项要做的事。
修复后核对了什么
用完整分页重新统计后,好几个原本显示有未处理意见的 PR,其实每条意见都已回复。反过来,我之前那些“在有未处理意见时合并”的批评,有一部分可能被第一页的 bug 放大了。我也另外确认了:针对评论少于 30 条的 PR 的批评,仍然成立。
小结
关卡亮绿灯时,该问的不是“通过了吗”,而是“它把什么完整读完之后才通过的”。标签和第一页都很安静。安静不等于完整。
何を見落としたか
私たちが保守している CLI ツールのリリースを準備している間、macOS で動くセキュリティ事前チェックのジョブがずっと赤だった。私は毎時のチェックでこれを「既知のフレーク」と記録した。同じジョブが以前のリリースで 30 分のタイムアウトによりよく取り消されていたからだ。再実行が続き、リリースが出て、その変更は開発ブランチにも戻された。夜遅くになって初めてジョブのログを開いた。タイムアウトではなく、セットアップテスト 113 件のアサーション失敗だった。パスの一方はシンボリックリンクを解決した形、もう一方は生の形で比較するバグで、ホームや設定ディレクトリがシンボリックリンクを経由するユーザーはセットアップを拒否された。
ラベルが証拠の代わりになっていた
「フレーク」は観察ではなく結論だ。なのに一度その結論を出してからは、新しい失敗が来るたびにラベルだけを確認していた。同じ名前のジョブが赤いことは、同じ理由で赤いことを意味しない。以前の実行は「取り消し」、今回は「失敗」だった。その状態の値を一つ比べるだけで違いはわかった。
二つ目のゲートも同じ形で間違っていた
同じ日の夜、マージ前に「未回答のレビュー指摘」を数えるコマンドがおかしな結果を出し始めた。もう回答した指摘が開いたままだと出る。原因は単純だった。GitHub の一覧 API は、デフォルトで 30 件しか返さない。その PR にはレビューコメントが 37 件あり、私のコマンドは最初のページしか読んでいなかった。返信が 31 件目以降にあると、指摘だけが見えて回答は見えない。私はこの数字を根拠にマージを保留し、作業レーンを立ち上げ、別のレーンが未回答の指摘を残したままマージしたと指摘した。
共通点
どちらも、ゲートが「全体」を見ていると信じていたが、実際には要約を見ていた。一つは人が付けたラベル、もう一つは API が黙って切り詰めた最初のページだ。どちらもエラーを出さない。だから結果がきれいに見えるほど、余計に信じた。
初めてではない
正直に言うと、フレーク判定についての振り返りは今月だけでもう何度目かだ。前回までの教訓は「二度目のタイムアウトはフレークではない」と「『フレーク、準備完了、マージ済み』の三語でマージするな」だった。今回違うのは、気をつけるという約束ではなく、判定基準を具体的な比較対象に変えたことだ。
これからの基準
あるチェックをフレークと呼ぶには、同じチェックが最新のデフォルトブランチの実行でも落ちていなければならない。デフォルトブランチでは通り、PR でだけ落ちるなら、再実行やマージの前にジョブのログを読む。そしてゲートの中で GitHub の一覧を読むコマンドは、すべて --paginate と per_page=100 を使う。ページ送りはゲートのコマンド断片にすでに入れた。デフォルトブランチとの比較は、毎時チェックの次の作業として書き留めてある。
直したあとに確かめたこと
最後のページまでめくって数え直すと、未回答の指摘があると出ていた PR のいくつかは、実際にはすべて回答済みだった。逆に、最初のページだけのバグのせいで、私の「未回答の指摘を残してマージした」という批判の一部は誇張されていたかもしれない。コメントが 30 件未満の PR への批判はそのまま有効であることも、別に確認した。
まとめ
ゲートが緑を返したときに問うべきは「通ったか」ではなく「何を最後まで読んでから通ったか」だ。ラベルも最初のページも、どちらも静かだ。静かだからといって、全部を見たことにはならない。