Skip to content
Back to skills

Wjs Publishing Testflight

ASecurity

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

  • 128 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added August 31, 2026
ai-agentsrubybashgitapiperformance

Works with

  • api

Security analysis

A100/100

Scanned August 31, 2026

npx -y skills add jianshuo/claude-skills --skill wjs-publishing-testflight --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wjs Publishing Testflight?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Wjs Publishing Testflight
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/jianshuo-wjs-publishing-testflight/badge)](https://www.skillsdirectory.com/skills/jianshuo-wjs-publishing-testflight)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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 | `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 |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.