revert を取り消すと、あとから当てた hotfix は戻らない
一度 revert した機能を再投入するとき、その revert をさらに revert すれば元に戻ると考えがちです。 ところが、機能を投入したあとに hotfix を当てていた場合、その hotfix は戻ってきません。 不具合を含んだままの版が復活します。
Rails アプリのジョブ基盤を Delayed Job から Solid Queue へ移行する作業で、これを踏みました。
起きたこと
過去に本番投入して revert していた PR を、git revert で復活させました。
すると、当時その PR に含まれていた不具合まで一緒に戻ってきました。
# web プロセスの起動時に NameError で落ちる
plugin :solid_queue if Rails.env.development?
# 当時あてた hotfix
plugin :solid_queue if ENV['SOLID_QUEUE_IN_PUMA'] || ENV.fetch('RAILS_ENV', 'development') == 'development'
Puma の設定 DSL は Rails の環境がロードされる前に評価されるため、その時点で Rails.env はまだ存在しません。
開発環境では RAILS_ENV が未設定でも動いてしまうことがあり、本番の起動時にだけ落ちます。
履歴の順序
原因は、リリース全体を巻き戻したときの履歴の形にあります。
- 機能を投入する PR
- その不具合を直す hotfix
- リリース全体の revert(1 と 2 の両方を打ち消す)
3 で hotfix も一緒に打ち消されているので、この状態から 1 だけを復活させると、2 が当たっていない版が戻ります。
手元で再現できます。
$ git log --oneline
83ff6b6 Reapply "機能を投入(不具合あり)"
8e4002e Revert "機能を投入(不具合あり)"
55b6464 Revert "hotfix"
10c0d9c hotfix
26bbe67 機能を投入(不具合あり)
Reapply は新しめの git が付ける表現で、revert の revert であることを示します。
この時点でファイルの中身は、hotfix を当てる前の状態に戻っています。
見落としやすいのは、打ち消しが 2 回に分かれている点です。
Revert "機能を投入" だけを見て取り消すと、その 1 つ下にある Revert "hotfix" が視界に入りません。
あとから当たった修正の探し方
機能を投入したコミットから、その revert までの区間で、同じファイルを触ったコミットを見ます。
$ git log --oneline --follow <機能投入>..<その revert> -- config/puma.rb
8e4002e Revert "機能を投入(不具合あり)"
55b6464 Revert "hotfix"
10c0d9c hotfix
hotfix が挟まっていることが分かります。
--follow を付けるのは、その間にファイルが改名されていても履歴を追うためです。
区間を指定せず git log -- <path> でも見えますが、対象のファイルが長く使われていると出力が増えて埋もれます。
復活させたい PR と、その revert の 2 点で挟むと、確認すべき範囲が絞れます。
打ち消した単位で戻す
今回の失敗は、打ち消しがリリース全体の単位だったのに、復活を機能単位でやったことから起きています。 単位を揃えれば、hotfix も一緒に戻ります。
$ git revert <Revert "機能を投入"> <Revert "hotfix">
$ cat config/puma.rb
plugin :solid_queue if ENV['SOLID_QUEUE_IN_PUMA'] || ENV.fetch('RAILS_ENV', 'development') == 'development'
リリースの巻き戻しがマージコミット 1 つの revert だった場合は、その 1 つを revert すれば済みます。 どちらにせよ、打ち消しに使ったコミットをすべて数えるのが起点です。
取り込み方の選択
打ち消しの単位が分かりにくい履歴もあります。 その場合は、復活させたい変更を明示的に選ぶほうが確実です。
- revert を取り消す:履歴に
Reapplyが残るので、何を復活させたのかが追いやすい - 機能と hotfix を cherry-pick する:復活させたい変更を自分で選ぶ。打ち消しの形に依存しない
$ git cherry-pick <機能投入> <hotfix>
どちらを選んでも結果は同じにできます。 分岐するのは、あとから履歴を読む人が何を読み取れるかです。
復活できたかを確かめる
ここまでは履歴を正しく読めた前提の話です。 読み落としても気づける確認を、最後に置いておきます。
不具合が出る前の、最後に正しかったコミットと突き合わせます。
$ git diff <hotfix を当てた時点> HEAD -- config/puma.rb
@@ -1 +1 @@
-plugin :solid_queue if ENV['SOLID_QUEUE_IN_PUMA'] || ENV.fetch('RAILS_ENV', 'development') == 'development'
+plugin :solid_queue if Rails.env.development?
差分が出れば、取りこぼしがあります。 何を取りこぼしたかも同時に分かります。
この確認は、hotfix の存在に気づいていなくても働きます。 探し方や取り込み方を間違えても、ここで止まります。
そもそも revert でよかったか
今回の一件は、リリース全体を revert したことが遠因になっています。 機能単位で止める手段があれば、hotfix を巻き込むこともありませんでした。
環境変数やフィーチャーフラグで機能を無効化しておけば、コードは残したまま止められます。 ただしフラグには、分岐が残り続ける、フラグ自体の削除を忘れる、といった別のコストがあります。 本番で問題が起きている最中に「フラグを入れる PR」を書く余裕がないこともあります。
そのため revert を選ぶこと自体は妥当でした。 避けられたのは、revert の単位をリリース全体にしたことのほうです。 不具合が 1 つの機能に閉じているなら、その機能の PR だけを revert すれば hotfix は履歴に残ります。
不具合そのものを機械で止める
ここまでは復活の手順の話でしたが、今回の不具合には別の防ぎ方もあります。
Rails.env を Puma の設定で参照する誤りは、本番相当の環境でアプリケーションを起動すれば必ず落ちます。
CI に起動確認を入れておけば、履歴の読み方に関係なく、この種の不具合はマージ前に止まります。
設定ファイルの評価順序に起因する不具合は、コードを読んで気づくより、起動させたほうが早く見つかります。
運用で減らす
それでも、復活の手順そのものは人が判断します。
revert した PR に「あとから当てた修正」を紐づけておくと、再投入のときに気づけます。
PR にコメントで hotfix の PR 番号を残す、reverted のようなラベルを付けて再投入時に確認する、といった運用です。
前掲の差分確認まで含めると、防ぎ方は 3 段になります。
- 復活の単位を揃える:打ち消しに使ったコミットをすべて数える
- 復活後に突き合わせる:最後に正しかった時点との差分を見る
- 不具合を機械で止める:本番相当の起動を CI で確認する
上の 2 つは取りこぼしを見つけるもので、3 つ目は取りこぼしても壊れないようにするものです。