为 iOS 项目配置 fastlane + GitHub Actions,实现推送 main 分支自动构建并上传 TestFlight。参考实现:Cathier 项目。触发词:「testflight」「fastlane」「自动构建」「CI TestFlight」「/wjs-publishing-testflight」。
npx skills add https://github.com/jianshuo/claude-skills --skill 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 的约定,新项目可自定):
MARKETING_VERSION 并推送cd /path/to/YourApp
bundle init
echo 'gem "fastlane"' >> Gemfile
bundle install
bundle exec fastlane init
fastlane/Appfileapp_identifier("com.YOUR_BUNDLE_ID")
apple_id("[email protected]")
itc_team_id("97847885")
team_id("97XBW2A43H")
fastlane/Matchfilegit_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
Gemfilesource "https://rubygems.org"
gem "fastlane"
Run bundle install then commit Gemfile.lock.
Save as .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
Run these once per repo (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 重要文档目录。
git commit --allow-empty -m "trigger CI" && git pushgithub.com/jianshuo/YOUR_APP/actions| 操作 | 命令 |
|------|------|
| 触发 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 |
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
Automatically creates user-facing changelogs from git commits by analyzing commit history, categorizing changes, and transforming technical commits into clear, customer-friendly release notes. Turns hours of manual changelog writing into minutes of automated generation.
Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
React Native and Expo best practices for building performant mobile apps. Use when building React Native components, optimizing list performance, implementing animations, or working with native modules. Triggers on tasks involving React Native, Expo, mobile performance, or native platform APIs.
React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification
Take jianshuo/wjs-publishing-testflight from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.