同じiOSプロジェクトでも、開発者のターミナルではビルドできるのに、クラウドMac上の非対話CIへ移すと、日本語を含む設定の読み込み、アクセント記号付きリソース名の解析、ログの切り出しなどに失敗することがあります。厄介なのは、こうした問題が依存関係の破損と誤認されやすい点です。ファイルは確かに存在するのに、スクリプトでは見つからないと表示される。ログ上では同じ名前に見えるのに、Gitでは異なる2つのパスとして扱われる。多くの場合、原因はUTF-8自体ではなく、ジョブのエントリーポイント、言語ランタイム、リポジトリ内のファイル名で文字コードの解釈が一致していないことにあります。
まず差異が生じているレイヤーを特定する
最初からシステム設定を変更してはいけません。まず問題を、ジョブプロセスのlocale、スクリプトランタイムのデフォルトエンコーディング、パイプライン内の個々のコマンドの挙動、ファイル名のUnicode正規化形式という4つのレイヤーに分けます。
対話型ターミナルとCIジョブの冒頭で、それぞれ次のコマンドを実行します。
printf 'shell=%s\n' "$SHELL"
locale
printf 'LANG=%s\nLC_ALL=%s\n' "${LANG:-unset}" "${LC_ALL:-unset}"
python3 -c 'import locale,sys; print(locale.getpreferredencoding(False), sys.getfilesystemencoding())'
ruby -e 'p [Encoding.default_external, Encoding.default_internal]'
出力は通常のビルド成果物として保存し、スクロールして消えるログだけに残さないでください。ターミナルでは en_US.UTF-8 と表示される一方、ジョブ内で変数が空なら、問題は起動時の境界にあります。localeが一致しているのにRubyが別のエンコーディングでファイルを読み込む場合は、スクリプトで誤ったオプションを明示的に指定していないか確認します。
「マシンがUTF-8に対応している」ことと、「すべてのジョブがUTF-8を使用している」ことは同じではありません。CIの実際の設定は、ログイン済みターミナルに表示される環境ではなく、プロセスが受け取った環境です。
ジョブのエントリーポイントでUTF-8を固定する
最も確実なのは、.zshrc に依存せず、runnerのラッパースクリプトまたはパイプラインジョブのエントリーポイントで環境を設定する方法です。非対話Shellでは、開発者が想定する形でこれらのファイルが読み込まれないことが多く、launchd から起動されたプロセスが別の環境を持つ場合もあります。
#!/bin/zsh
set -euo pipefail
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
locale
exec "$@"
このスクリプトを ci/run-utf8.zsh として保存し、実行権限を付与したうえで、すべてのビルドコマンドをこのスクリプト経由で実行します。
chmod +x ci/run-utf8.zsh
ci/run-utf8.zsh ./ci/build.zsh
ログを「修正」するために、グローバルで export LC_ALL=C を実行してはいけません。C localeは、安定したバイト順での並べ替えが必要な単一コマンドには適していますが、文字分類、大文字・小文字変換、正規表現の挙動も変えてしまいます。必要な場合は、LC_ALL=C sort input.txt のように、そのコマンドだけへ適用範囲を限定してください。
スクリプト自体の読み込み方法も確認する
Pythonでは encoding="utf-8" を明示し、Rubyでは File.read(path, encoding: "UTF-8") を使用できます。Shellでテキストを1行ずつ読み込む場合は IFS= read -r を使い、バックスラッシュや行頭・行末の空白が書き換えられないようにします。JSON、plist、YAMLはそれぞれ対応するパーサーに処理させ、grep や sed で構造を推測してはいけません。
Unicodeファイル名の正規化を検査する
文字 é は1つのコードポイントでも、文字と結合文字の組み合わせでも表現できます。見た目は同じでも、バイト列は異なります。リポジトリがmacOS、Linux、圧縮ファイルの間を移動すると、この違いがリソースの重複、スクリプトが見つからないエラー、大文字・小文字だけを変更したコミットが正しく反映されない問題として現れることがあります。
CIに次の読み取り専用チェックを追加します。
from pathlib import Path
import sys
import unicodedata
bad = []
for path in Path(".").rglob("*"):
if ".git" in path.parts:
continue
raw = path.as_posix()
normalized = unicodedata.normalize("NFC", raw)
if raw != normalized:
bad.append((raw, normalized))
for raw, normalized in bad:
print(f"non-nfc: {raw!r} -> {normalized!r}")
sys.exit(1 if bad else 0)
ci/check_unicode_paths.py として保存し、依存関係をインストールする前に実行します。このチェッカーは問題を報告するだけで、自動的な名前変更は行いません。正規化後のパスが既存ファイルと競合する可能性があるためです。修正時は、まずGitインデックス上の実際の名前を確認します。
git -c core.quotepath=false ls-files
git -c core.quotepath=false status --short
その後、git mv で名前を変更し、独立したコミットとして記録します。大文字・小文字だけを変更する場合は、いったん一時的な名前へ移動してから目的の名前へ移動します。完了後は必ず新しいディレクトリへクローンしてビルドし、ファイルシステムのキャッシュに影響された既存ワークスペースで検証してはいけません。
ログに元の証拠を残す
文字コードの問題が tee、ログ収集ツール、JSONエスケープなどを通過すると、元のバイトが置換され、最終的に疑問符や置換文字しか残らないことがあります。調査中は、人が読めるログと重要ファイルのバイト表現を両方保存します。
file -I Config/環境.json
xxd -g 1 -l 96 Config/環境.json
plutil -lint App/Info.plist
file の判定だけを最終的な結論にしてはいけません。これは手掛かりを提供するだけです。実際にファイルを読み込むプログラムでは、引き続きUTF-8を明示的に指定し、デコードに失敗した場合はゼロ以外の終了ステータスを返す必要があります。ビルドスクリプトでも、cmd | tee build.log を実行した後に先頭コマンドの終了コードを無視してはいけません。zshでは $pipestatus を参照するか、重要なコマンドとログ収集を分離します。
ログには、失敗したパスをエスケープした形式でも記録します。結合文字、改行、不可視の空白を識別するには、直接出力するよりもPythonの repr() やRubyの String#dump が適しています。
マージ前ゲートと修正手順を整備する
保守しやすいゲートでは、確認できる事実だけを検査し、リポジトリを独断で変更しないことが重要です。次の順序で実行することを推奨します。
- localeと各ランタイムのエンコーディングを出力する。
- リポジトリのパスがNFCであることを確認する。
- 正規化後に同名のパスが発生しないか確認する。
- 明示したエンコーディングで重要なテキスト設定を読み込む。
- 新しいワークスペースで最小構成のビルドを実行する。
- 環境プローブ、パスレポート、失敗ログを保存する。
特定のrunnerだけでジョブが失敗する場合は、すべてのキャッシュを削除する前に、まず環境プローブを比較し、次にGitインデックスを比較します。パスレポートに異常がある場合は、先にファイル名を修正し、独立したコミットを作成します。パスが正常でも解析に失敗する場合は、ファイル内容のエンコーディング宣言と読み込みオプションを確認します。この順序なら、環境、リポジトリ、ツールそれぞれの問題を切り分けられます。
RentMini上のジョブでも、この一連のチェックをリポジトリへ組み込み、プロジェクト側でバージョン管理する必要があります。特定のクラウドMacに手作業で施した設定へ依存してはいけません。エントリースクリプト、Unicodeチェッカー、ログの証拠をコードとともにレビューすることで、ワークスペースを再作成した場合やノードを変更した場合でも、一貫した挙動を得られます。
よくある質問
ターミナルで正常なスクリプトがCIで文字化けするのはなぜですか?
CIは通常、非対話プロセスから起動され、同じシェル初期化ファイルを読みません。ジョブの入口でLANGとLC_ALLを設定し、各ランタイムが実際に使う文字コードも確認します。
NFCではないファイル名をCIで自動変更してもよいですか?
自動変更は避け、まず一覧を出して検査を失敗させます。大文字小文字と正規化後の衝突を確認し、専用コミットでgit mvを行ってから新規チェックアウトで再検証します。
次回のビルドに備えて専用物理Mac miniを準備
M4の構成、利用期間、ノードを選択します。各注文には専用の物理デバイスが割り当てられ、実際の利用可能状況はコンソールにリアルタイムで表示されます。