| 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
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("jianshuo@hotmail.com")
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
app = .find()
version = app.get_edit_app_store_version( )
.user_error!() version
localizations = version.get_app_store_version_localizations
localizations.each ||
text = notes[loc.locale] || notes[loc.locale.split().first] || notes[]
text.? || text.empty?
loc.update( { text })
()
upload_to_app_store(
api_key,
app_version,
,
,
,
,
)
push_release_notes_via_spaceship( build_release_notes)
upload_to_app_store(
api_key,
app_version,
,
,
,
,
,
,
,
{
,
,
},
)
()
match(
,
,
.strict_encode64([]),
api_key
)
increment_build_number( build_num)
build_app(
scheme,
,
,
{
{ => }
}
)
last_tag = .strip
raw = last_tag.empty? ?
.strip :
.strip
commits = raw.gsub(, ).gsub(, ).strip
en = commits.empty? ? :
zh = commits.empty? ? :
{ => en, => en, => zh }
()
parts = version.to_s.split()
major = parts[].to_i
minor = (parts[] || ).to_i +
minor > ? :
()
project_path = .expand_path(, __dir__)
project = .open(project_path)
project.targets.select { || t.name == }.each ||
target.build_configurations.each ||
c.build_settings[] = version
project.save
desc
lane
setup_ci
api_key = app_store_connect_api_key(
[],
[],
[],
,
,
)
build_num = next_build_number( api_key)
last_tag = .strip
last_released = last_tag.sub(, )
pbxproj_version = get_version_number( , )
auto_release = (build_num % ).zero?
auto_release
highest = [pbxproj_version, last_released].reject(&)
.max_by { || .new(v) }
current_version = bump_minor(highest)
set_marketing_version(current_version)
is_release =
current_version = pbxproj_version
is_release = last_released.empty? ||
.new(current_version) > .new(last_released)
sign_and_build( api_key, build_num)
is_release
guard_not_in_review
upload_and_submit_for_review( api_key, current_version)
sh()
sh()
last_t = .strip
commits = last_t.empty? ?
.strip :
.strip
commits = commits.empty?
upload_to_testflight(
api_key,
,
,
,
)
sh()
desc
lane
current = get_version_number( , )
new_version = bump_minor(current)
set_marketing_version(new_version)
.success()
.message()
desc
lane
setup_ci
api_key = app_store_connect_api_key(
[],
[],
[],
,
,
)
build_num = next_build_number( api_key)
app_version = get_version_number( , )
sign_and_build( api_key, build_num)
guard_not_in_review
upload_and_submit_for_review( api_key, app_version)
Gemfile
source "https://rubygems.org"
gem "fastlane"
Run bundle install then commit Gemfile.lock.
Step 3 — GitHub Actions Workflow
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
fetch-tags: true
token: ${{ secrets.FEEDBACK_PAT }}
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.2.app
Step 4 — GitHub Secrets
Run these once per repo (REPO=jianshuo/YOUR_APP):
REPO=jianshuo/YOUR_APP
echo "YOUR_MATCH_PASSWORD" | gh secret set MATCH_PASSWORD --repo $REPO
echo -n "jianshuo:YOUR_GITHUB_PAT" | base64 | gh secret set MATCH_GIT_BASIC_AUTH --repo $REPO
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
echo "YOUR_GITHUB_PAT" | gh secret set FEEDBACK_PAT --repo $REPO
ASC API Key 文件位置:见 [[apple-developer-credentials]],在 iCloud 重要文档目录。
Step 5 — 验证
- 推一个空 commit:
git commit --allow-empty -m "trigger CI" && git push
- 看 Actions tab:
github.com/jianshuo/YOUR_APP/actions
- 首次成功后 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 |