Operations2026.09.10 · 5 min read

git tag --contains は「入っている」と答えたが、その版のアプリに機能は無かった

認証基盤・クラウドインフラ

勤怠アプリの問い合わせに「実装済みなので次のリリースで届きます」と返信を書きかけて、念のため配信状況を確かめた。機能のコミットはmain にあり、git tag --contains はストア提出タグを7つ返した。それでも、そのどれをインストールしても機能は存在しない。EAS Build のプロファイルに書いた環境変数が off で、ビルド時にネイティブごと落ちていた。

タグは「入っている」と答えた

このリポジトリでは、ストアに提出した断面に vX.Y.Z-ios / vX.Y.Z-android の注釈付きタグを打っている。「この修正はどの版で届いたか」を git tag --contains で引けるようにするための運用で、実際、返信の前に必ずここを見ることになっている。

問題の機能(勤務状況の外部表示 = iOS の Live Activity と Android の勤務中通知)は 2026-08-24 と 08-25 のコミットで実装済みだった。タグを引くと、そのあと提出したすべての断面が並ぶ。

コードは入っている / しかし既定は off
$ git tag --contains 3e689b6
v2.2.0-android
v2.2.0-ios
v2.3.0-android
v2.3.0-ios
v2.3.1-android
v2.3.1-ios
v2.3.2-ios

$ for t in v2.2.0-ios v2.3.0-ios v2.3.1-ios v2.3.2-ios; do
    echo "== $t"; git show $t:app.config.js | grep WORK_STATUS; done
== v2.2.0-ios
const workStatus = process.env.WORK_STATUS === 'on' ? 'on' : 'off'
== v2.3.0-ios
const workStatus = process.env.WORK_STATUS === 'on' ? 'on' : 'off'
== v2.3.1-ios
const workStatus = process.env.WORK_STATUS === 'on' ? 'on' : 'off'
== v2.3.2-ios
const workStatus = process.env.WORK_STATUS === 'on' ? 'on' : 'off'

下半分が答えになっている。どの提出断面でも workStatus は環境変数 WORK_STATUS が明示的に on のときだけ有効で、既定は off だった。コードは7つのタグすべてに含まれ、機能はどの版にも存在しない。両方とも正しい。

機能はビルド時に丸ごと外れていた

フラグが切り替えているのは画面の表示ではなく、app.config.js の plugins 配列そのものだった。

app.config.js
// 勤務状況の外部表示(iOS Live Activity / Android 勤務中通知)。既定 off。
// on にするとウィジェット拡張が生成され、iOS の署名に本体+拡張の2本の
// プロファイルと App Group が要る
const workStatus = process.env.WORK_STATUS === 'on' ? 'on' : 'off'

plugins: [
  // 勤務状況の外部表示は WORK_STATUS=on の版だけ組み込む。off の版は Android の
  // manifest からモジュールの権限とレシーバーを取り消す
  ...(workStatus === 'on'
    ? [
        './plugins/withWidgetFont',
        ['expo-widgets', {
          bundleIdentifier: WIDGET_ID[variant],    // com.example.kintai.widgets
          groupIdentifier: APP_GROUP_ID[variant],  // group.com.example.kintai
          enablePushNotifications: false,
          widgets: [],
        }],
        './plugins/withNativeLiveActivityBanner',
        './plugins/withLiveActivityActionDurability',
      ]
    : ['./plugins/withoutWorkStatusAndroid']),
],

off の版では expo-widgets とその周辺の config plugin が配列に入らないので、iOS のウィジェット拡張が生成されない。Android 側は逆に、取り消し用のプラグインが manifest から権限とレシーバーを消す。JS 側もこれに追随していて、実装の読み込み自体が入口で分岐する。

src/app/workStatus/enabled.ts
/**
 * 勤務状況の外部表示を組み込んだ版か。app.config.js が WORK_STATUS=on で焼いた
 * ときだけ true。off の版にはウィジェット拡張も expo-widgets のネイティブも
 * 無いので、この値で実装の読み込み自体を切り替える。
 */
export const workStatusEnabled: boolean =
  Constants.expoConfig?.extra?.workStatus === 'on'

つまり off で焼いた版には、ネイティブの拡張も、それを呼ぶ JS の経路も存在しない。バンドルに文字列が残っているかどうかの話ではないので、端末側でどれだけ操作しても機能は出てこない。

コードがその版に入っていることと、その版で機能が動くことは別の事実だ。ビルド時に分岐するフラグを1つでも持ったら、前者から後者を推論できなくなる。

「届いたか」はプロファイルを見ないと言えない

WORK_STATUS は最初、app.config.js の既定値だけで off になっていた。eas.json には何も書いていない。これが厄介で、「off である」という事実がどこにも書かれていないまま off になる。当時のコメントは「2.2.0 では出さないので既定 off」で、次の版で on にするつもりだったことしか読み取れない。結果として、課題管理側の「実装済み・次回リリースで公開」という記述が版をまたいで古くなり、それを信じて顧客返信を書きかけた。

直したのは 2026-09-05 のコミットで、やったことは2つだけだ。コメントを「2.3.0 でも出さず、2.4.0 以降の対象。既定 off」に書き換え、eas.json の production と prod-internal の env に "WORK_STATUS": "off" を明示した。既定と同じ値なので、ビルドの結果は1バイトも変わらない。変わるのは、提出用プロファイルを開いた人が「この版には入っていない」と読めるようになることだけで、それが目的だった。

検討して捨てた案が1つある。フラグを消して機能を常時 on にしてしまう、というものだ。捨てたのは、on にすると iOS の署名が本体+ウィジェット拡張の2本の provisioning profile と App Group を要求するようになるからで、審査に出す直前に署名の構成を変えることになる。フラグは「まだ出さない」を安全に運ぶために置いたものであり、消すべきなのはフラグではなく、フラグの状態が読み取れない状態のほうだった。

以来、「この修正は届いたか」を判定するときは3つ見ている。

  • その版のコミットに機能が入っているか(git tag --contains)。ここまでしか答えないのが今回の落とし穴だった。
  • その提出断面の eas.json で、実際に使ったプロファイルの env がどうなっていたか。
  • そのプロファイルで焼いたビルドを、本当にそのストアへ出したか(内部配布用のプロファイルで焼いた版を提出していないか)。

検査が全部通ったのに、実画面で3行に割れた

同じ週にもう1つ、似た形の外し方をした。問い合わせを受けて日本語の文言を直したときのことだ。このリポジトリには翻訳の構造検査があり、日本語を正本として en / ko / zh-TW を突き合わせる。

scripts/check-i18n-locales.mjs
if (JSON.stringify(placeholders(source)) !== JSON.stringify(placeholders(translated))) {
  errors.push(`${path}: placeholder mismatch`)
}
if (source.split('\n').length !== translated.split('\n').length) {
  errors.push(`${path}: newline mismatch`)
}
const forbiddenMatches = translated.match(FORBIDDEN_TERMS[locale]) ?? []
if (forbiddenMatches.length > 0) {
  errors.push(`${path}: forbidden term ${JSON.stringify(forbiddenMatches[0])}`)
}

見ているのはプレースホルダの一致、改行数の一致、訳語のゆれ(禁止語)、韓国語の助詞、そして英語だけに設けた画面が狭い箇所の文字数上限だ。文言を直したあと、npm test で3言語とも「構造・用語検査に合格」と出た。型チェックもテストも通った。

それでもエミュレータの実画面を見ると、ある案内文が2行から3行に割れ、最後の「す。」だけが行に取り残されていた。原因は日本語の文言を4文字伸ばしたことで、翌日のコミットで同じ意味を短い指示語で言い換えて元の字数に戻した。上の検査に引っかかる要素は1つもない。日本語は正本なので他言語との突き合わせの対象ではなく、文字数上限を持っているのは英語の一部の項目だけで、そもそも折り返しは実際の描画幅とフォントを知らないと決まらない。

まとめ

git tag --contains も翻訳の構造検査も、それぞれの問いには正しく答えている。前者は「このコミットはその断面に含まれるか」、後者は「訳文の構造は正本と一致しているか」だ。どちらも「利用者の画面でどう見えるか」には答えていない。答えていないことを答えたことにすると、顧客への返信が嘘になる。

ビルド時に分岐するフラグは、既定値に頼ると「今この版がどちら側なのか」がどこにも書かれない。既定と同じ値であっても、提出に使うプロファイルに明示して書いておく。差分は出ないが、あとから読む人が推論しなくて済む。

ExpoEAS BuildFeature FlagRelease