🚀
fastlane deliverでアップロードが失敗するので、App Store Connect Build upload APIで解決した
ある時から、 fastlane deliver を使い、TransporterでAppStore Connectへのアップロードが失敗するようになりました。
[17:55:49]: iTunes Transporter output above ^
[17:55:49]: No value present
Return status of iTunes Transporter was 1: No value present
The call to the iTMSTransporter completed with a non-zero exit status: 1. This indicates a failure.
原因
こちらのIssueと問題は同じで、Transporterのバージョンによる不具合のようでした。
Transporterをダウングレードしたとしても、実行時にTransporterが自動的にアップデートされるようで、ダウングレードしてもまた同じ問題が発生するとのことでした…
対応
Issue内で言及されている通り、WWDC2025で発表された新しいBuild upload APIを使うことで、Transporterに依存しないアップロード処理ができるようでした。
過去にもTransporter周りでアップロードができない問題があったため、App Store Connect APIを使った対応に切り替えました。
処理の流れ
アップロードの処理の流れとしてはこのような感じです。
UploadToAppStoreConnectAction を作成する
fastlane/actions/upload_to_app_store_connect.rb を作成し、次のような処理を実装しました。
fastlane/actions/upload_to_app_store_connect.rb
require 'faraday'
require 'json'
module Fastlane
module Actions
class UploadToAppStoreConnectAction < Action
def self.run(params)
# JWT トークンを取得(app_store_connect_api_keyから返されたもの)
jwt_token = params[:jwt_token]
# IPAファイルのパスを取得
ipa_path = params[:ipa] || Actions.lane_context[SharedValues::IPA_OUTPUT_PATH]
unless ipa_path && File.exist?(ipa_path)
UI.user_error!("IPAファイルが見つかりません: #{ipa_path}")
end
UI.message("IPAファイル: #{ipa_path}")
file_size = File.size(ipa_path)
UI.message("ファイルサイズ: #{file_size / 1024 / 1024}MB")
# IPAファイルからバージョン情報を取得
version_info = extract_version_info(ipa_path)
UI.message("バージョン: #{version_info[:version]} (#{version_info[:build]})")
begin
# App IDを取得
app_id = get_app_id(jwt_token, params[:bundle_id])
UI.message("App ID: #{app_id}")
# ステップ1: buildUploadsタスクを作成
UI.message("ステップ1: buildUploadsタスクを作成中...")
build_upload = create_build_upload(jwt_token, app_id, version_info)
build_upload_id = build_upload["data"]["id"]
UI.message("Build Upload ID: #{build_upload_id}")
# ステップ2: buildUploadFilesを作成
UI.message("ステップ2: buildUploadFilesを作成中...")
upload_info = create_build_upload_file(
jwt_token,
build_upload_id,
File.basename(ipa_path),
file_size
)
build_upload_file_id = upload_info["data"]["id"]
UI.message("Build Upload File ID: #{build_upload_file_id}")
upload_operations = upload_info["data"]["attributes"]["uploadOperations"]
UI.message("アップロード操作を#{upload_operations.length}件取得しました")
# ステップ3: IPAファイルをアップロード
UI.message("ステップ3: IPAファイルをアップロード中...")
upload_file(ipa_path, upload_operations)
UI.message("アップロードが完了しました")
# ステップ4: アップロードを完了
UI.message("ステップ4: アップロードをコミット中...")
commit_upload(jwt_token, build_upload_file_id)
UI.success("✅ ビルドのアップロードが完了しました")
UI.message("TestFlightでビルドが処理されるのをお待ちください")
rescue => ex
UI.error("アップロード中にエラーが発生しました: #{ex.message}")
UI.error(ex.backtrace.join("\n"))
UI.user_error!("App Store Connectへのアップロードに失敗しました")
end
end
def self.extract_version_info(ipa_path)
# IPAファイルを一時展開してInfo.plistを読み取る
require 'tmpdir'
require 'plist'
Dir.mktmpdir do |tmpdir|
# IPAを解凍
`unzip -q "#{ipa_path}" -d "#{tmpdir}"`
# Payload内のappを探す
payload_dir = File.join(tmpdir, "Payload")
app_bundle = Dir.glob(File.join(payload_dir, "*.app")).first
if app_bundle.nil?
UI.user_error!("IPAファイル内にappバンドルが見つかりませんでした")
end
# Info.plistを読み取る
info_plist_path = File.join(app_bundle, "Info.plist")
info_plist = Plist.parse_xml(info_plist_path)
{
version: info_plist["CFBundleShortVersionString"],
build: info_plist["CFBundleVersion"]
}
end
end
def self.api_request(method, path, jwt_token, body = nil)
conn = Faraday.new(url: 'https://api.appstoreconnect.apple.com') do |f|
f.request :json
f.response :json
f.adapter Faraday.default_adapter
end
response = conn.send(method) do |req|
req.url path
req.headers['Authorization'] = "Bearer #{jwt_token}"
req.headers['Content-Type'] = 'application/json'
req.body = body if body
end
unless response.success?
error_message = response.body.is_a?(Hash) ? response.body.dig("errors", 0, "detail") : response.body
UI.user_error!("API リクエストが失敗しました: #{response.status} - #{error_message}")
end
response.body
end
def self.get_app_id(jwt_token, bundle_id = nil)
# アプリ一覧を取得
response = api_request(:get, '/v1/apps', jwt_token)
apps = response["data"]
if apps.empty?
UI.user_error!("アプリが見つかりませんでした")
end
# bundle_idが指定されている場合は検索
if bundle_id
app = apps.find { |a| a["attributes"]["bundleId"] == bundle_id }
if app.nil?
UI.user_error!("Bundle ID #{bundle_id} に一致するアプリが見つかりませんでした")
end
return app["id"]
end
# 最初のアプリを返す
apps.first["id"]
end
def self.create_build_upload(jwt_token, app_id, version_info)
body = {
data: {
type: "buildUploads",
attributes: {
cfBundleShortVersionString: version_info[:version],
cfBundleVersion: version_info[:build],
platform: "IOS"
},
relationships: {
app: {
data: {
type: "apps",
id: app_id
}
}
}
}
}
api_request(:post, '/v1/buildUploads', jwt_token, body)
end
def self.create_build_upload_file(jwt_token, build_upload_id, file_name, file_size)
body = {
data: {
type: "buildUploadFiles",
attributes: {
fileName: file_name,
fileSize: file_size,
assetType: "ASSET",
uti: "com.apple.ipa"
},
relationships: {
buildUpload: {
data: {
type: "buildUploads",
id: build_upload_id
}
}
}
}
}
api_request(:post, '/v1/buildUploadFiles', jwt_token, body)
end
def self.upload_file(ipa_path, upload_operations)
require 'net/http'
require 'uri'
# ファイルを読み込む
file_data = File.binread(ipa_path)
upload_operations.each_with_index do |operation, index|
offset = operation["offset"]
length = operation["length"]
url = operation["url"]
method = operation["method"] || "PUT"
headers = operation["requestHeaders"] || []
# ファイルの該当部分を取得
chunk = file_data[offset, length]
UI.message("チャンク #{index + 1}/#{upload_operations.length} をアップロード中... (#{method} #{length} bytes)")
# アップロード
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
# HTTPメソッドに応じてリクエストを作成
request = case method.upcase
when "PUT"
Net::HTTP::Put.new(uri.request_uri)
when "POST"
Net::HTTP::Post.new(uri.request_uri)
else
UI.user_error!("サポートされていないHTTPメソッド: #{method}")
end
# リクエストヘッダーを設定
無事アップロード失敗の問題が解決しましたheaders.each do |header|
request[header["name"]] = header["value"]
end
# ボディを設定
request.body = chunk
# リクエストを送信
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
UI.user_error!("アップロードが失敗しました: #{response.code} #{response.message}")
end
end
UI.success("全てのチャンクのアップロードが完了しました")
end
def self.commit_upload(jwt_token, build_upload_file_id)
# buildUploadFileの状態を更新して完了を通知
body = {
data: {
id: build_upload_file_id,
type: "buildUploadFiles",
attributes: {
uploaded: true
}
}
}
api_request(:patch, "/v1/buildUploadFiles/#{build_upload_file_id}", jwt_token, body)
end
def self.description
"App Store Connect APIを直接使用してIPAファイルをアップロード"
end
def self.available_options
[
FastlaneCore::ConfigItem.new(
key: :jwt_token,
env_name: "APP_STORE_CONNECT_JWT_TOKEN",
description: "JWT Token string from Spaceship::ConnectAPI::Token",
type: String,
optional: false
),
FastlaneCore::ConfigItem.new(
key: :ipa,
env_name: "UPLOAD_IPA_PATH",
description: "IPAファイルのパス",
type: String,
optional: true
),
FastlaneCore::ConfigItem.new(
key: :bundle_id,
env_name: "BUNDLE_ID",
description: "Bundle ID (指定しない場合は最初のアプリを使用)",
type: String,
optional: true
)
]
end
def self.authors
["Teller Team"]
end
def self.is_supported?(platform)
[:ios, :mac].include?(platform)
end
end
end
end
上記のActionを使い、次のようにビルドとアップロード処理を行います。
desc "Deploy to AppStore"
lane :upload_to_appstore do
xcodes(version: "26.0.1")
# Build
build_ios_app(
workspace: "./MyApp.xcworkspace",
scheme: "MyApp",
configuration: "Release",
export_method: "app-store"
)
# Upload to AppStore using custom action (bypasses iTMSTransporter)
# Spaceshipを使ってJWTトークンを生成
# 改行文字を正しく処理
key_content = ENV['ASC_KEY_CONTENT'].gsub('\n', "\n")
token = Spaceship::ConnectAPI::Token.create(
key_id: ENV['ASC_KEY_ID'],
issuer_id: ENV['ASC_ISSUER_ID'],
key: key_content
)
upload_to_app_store_connect(
jwt_token: token.text,
bundle_id: ENV['BUNDLE_ID'] # ← 環境変数から取得するように変更
)
end
まとめ
Transporterを使ったアップロードから、App Store Connect APIを使ったアップロードに切り替え、無事アップロード失敗の問題が解決しました。
Build upload APIではステップがいくつかありますが、処理内容自体は複雑ではないので、Fastlaneを使わずにGitHub Actionsのscriptsで対応してしまうのでも良かったかもですね。
それでは、よいモバイルアプリ開発ライフを!
参考
Discussion