TL;DR
- 本ブログのリポジトリには「失敗台帳」があり、行を
L-0001形式の連番で識別します。採番規則は 「マージ済みmainの最大 ID の次から採る」。この規則を全員が正しく守ったうえで、2026-09-09 の 1 日に 2 回、まったく別の観測に同じ ID が割り当てられました(社内データ)。 - 原因は書き手のミスではありません。並行して切られた 2 本のブランチは、どちらも同じ「次の番号」を見ます。 規則が参照するのは「マージ済みの最大値」で、いま誰かが手元で使っている番号はそこに含まれないからです。
- 1 回目(PR #480 / #481、
L-0020〜L-0023)は人間が気づいて手で振り直しました。その後 base と head を突き合わせる検出ガードを入れました。 - ガードを入れても、2 回目(PR #483 / #485、
L-0030)は CI に見えていません。 衝突していた最中の run はledger id collision check passed.を出しています(run34323369686、2026-09-09T07:21Z)。on: pull_requestは base への push で再発火せず、ガードの base 入力はマージ済みmainだけだからです。この観測は本記事の PR で台帳にL-0054として起票し、追跡先を Issue #493 にしています(基準コミットa184564には未反映)。 - ID 形式は変えないと決めました。 判断の根拠は移行コストの実測です。
L-\d{4}の参照は基準時点で 32 ファイル・252 箇所でしたが、うち 14 ファイル・77 箇所は「1 PR = 1 worklog」契約から機械的に生じる分なので分離しました(社内データ)。自発的に広がった参照は 18 ファイル・175 箇所で、うち 4 ファイルは公開記事本文 1 本とgate.md3 本です。 - 第 6 節に、この検出窓を使い捨てリポジトリで再現するスクリプトを置きました。base が進んでいない状態では exit 0、base が進むと exit 1 になります。異常系 5 系統の終了コードも表にしてあります。
- 執筆中に同じ衝突が 2 回再発しました。 まず並行していた 5 本のうち 3 本が
L-0041を別々の観測に割り当て、手で移した先のL-0047も、先行 PR がレビュー対応で ID を増やしたことで事後的に衝突しました(第 8 節)。回避したのは規則でもガードでもなく、他の PR の head を直接覗くという手作業です。 - これは 1 リポジトリ・1 台帳の観測です(計 4 件)。「連番 ID をやめるべき」という一般論ではありません。 持ち帰ってほしいのは「自分の採番規則が、並行している他人の手元を見ているか」を確かめる手順のほうです。
はじめに:全員が規則を守って、それでも衝突した
採番の事故は、たいてい「誰かが規則を読まなかった」話として片付きます。今回はそうではありませんでした。
本ブログのリポジトリには spec/article_failure_ledger.md という失敗台帳があります。記事制作やガード整備で見つかった「Harness をすり抜けた失敗」を 1 行 1 観測で追記していく、追記専用のファイルです。行の識別子は L-0001 のような 4 桁連番で、採番規則は SSoT(spec/article_retrospective_loop.md)に次のように書かれています。
新しい行の
idは、その PR の base ブランチ(origin/main)にある最大idより後から採る。
この規則は 1 本ずつ順に作業する限り正しく機能します(この形の問題を避けるために、Rails の Active Record マイグレーションはファイル名の接頭辞に連番ではなく UTC タイムスタンプを使っています)。壊れるのは並行したときです。規則が参照している「マージ済み main の最大値」には、いま別のブランチで進行中の採番が含まれていないからです。2 本のブランチが同時に切られていれば、両方が同じ値を読み、同じ「次の番号」を採ります。手順を守れば守るほど、必ず同じ番号になります。
この記事は、その衝突がマージ済みの履歴に 2 回、そして執筆中にさらに 2 回起きた記録です。一般論ではなく、2026-09-10 時点でリポジトリの履歴・CI ログ・ガード実装を実際に読んで数えた値だけを扱います。
同じリポジトリで「置いたガードが実は一度も発火していなかった」型を分類した記事として 効いていないガードを見つける7つの型 があります。本記事はその続きにあたり、発火はするが、発火してほしい瞬間には入力が揃っていないガードの話です。

1. 測定条件と基準時点
台帳の中身も参照の本数も日々増えるので、いつ時点の話かを先に固定します。
| 項目 | 値 |
|---|---|
| 対象リポジトリ | 本ブログのリポジトリ(非公開) |
| 基準コミット | origin/main = a184564(2026-09-10) |
| 基準時点の記事数 | 224 本 |
| 基準時点の台帳行数 | 40 行(L-0001〜L-0040。本記事の PR で L-0054 を追加する) |
| 衝突が起きた日 | 2026-09-09(1 回目 03:56〜05:45Z / 2 回目 07:05〜07:55Z) |
| CI ログの取得日 | 2026-09-10 |
| 検出ガード | scripts/check_ledger_id_collision.py(PR #482 で導入) |
| 配線 | .github/workflows/article-risk-gate-check.yml の check-ledger-id-collision ジョブ |
以降 社内データ と付した値は、すべてこの条件で筆者が実測したものです。台帳の行数も参照箇所も単調に増えるので、後日同じコマンドを走らせても同じ数字にはなりません。 数え方のほうを持ち帰ってください。
2. 1 回目:人間が気づいて、手で振り直した
最初の衝突は PR #480 と PR #481 の間で起きました。
| PR | 作成 | マージ | 採った ID |
|---|---|---|---|
| 先行 PR(ガード整備) | 2026-09-09T03:56Z | 2026-09-09T05:39Z | L-0020〜L-0023 |
| 後発 PR(記事) | 2026-09-09T04:36Z | 2026-09-09T05:45Z | 同じ L-0020〜L-0023 |
(非公開リポジトリの PR #480 / #481。以降の PR 番号・L-XXXX も同リポジトリの内部識別子で、読者が開いて確認することはできません。時系列と件数の対応を追うための符号として読んでください。)
どちらのブランチも origin/main の最大 ID が L-0019 の時点で切られています(git show 8566cc9:spec/article_failure_ledger.md の最終観測行が L-0019)。#480 が open のあいだに #481 が作成され、両者は約 1 時間並行していました。両方が「次は L-0020」と読み、まったく別の 4 つの観測に同じ 4 つの ID を割り当てました。どちらのワーカーも規則を破っていません。
このとき衝突を見つけたのは CI ではなく、レビューしていた人間です。L-0020 が 2 つの別の内容を指していることに気づき、後発側を手で振り直して解決しました。この観測が台帳に L-0028 として escape_mode: E5(充足不能な契約)で起票されたのは、衝突そのものより後です(当時の台帳は L-0023 までしかありません)。衝突を直すことと、衝突が起きた構造を記録に残すことは別の作業でした。
台帳
idの採番規約が「マージ済みmainの最大 ID の次」という手順にしか根拠を持たず、並行ブランチが規約どおりに採番しても同じ ID を割り当てる(構造的に充足不能)。(…)—
spec/article_failure_ledger.mdのL-0028行、summary列より(後半は省略)
「手順を守っても必ず起きる」ことが確定した時点で、手順の書き直しは対策になりません。「気をつける」で防げないものに対しては、検出を機械側へ置くしかないというのがこのときの判断でした。
3. ID 形式を変えなかった理由(移行コストの実測)
衝突を根本から消す方法は、実は分かりやすいものが存在します。ID を衝突しない形式にすることです。日付+短ハッシュ、UUID の断片、PR 番号込み — どれも並行ブランチ間で衝突しません。
採らなかった理由は 2 つあり、片方は測れる値です。
3-1. 既存 ID の参照が広がりすぎていた
L-\d{4} は台帳の中だけで使われているわけではありません。ワークログ、ガードの実装、ガードのテスト、CI の workflow、そして公開済みの記事本文からも参照されています。数えたのが次の値です(-- の後ろのパススペックは git-grep の記法で、:! は除外を意味します)。
# 台帳本体を除いた、L-XXXX を参照している tracked ファイル数と出現数
git grep -lE 'L-[0-9]{4}' -- . ':!spec/article_failure_ledger.md' | wc -l
git grep -oE 'L-[0-9]{4}' -- . ':!spec/article_failure_ledger.md' | wc -l
| 時点(コミット) | 台帳の行数 | 参照ファイル数 | うち spec/worklog/ | 参照箇所 | うち spec/worklog/ |
|---|---|---|---|---|---|
1 回目の衝突の直後(d7b02c8、2026-09-09) | 23 | 14 | 7 | 117 | 36 |
本記事の基準時点(a184564、2026-09-10) | 40 | 32 | 14 | 252 | 77 |
この増分の一部は、測るまでもなく決まっています。 このリポジトリは「1 PR = 1 worklog」の運用で、台帳に行を足した PR は自分が採った ID を必ず自分の worklog に書きます。したがって spec/worklog/ の増加本数(7 → 14)は、この区間で台帳を触った PR の本数と定義上一致する値であって、「参照が広がった」という観測ではありません。基準時点でも 32 ファイル中 14 本・252 箇所中 77 箇所が worklog です。
構造で決まる分を引くと、自発的に広がった参照は 7 → 18 ファイル / 81 → 175 箇所です(社内データ)。ID 形式を変えるときに書き換えが要るのもこちら側で、追記専用の過去 worklog は既存 ID を既存形式のまま残せるため移行対象になりません。
自発的に広がった 18 ファイルのうち 4 ファイルは content/posts/ 配下で、内訳は公開済みの記事本文 1 本と判断記録(gate.md)3 本です。形式を変えるなら、公開記事の本文書き換えを伴います。
なお、この検出ガードを入れた PR のワークログには当時の値が「15 ファイル・117 箇所」と記録されていますが、同じコミットを同じコマンドで測り直すと 14 ファイルでした。1 件の差の由来は追えていません。本記事は自分で測り直した値のほうを使っています。
そのうえで注意したい点があります。この 2 点だけで「参照は指数的に増える」とは言えません。 測ったのは台帳整備の PR が集中していた 2 日間の 2 時点だけで、しかも増分の 39%(18 のうち 7 ファイル)は上で分離した構造的な分です。言えるのは「移行コストは固定ではなく、先送りするほど上がる向きに動いていた」ところまでです。
3-2. 会話で使われる ID でなければ意味がない
もう 1 つは測れない理由です。台帳の ID はレビューや issue で口頭・文章の参照に使われます(「L-0015 の件」)。長い ID は運用で使われなくなる、という判断でした。これは筆者の運用上の見立てであって、実測はしていません。根拠の強さが違うものを、同じ強さで並べないでおきます。
結果として選んだのは「形式は変えず、衝突を merge 前に検出する」でした。次節はその結果です。
4. 2 回目:ガードがあっても、衝突している最中は見えなかった
PR #482 で入れたガードは、base と head の 2 点で台帳を突き合わせ、4 種類の違反を検出します。
| # | 検査 |
|---|---|
| C1 | head の台帳内で同じ id が 2 回以上出る |
| C2 | base にも head にもある id の観測を同定する列が変わっている(=別の観測に付け替えた。本命)。ただしこれは PR #485 で直した後の姿で、初版は行全体のバイト比較でした(第 5 節) |
| C3 | head が追加した id が base の最大 id 以下(欠番の再利用) |
| C4 | base にあった id が head から消えている |
ここまでは筋が通っています。問題は base に何が入っているかでした。
2 回目の衝突は PR #483 と PR #485 の間で起きました。CI の run ログから時系列を復元します。すべて 2026-09-09 の UTC です。
| 時刻 | 出来事 | ガードが見た base | 検査した head | ガードの結果 |
|---|---|---|---|---|
| 07:05 | PR #483 作成。L-0030 を採る | — | — | — |
| 07:14 | PR #485 作成。同じ L-0030 を別の観測に採る(同 PR は最終的に L-0030〜L-0032 の 3 件を追加する。07:15 時点の head では 2 件) | — | — | — |
| 07:15 | PR #485 の run 34322898142 | eedcf720(29 行) / origin/main(29 行) | 7a137ba(31 行。追加 ID は L-0030 / L-0031 の 2 件) | exit 1(ただし C2 の別要因。第 5 節) |
| 07:21 | PR #485 の run 34323369686。この間に head へ C2 の比較列を限定する修正を push している | eedcf720(29 行) / origin/main(29 行) | 8fc30a8(32 行。L-0030〜L-0032 を含む) | exit 0 ledger id collision check passed. |
| 07:50 | PR #483 が merge され、origin/main が a6162ce(30 行)へ | — | — | — |
| 07:55 | PR #485 の run 34326380167 | a6162ce(30 行) / origin/main(30 行) | 4b49a51(33 行) | exit 0。head の追加 ID は L-0031〜L-0033 へ 1 つずつ繰り上げ済み |
07:21 の run が重要です。 この瞬間、L-0030 は 2 つの別の観測に割り当てられていました(PR #483 の「公開判断が実装されないまま約 5 か月経った」と、PR #485 の「昇格ループに deferred 状態が無い」)。ガードは正常に起動し、正常に比較し、そして ledger id collision check passed. と出して exit 0 を返しています。
07:15 と 07:21 で結果が反転しているのは base が変わったからではありません。base はどちらも 29 行のままで、変わったのは head です(第 5 節で扱う C2 の偽陽性を、この 6 分のあいだに直して push しています)。衝突の有無に関わる入力は、この間 1 バイトも変わっていません。
理由は 2 つ重なっています。
- ガードの base 入力はマージ済みの
mainだけ。 渡しているのはpull_request.base.shaとorigin/<base branch>の 2 つで、どちらも「マージ済み」です。並行して open な PR の head は入力に含まれません。衝突相手はそこに居ます。 on: pull_requestは base への push で再発火しない。types:を指定しないpull_requestの既定はopened/synchronize/reopenedで、いずれも head 側のイベントです(GitHub Docs: Events that trigger workflows)。07:50 にorigin/mainが進んでも、PR #485 の CI は自動では動き直しません。実際に動いたのは、head 側が rebase / push した 07:55 でした。
つまり**「両方の PR が古い base の上にいる区間」は、どちらの CI も衝突を見られない**構造になっています。実際に落ちるのは rebase / push / 再実行の時点で、そのときには手元で振り直しが済んでいることもあります(今回がそうでした)。
この観測は本記事の PR で、台帳に L-0054 として escape_mode: E4(部分測定の全体化)で起票しています。マージ済みの base という一部だけを測って、「ID 衝突は無い」という全体の結論を出していたためです。同じ mode の既存観測(L-0009)と合わせて昇格条件に達したので昇格し、対策の設計は Issue #493 に置きました。この「観測がたまったら Harness の改善へ昇格させる」ループそのものは AIの推論を自動化へ昇格させるループの穴 で扱っています。対策の実装はこの記事の PR には含めません(本リポジトリでは「対策の実装は必ず単独 PR」という契約になっています)。
5. ガードの唯一の本番発火は、偽陽性だった
もう 1 つ、測って初めて分かったことがあります。
このガードが本番の CI で exit 1 を返したのは、導入から本記事の基準時点までに 1 回だけです(run 34322898142、第 4 節の表の 07:15)。そしてその 1 回は、採番衝突ではありませんでした。
実際の出力では各 ERROR に base : / head : の行表示が続きます。ここでは 1 行目だけを抜粋します。
ERROR: C2: `L-0023` は base に既に存在しますが、head で行の内容が変わっています
(既存 ID を別の観測に付け替えた=並行ブランチ間の採番衝突)。
ERROR: C2: `L-0024` は base に既に存在しますが、head で行の内容が変わっています
(既存 ID を別の観測に付け替えた=並行ブランチ間の採番衝突)。
L-0023 と L-0024 は付け替えられていません。台帳の契約が要求する状態遷移(実データでは L-0023 が open → fixed、L-0024 が wont_fix → deferred。どちらも status と improvement_pr の値が変わる)が起きただけです。初版の C2 は行全体をバイト比較していたため、契約どおりに運用すると必ず落ちる状態になっていました。この観測は L-0033 として起票され、比較対象を「観測を同定する列」に限定する修正が入っています。
並べるとこうです(社内データ)。ガードの評価に使えるのは、ガードが存在していた期間だけなので、1 回目(#480 / #481、merge は 05:39Z / 05:45Z)はここでは数えません。ガードの導入は PR #482(merge 06:57Z)で、1 回目より後だからです。「導入前の衝突を止められなかった」は測定ではなく定義上必ずそうなる値で、出せば第 3 節で避けたのと同じ誤りになります。
| 事象 | 件数 |
|---|---|
| ガード導入後に起きた採番衝突 | 3(#483 / #485、第 8 節の L-0041 3 重割当、L-0047 の事後衝突) |
| そのうちガードが merge 前に名指しで止めたもの | 0 |
| ガードが本番で exit 1 を返した回数 | 1 |
| そのうち採番衝突だったもの | 0 |
この 4 行を「ガードは無意味だった」と読むのは行き過ぎです。 ガードが評価対象にできた衝突は 3 件、発火は 1 回しかありません。ガードが入ってから 07:55 の run のように「状態遷移 2 件を C2 の対象外として明示的に出力したうえで通す」挙動は取れており、少なくとも黙って通してはいません。言えるのは 「置いた場所と、検出したい瞬間がずれていた」 ところまでです。
6. 検出窓を使い捨てリポジトリで再現する
第 4 節の主張(base が進むまで見えない)は、口で言うと「そういう気がする」で終わります。再現できる形にしておきます。
次のスクリプトは、空のリポジトリに台帳を作り、2 本の並行ブランチが同じ L-0002 を別の観測へ割り当てた状態を作って、ガードを走らせます。正常系 2 系統に加えて、異常系 5 系統も同じスクリプトの中で走ります(「対象 0 件でも合格に見える」形になっていないかは、正常系だけでは分からないため)。ガード本体(check_ledger_id_collision.py)のパスを引数に渡します。
#!/usr/bin/env bash
# usage: repro.sh /path/to/check_ledger_id_collision.py
set -u
# cd する前に絶対パスへ直す(相対パスのままだと全系統が exit 2 になる)
GUARD="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
ROOT="$(mktemp -d)"; cd "$ROOT" || exit 9
trap 'rm -rf "$ROOT"' EXIT
# git init -b は git 2.28 以降。古い環境では init 後に branch -m main
git init -q -b main && git config user.email [email protected] && git config user.name t
mkdir -p spec
{
echo '| id | observed_at | origin | escape_mode | summary | evidence | status | improvement_pr | revert_pr |'
echo '| --- | --- | --- | --- | --- | --- | --- | --- | --- |'
echo '| L-0001 | 2026-09-10 | harness | E1 | 最初の観測 | PR #1 | open | | |'
} > spec/article_failure_ledger.md
git add -A && git commit -qm base
# 並行する2本。どちらも main(最大 L-0001) から切って「次は L-0002」を採る
git checkout -qb pr-a
echo '| L-0002 | 2026-09-10 | harness | E3 | Aの観測 | PR #2 | open | | |' >> spec/article_failure_ledger.md
git commit -qam a
git checkout -q main && git checkout -qb pr-b
echo '| L-0002 | 2026-09-10 | harness | E4 | Bの観測(Aとは別物) | PR #3 | open | | |' >> spec/article_failure_ledger.md
git commit -qam b
# CI と同じく --base を2つ渡す(PR の base sha と origin/<base branch>)。
# 今回はどちらもマージ済み main を指すので、結果は1つ渡した場合と変わらない。
run() { local label="$1"; shift
echo "### $label"
python3 "$GUARD" --repo-root "$ROOT" "$@"; echo " exit=$?"; }
echo '== 1. 両方 open・base は古いまま(PR B 側の CI が見ているもの) =='
run 'base=main x2 / head=pr-b' --base main --base main --head pr-b --require-target
echo '== 2. PR A が先に merge された後(base が進んだ)=='
git checkout -q main && git merge -q --no-edit pr-a
run 'base=main(merged A) x2 / head=pr-b' --base main --base main --head pr-b --require-target
echo '== 3. 異常系(「対象0件でも合格に見える」形になっていないか) =='
run 'base ref が解決できない' --base nosuchref --head pr-b --require-target
run 'head ref が存在しない' --base main --head nosuchhead --require-target
git checkout -q -b empty main
head -2 spec/article_failure_ledger.md > tmp && mv tmp spec/article_failure_ledger.md
git commit -qam empty
run '観測行 0 件 + --require-target' --base main --head empty --require-target
run '観測行 0 件(--require-target なし)' --base main --head empty
run '台帳に変更なし(追加 0 件)' --base main --head main --require-target
実行結果(社内データ)。見出し行は紙面の都合で短縮し、ガードの出力も要約行だけを抜粋しています。実際にはスクリプトの echo 文字列がそのまま出ます:
== 1. 両方 open・base は古いまま(PR B 側の CI が見ているもの) ==
### base=main x2 / head=pr-b
検査対象: head(pr-b) の観測行 2 件 / base 2/2 件を解決 [main(1 行), main(1 行)]
head が追加した ID: 1 件(L-0002)
ledger id collision check passed.
exit=0
== 2. PR A が先に merge された後(base が進んだ)==
### base=main(merged A) x2 / head=pr-b
検査対象: head(pr-b) の観測行 2 件 / base 2/2 件を解決 [main(2 行), main(2 行)]
ERROR: C2: `L-0002` は base に既に存在しますが、head で**観測を同定する列**が
変わっています(既存 ID を別の観測に付け替えた=並行ブランチ間の採番衝突)。
exit=1
衝突している事実は 1 と 2 で一切変わっていません。 変わったのは base に何が入っているかだけです。これが第 4 節で CI ログから読み取った構造と同じものであることを、独立した最小環境で確認できます。
異常系も一緒に走らせる
上のスクリプトの == 3. ブロックがこれです。実測(社内データ):
| 与えた状況 | 実測 |
|---|---|
| base ref が解決できない(未 fetch 相当) | exit 1「比較が成立していないので『合格』にしません」 |
| head ref が存在しない | exit 1 |
台帳の観測行が 0 件 + --require-target | exit 2(0 件を合格にしない) |
台帳の観測行が 0 件(--require-target なし) | exit 0。ただし WARNING: ... 何も検査していません を stderr へ出し、合格とは別の出力になる |
| 台帳に変更なし(追加 0 件) | exit 0。「この PR は台帳に行を追加していません」と明示 |
なお base ref が解決できない の行だけは、head が追加した ID: 2 件(L-0001, L-0002) という一見おかしな出力を伴います。base を 1 つも読めていないので「base に無い ID」が全件になるためで、そのうえで exit 1 に倒しています(比較が成立していないものを合格にしない)。
この 5 行が、第 5 節の「ガードは無意味だった、とは言えない」の根拠です。空振りを合格と同じ顔で報告しないところは実装されています。足りないのは検出窓のほうでした。この「壊した入力を 1 回通す」二面検証の手順そのものは 効いていないガードを見つける7つの型 にまとめてあります。
7. 自分の環境に持ち帰る(マイグレーション番号 / ADR 番号 / チケット番号)
ここからは本ブログのリポジトリの話ではなく、読者が自分の仕組みに当てはめて確かめるための手順です。断定はしません。当てはまるかどうかは自分の環境で測ってください。
連番で採られる識別子は、台帳に限らずいくつもあります。
- DB マイグレーションのファイル番号(
0042_add_column.sql)。マイグレーション自体の事故パターンは DBマイグレーション事故パターン に別途あります - 手書きのチケット / ADR / RFC 番号(
ADR-0017) - フィクスチャやシードデータの固定 ID
- 手で採番する feature flag のキー
- 手書きのエラーコード / メトリクス名
これらが必ず衝突するとは言いません。 衝突するかどうかは、採番の規則が何を参照しているかで決まります。次の 3 問で切り分けられます。
| 問い | 「はい」のとき |
|---|---|
| Q1. 「次の番号」を決めるとき、参照するのはマージ済みの状態か? | 並行ブランチの採番は見えていない。衝突は運用の運の問題になる |
| Q2. 衝突したとき、何が壊れるかを言えるか? | 言えないなら、まだ影響範囲を測っていない。「番号が被る」だけなのか、参照が壊れるのかで対策が変わる |
| Q3. 既存 ID はいくつのファイルから参照されているかを数えたか? | 数えていないなら「形式を変える/変えない」の判断材料がまだ無い |
Q3 は 1 行で測れます。自分の ID 形式に置き換えてください。「判断の前にまず測る」という進め方については tokenmaxxingを避けるAI開発KPI も参考になります。
# 例: ADR-0001 形式。台帳/索引そのものは除いて数える
git grep -lE 'ADR-[0-9]{4}' -- . ':!docs/adr/index.md' | wc -l # 参照ファイル数
git grep -oE 'ADR-[0-9]{4}' -- . ':!docs/adr/index.md' | wc -l # 参照箇所
そのうえで取れる方向は、少なくとも 3 つあります。どれが正解かは、上の 3 問の答え次第です。
| 方向 | 向いている状況 | 引き受けるコスト |
|---|---|---|
| A. 採番自体を衝突しない形式にする(ハッシュ / UUID 断片 / PR 番号込み) | 参照が少ない、または人間が口頭で参照しない ID | 既存参照の移行。会話での可読性 |
| B. 形式は変えず、衝突を機械で検出する | 参照が既に広い。ID の可読性を残したい | 検出窓の設計。本記事の第 4 節がここで失敗した箇所 |
| C. 採番をマージ時点まで遅らせる(merge queue / 番号を後付けする) | 番号を先に決める必要が無い | 作業中に ID で参照できない不便さ |
本ブログのリポジトリは B を選び、検出窓を外しました。B を選ぶなら、検出したい瞬間にガードの入力へ衝突相手が入っているかを、必ず別途確かめてください。 具体的には次の 2 点です。
- ガードの base 入力に、並行して open なブランチの head が入っているか。 マージ済みだけを見ているなら、見えるのは「衝突が確定した後」です。
- CI が base の進行で再評価されるか。
on: pull_requestだけなら再発火しません(GitHub Docs: Events that trigger workflows)。「マージ前にブランチが最新であること」を必須にする、merge queue を使う、あるいは base 進行で再実行する経路を足す、のいずれかが要ります。
この 2 点も、読者の環境で測れます。
# (a) ガードのジョブが何をトリガにしているか。pull_request 単独なら base 進行で再発火しない
grep -nE '^on:' -A 8 .github/workflows/*.yml
# (b) merge 前にブランチが最新であることを必須にしているか(GitHub の場合)
# 設定の意味は Docs の "Require branches to be up to date before merging" を参照
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
gh api repos/:owner/:repo/branches/main/protection \
--jq '.required_status_checks.strict' # true なら base 進行後に再評価される
# protection 未設定なら 404、private かつ無料プランなら 403 が返る。
# いずれも strict を取得できないので false 相当(再評価されない)として扱う
# (c) merge queue を使っているか(workflow 側)
grep -rn 'merge_group' .github/workflows/
(b) が false(または protection 未設定で 404 / private + 無料プランで 403。どちらも strict を取得できないので false 相当)で、(a) が pull_request 単独、(c) が 0 件なら、本記事の第 4 節と同じ検出窓のずれが起きうる構成です。起きるかどうかは並行の頻度次第なので、そこは自分の PR の重なり方を見て判断してください。
そして 1 つだけ、規則の書き方について言えることがあります。「マージ済みの最大値の次を採る」という規則は、並行作業がある環境では「衝突しない」ことを保証していません。 保証しているのは「1 本ずつ順に作業すれば衝突しない」ことだけです。規則がどちらを保証しているのかは、規則の文面からは読み取れません。読み取れるのは、実際に並行させて 1 回試したときの結果だけです。
8. 執筆中に、同じ衝突が 3 本のブランチで起きた
この記事を書いているあいだに、同じマシン上で同じリポジトリのワークツリーが 5 本(本記事を含む)並行していました。基準時点 a184564 の台帳最大 ID は L-0040 なので、規則どおりなら次は L-0041 です。
執筆の途中で、まだ push されていない他のワークツリーの台帳を直接読んで数えました(2026-09-10、社内データ)。
# 同一リポジトリの並行ワークツリーが、いま何番を使っているかを直接読む
grep -hoE '^\| L-00[0-9]{2} \|[^|]*\|[^|]*\|[^|]*\|' \
/path/to/worktrees/*/spec/article_failure_ledger.md | sort -u
結果、L-0041 が 3 つの別の観測に割り当てられていました。
| ブランチ | L-0041 に入っていた観測(origin / escape_mode) |
|---|---|
| 別の記事 PR その 1 | D-6 / E4 — 上限で決まる値を測定結果として並べていた |
| 別の記事 PR その 2 | harness / E1 — 免除の不在を固定するテストが一度も発火しない |
| 本記事(当初) | harness / E4 — 本記事の第 4 節の観測 |
3 本とも規則を守っています。 そして 3 本とも、この時点では相手の採番を見ていません。第 4 節で説明した検出ガードも、これらのブランチが未 push である以上、何も見られません。
筆者は自分の行を L-0047(他の 3 本が使っている最大値より後)へ手で移しました。これは第 7 節の方向 B(形式は変えず機械で検出する)ではなく、「並行相手を人間が直接覗きに行く」という、規則にも CI にも書かれていない回避です。 記事の主張がそのまま自分に当たった形なので、隠さずに書いておきます。
なお、この覗き見が成立したのは 5 本すべてが同じマシンのワークツリーに存在していたからです。別々の環境で作業していれば、この確認はできませんでした。
8-2. 手で回避した番号が、もう一度衝突した
そして L-0047 も衝突しました。今度は採番の後に起きています。
上の表の「別の記事 PR その 1」は、独立レビューへの対応として台帳に観測を追加しました。追加後、その PR の head には L-0041〜L-0051 が入っています(git show <head>:spec/article_failure_ledger.md で確認)。L-0047 はその範囲の内側です。
| 時点 | 起きたこと |
|---|---|
| 採番したとき | 相手の使用範囲は L-0041〜L-0046。L-0047 は空いていた |
| 相手のレビュー対応後 | 相手の使用範囲が L-0041〜L-0051 に伸び、L-0047 が内側に入った |
採番した瞬間の判断は正しく、その後に相手が変わったことで事後的に衝突したという形です。第 4 節の検出窓のずれは「相手が見えない」問題でしたが、こちらは「一度見て正しかった結果が、時間とともに正しくなくなる」問題です。開いている PR は本文もレビュー対応で変わるので、採番の正しさに賞味期限があることになります。
筆者は 2 度目の振り直しとして、その時点で開いていた 6 本すべての head を数え、最大 L-0053 の次の L-0054 を採りました。これも人間の手作業です。
# open な PR の head を全部見て、使用中の最大 ID を出す(GitHub の場合)
for sha in $(gh pr list --state open --json headRefOid --jq '.[].headRefOid'); do
git fetch -q origin "$sha"
git show "$sha:spec/article_failure_ledger.md" | grep -oE '^\| L-[0-9]{4}'
done | sort -u | tail -1
実行結果は | L-0053 でした(社内データ、open な PR 6 本に対して実行)。だから次は L-0054 です。
このループが第 7 節の方向 B に足りていなかったものです。第 4 節のガードは base(マージ済み)しか見ませんが、衝突相手は open な head の側にいます。ただし fork からの PR や API の権限・レート制限では成立しないので、これをそのまま CI に置けば新しい「空振り合格」の経路になりえます(本記事の対策設計でも、そこが最大のリスクとして残っています)。
9. まとめ
- 連番 ID の採番規則が参照するのが「マージ済みの状態」なら、並行ブランチの採番はその視野に入りません。本ブログのリポジトリでは、規則を正しく守ったブランチがマージ済みの記録で 2 回、執筆中の実地で 2 回、同じ ID を採りました(
社内データ、計 4 件)。 - 検出を機械へ置いたあとも、検出窓が衝突している瞬間を含んでいるかは別の問題でした。実測では、衝突していた最中の CI が
passedを出しています。 - ID 形式を変えない判断は、参照の広がりを実測したうえでの選択でした(基準時点で 32 ファイル 252 箇所。うち構造的に決まる worklog 分を引くと 18 ファイル 175 箇所)。この値が読者の環境でも同じとは限りません。 数え方と、構造で決まる分を引くという手順だけ持ち帰ってください。
- 執筆中に同じ衝突が 2 回再発しました(第 8 節)。2 回目は採番の後に相手が ID を増やしたことで起きたもので、採番の正しさに賞味期限があることを示しています。回避に使ったのは規則でも CI でもなく、「並行相手の head を直接覗く」という手作業です。
- これは 1 リポジトリの観測です。示せたのは「起きる構造がある」ことと「その構造は自分の環境でも 3 問で確かめられる」ことまでで、「連番 ID をやめるべき」までは示していません。
FAQ
Q. 連番 ID は使うべきではないのですか?
そこまでは言えません。本記事が示したのは、「マージ済みの最大値の次」という採番規則は並行作業に対して衝突しない保証を持たないという 1 点です。連番の可読性には実際の価値があり(会話で参照できる、順序が分かる)、参照が広がった後の移行コストは、実測した 2 時点のあいだ(約 1 日)に 7 ファイル → 18 ファイルへ動いていました(「1 PR = 1 worklog」契約から機械的に生じる分を引いた後の値)。トレードオフは環境ごとに違います。
Q. 単に rebase を頻繁にすれば防げるのでは?
衝突の発覚を早められますが、防止にはなりません。rebase した時点で相手がまだ merge されていなければ、base には相手の ID が入っていないからです。実測でも、07:21 の CI は base が古いまま passed を返しています。rebase は「衝突が確定した後に気づく」タイミングを早める手段であって、「並行中に見える」ようにする手段ではありません。
Q. 台帳やマイグレーション番号を UUID にすれば終わりでは?
衝突は終わります。引き換えに 2 つ引き受けます。1 つは既存参照の移行で、本ブログのリポジトリでは基準時点で 32 ファイル・252 箇所が既存 ID を参照していましたが、そのうち 14 ファイル・77 箇所は追記専用の過去 worklog で、形式を変えても書き換えは要りません(既存 ID は既存形式のまま残せる)。実際の移行対象は 18 ファイル・175 箇所で、うち content/posts/ 配下が 4 ファイル(公開記事本文 1 本と gate.md 3 本)です。もう 1 つは可読性で、こちらは筆者の運用上の見立てであり実測していません。移行コストは数えられるので、判断の前にまず数えることをおすすめします。
Q. CI のガードは結局役に立たなかったのですか?
「ガードが存在していた期間に起きた 3 件(第 4 節と第 8 節)を、いずれも merge 前に止められなかった」は事実ですが、分母が 3 件なので「役に立たない」までは言えません(1 回目の衝突はガード導入前なので分母に入りません)。同じガードは、異常系 5 系統で空振りを合格と区別しており(第 6 節)、契約どおりの状態遷移も件数つきで明示して通しています。問題はガードの有無ではなく、入力に衝突相手が入っていなかったことでした。
Q. どこから確かめればいいですか?
第 7 節の 3 問です。特に Q3(既存 ID が何ファイルから参照されているか)は git grep 2 行で測れて、「形式を変える/変えない」の判断材料になります。測らずに決めると、移行コストが上がり続ける側に自動的に倒れます。
