1. 前提と環境
保守対象は、数年前に作られた React Native 0.68(Expo Bare)のアプリです。iOS / Android 両方。手元の開発機は Windows のみ、Mac は所有していません。
| 項目 | 内容 |
|---|---|
| プロジェクト | React Native 0.68 / Expo Bare workflow |
| ローカル環境 | Windows のみ(Mac なし、実機なし) |
| CI | GitHub Actions macos-26(arm64 標準ランナー) |
| ゴール | App Store 提出用に署名済みの .ipa を作る |
| 追加費用 | ゼロ(無料枠内で完結) |
なぜ Xcode 26 が必須なのか
Apple の Upcoming Requirements に明記されています。
Since April 28, 2026 — Apps uploaded to App Store Connect must be built with Xcode 26 or later using an SDK for iOS 26, iPadOS 26, tvOS 26, visionOS 26, or watchOS 26.
つまり 2026年4月28日以降、Xcode 25以前でビルドしたバイナリは受け付けられません。そして Xcode は macOS 専用です。ここが出発点でした。
Android 側も同時期に Google Play が 2026年8月31日までに targetSdk 36(Android 16) を要求していますが、こちらは Windows でビルドできるので本記事では扱いません。
2. 選定:なぜ GitHub Actions なのか
EAS Build(Expo)で二重に詰んだ
まず Expo の EAS Build を試しました。無料プランでも iOS ビルドが月15回使えます。
1つめの壁:45分タイムアウト
EAS の Free プランはビルドタイムアウトが45分(有料プランは2時間)です。古い RN プロジェクトは Pod のコンパイルが重く、45分では完走しませんでした。
2つめの壁:Xcodeのバージョン
より本質的だったのはこちらです。ログを追っていて、ビルドに使われている Xcode が 26 ではないことに気づきました。仮に45分で完走していても、そのIPAは4月28日要件を満たさず提出できません。
教訓:クラウドビルドサービスを使うときは、成否より先に
xcodebuild -versionの出力を確認する。「ビルドが通るか」と「ストアが受け取るか」は別問題。
GitHub Actions の macOS ランナー
macos-26 ランナーは 2026年2月26日に GA になっています(macos-26 / macos-26-intel / macos-26-large / macos-26-xlarge)。ワークフローに1行書くだけで Xcode 26 入りの環境が立ち上がります。
runs-on: macos-26
ただしコストは正直に把握しておくべきです。
| ランナー | 単価(GitHub公式) |
|---|---|
| Linux 2コア | $0.006 / 分 |
| Windows 2コア | $0.010 / 分 |
| macOS 3〜4コア | $0.062 / 分 |
Linux の約10倍です。GitHub Free の月2,000分の枠も、macOS で回せばそのぶん速く減ります。「リリース前に数回だけ」なら無料枠に収まりますが、常時CIで回すなら課金前提で考えてください(料金は変わりやすいので実行前に公式を確認)。
3. ワークフロー全文
実際に使っているものを汎用化したものです。プロジェクト名やスキーム名は置き換えてください。
設計方針は「アーカイブは署名なし、exportで署名する」の2段構成です。理由は後述します。
# .github/workflows/ios-build.yml
name: iOS Build
on:
push:
branches: [ ios-release ]
workflow_dispatch:
env:
SCHEME: MyApp
WORKSPACE: ios/MyApp.xcworkspace
jobs:
build:
runs-on: macos-26
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# まずこれ。要件を満たす環境かを最初に確認する
- name: Show toolchain
run: |
xcodebuild -version
xcodebuild -showsdks | grep -i ios
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install JS dependencies
run: npm ci --legacy-peer-deps
# --- ここに後述のパッチ適用ステップが入る ---
- name: Install CocoaPods dependencies
working-directory: ios
run: |
rm -f Podfile.lock # CI上ではlockを破棄して再解決(理由は 4-1)
pod install --repo-update
- name: Import signing certificate
env:
P12_BASE64: ${{ secrets.IOS_DIST_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_DIST_P12_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain"
echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12"
security create-keychain -p "" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "" "$KEYCHAIN"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" \
-P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" \
$(security list-keychains -d user | tr -d '"')
security find-identity -v -p codesigning "$KEYCHAIN"
- name: Install provisioning profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
run: |
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
echo "$PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/app.mobileprovision"
UUID=$(security cms -D -i "$RUNNER_TEMP/app.mobileprovision" \
| plutil -extract UUID raw -)
cp "$RUNNER_TEMP/app.mobileprovision" "$PROFILE_DIR/$UUID.mobileprovision"
echo "PROFILE_UUID=$UUID" >> "$GITHUB_ENV"
echo "installed profile: $UUID"
# 署名なしでコンパイルだけ通す
- name: Archive (unsigned)
run: |
set -o pipefail
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/$SCHEME.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
| tee "$RUNNER_TEMP/archive.log"
# exportのタイミングでApp Store用プロファイルで署名する
- name: Export IPA (signed)
run: |
set -o pipefail
cat > "$RUNNER_TEMP/ExportOptions.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key> <string>app-store-connect</string>
<key>signingStyle</key> <string>manual</string>
<key>teamID</key> <string>YOUR_TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.myapp</key>
<string>YOUR_PROFILE_NAME</string>
</dict>
<key>uploadSymbols</key> <true/>
<key>compileBitcode</key> <false/>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/$SCHEME.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
-exportPath "$RUNNER_TEMP/export" \
| tee "$RUNNER_TEMP/export.log"
# 失敗しても必ずログを残す。手元にMacがない以上、ログが唯一の情報源
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-build-${{ github.run_number }}
path: |
${{ runner.temp }}/export/*.ipa
${{ runner.temp }}/*.log
retention-days: 14
method の値に注意
ExportOptions.plist の method は、Xcode 15.4 以降で app-store → app-store-connect に改称されています。古い Xcode に app-store-connect を渡すと逆に落ちます。
error: exportOptionsPlist error for key 'method':
expected one of {app-store, ad-hoc, enterprise, development, validation},
but found app-store-connect
エラーメッセージが受理可能な値を列挙してくれるので、迷ったら1回落として確認するのが早いです。
なぜ archive と export で署名を分けるのか
1本で署名まで通そうとすると、コンパイルエラーなのか署名エラーなのか切り分けられません。手元にMacがあれば再現して確かめられますが、こちらは全部CI越しです。
-
archiveはCODE_SIGNING_ALLOWED=NOでコンパイルだけに集中させる -
exportでキーチェーンとプロファイルを使って署名する
こう分けておくと、失敗ログを見た瞬間にどちらの問題か分かります。手元で再現できない環境では、切り分けやすさが速度に直結します。
Secrets の作り方(Windows / PowerShell)
Macがないので、証明書のBase64化もWindows側でやります。
# 配布証明書(.p12)
[Convert]::ToBase64String([IO.File]::ReadAllBytes("dist.p12")) | Set-Clipboard
# → GitHub Secrets: IOS_DIST_P12_BASE64
# プロビジョニングプロファイル
[Convert]::ToBase64String([IO.File]::ReadAllBytes("app.mobileprovision")) | Set-Clipboard
# → GitHub Secrets: IOS_PROFILE_BASE64
.p12 のパスワードは IOS_DIST_P12_PASSWORD に登録します。この3つの登録作業だけは、自動化せず手でやることを勧めます(後述)。
4. 実際に踏んだ5件
runs-on: macos-26 と書けば動く、とはなりませんでした。6回目のビルドでようやく成功しています。5件すべて載せます。
4-1. Podfile.lock と Firebase 系ライブラリのバージョン矛盾
症状:pod install が依存解決に失敗。ローカルのlockに記録されたFirebaseのバージョンと、react-native-firebase が要求するバージョンが噛み合わない。
原因:lockファイルが「誰かのMacの上で、当時のPod環境で解決された結果」を保存している。数年後のCI環境では前提が違う。
対処:CI上ではlockを破棄して再解決し、Podfile側でFirebaseのバージョンを明示的に固定する。
# ios/Podfile の先頭
$FirebaseSDKVersion = '9.6.0' # react-native-firebase が要求する版に合わせる
- name: Install Pods
working-directory: ios
run: |
rm -f Podfile.lock
pod install --repo-update
lockを消すのは本来アンチパターンですが、「lockが指す世界がもう存在しない」場合は再解決するしかありません。代わりに Podfile 側でバージョンを固定して再現性を担保します。
4-2. boost の配布サーバーが閉鎖されていた
症状:
[!] Error installing boost
Verification checksum was incorrect, expected xxxx, got yyyy
原因:RN 0.68 の boost.podspec が指している JFrog の配布URLが閉鎖済みで、404のHTMLが落ちてきていた。それをtarballとしてハッシュ検証するので当然一致しない。チェックサム不一致は「ファイルが壊れている」ではなく「ファイルじゃないものが落ちてきている」ことが多いです。
対処:公式アーカイブにURLを差し替えるパッチをCIで当てる。
- name: Patch boost download URL
run: |
PODSPEC=node_modules/react-native/third-party-podspecs/boost.podspec
sed -i '' \
-e 's#https://boostorg\.jfrog\.io/artifactory/main/release#https://archives.boost.io/release#' \
"$PODSPEC"
grep -n 'spec.source' "$PODSPEC"
sha は同じファイルなので変更不要です。恒久対応するなら patch-package に載せてください。
4-3. Yoga のリテラル演算子が Xcode 26 で警告 → -Werror でエラー
症状:
error: identifier '_pt' preceded by whitespace in a literal operator declaration is deprecated
原因:operator"" _pt(...) という空白ありの旧記法。新しい clang では非推奨警告になり、RN のビルド設定が -Werror を付けているためエラーに昇格する。
対処:空白を削って新記法にする。
// node_modules/react-native/ReactCommon/yoga/yoga/YGValue.h
- YGValue operator"" _pt(long double value) {
+ YGValue operator""_pt(long double value) {
対象ファイルの -Werror を落とす手もありますが、警告全体を潰すと他の実害ある警告まで見えなくなるので、記法修正を優先し、どうしても残る箇所だけ限定的に無効化しました。
4-4. boost 1.76 の std::unary_function(C++17で削除済み)
症状:
error: no template named 'unary_function' in namespace 'std'
原因:std::unary_function は C++11 で非推奨、C++17 で標準から削除されました。boost 1.76 はまだ使っています。Xcode 26 の既定C++標準では通りません。
対処:継承をやめて、必要な typedef を直接書く。
- struct hash_base : std::unary_function<T, std::size_t> {};
+ struct hash_base {
+ typedef T argument_type;
+ typedef std::size_t result_type;
+ };
_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION を定義して逃げる方法もありますが、削除済み機能を復活させるフラグは次の世代で消えます。置換のほうが寿命が長いと判断しました。
4-5. GoogleService-Info.plist が Git 管理外だった
症状:CIでのみ Firebase の初期化に必要なファイルが見つからない。ローカルでは誰も気づいていない。
原因:.gitignore に入っていた。**「Macを持っている人の手元にだけ存在するファイル」**になっていた。
対処:Git の履歴から発掘して戻す。3年以上前のコミットに残っていました。
# そのパスを削除したコミットを探す
git log --all --diff-filter=D --name-only -- '*GoogleService-Info.plist'
# 削除される直前の状態を取り出す
git checkout <削除コミットのハッシュ>^ -- ios/production/GoogleService-Info.plist
GoogleService-Info.plistは秘密鍵ではありません(クライアント識別子であり、アプリのバイナリに同梱されて配布される)。とはいえ組織の方針次第なので、コミットするかSecrets経由で流し込むかは各自で判断してください。
5件を並べて分かること
| # | 分類 | 本質 |
|---|---|---|
| 4-1, 4-5 | リポジトリに入っていなかったもの | 特定のマシンの上でだけビルドが通っていた |
| 4-2 | 外部世界の変化 | 依存先の配布サーバーは消える |
| 4-3, 4-4 | 言語標準とコンパイラの前進 | 5年止めた分は一度に払う |
5件のうち、いわゆる「コードのバグ」は1件もありません。 全部、環境の前提が時間とともにズレたことによるものです。裏を返せば、Macを持っていなかったからこの4-1と4-5が発見できたとも言えます。
5. Macなしで成果物を検証する
ビルドが通っても、それが正しいIPAかは別問題です。しかし codesign も PlistBuddy も macOS 専用。そこで 検証もCIに入れるか、Windows側でzipとして解析するかの二択になります。
CI側で検証するステップ
- name: Verify IPA
run: |
cd "$RUNNER_TEMP/export"
IPA=$(ls *.ipa)
unzip -q -o "$IPA" -d extracted
APP=$(ls -d extracted/Payload/*.app)
echo "--- Info.plist ---"
/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print DTXcode" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print DTSDKName" "$APP/Info.plist"
echo "--- 署名 ---"
codesign -dv --verbose=4 "$APP" 2>&1 | head -20
echo "--- 埋め込みプロファイル ---"
security cms -D -i "$APP/embedded.mobileprovision" \
| plutil -extract Name raw -
echo "--- Firebase設定の同梱確認 ---"
ls "$APP" | grep -i GoogleService
DTXcode と DTSDKName の確認は必須です。 ここが Xcode 26 / iOS 26 SDK になっていなければ、提出しても弾かれます。CIで機械的に確認できるようにしておくと安心です。
Windows側で確認する(Python)
IPAは実体がzipなので、Windowsでも中身は読めます。Info.plist はバイナリplistですが plistlib が対応しています。
import plistlib, re, zipfile
def inspect(ipa_path: str) -> dict:
with zipfile.ZipFile(ipa_path) as z:
name = next(n for n in z.namelist()
if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", n))
info = plistlib.loads(z.read(name))
return {
"bundle_id": info.get("CFBundleIdentifier"),
"version": info.get("CFBundleShortVersionString"),
"build": info.get("CFBundleVersion"),
"xcode": info.get("DTXcode"),
"sdk": info.get("DTSDKName"),
}
print(inspect("MyApp.ipa"))
# {'bundle_id': 'com.example.myapp', 'version': '1.2.3',
# 'build': '16', 'xcode': '2600', 'sdk': 'iphoneos26.0'}
署名の検証(codesign)だけはWindowsでは代替できないので、そこはCIに任せます。
6. 運用上の勘所
ログを常時アーティファクト化する
これが最重要です。手元で再現できない以上、1回のビルドから取れる情報がすべてです。
- name: Upload artifacts
if: always() # ← ここ。失敗時こそログが要る
uses: actions/upload-artifact@v4
if: always() を付け忘れると、失敗したときに何も残りません。私はこれを最初に固めたおかげで6回で収束しました。
人間がやるべきこと/自動化してよいこと
今回、ログ解析とパッチ作成はAI(Claude Code)に大きく助けられました。数千行のビルドログから該当箇所を特定する作業は、正直もう人間がやる仕事ではないと感じています。
一方で、次の3つは自動化も委譲もしませんでした。
| 領域 | 判断 |
|---|---|
| 秘密鍵・証明書の変換、Secretsへの登録、パスワード入力 | 人間が手で行う。事故が取り返しのつかない領域 |
| 有料プランの契約、方針転換(EAS→Actions)の意思決定 | 人間。コストと責任が伴う |
| ビルドログ解析、YAML設計、パッチ作成、公式要件の裏取り | 自動化・委譲してよい |
基準はシンプルで、**「間違えたときに取り返しがつくか」**です。
7. この方法の限界
- 実機デバッグはできません。 できるのは「提出用IPAを作る」ところまで。シミュレータを触りながらの開発には向きません
- 無料ではありません。 macOSランナーはLinuxの約10倍単価。低頻度だから枠内に収まっただけです
- 証明書をクラウドに置くことになります。 Secretsは暗号化されますが、組織の規定次第では判断が必要です
- 古い構成の延命策です。 本丸は React Native のバージョンアップ。Google Play は 64bit 端末向けの 16KBメモリページサイズ対応を求めており、2027年2月1日以降は非対応の更新をリリースできなくなります。古いネイティブライブラリを抱えたままではいずれ詰みます
今回やったのは時間を買う作業です。買った時間で本丸に着手しないと意味がありません。
おわりに
「Macがないから iOS はできない」は、2026年時点ではもう正確ではありません。runs-on: macos-26 の1行で、Xcode 26 の環境は10分で手に入ります。
そしてMacがないという制約は、結果的に良い方向に働きました。Macがあれば私は自分の手元でビルドして終わりにしていたはずで、「誰か一人のマシンでしかビルドできない」という状態を温存していたと思います。残ったのはIPA 1個ではなく、誰でも再現できるパイプラインでした。
同じ状況の方の役に立てば幸いです。
参考
- Apple Developer — Upcoming Requirements(2026年4月28日以降、Xcode 26 + iOS 26 SDK 必須)
- GitHub Changelog — macos-26 is now generally available for GitHub-hosted runners(2026年2月26日 GA)
- GitHub Docs — Actions runner pricing(macOS $0.062/分、Linux $0.006/分)
- GitHub Docs — GitHub Actions billing(GitHub Free は月2,000分)
- Expo — Pricing(Freeプランはビルドタイムアウト45分、有料は2時間)
- Google Play — Target API level requirements(2026年8月31日までに API 36)
- Android Developers — Support 16 KB page sizes(2027年2月1日以降、非対応の更新はリリース不可)