feature か fix を必須で書かせ、上げる桁をそこから決めるようにした話です。ただし、ルールを必須にした直後に、並行して入った別のPRがそのルールを満たしておらず、mainのCIが一度止まっています。
お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」には、利用者向けの更新履歴ページがあり、変更ごとに「2.28.23」のようなバージョン番号を付けています。2026年10月14日、この番号の付け方を「新機能なら2桁目、修正なら3桁目」に改め、過去の番号もすべて振り直しました。今回は、なぜ番号が伸び続けていたのか、どう直したのか、そして直した直後に何が起きたのかを書きます。
新機能を出してもバージョンが2.28.xのままだった
バージョン番号「X.Y.Z」には、よく使われる約束事があります。1桁目は大きな作り直し、2桁目は機能の追加、3桁目は修正です。この約束事は「セマンティックバージョニング」と呼ばれます。番号を見るだけで「今回は新しいことができるようになったのか、直っただけなのか」が分かる、というのが利点です。
DevPickの更新履歴も、この形の番号を使っていました。ところが実際には、新しい画面を足しても、英語表示に対応しても、細かい不具合を直しても、上がるのは毎回3桁目だけでした。2.28.1、2.28.2、2.28.3……と伸び続け、まだまとめていない分だけで2.28.154まで進んでいました。154個目の修正版に見えますが、その中には新機能もたくさん混ざっています。
原因は、番号を自動で振る仕組みの作りにありました。DevPickの更新履歴は、2026年9月19日から「フラグメント方式」で管理しています。以前は1つのファイルに各PRが追記していたため、並行して進むPRどうしで同じ箇所を書き換え、衝突していました。そこで、PRごとに小さなJSONファイル(フラグメント)を1つずつ追加する形に変えました。ファイルが別々なので、衝突は起きません。
フラグメントには、番号を書かないのが基本です。番号は、すでに確定している最新の番号から、表示するときに自動で振ります。たまったフラグメントは、ときどき集約スクリプトで確定済みの履歴へまとめます。そのため、表示時の採番と集約時の採番の2か所に同じ計算があります。問題は、その計算が「番号の指定がなければ3桁目を1つ上げる」だけだったことです。フラグメントには変更の種類を書く欄がなく、仕組みの側は、それが新機能なのか修正なのかを知る手段を持っていませんでした。
以前のフラグメントは、次のような形でした(単純化した例です)。種類を書く欄がありません。
{
"date": "2026-10-13",
"changes": ["設定で表示言語を切り替えられる機能を追加"]
}
// 単純化した例:以前の自動採番
if (entry.version === undefined) {
patch += 1; // 種類に関係なく、常に3桁目だけを上げる
}
確定済みの履歴の最新は、フラグメント方式に切り替えた9月19日時点の2.28.23でした。そこから採番ルールを変えるまでに、まとめていないフラグメントが131件たまり、23に131を足して2.28.154になっていた、という計算です。番号が減ったり重複したりしているわけではないので、エラーも警告も出ません。並び順としては正しいまま、番号だけが意味を失っていました。
IT女子 アラ美フラグメントにtypeを必須にして、上げる桁を決める
仕組みが種類を知らないなら、書く側に教えてもらうしかありません。そこで、フラグメントに type という欄を追加し、必須にしました。値は feature(新機能)と fix(修正)の2つだけです。feature なら2桁目を上げて3桁目を0に戻し、fix なら3桁目を上げます。
{
"date": "2026-10-14",
"type": "feature",
"changes": ["〜できる機能を追加"]
}
難しいのは、どちらの種類にするかの線引きです。「改善」は新機能なのか修正なのか、人によって感覚が違います。そこで、桁ごとの意味を表にした規則の文書を1つ作り、判断はその文書を正本にすることにしました。
| 桁 | 上げる変更 | フラグメントでの指定 |
|---|---|---|
| 1桁目 | サービスの方向性を変えるなど、使い方が大きく変わるもの | 番号を明示する(直前より大きい値のときだけ有効) |
| 2桁目 | 新しい機能・画面・設定・操作・ニュースソース・対応言語など、利用者ができることが増えるもの | "type": "feature" |
| 3桁目 | 不具合の修正、既存機能の改善(見た目・文言・性能・精度・SEO・アクセシビリティ)、既存画面へのリンクや案内の追加、規約・説明文の更新 | "type": "fix" |
迷ったときの基準も、1つの問いにまとめています。「この変更で、利用者が新しくできるようになった操作や、新しく見られるようになった画面・情報があるか」です。あれば feature、すでにできたことが正しく・速く・分かりやすくなっただけなら fix にします。1つのフラグメントに新機能と修正が混ざっている場合は、feature に寄せます。
この基準だと、性能の改善やSEOの改善は、どれだけ手間がかかっていても fix になります。作る側の苦労ではなく、利用者から見て何が増えたかで分ける、という線の引き方です。ちなみに、今回の採番ルールの変更そのものも、利用者が新しくできることは増えていないので fix として登録しています。
規則の文書を正本にしたのは、判断する場所が複数あるからです。DevPickでは、AIコーディングエージェントがIssueの実装からフラグメントの追加までを行っています。プロジェクト全体の指示書、Issueに取り組む手順、セルフレビューの手順のそれぞれに「type は必須で、どちらにするかは規則の文書を見る」と追記しました。基準そのものを各所に書き写すと、どこかを直したときに食い違うので、書き写さずに参照させています。



採番する場所とチェックを同じ規則にそろえる
前の章で書いたとおり、番号を計算する場所は、表示時の採番と集約スクリプトの2か所あります。さらに、フラグメントの書き方を検査するチェックスクリプトも、番号が巻き戻っていないかを確かめるために同じ計算をしています。つまり、同じ計算が3か所にありました。どれか1か所だけを直すと、ページに出る番号と、集約して確定する番号がずれてしまいます。そこで、3か所を同じ変更でまとめて書き換えました。
計算の中身はシンプルです。表示時の採番では、次の番号を決める関数を1つ用意しました。
type SemVer = [number, number, number];
type ChangeType = "feature" | "fix";
function nextVersion(current: SemVer, type: ChangeType | undefined): SemVer {
if (type === "feature") return [current[0], current[1] + 1, 0];
return [current[0], current[1], current[2] + 1];
}
ここで、type が書かれていない場合の扱いを、場所ごとに変えています。
- チェックスクリプト:
typeがない、またはfeature・fix以外の値なら違反として止める。CIのLintジョブと、エージェントがファイルを編集した直後に走るフックの両方で動く - 集約スクリプト:
typeがない、または不正な値ならエラーで止まり、確定済みの履歴へは書き込まない - 表示時の採番:
typeがないものは3桁目を上げる側(fix扱い)に倒して表示し、ページは壊さない。feature・fix以外の値が書かれたフラグメントは表示しない
書き忘れを止めるのはチェックの役目で、表示の役目ではない、という分担です。チェックをすり抜けたフラグメントが1件あっただけで更新履歴ページ全体がエラーになるのは避けたかったので、表示側だけは止まらず小さい方の桁に倒すようにしました。
テストも同じ変更で足しています。fix・feature・fix の順にフラグメントを置いたとき、番号が「3桁目が1つ上がる → 2桁目が上がって3桁目が0 → その3桁目が1になる」と進むか。不正な種類を書いたフラグメントが表示から外れるか。実際にリポジトリにある未集約のフラグメントが、すべて type を持っているか。そして、type のないフラグメントを編集したときに、フックが「feature か fix を指定してください」と止めるか。最後のフックのテストは、エージェントが書き忘れたその場で気づけるようにするためのものです。



過去の182件と未集約の131件を分類し直した
規則を変えるだけなら、「これから追加する分だけ新しい規則で数える」こともできます。ただ、それだと2.28.154の続きから番号が始まり、過去の履歴には新機能も修正も同じ桁で並んだままになります。今回は、採番ルールの変更と同じ変更の中で、過去の履歴も同じ基準で分類し直し、番号を振り直しました。
対象は2つあります。1つは、2.0.0以降の確定済みの履歴182件です。2.0.0は、2026年8月にDevPickを開発者向けの技術記事キュレーションからAIニュース全般へ方向転換したときの番号で、ここを起点にしました。もう1つは、まだ集約していないフラグメント131件です。こちらは各ファイルに type を書き足しました。
| 対象 | 件数 | feature |
fix |
振り直し前の最新 | 振り直し後の最新 |
|---|---|---|---|---|---|
| 確定済みの履歴(2.0.0以降) | 182件 | 41件 | 140件 | 2.28.23 | 2.41.0 |
| 未集約のフラグメント | 131件 | 36件 | 95件 | 2.28.154 | 2.77.1 |
確定済みの182件のうち、起点の2.0.0を除く181件を分けると、新機能が41件、修正が140件でした。未集約の131件は、新機能が36件、修正が95件です。件数は、振り直し後の履歴とフラグメントから数え直した値です。
数えてみると、新機能は全体のおよそ4分の1でした。これまでの番号では、その4分の1が残りの4分の3に埋もれて見分けられなかったことになります。振り直した後は、2桁目を見れば「方向転換してから新機能を77回出した」と読めます。
振り直しには副作用もあります。振り直すと、過去に更新履歴ページで表示していた番号は変わります。たとえば、確定済みの最新だった2.28.23は2.41.0になりました。番号が変わったことは、更新履歴そのものに「過去の番号を振り直した」と1行載せています。



2分前にマージされたPRのフラグメントで、mainのLintが落ちた
採番ルールの変更は、10月14日の朝にmainへマージしました。ところが、マージ直後のmainのCIが失敗し、デプロイがスキップされました。原因は、ほぼ同時に進んでいた別のPRです。時系列で並べると、次のとおりです(時刻は日本時間)。
| 時刻 | 出来事 |
|---|---|
| 8:32 | 採番ルールを変えるPRのCIが走り始める |
| 8:43 | 別のPR(AI要約の方針を公開し、既存画面から案内するもの)がmainにマージされる。このPRのフラグメントには type がない |
| 8:45 | 別のPRは自身のmainのCIでデプロイまで完了する。同じころ、採番ルールを変えるPRのCIが成功し、22秒後にmainへマージされる |
| 8:46 | mainのCIでLintジョブが失敗し、テスト・ビルド・デプロイがすべてスキップされる |
| 8:52 | 先にマージされたフラグメントに type を付ける修正がマージされる。この修正に対するmainのCIは成功する |
| 8:54 | デプロイが完了し、採番ルールの変更が本番に出る |
mainのLintが出した違反は1件だけでした。
- entries/20261014-ai-summary-editorial-policy.json: type に "feature"(新機能・新画面・新設定の追加。2桁目を上げる)か "fix"(不具合修正・既存機能の改善。3桁目を上げる)を指定してください: undefined
どちらのPRも、単独では正しい状態でした。別のPRがCIを通った時点では、mainにまだ「type は必須」というルールがなかったので、type のないフラグメントは正しい書き方です。採番ルールを変えるPRのCIが走り始めた8時32分には、別のPRのフラグメントはまだmainになかったので、検査の対象に入っていません。2つが続けてmainに入った瞬間に初めて、「ルールを満たさないファイルがある」状態ができあがりました。
これは前日に書いた並行PRのmigration head分岐をマージ前の合成ツリー検査で止めた話と同じ構造です。あちらでは、単独では正しい2本のPRのマイグレーションが、合流した時点で分岐していました。DevPickには、その対策として最新mainとPRを合成して検査する仕組みを入れていますが、見ているのはマイグレーションの親子関係だけです。更新履歴のフラグメントは対象外なので、今回の合流は止まりませんでした。
この件から言えるのは、「必須項目を増やす変更」は、それ自体が並行PRを壊す側に回りやすいということです。採番ルールを変えるPRは、その時点でリポジトリにあった131件には type を書き足していました。それでも、レビュー中やCI実行中の別のPRが新しく持ち込むフラグメントまでは、手が届きません。フラグメント方式は、もともと並行PRどうしの衝突をなくすために入れた仕組みです。ファイルが分かれているのでGit上の衝突は起きませんが、そのぶん「同時に入ってきた中身がルールに合っているか」は、マージしてみるまで分かりませんでした。
修正では、先にマージされたフラグメントに fix を付けました。そのPRのコミットは「機能追加」として登録されていましたが、中身は「方針の公開と、既存画面からその方針への案内の追加」です。規則の文書の基準では、既存画面へのリンクや案内の追加は fix に当たります。コミットの分類と、利用者向けの type は必ずしも一致しません。迷ったときに見るのは、コミットの分類ではなく規則の文書の基準です。
mainのCIが落ちてから修正がマージされるまでは約6分でした。先にマージされた別のPRは、自身のCIでデプロイまで終わっていました。止まったのは採番ルールの変更のほうで、修正後のCIでデプロイされる8時54分まで本番に出ていません。この記事を書いている時点では、同じ形の事故をマージ前に止める仕組みは追加していません。今回はLintの失敗としてCIの結果に出ていたので、約6分で修正が入りました。デプロイが止まったことが見えないと、変更が本番に出ないまま時間がたちます。その怖さは、以前のno-deployのPRをマージしたら前のデプロイまで消えていた話でも書きました。



まとめ
更新履歴のバージョンが2.28.154まで伸びていたのは、番号を自動で振る仕組みが、変更の種類を知らなかったからでした。フラグメントに type を必須にして、feature なら2桁目、fix なら3桁目を上げるようにしました。番号を計算する3か所は、同じ変更でまとめてそろえています。過去の182件と未集約の131件も同じ基準で分類し直し、最新の番号は2.77.1になりました。
今回いちばん大事だと考えているのは、線引きを1つの文書に寄せたことです。「改善は新機能か修正か」は、感覚に任せると書く人ごとにぶれます。「利用者が新しくできることが増えたか」という1つの問いにまとめ、エージェントの手順書からもそこを参照させました。実際、直後の修正では、コミットの分類が「機能追加」だった変更を、この基準で fix に分類しています。
一方で、必須項目を増やした直後に、並行して入った別のPRのフラグメントでmainのLintが落ちました。どちらのPRも単独では正しく、合流して初めて壊れる形です。前日のマイグレーションの話と同じ構造で、検査の対象を「PR単独」から「合流した結果」に広げない限り、ルールを足すたびに起こりうると考えています。












