mcpField
W
wjs-publishing-testflight

wjs-publishing-testflight

github.com/jianshuoクラウド・DevOps13762 回インストール今日更新
リポジトリ

wjs-publishing-testflight について

fastlane と GitHub Actions で TestFlight のビルドとリリースを自動化する、iOS 開発者向けの設定ガイドです。署名、ビルド番号、ワークフロー設定、問題への対処を扱います。

主な機能

  1. 01fastlane と GitHub Actions の設定
  2. 02main への push 後に TestFlight 用ビルド
  3. 03署名証明書とビルド番号の管理
  4. 04App Store 審査の手動実行

活用シーン

  1. 01iOS アプリの継続的ビルド設定
  2. 02TestFlight 配布の自動化
  3. 03署名や ASC 認証の問題対応

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jianshuo/claude-skills --skill wjs-publishing-testflight

インストール後、Claude Code で/wjs-publishing-testflightを入力して読み込まれたか確認します。

詳細

wjs-publishing-testflight

iOS プロジェクトに fastlane + GitHub Actions を導入し、main への push → 自動ビルド → TestFlight への配信を実現します。参考実装は Cathier プロジェクト(github.com/jianshuo/Cathier)です。

事前情報(すべてのプロジェクト共通)

項目値
Apple ID[email protected]
Team ID97XBW2A43H
ITC Team ID97847885
ASC API Key IDS6363V64RS
ASC Issuer ID69a6de82-56b6-47e3-e053-5b8c7c11a4d1
ASC API Key ファイルiCloud の重要書類ディレクトリにある .p8 ファイル([[apple-developer-credentials]] を参照)
Match 証明書リポジトリの形式https://github.com/jianshuo/<APP>-certs.git

ワークフローのロジック

git push → main
    ↓
GitHub Actions (macos-15, Xcode 26.2)
    ↓
fastlane beta
    ├─ 查询 ASC 最新 build number → 加 1
    ├─ match(readonly,拉 appstore 证书)
    ├─ increment_build_number
    ├─ build_app (app-store export)
    ├─ build_num % 10 == 0 → 自动 App Store 提审(bump marketing version)
    ├─ pbxproj MARKETING_VERSION > 最新 release/* tag → 手动 bump → 提审
    └─ 否则 → upload_to_testflight + 打 testflight/<build_num> tag

自動リリースのルール(Cathier の規約です。新規プロジェクトでは独自に設定できます):

  • 10 回目ごとの build(build_num % 10 == 0)で、minor バージョンを自動的に上げて App Store に提出します
  • または、開発者が pbxproj の MARKETING_VERSION を手動で変更して push します
  • CI は pbxproj を commit せず、main に push することもありません

Step 1 — fastlane の初期化

cd /path/to/YourApp
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install
bundle exec fastlane init

Step 2 — ファイルの内容

fastlane/Appfile
app_identifier("com.YOUR_BUNDLE_ID")
apple_id("[email protected]")
itc_team_id("97847885")
team_id("97XBW2A43H")
fastlane/Matchfile
git_url("https://github.com/jianshuo/YOUR_APP-certs.git")
storage_mode("git")
type("development")

証明書リポジトリを作成するため、最初に一度実行します(ローカル):

bundle exec fastlane match init
bundle exec fastlane match appstore
bundle exec fastlane match development
fastlane/Fastfile

以下は Fastfile の全体です。コピーした後、BUNDLE_ID を実際の値に置き換えてください:

require "set"

default_platform(:ios)

BUNDLE_ID = "com.YOUR_BUNDLE_ID"

platform :ios do

  def next_build_number(api_key:)
    latest = latest_testflight_build_number(
      api_key:            api_key,
      app_identifier:     BUNDLE_ID,
      initial_build_number: 0
    )
    latest + 1
  end

  def guard_not_in_review
    require "spaceship"
    app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
    UI.user_error!("App not found on App Store Connect") unless app
    versions = app.get_app_store_versions(filter: { platform: "IOS" })
    versions.each do |v|
      if v.app_version_state == "IN_REVIEW"
        UI.user_error!("Version #{v.version_string} is IN_REVIEW — wait for review to finish.")
      end
    end
  end

  def push_release_notes_via_spaceship(notes:)
    require "spaceship"
    app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
    version = app.get_edit_app_store_version(platform: "IOS")
    UI.user_error!("No edit-state App Store Version found") unless version
    localizations = version.get_app_store_version_localizations
    localizations.each do |loc|
      text = notes[loc.locale] || notes[loc.locale.split("-").first] || notes["default"]
      next if text.nil? || text.empty?
      loc.update(attributes: { whats_new: text })
    end
  end

  def upload_and_submit_for_review(api_key:, app_version:)
    upload_to_app_store(
      api_key:                    api_key,
      app_version:                app_version,
      skip_metadata:              true,
      skip_screenshots:           true,
      submit_for_review:          false,
      run_precheck_before_submit: false,
    )
    push_release_notes_via_spaceship(notes: build_release_notes)
    upload_to_app_store(
      api_key:                    api_key,
      app_version:                app_version,
      skip_metadata:              true,
      skip_screenshots:           true,
      skip_binary_upload:         true,
      submit_for_review:          true,
      automatic_release:          true,
      reject_if_possible:         true,
      run_precheck_before_submit: false,
      submission_information: {
        add_id_info_uses_idfa:             false,
        export_compliance_uses_encryption: false,
      },
    )
  end

  def sign_and_build(api_key:, build_num:, scheme: "YOUR_SCHEME")
    match(
      type:                "appstore",
      readonly:            true,
      git_basic_authorization: Base64.strict_encode64(ENV["MATCH_GIT_BASIC_AUTH"]),
      api_key:             api_key
    )
    increment_build_number(build_number: build_num)
    build_app(
      scheme:        scheme,
      export_method: "app-store",
      xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore #{BUNDLE_ID}'",
      export_options: {
        provisioningProfiles: { BUNDLE_ID => "match AppStore #{BUNDLE_ID}" }
      }
    )
  end

  def build_release_notes
    last_tag = `git tag --list 'release/*' --sort=-version:refname 2>/dev/null | head -1`.strip
    raw = last_tag.empty? ?
      `git log -10 --pretty=format:"- %s"`.strip :
      `git log #{last_tag}..HEAD --pretty=format:"- %s"`.strip
    commits = raw.gsub(/<[^>]*>/, "").gsub(/[ \t]+/, " ").strip
    en = commits.empty? ? "Bug fixes and performance improvements." : "What's new:\n#{commits}"
    zh = commits.empty? ? "修复 bug 和性能改进。" : "更新内容:\n#{commits}"
    { "default" => en, "en-US" => en, "zh-Hans" => zh }
  end

  def bump_minor(version)
    parts = version.to_s.split(".")
    major = parts[0].to_i
    minor = (parts[1] || "0").to_i + 1
    minor > 9 ? "#{major + 1}.0" : "#{major}.#{minor}"
  end

  def set_marketing_version(version)
    require "xcodeproj"
    project_path = File.expand_path("../YOUR_APP.xcodeproj", __dir__)
    project = Xcodeproj::Project.open(project_path)
    project.targets.select { |t| t.name == "YOUR_SCHEME" }.each do |target|
      target.build_configurations.each do |c|
        c.build_settings["MARKETING_VERSION"] = version
      end
    end
    project.save
  end

  desc "Build and upload to TestFlight (auto-detects App Store release)."
  lane :beta do
    setup_ci
    api_key = app_store_connect_api_key(
      key_id:                ENV["ASC_API_KEY_ID"],
      issuer_id:             ENV["ASC_API_ISSUER_ID"],
      key_content:           ENV["ASC_API_KEY_CONTENT"],
      is_key_content_base64: true,
      duration:              1200,
      in_house:              false
    )
    build_num       = next_build_number(api_key: api_key)
    last_tag        = `git tag --list 'release/*' --sort=-version:refname 2>/dev/null | head -1`.strip
    last_released   = last_tag.sub(%r{^release/}, "")
    pbxproj_version = get_version_number(xcodeproj: "YOUR_APP.xcodeproj", target: "YOUR_SCHEME")
    auto_release    = (build_num % 10).zero?

    if auto_release
      highest = [pbxproj_version, last_released].reject(&:empty?)
                                                 .max_by { |v| Gem::Version.new(v) }
      current_version = bump_minor(highest)
      set_marketing_version(current_version)
      is_release = true
    else
      current_version = pbxproj_version
      is_release = last_released.empty? ||
                   Gem::Version.new(current_version) > Gem::Version.new(last_released)
    end

    sign_and_build(api_key: api_key, build_num: build_num)

    if is_release
      guard_not_in_review
      upload_and_submit_for_review(api_key: api_key, app_version: current_version)
      sh("git tag release/#{current_version} || true")
      sh("git push origin release/#{current_version} || true")
    else
      last_t = `git describe --tags --abbrev=0 2>/dev/null`.strip
      commits = last_t.empty? ?
        `git log -10 --pretty=format:"• %s"`.strip :
        `git log #{last_t}..HEAD --pretty=format:"• %s"`.strip
      commits = "• No notable changes" if commits.empty?
      upload_to_testflight(
        api_key:                           api_key,
        skip_waiting_for_build_processing: false,
        distribute_external:               false,
        notify_external_testers:           false,
        changelog:                         "Build #{build_num}\n\nWhat changed:\n#{commits}"
      )
      sh("git tag testflight/#{build_num} || true")
    end
  end

  desc "Bump MARKETING_VERSION (2.2→2.3). Commit pbxproj + push → next CI run submits to review."
  lane :bump do
    current     = get_version_number(xcodeproj: "YOUR_APP.xcodeproj", target: "YOUR_SCHEME")
    new_version = bump_minor(current)
    set_marketing_version(new_version)
    UI.success("Bumped: #{current} → #{new_version}")
    UI.message("Now: git add *.xcodeproj/project.pbxproj && git commit && git push origin main")
  end

  desc "Explicit App Store release (bypasses auto-detection)."
  lane :release do
    setup_ci
    api_key = app_store_connect_api_key(
      key_id:                ENV["ASC_API_KEY_ID"],
      issuer_id:             ENV["ASC_API_ISSUER_ID"],
      key_content:           ENV["ASC_API_KEY_CONTENT"],
      is_key_content_base64: true,
      duration:              1200,
      in_house:              false
    )
    build_num   = next_build_number(api_key: api_key)
    app_version = get_version_number(xcodeproj: "YOUR_APP.xcodeproj", target: "YOUR_SCHEME")
    sign_and_build(api_key: api_key, build_num: build_num)
    guard_not_in_review
    upload_and_submit_for_review(api_key: api_key, app_version: app_version)
  end

end
Gemfile
source "https://rubygems.org"
gem "fastlane"

bundle install を実行してから、Gemfile.lock を commit します。

Step 3 — GitHub Actions ワークフロー

.github/workflows/build.yml として保存します:

name: Build & Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      destination:
        description: 'Deploy target'
        required: true
        default: 'testflight'
        type: choice
        options:
          - testflight
          - appstore

jobs:
  build:
    runs-on: macos-15

    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0        # full history for commit log in changelog
          fetch-tags: true
          token: ${{ secrets.FEEDBACK_PAT }}

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_26.2.app

      - name: Show Xcode info
        run: xcodebuild -version

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      # PRs: build only (verify it compiles, no signing)
      - name: Build for verification
        if: github.event_name == 'pull_request'
        run: |
          xcodebuild build \
            -project YOUR_APP.xcodeproj \
            -scheme YOUR_SCHEME \
            -destination 'generic/platform=iOS' \
            -skipPackagePluginValidation \
            CODE_SIGNING_ALLOWED=NO

      # Push to main → TestFlight (or App Store if auto/manual release)
      - name: Run fastlane
        if: github.event_name != 'pull_request'
        env:
          MATCH_PASSWORD:       ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTH: ${{ secrets.MATCH_GIT_BASIC_AUTH }}
          ASC_API_KEY_ID:       ${{ secrets.ASC_API_KEY_ID }}
          ASC_API_ISSUER_ID:    ${{ secrets.ASC_API_ISSUER_ID }}
          ASC_API_KEY_CONTENT:  ${{ secrets.ASC_API_KEY_CONTENT }}
        run: |
          DEST="${{ github.event.inputs.destination }}"
          if [ "$DEST" = "appstore" ]; then
            bundle exec fastlane release
          else
            bundle exec fastlane beta
          fi

      - name: Push tags
        if: success() && github.event_name != 'pull_request'
        run: git push --tags || true

Step 4 — GitHub Secrets

各リポジトリで一度実行します(REPO=jianshuo/YOUR_APP):

REPO=jianshuo/YOUR_APP

# 1. match encryption password (same as your local match password)
echo "YOUR_MATCH_PASSWORD" | gh secret set MATCH_PASSWORD --repo $REPO

# 2. base64(username:PAT) for certs repo access
echo -n "jianshuo:YOUR_GITHUB_PAT" | base64 | gh secret set MATCH_GIT_BASIC_AUTH --repo $REPO

# 3. ASC API key (same key across all apps)
echo "S6363V64RS" | gh secret set ASC_API_KEY_ID --repo $REPO
echo "69a6de82-56b6-47e3-e053-5b8c7c11a4d1" | gh secret set ASC_API_ISSUER_ID --repo $REPO
cat ~/path/to/AuthKey_S6363V64RS.p8 | base64 | gh secret set ASC_API_KEY_CONTENT --repo $REPO

# 4. GitHub PAT (needs repo + workflow scopes) for checkout with tag push
echo "YOUR_GITHUB_PAT" | gh secret set FEEDBACK_PAT --repo $REPO

ASC API Key ファイルの場所:[[apple-developer-credentials]] を参照してください。iCloud の重要書類ディレクトリにあります。

Step 5 — 検証

  1. 空の commit を push します:git commit --allow-empty -m "trigger CI" && git push
  2. Actions タブを確認します:github.com/jianshuo/YOUR_APP/actions
  3. 初回の成功後、TestFlight に新しい build が表示されます

日常の操作

操作コマンド
TestFlight build を実行git push origin main(任意の commit)
App Store に手動で審査を申請GitHub Actions → Run workflow → appstore を選択
ローカルでバージョンを上げ、次回の CI で審査を申請bundle exec fastlane bump → pbxproj を commit → push
直ちに審査を申請bundle exec fastlane release(ローカルで実行。ローカル証明書が必要)

置換項目

新規プロジェクトで上記のファイルをコピーした後、全体を置換します:

プレースホルダー置換後
YOUR_BUNDLE_ID実際の bundle identifier
YOUR_APPrepo/ファイル名。例:Cathier
YOUR_SCHEMEXcode scheme 名

よくある問題

現象原因解決方法
No matching provisioning profiles foundmatch を実行していない、または certs repo が空ローカルで bundle exec fastlane match appstore を実行します
ASC で 401 UnauthorizedASC API Key の内容に誤りがある、または期限切れ.p8 を再度 base64 エンコードし、secret に設定します
build number が増えないMATCH_GIT_BASIC_AUTH が設定されておらず、match が失敗して中断secret の形式を確認します(base64 は改行なし)
tag の push に失敗FEEDBACK_PAT の権限が不足PAT に repo + workflow スコープが必要です
IN_REVIEW エラーバージョンが審査中審査の終了を待つか、キャンセルしてから release を実行します

コメント