wjs-publishing-testflight

$npx mdskill add jianshuo/claude-skills/wjs-publishing-testflight

Configures fastlane + GitHub Actions for automatic TestFlight builds on main push.

  • Solves manual iOS build and TestFlight upload process.
  • Depends on fastlane, GitHub Actions, Apple Developer Portal, and match.
  • Auto-increments build number and conditionally bumps marketing version for release.
  • Delivers TestFlight upload and tags via CI pipeline on main branch push.

SKILL.md

.github/skills/wjs-publishing-testflightView on GitHub ↗
---
name: wjs-publishing-testflight
description: 为 iOS 项目配置 fastlane + GitHub Actions,实现推送 main 分支自动构建并上传 TestFlight。参考实现:Cathier 项目。触发词:「testflight」「fastlane」「自动构建」「CI TestFlight」「/wjs-publishing-testflight」。
---

# wjs-publishing-testflight

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

## 前置信息(所有项目通用)

| 项目 | 值 |
|------|-----|
| Apple ID | `[email protected]` |
| 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("[email protected]")
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 |

More from jianshuo/claude-skills

SkillDescription
wangjianshuo-perspective|
wjs-auditing-projectUse when the user asks to audit what's wrong with a project, "make it right", "看看项目出了什么问题", "为什么用户的需求还没上线", "为什么没提交App Store", "为什么没新build", or wants a holistic state-of-the-project check covering unmerged branches, stalled PRs, failed GitHub Actions, stale builds, plan drift (TODOS.md / ROADMAP), unreleased commits, and log errors. Runs read-only investigation, presents a grouped checklist, fixes only after explicit user confirmation. Aware of the Cathier iOS app workflow (Xcode + fastlane + auto-merge @claude PRs from in-app feedback).
wjs-burning-subtitlesUse when the user has a video + an SRT and wants the subtitles either burned into the pixels (libass, always-visible) or soft-muxed as a togglable track. Also handles the final composite step for the localization pipeline — burn subs, mix a dub track, and keep the original audio as a low-volume bed, all in ONE ffmpeg encode (no cascade). Verifies libass availability and auto-downloads a static evermeet ffmpeg build when Homebrew's stripped binary lacks it. Triggers — "烧字幕", "硬字幕", "burn subtitles", "burn-in subs", "embed subtitle", "soft mux SRT", "把字幕烧进视频", "做最终合成".
wjs-cleaning-spamUse when the user complains about spam on his X/Twitter posts — 同城面付 / 寻固炮 / 线下上门 / 免费破处 这类引流号在他推文下刷的 emoji 垃圾回复 — and wants them removed. Covers the last 7 days (X recent-search window). Triggers — "把这些spam删掉", "清理X垃圾回复", "推文下面好多引流号", "clean spam replies", "/wjs-cleaning-spam".
wjs-converting-text-to-videoUse when the user wants a 王建硕-style WeChat article (article.md) turned into a narrated short MP4 video — TTS voiceover via 火山引擎 Volcano TTS, HyperFrames CSS/GSAP animation per scene, subtle SFX, abstract watercolor background, full pipeline rendering to 1080×1920 portrait MP4 (30-90s). Triggers — "把这篇文章做成视频", "做一个解说视频", "讲解视频", "/wjs-converting-text-to-video".
wjs-converting-wp-to-hugoUse when migrating a WordPress site to a Hugo static site on GitHub Pages from a WXR export (.xml) plus the wp-content/uploads folder — preserving /archives/<id>/ URLs, localizing images, and deploying via GitHub Actions. Triggers — "把 WordPress 迁成 Hugo", "wordpress 转静态站", "migrate WordPress to Hugo", "WXR to Hugo", "publish WordPress to GitHub Pages", "/wjs-converting-wp-to-hugo".
wjs-distilling-styleUse when the user wants to capture a writer's voice from a few sample articles and rewrite text to sound like that author — "蒸馏文风", "提炼XX的风格", "把这篇改成XX的味儿", "学某某笔法改写", "mimic this writer", "match this author's style", "style transfer". Works for any author, 王建硕 included.
wjs-dubbing-videoUse when the user has a video + a target-language SRT and wants the video to actually speak that language — generates a time-aligned TTS voice dub. Routes by voice ID — Volcano (豆包) TTS for Chinese, edge-tts neural for any language. Defaults to one voice (single-speaker); opt-in multi-speaker via visual diarization. Outputs `*_<lang>_dub.mp4` with the dub audio in place of the original. Final mixing (audio bed + burn-in) is handed off to `/wjs-burning-subtitles`. Triggers — "配音", "中文配音", "Chinese dub", "voice over this", "dub the video", "TTS this SRT", "different voice for each speaker".
wjs-eating-and-growing吃一堑长一智 — 走完 5 步交互式反思(堑 → 自动输出 → 旧权重 → 新参数 → 替代动作),从「情绪复盘」推进到「行为训练」,把第一反应这一层 L3 权重练新。Use when 王建硕 reflects on a personal setback, mistake, or recurring pattern (反思, 复盘, 回顾, 总结教训, 吃一堑, 长一智, "这次又栽了", "怎么又这样", "为什么我总是…", "想开点都做不到", "知道道理但做不到"). For the user as a human, not for Claude's task post-mortems.
wjs-editing-multicamUse when the user has 2+ recordings of the same event (each with a `.sync.json` sidecar from wjs-syncing-multicam) and wants them combined into a single MP4 — auto-switching between cams second-by-second on audio energy, with optional picture-in-picture inset. Triggers — "auto-edit multicam", "做个剪辑", "切几个机位", "把这几个视频合成一个", "combine these angles", "PiP overlay".