新しい iPhone の画面サイズに合わせて、手元の 4 本のアプリの定数ヘッダを一斉に触っていた週のことです。同じ時期に Firebase を CocoaPods から Swift Package Manager へ移す作業も重なっていて、project.pbxproj の差分が毎日のように数百行ずつ動いておりました。
作業そのものは進んでいました。ビルドは通り、シミュレータでも問題は出ません。異変に気づいたのは、壁紙アプリの Release ビルドを実機に載せて最終確認をしていたときでした——低い密度のバケット向けに置いた画像が、一枚も出てこないのです。
Xcode は普通に開きます。警告も出ません。原因は、リソースのファイル参照がターゲットのビルドフェーズから外れていたことでした。数百行の差分のどこかで、参照の一行が別のグループへ移り、Copy Bundle Resources から静かに抜けていたのです。
その日から私は、プロジェクトファイルの編集だけを Claude Code の手の外に置いています。理由は「エージェントが信用できないから」ではありません。私自身が手で編集しても同じ事故を起こすからです。 問題は編集する主体ではなく、この形式のファイルが「壊れたことを教えてくれない」点にありました。
開けなくなる壊れ方と、開けてしまう壊れ方
project.pbxproj の失敗には、性質のまったく違う二種類があります。
一つ目は、構造が壊れて Xcode が開けなくなる形です。UUID の対応が崩れる、クォートが閉じない、セクションの終端が消える——このどれかが起きると、プロジェクトを開いた瞬間にエラーが出ます。損害は「作業が止まる時間」だけで、git checkout すれば元に戻ります。
二つ目は、構造としては正しいまま、意味だけがずれる形です。ファイル参照は生きていて、Xcode も開き、ビルドも通ります。ただ、そのファイルがどのターゲットのどのフェーズに属しているかが変わっている——実機でしか症状が出ないのは、この形です。
| 観点 | 開けなくなる壊れ方 | 開けてしまう壊れ方 |
| 気づく場所 | Xcode を開いた瞬間 | 実機確認・審査・ユーザーからの報告 |
| 気づくまでの時間 | 数十秒 | 数日 |
| CI での検出 | ビルドが落ちるので検出できます | ビルドは緑のまま通ります |
| 復旧コスト | 直前のコミットへ戻すだけ | どの差分が原因かの特定から始まります |
| 再発のしやすさ | 低い(すぐ学習します) | 高い(学習の機会が来ません) |
一つ目ばかりを警戒していたのが、私の誤りでした。構文チェックを足しても、二つ目はまったく減りません。
開けなくなる変更より、開けてしまう変更を先に疑います。
この一行に辿り着いてから、対策の置き場所が変わりました。守るべきはファイルの構文ではなく、ターゲットとファイルの対応表だったのです。
直接編集とスクリプト経由の、実際の違い
選択肢は二つあります。Claude Code に project.pbxproj をテキストとして編集させるか、xcodeproj gem を使うスクリプトを書かせて、そのスクリプトを実行させるか。
前者は速いのです。差分も一目で読めます。私も最初の一週間はこちらで進めていて、実際にほとんどの変更は成功していました。
問題は失敗の残り方です。テキスト編集では、変更が「意図した意味」になったかを検証する手段が差分の目視しかありません。数百行の差分の中で、意味のある変更は 4 行ということが普通に起こります。残りは UUID の並び替えと空白の揺れで、目視の精度はそこで落ちます。
| 観点 | テキストとして直接編集 | xcodeproj スクリプト経由 |
| 変更の単位 | 行 | オブジェクト(ターゲット・フェーズ・参照) |
| 失敗の現れ方 | 構文崩れか、意味のずれ | スクリプトが例外で止まります |
| 再実行 | 差分が二重に当たる恐れがあります | 冪等に書けば何度でも通せます |
| レビューの対象 | 数百行の差分 | 数十行のスクリプト |
| 他の 3 本への横展開 | 手で繰り返します | 同じスクリプトを引数違いで回します |
| 着手までの手間 | ゼロ | gem の導入とスクリプト作成 |
4 本のアプリに同じ変更を入れる場面では、後者の優位がはっきり出ました。一本目でスクリプトを書き切ってしまえば、残りの 3 本は引数を変えて回すだけになります——一方で、アプリが 1 本しかないなら、この手間は割に合わないかもしれません。
編集の入口をスクリプト 1 本に絞る
私が最初にスクリプト化したのは、Crashlytics の dSYM アップロードを Run Script フェーズとして足す作業でした。CocoaPods から SPM へ移したときに ${PODS_ROOT} を指していた参照が外れ、シンボル化されないクラッシュが数日流れていた箇所です。
冪等性がすべてでした。何度実行しても同じ状態に落ち着くこと——これが担保できないと、スクリプトはテキスト編集より危険になります。
#!/usr/bin/env ruby
# add_run_script.rb — Run Script フェーズを冪等に追加します
# gem install xcodeproj
# ruby add_run_script.rb MyApp.xcodeproj MyApp
require "xcodeproj"
PHASE_NAME = "[Dolice] Upload dSYMs"
project_path, target_name = ARGV
abort("usage: add_run_script.rb <project.xcodeproj> <target>") unless project_path && target_name
project = Xcodeproj::Project.open(project_path)
target = project.targets.find { |t| t.name == target_name }
abort("target not found: #{target_name}") unless target
# 名前で既存フェーズを引き当てます。ここを UUID で探すと、
# 別マシンで作られたフェーズを見落として二重に足してしまいます
phase = target.shell_script_build_phases.find { |p| p.name == PHASE_NAME }
phase ||= target.new_shell_script_build_phase(PHASE_NAME)
phase.shell_path = "/bin/sh"
phase.shell_script = <<~SH
set -euo pipefail
# SPM 移行後は Pods 配下ではなく DerivedData 内の実行ファイルを指します
BIN="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"
test -x "$BIN" || { echo "warning: crashlytics run not found at $BIN"; exit 0; }
"$BIN"
SH
phase.input_paths = ["${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"]
phase.run_only_for_deployment_postprocessing = "0"
# フェーズの位置も固定します。順序が動くと、成果物が揃う前に走ります
target.build_phases.delete(phase)
target.build_phases << phase
project.save
puts "ok: #{target_name} -> #{PHASE_NAME}"
要点は 3 つあります。
- 既存フェーズの引き当てを UUID ではなく名前で行うこと。UUID はマシンごとに変わるため、別の環境で追加されたフェーズを見落とします
- スクリプト本体を毎回上書きすること。差分更新にすると、古い記述が残ったまま新しい行が足されます
- フェーズの位置を明示的に末尾へ移すこと。順序が暗黙のままだと、ビルド成果物が揃う前に走る事故が起きます
このスクリプトを Claude Code に書かせて、実行も任せています。任せているのは「スクリプトの作成と実行」であって、「プロジェクトファイルの編集」ではありません——この違いが、あとで効いてきます。
変更を「開けるか」ではなく「棚卸しが合うか」で見る
スクリプト化しても、意味のずれは完全には消えません。消えないので、検出する側を用意しました。
project.pbxproj は OpenStep 形式のプロパティリストなので、macOS の plutil でそのまま JSON に落とせます。専用のパーサを書かなくても、ターゲットごとのファイル一覧を取り出せるのです。
#!/usr/bin/env python3
"""pbx_inventory.py — ターゲット別のビルドフェーズ所属ファイルを一覧にします
python3 pbx_inventory.py MyApp.xcodeproj > before.txt
# ここで pbxproj を変更する作業を行います
python3 pbx_inventory.py MyApp.xcodeproj > after.txt
diff before.txt after.txt
"""
import json
import subprocess
import sys
from pathlib import Path
PHASES = {
"PBXSourcesBuildPhase": "Sources",
"PBXResourcesBuildPhase": "Resources",
"PBXFrameworksBuildPhase": "Frameworks",
}
def load(xcodeproj: Path) -> dict:
pbx = xcodeproj / "project.pbxproj"
# plutil は OpenStep 形式のまま読めます。変換結果は標準出力にだけ出し、
# 元ファイルには決して書き戻しません(-o - が要点です)
raw = subprocess.run(
["plutil", "-convert", "json", "-o", "-", str(pbx)],
capture_output=True, check=True,
).stdout
return json.loads(raw)["objects"]
def path_of(objects: dict, ref: str) -> str:
"""ファイル参照を親グループごと辿って、リポジトリ相対に近い形へ戻します"""
node = objects.get(ref, {})
name = node.get("path") or node.get("name") or ref
for key, value in objects.items():
if value.get("isa") == "PBXGroup" and ref in value.get("children", []):
parent = path_of(objects, key)
return f"{parent}/{name}" if parent else name
return name
def main(xcodeproj: str) -> int:
objects = load(Path(xcodeproj))
rows = []
for target in (o for o in objects.values() if o.get("isa") == "PBXNativeTarget"):
for phase_ref in target.get("buildPhases", []):
phase = objects.get(phase_ref, {})
label = PHASES.get(phase.get("isa"))
if not label:
continue
for build_file_ref in phase.get("files", []):
file_ref = objects.get(build_file_ref, {}).get("fileRef")
if not file_ref:
continue
rows.append(f"{target['name']}\t{label}\t{path_of(objects, file_ref)}")
# 並び順は UUID の都合で毎回変わるため、必ずソートしてから出します
for row in sorted(set(rows)):
print(row)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
出力はターゲット名・フェーズ・ファイルパスの 3 列です。作業の前後で取って diff を通すと、意図しない出入りだけが残ります。
$ diff before.txt after.txt
< BeautifulWallpapers Resources Resources/Images/ldpi/placeholder.png
冒頭の事故は、まさにこの一行が消える形で起きていました。数百行の差分を目で追っていたときには見えず、3 列に畳んだ途端に見えたのです——情報を減らしたほうが見つかるという、順序が逆に感じられる結果でした。
落とし穴として踏んだところ
path_of の親グループ探索は、素直に書くと参照の数だけ全オブジェクトを走査します。大きめのプロジェクトでは目に見えて遅くなるので、実運用では子から親への辞書を先に一度だけ作る形に直しました。掲載したものは読みやすさを優先した版です。
もう一つ、plutil が失敗する場合はプロジェクトファイル自体が壊れています。この棚卸しは「開けてしまう壊れ方」を狙ったものですが、副産物として「開けなくなる壊れ方」も同時に捕まえてくれます。
Claude Code 側で境界を宣言する
運用ルールを頭の中に置くだけでは、忙しい日に崩れます。私は設定ファイルに書き下すことにしました。
{
"permissions": {
"deny": [
"Edit(**/*.xcodeproj/project.pbxproj)",
"Write(**/*.xcodeproj/project.pbxproj)",
"Edit(**/*.xcworkspace/contents.xcworkspacedata)"
],
"allow": [
"Bash(ruby scripts/xcode/*.rb:*)",
"Bash(python3 scripts/xcode/pbx_inventory.py:*)"
]
}
}
deny と allow を両方書いている点が要点です。編集を塞ぐだけだと、代わりに sed や python3 -c で書き換える経路が残ります——通ってほしい道を明示しておかないと、塞いだ道の脇に細い道ができるのです。
設定を足したあとは、必ず効きを挙動で確かめてください。キー名を一文字間違えても、多くのツールは黙って無視します。私は project.pbxproj に一行足すよう指示して、拒否されることを確認してから運用に入れました。この「押した許可を数え直す」作業そのものについては、Claude Code で「今後確認しない」を押した範囲を棚卸しするにまとめてあります。
読み取りの範囲についても同じ考え方が使えます。プロジェクトファイルは行数のわりに情報が薄く、文脈を無駄に埋めます。除外の判断基準は生成物1ファイルが手書きコード70本より重いので、Claude Code に読ませる範囲を決め直しましたに書きました。
同期フォルダの下で起きる、もう一つの壊れ方
これは Xcode の問題ではないのですが、同じ週に踏んだので併せて書き残します。
私はソースを Dropbox の配下に置いています。プロジェクトファイルを保存した直後にビルドが走ると、同期側が中間ファイルを拾い、project.pbxproj の競合コピー のようなファイルが横に生まれることがあります。Xcode 自体は元のファイルを掴んだままなので、そのときは何も起きません。問題は数日後、どちらが最新か分からなくなったときに起きます。
# ビルド生成物を同期の対象外にします(Dropbox 固有の拡張属性です)
for d in build DerivedData "*.xcodeproj/project.xcworkspace/xcuserdata"; do
find . -type d -path "./$d" -print0 2>/dev/null \
| xargs -0 -I{} xattr -w com.dropbox.ignored 1 {}
done
# すでに生まれている競合コピーを洗い出します
find . -name "*競合コピー*" -o -name "*conflicted copy*" | sort
xattr を当てるのは作業ディレクトリを作った直後に一度だけです。私は新しいアプリのリポジトリを clone した直後に走らせる手順の一部に入れました。
直接編集を許してよい場面
すべてを塞ぐ運用は長続きしません。私が例外にしているのは次の 3 つです。
xcuserdata 配下のように、そもそも git で追跡していないファイル
- 新しいソースファイルを 1 つ足すだけの変更。これは Xcode の GUI で足したほうが速く、棚卸しの差分も 1 行で読めます
- 明らかに壊れたプロジェクトを復旧する場面。ここでは開けるようにすることが最優先で、意味のずれは後から棚卸しで拾えます
逆に、迷ったら必ずスクリプト経由にすることをお勧めするのは、ビルドフェーズ・ターゲットメンバーシップ・ビルド設定という 3 つに触る変更です。この 3 つは、まさに「開けてしまう壊れ方」が生まれる場所だからです。
いま引いている線
半年ほどこの形で回してきて、リリース直前に慌てる回数が明らかに減りました。減った理由は、エージェントの精度が上がったからではありません。壊れたことに気づく仕組みを、ビルドの外側に一つ置いたからです。
AdMob の設定やストアの掲載情報を触る作業でも、私は同じ順番で考えるようになりました。変更を止めるのではなく、変更が意味どおりに入ったことを機械が確かめられる形へ直しておきます——それができない対象だけを、手の届かない場所に移すのです。
今日から試すなら、まず pbx_inventory.py を一度だけ走らせて、いまのターゲットとファイルの対応を before.txt として残しておいてください。次にプロジェクトファイルを触るとき、その一枚があるかないかで、見えるものが変わります。
お読みいただきありがとうございました。私自身もまだ線を引き直している途中で、例外の 3 つ目は今後もう少し狭くなるかもしれません。