Xcode を開かずに新規iOSアプリを TestFlight へ上げる:archive は通るのに export だけ落ちるとき
新しい bundle id で作ったアプリを CLI だけで配信したい。archive は成功するのに exportArchive だけが Cloud signing permission error / No profiles were found で落ちる。export を手動署名に倒すと越えられる。
新しい bundle id(アプリを識別する文字列。com.example.app のような形)で作ったアプリを、Xcode の画面を一度も開かずに TestFlight まで上げたい。xcodebuild archive は通るのに、xcodebuild -exportArchive だけが落ちる。2回同じところで止まって、原因が署名の権限だと分かった。
事象
- 新規の bundle id で、配布用のプロビジョニングプロファイル(このアプリを配布用に署名してよい、という許可証)がまだ1枚も無い
- Xcode にアカウントをログインさせていない(CI や、GUI を開きたくない環境)
xcodebuild archiveは成功し、.xcarchiveもできるxcodebuild -exportArchiveだけがこれで落ちる
error: exportArchive: Cloud signing permission error
error: exportArchive: No profiles for 'com.example.app' were found
コードもビルド設定も触っていないので、原因がプロジェクト側にあるようには見えない。
原因
2つが重なっている。
ひとつ。クラウド署名(Xcode が Apple 側に配布証明書とプロファイルを自動生成させる仕組み)を使うには、App Store Connect API キーに Admin 権限が要る。Developer や App Manager の権限しか持たないキーだと、「クラウド管理の配布証明書へのアクセス権が無い」という趣旨のメッセージが返る。これが Cloud signing permission error の中身だ。
もうひとつ。新規 bundle id には配布プロファイルがまだ存在しない。クラウド署名で作ってもらえないなら、手動署名に切り替えるしかないが、切り替えたところで指すプロファイルがローカルに無い。だから No profiles ... were found が続く。
切り分けの手がかりは、archive と export の非対称だ。落ちているのは export の段=配布用の署名一式が要求される場所で、archive の段ではまだそこまで要らない。ビルドの中身ではなく、署名の種類が切り替わる境目で止まっている。
解決策
Admin 権限のキーを用意できるなら、それが一番早い。権限を広げたくない場合や、そもそも自分が Account Holder ではない場合は、export だけを手動署名に倒すと越えられる。archive は自動署名のままでいい。
手順は4つ。
1. API キーで xcodebuild を認証させる
.p8(Apple が発行する秘密鍵ファイル)のパス・キーID・Issuer ID の3点を渡す。
xcodebuild archive \
-workspace App.xcworkspace -scheme App \
-configuration Release -destination 'generic/platform=iOS' \
-archivePath build/App.xcarchive \
-allowProvisioningUpdates \
-authenticationKeyPath "$HOME/private_keys/AuthKey_XXXXXXXXXX.p8" \
-authenticationKeyID XXXXXXXXXX \
-authenticationKeyIssuerID 00000000-0000-0000-0000-000000000000
2. bundle id を API で登録する
まず API 用のトークンを作る。App Store Connect API は ES256 署名の JWT を要求する。ここに罠がひとつあって、Node 標準の crypto は既定で DER 形式の署名を出すが、Apple が受け付けるのは生の64バイト形式(P1363)だ。dsaEncoding の指定を忘れると、正しい鍵でも 401 が返る。
import { createSign, createPrivateKey } from 'node:crypto'
import { readFileSync } from 'node:fs'
const b64url = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const now = Math.floor(Date.now() / 1000)
const header = b64url({ alg: 'ES256', kid: KEY_ID, typ: 'JWT' })
const payload = b64url({
iss: ISSUER_ID,
iat: now,
exp: now + 600, // 20分以内。長すぎると弾かれる
aud: 'appstoreconnect-v1',
})
const signer = createSign('SHA256')
signer.update(`${header}.${payload}`)
const sig = signer
.sign({ key: createPrivateKey(readFileSync(P8_PATH)), dsaEncoding: 'ieee-p1363' })
.toString('base64url')
const jwt = `${header}.${payload}.${sig}`
dsaEncoding: 'ieee-p1363' を付けると署名部が64バイト固定になる。これが正しい形。付け忘れると70バイト前後のDERになり、見た目は同じ JWT なのに認証だけ通らない。
トークンができたら登録する。
curl -X POST https://api.appstoreconnect.apple.com/v1/bundleIds \
-H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"data":{"type":"bundleIds","attributes":{
"identifier":"com.example.app","name":"Example App","platform":"IOS"}}}'
3. 配布プロファイルを自作して、ローカルに置く
POST /v1/profiles で作る。profileType は App Store 配布なら IOS_APP_STORE。関連付けとして、さきほどの bundle id と、手元にある配布証明書(GET /v1/certificates で certificateType が IOS_DISTRIBUTION のもの)のIDを渡す。
curl -X POST https://api.appstoreconnect.apple.com/v1/profiles \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"data":{"type":"profiles",
"attributes":{"name":"Example App Store Profile","profileType":"IOS_APP_STORE"},
"relationships":{
"bundleId":{"data":{"type":"bundleIds","id":"'"$BUNDLE_ID_RESOURCE_ID"'"}},
"certificates":{"data":[{"type":"certificates","id":"'"$CERT_ID"'"}]}}}}'
ここからが飛ばしやすい。レスポンスの profileContent はプロファイル本体を base64 にしたもので、これをデコードして所定の場所に置かないと export は見つけられない。ファイル名はレスポンスの uuid を使う。
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
UUID=$(jq -r '.data.attributes.uuid' profile.json)
jq -r '.data.attributes.profileContent' profile.json | base64 --decode \
> ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
作っただけで満足して次に進むと、また No profiles ... were found が出る。API 上に存在することと、手元から見えることは別だ。
4. 手動署名で export する
ExportOptions.plist で署名方式を明示する。provisioningProfiles に書くのはプロファイルの名前で、UUIDではない。
<key>method</key> <string>app-store-connect</string>
<key>signingStyle</key> <string>manual</string>
<key>signingCertificate</key><string>iPhone Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.app</key><string>Example App Store Profile</string>
</dict>
method は Xcode 15 で app-store から app-store-connect に変わった。古い手順書をコピーすると値だけ古いままになる。
xcodebuild -exportArchive \
-archivePath build/App.xcarchive \
-exportPath build/ipa \
-exportOptionsPlist ExportOptions.plist
xcrun altool --upload-app -f build/ipa/App.ipa -t ios \
--apiKey XXXXXXXXXX --apiIssuer 00000000-0000-0000-0000-000000000000
altool は --apiKey にIDしか受け取らず、.p8 本体は決まった場所から読む。~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8 に置いておく。鍵をどこか整理しやすい場所へ移すと、ここが静かに壊れる。
補足:API でやれないことがひとつだけ残る
App Store Connect 上のアプリの枠そのものは、API では作れない。Apple のリファレンスにも、このAPIで新規アプリを作らず App Store Connect のサイトで作るように、と書かれている。ブラウザで1回だけアプリを登録する必要がある。裏を返すと、そこから先の bundle id 登録・プロファイル作成・ビルド・アップロードは全部 CLI に寄せられる。
なお、公開済みアプリの更新でどこまで自動化できるかは別記事「App Store Connect API で iOS リリースを自動化する」で扱った。この記事は、その手前にある「初回ビルドをどう通すか」の話になる。
次に同じ症状を見たときの1手
archive が通って -exportArchive だけが落ちたら、権限を取りに行く前に ExportOptions.plist を manual に倒す。プロファイルを1枚自作して所定の場所に置けば済む話で、Admin 権限の申請より速い。エラー文言が Cloud signing と言っているせいでクラウド側を直しに行きたくなるが、直すべきなのは自分の export の設定のほうだった。