# Wjs Publishing Testflight

> 为 iOS 项目配置 fastlane + GitHub Actions，实现推送 main 分支自动构建并上传 TestFlight。参考实现：Cathier 项目。触发词：「testflight」「fastlane」「自动构建」「CI TestFlight」「/wjs-publishing-testflight」。

- Skill: `jianshuo/wjs-publishing-testflight` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jianshuo/wjs-publishing-testflight`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jianshuo/wjs-publishing-testflight/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jianshuo (https://skillmd.com/u/jianshuo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jianshuo/wjs-publishing-testflight

---


# wjs-publishing-testflight

为 iOS 项目接入 fastlane + GitHub Actions，推送 main → 自动构建 → TestFlight。参考实现是 Cathier 项目（`github.com/jianshuo/Cathier`）。

## 前置信息（所有项目通用）

| 项目 | 值 |
|------|-----|
| Apple ID | `jianshuo@hotmail.com` |
| Team ID | `97XBW2A43H` |
| ITC Team ID | `97847885` |
| ASC API Key ID | `S6363V64RS` |
| ASC Issuer ID | `69a6de82-56b6-47e3-e053-5b8c7c11a4d1` |
| ASC API Key 文件 | iCloud 重要文档目录下的 `.p8` 文件（见 [[apple-developer-credentials]]）|
| Match certs repo 格式 | `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
```

**Auto-release 规则**（Cathier 的约定，新项目可自定）：
- 每第 10 个 build（build_num % 10 == 0）自动 bump minor 版本并提交 App Store
- 或开发者手动改 pbxproj 的 `MARKETING_VERSION` 并推送
- CI 永远不 commit pbxproj，不 push main

## Step 1 — 初始化 fastlane

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

## Step 2 — 文件内容

### `fastlane/Appfile`

```ruby
app_identifier("com.YOUR_BUNDLE_ID")
apple_id("jianshuo@hotmail.com")
itc_team_id("97847885")
team_id("97XBW2A43H")
```

### `fastlane/Matchfile`

```ruby
git_url("https://github.com/jianshuo/YOUR_APP-certs.git")
storage_mode("git")
type("development")
```

先跑一次建立证书仓库（本地）：

```bash
bundle exec fastlane match init
bundle exec fastlane match appstore
bundle exec fastlane match development
```

### `fastlane/Fastfile`

以下是完整 Fastfile，复制后把 `BUNDLE_ID` 替换为真实值：

```ruby
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`

```ruby
source "https://rubygems.org"
gem "fastlane"
```

Run `bundle install` then commit `Gemfile.lock`.

## Step 3 — GitHub Actions Workflow

Save as `.github/workflows/build.yml`:

```yaml
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

Run these once per repo (`REPO=jianshuo/YOUR_APP`):

```bash
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：`git commit --allow-empty -m "trigger CI" && git push`
2. 看 Actions tab：`github.com/jianshuo/YOUR_APP/actions`
3. 首次成功后 TestFlight 里会出现新 build

## 日常操作

| 操作 | 命令 |
|------|------|
| 触发 TestFlight build | `git push origin main`（任何 commit）|
| 手动提审 App Store | GitHub Actions → Run workflow → 选 `appstore` |
| 本地 bump 版本 → 下次 CI 提审 | `bundle exec fastlane bump` → commit pbxproj → push |
| 强制立即提审 | `bundle exec fastlane release`（本地跑，需本地证书）|

## 替换清单

新项目复制上面文件后，全局替换：

| 占位符 | 替换为 |
|--------|--------|
| `YOUR_BUNDLE_ID` | 实际 bundle identifier |
| `YOUR_APP` | repo/文件名，如 `Cathier` |
| `YOUR_SCHEME` | Xcode scheme 名 |

## 常见问题

| 现象 | 原因 | 解决 |
|------|------|------|
| `No matching provisioning profiles found` | match 未跑或 certs repo 空 | 本地跑 `bundle exec fastlane match appstore` |
| `401 Unauthorized` on ASC | ASC API Key 内容有误或过期 | 重新 base64 encode `.p8` 写入 secret |
| build number 没递增 | `MATCH_GIT_BASIC_AUTH` 未设置导致 match 失败中断 | 检查 secret 格式（base64 无换行）|
| tag push 失败 | `FEEDBACK_PAT` 权限不够 | PAT 需要 `repo` + `workflow` scope |
| `IN_REVIEW` 报错 | 版本正在审核 | 等审核结束或取消后再触发 release |

