🚀

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のバージョンによる不具合のようでした。
https://github.com/fastlane/fastlane/issues/29703

Transporterをダウングレードしたとしても、実行時にTransporterが自動的にアップデートされるようで、ダウングレードしてもまた同じ問題が発生するとのことでした…

対応

Issue内で言及されている通り、WWDC2025で発表された新しいBuild upload APIを使うことで、Transporterに依存しないアップロード処理ができるようでした。
https://developer.apple.com/videos/play/wwdc2025/324/

過去にも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で対応してしまうのでも良かったかもですね。

それでは、よいモバイルアプリ開発ライフを!

参考

https://developer.apple.com/videos/play/wwdc2025/324/
https://developer.apple.com/documentation/appstoreconnectapi/build-uploads
https://github.com/fastlane/fastlane/issues/29703
https://gitlab.com/-/snippets/4898229

テラーノベル テックブログ

Discussion