gstack /ship 的 Apple App Store / TestFlight 发布适配器:一次授权、全程 CLI 的商店发布工作流
导读
本文讲解 gstack 的 /ship 工作流在目标为 Apple 平台 App(App Store / TestFlight) 时切入的专用分节 ship/sections/apple-release.md。它把"发布到商店"从"发布到仓库"的常规 ceremony 中剥离出来:全程只允许 两次交互(一次事前授权、一次素材缺失时的追问),其余上传、密钥铸造、商店页填写与提交全部由 fastlane 命令行自动完成。读完你会掌握这套适配器的触发条件、授权时刻的精确边界、基于 App Store Connect API Key 的最小权限密钥铸造方法、durable-effect 幂等日志的使用方式,以及错误分级与升级顺序。
一、适配器是什么、何时被读取
1.1 触发条件:Apple 平台 App
/ship 技能本身是一个"决策树骨架":各分节按需加载,何时读哪个分节由骨架正文(而不是机器谓词)决定,这一点在分节注册表 ship/sections/manifest.json 中有明确注释。
apple-release 分节的触发条件(见 ship/sections/manifest.json 与 ship/SKILL.md 的 Section index):
- 仓库内包含
.xcodeproj或.xcworkspace; - 或存在带 app product 的 Swift Package;
- 且用户的诉求是 商店分发(App Store、TestFlight、"release my app")。
1.2 读取顺序被测试钉死
关键点是:该分节必须在 Step 1 的 branch gate(分支门禁)与任何 preflight 之前读取。ship/SKILL.md 的 Step 0.9 "Apple target detection" 明确定位了这一点,而 test/ship-apple-gate.test.ts 把该顺序做成了字节级回归测试,防止未来重构悄悄改变行为:
appleRelease.md的读取指令必须出现在 branch gate 文本之前(test/ship-apple-gate.test.ts);- store 分发必须显式绕过 branch/PR ceremony(
Store distribution proceeds ...,test/ship-apple-gate.test.ts); - 非 Apple 场景下的 branch gate 必须"字节不变且只出现一次"(test/ship-apple-gate.test.ts),防止 Apple 路径反向削弱普通仓库 landing 的安全门禁。
测试还校验了分节正文中的关键"脊梁"锚点,包括 one authorization moment、fastlane spaceauth、iris/v1/apiKeys、appPriceSchedules、"CLASSIFY the error before touching credentials" 与 "Never abort an App Store release over branch topology"(test/ship-apple-gate.test.ts)——本文后面的每一节都会逐一展开这些主题。
1.3 核心理念:store 分发 ≠ 仓库 landing
适配器的前提是:商店分发是独立的发布通道,不是仓库落地。因此:
- 若用户要发布到 App Store / TestFlight,无论当前在哪个分支,都直接走适配器完成全流程;
- 干净工作树落在 base branch 上,是正常且合法的归档与上传状态(独狼开发者的常态);
- 绝不因分支拓扑而中止 App Store 发布。
分支门禁、commit-review-PR 流水线、merge queue 等仓库 ceremony,只在用户要求落地仓库变更时才适用(ship/SKILL.md 原文:branch gate 与 repository-landing pipeline 只适用于 repository-landing 诉求)。
二、工具栈与发布授权边界
2.1 一个工具跑完整个发布:fastlane
整套发布由机器级 fastlane 驱动,各子命令职责如下:
| 命令 | 职责 |
|---|---|
produce |
创建 app record 与 bundle ID |
cert / sigh |
签名:签发分发证书与 App Store profile |
gym |
archive 与签名导出(驱动 xcodebuild) |
pilot |
TestFlight 上传与测试员管理 |
deliver |
元数据、截图、二进制上传、Submit for Review |
frameit |
设备相框(截图加框) |
缺少时以一行声明安装 brew install fastlane,不允许把它当问题来问——发布授权本身就覆盖了机器级工具安装。反过来,也绝不允许额外安装任何其他 App Store CLI 工具。
2.2 运行中的凭据纪律
整个运行过程中,不得向用户提及任何 API Key、.p8 文件、session 或任何凭据格式——唯一例外是收尾报告里的一次性常驻凭据披露(见第七节)。原因很务实:用户付了 US$99 年费,想要的是"发出去",而不是被一轮轮凭据问题打断;同时发布本身也不会给用户的项目增加任何新依赖。
2.3 非 macOS 主机怎么办
只有 build 环节强依赖 Mac:归档、签名、二进制上传走的是 Xcode 的 macOS-only 工具链,Apple 不在别的平台发布它,也没有任何工具能绕开。因此:
- 在非 macOS 主机上要直说,然后把恰好那几步路由到 macOS CI runner(例如 GitHub Actions 的
macosrunner,执行同样的gym、deliver/pilot命令,把已铸造的 upload key 作为 CI secret 注入——key 认证正是 CI 想要的形态); - sign-in、密钥铸造、元数据、截图、定价、提交判断等属于纯 API 操作,留在用户自己的机器上完成;
- 既不能说"整个发布离开 Mac 就不可能",也不能假装 build 环节可以在非 Mac 上跑。
三、唯一的授权时刻(The One Authorization Moment)
整个旅程恰好只允许两次交互。第一次发生在一开始,是唯一需要用户动手的时刻。
3.1 授权 + 会员资格
先确认用户持有 付费 Apple Developer Program 会员(US$99/年)——App Store 与 TestFlight 都要求它——然后授权这次发布。
没有会员:立即停止 App Store 路径。此时才给出两条出路:
- 通过 Third-Party Web Actions 契约(gstack 自带的浏览器栈驱动、用户手动接手人机环节)引导用户在 developer.apple.com 自助完成 enrollment(这是一笔由用户自己完成的购买,激活可能需要一两天);
- 如实说明免费账号的天花板:只能在自有设备上做 personal-team 安装,7 天后过期,无 TestFlight、无 App Store。
关于 Third-Party Web Actions:这段契约的规范文本沉淀在 scripts/resolvers/third-party-actions.ts,核心是"先主动提出驱动浏览器,绝不先甩一份手工步骤清单",且每次浏览前必须有一次显式提问(A 我驱动 / B 手工步骤 / C 推迟),密码与支付等环节一律 handoff 给用户。
3.2 定价:同一口气里问一次,每个 App 终生一次
定价属于授权问题的同一段表述,绝不作为独立的打断:
- 先查决策库:
bin/gstack-decision-search --scope repo --query "pricing"(仓库内对应工具见 bin/gstack-decision-search); - 在授权问题里一次问清:免费还是付费(付费再问价格);
- 把答案持久化(scope
repo),让以后任何发布都不再重问——相关 CLI 见 bin/gstack-decision-log; - 若答案是付费,就在当下如实说明一次性 Paid Apps 银行/税务协议的存在——因为不签它,什么也卖不出去;
- 价格是 launch 决策,agent 绝不静默默认:免费发布一经上架无法"撤销发布"。
3.3 Apple 登录:发生在同一时刻
登录在同一授权时刻内完成:
# 在 Claude Code 里,用户输入感叹号前缀让凭据直达 Apple
! fastlane spaceauth -u <apple-id>
用户在会话内输入密码与一个双重验证码,直接交给 Apple。只有主机没有交互路径时,才回退到独立终端窗口。
纪律要点:
- 把打印出来的 session token 挡在 transcript 之外;
~/.fastlane/spaceship/里的缓存 cookie 才是 fastlane 实际使用的凭据; - 永远不存储、不回显、不记录密码或 token;
- session 过期时,重跑同一条命令即可。
3.4 登录后立刻铸造常驻上传密钥
首次 sign-in 之后立即从 session 铸造常驻 upload key(方法见第五节的 Archive and upload 步骤 4)。当该 key 已存在于 ~/.gstack/apple/api-key.json 且无需新建 app record 时,完全跳过 sign-in:重复发布将零登录地直接授权并推进。
3.5 第二次交互(条件性)
第二次交互只有在 preflight 发现图标或截图缺失时才发生:即下一节的"商店素材"问题。其余一切——工具安装、上传、商店页、提交——都已被授权覆盖,直接推进即可。任何授权菜单、工具选择问题、计划确认、逐步叙述请求,都属于契约违规。
每次提问都要带上
(recommended)标记与决策库的AUTO_DECIDE语义,这点与 gstack 的通用 AskUserQuestion 规范一致(参见 ship/SKILL.md 的 Question Tuning 部分)。
四、发布前检查(Release Preflight)
归档之前先解析并核验下列各项。授权边界内能修的修掉,其余一律作为 blocking finding 报告:
- 签名:app target 上的 development team;当 distribution certificate 与 App Store profile 不存在时,
cert与sigh会现场铸造。 - 版本号:用户应看到的 marketing version,加上一个严格大于该版本任何已上传 build 的 build number。
- 依赖:
xcodebuild -resolvePackageDependencies必须成功;若存在Podfile或Cartfile,其 install 步骤必须已执行且 lockfile 为最新。 - App Store 校验阻断项:
- 完整的 app icon 集合,含 1024pt marketing icon;
- launch screen;
- 每个 app 触及的隐私门控 API 都配齐 usage-description 字符串;
- 必需的 privacy manifests;
- export-compliance 回答(
ITSAppUsesNonExemptEncryption); - 合理的 deployment target。
五、商店素材:第二个(也是最后一个)问题
仅在 preflight 发现图标或截图缺失时,问一次,然后按选择执行、不再追问。
同样遵循"每个 App 终生一次":问之前先查决策库(bin/gstack-decision-search --scope repo --query "store assets");已settle的选择(含"推迟截图"或"仅 TestFlight")被静默应用、绝不重问。回答后持久化(gstack-decision-log,scope repo),用户以后通过口头改口而不是被重新追问来变更。
可选项(均来自文档原文,图片素材是唯一可能需要本地产物而非仓库图片的场景,故此处不配图):
| 选项 | 说明 | 是否需要用户的图像生成 key |
|---|---|---|
| App icon | npx snapai(app-icon agent skill)用用户自己的 image-generation key 生成单张 1024×1024;Xcode 15+ 从这一张图派生所有尺寸 |
是 |
| 营销截图(免费本地、无 API key) | app-store-screenshots deck editor skill:scaffold、用模拟器截图与收益标题预填 deck JSON、导出覆盖全部所需 iPhone 尺寸的 bundle(导出可无头自动化)。营销级并不需要图像后端 |
否 |
| 朴素相框(免费本地) | 在模拟器里捕获构建好的 app,用 fastlane frameit 加框——不想做 deck 时的最小选项 |
否 |
| AI 增强营销截图 | aso-appstore-screenshots agent skill(收益标题、分镜面板、精确的 App Store 尺寸)——唯一需要用户自带 image-generation key 的截图选项;已安装时按其工作流执行,而不是重新实现 |
是 |
| 用户自备文件 | 永远是合法答案;校验尺寸后继续 | 不适用 |
两条硬性纪律:
- 选项必须来自 ask-time 对已安装 skill 的实时检查,绝不凭记忆或更早会话的 turn 来拼装。只要
app-store-screenshotsdeck editor skill 已安装,其免费无 key 选项必须出现在列表里;漏掉它等同于谎称"截图需要 API key",属于契约违规。 - 已存在的素材直接跳过整个问题;退出时宣布本次生成了什么。
六、归档与上传(Archive and Upload)
6.1 归档与签名导出
用 gym 归档并导出签名的 Release build(它驱动 xcodebuild 与 preflight 铸好的签名)。有自定义归档需求的项目可直接降到 xcodebuild archive。两种路径的输出都是 App Store 签名的 .ipa。
6.2 上传 = 外部持久效应(幂等日志)
上传是 external effect,按 durable-effect 契约执行:
- 执行前,把 key
appstore.upload.<bundle-id>.<build>追加到~/.gstack/projects/$SLUG/apple-effects.log; - 若该 key 已存在(来自先前崩溃或重试),把本次上传视为 possibly-done,绝不重跑;
- 有歧义时绝不盲目重传——先到 App Store Connect 检查该 build 是否存在。
6.3 凭据边界
缓存 session、铸造的 key 以及每个凭据文件都是 env 级或 file 级秘密:绝不进 argv、绝不回显、绝不提交。
6.4 核心机制:永不索取 app-specific password
这是适配器最有价值的一条工程判断:
- 绝不要求 app-specific password——session 会自己铸造 upload key。
- 原因在 fastlane 的文档化认证机制里:Apple 的二进制上传工具(iTMSTransporter,
deliver/pilot为传.ipa而 shell out 给它)不接受 web session,只接受 App Store Connect API Key 或 app-specific password。Apple 报错-22938("Sign in with the app-specific password")正是 Transporter 在说这件事。 - 这不是门禁也不是问题——因为 web session 能静默创建这把 key。
铸造流程(文档原样继承了调用链,此处整理为分步操作):
- 通过 fastlane 内置的 spaceship 登录复用缓存 cookie(
Spaceship::Tunes.login(<apple-id>)+ raw client 请求)。 POST https://appstoreconnect.apple.com/iris/v1/apiKeys,JSON:API body 精确限定到正在发布的 App,而非全部 App:
{
"data": {
"type": "apiKeys",
"attributes": {
"nickname": "gstack-upload",
"allAppsVisible": false,
"roles": ["APP_MANAGER"],
"keyType": "PUBLIC_API"
},
"relationships": {
"apps": { "data": [ { "type": "apps", "id": "<asc-app-id>" } ] }
}
}
}
其中 <asc-app-id> 是 App Store Connect 的 app id——来自 produce 的输出,或 GET https://appstoreconnect.apple.com/iris/v1/apps?filter[bundleId]=<bundle-id>。
要点解析:
allAppsVisible:false+ 显式appsrelationship 是刻意的最小权限:一把allAppsVisible:true的 APP_MANAGER key 等于对整个 team 下所有 App 的常驻控制权,一旦机器失陷就是不必要的爆炸半径。appsrelationship 是必需项而非可选项:无 App 关联的 key 什么也看不见,上传会以权限错误失败——所以是"把 key 绑到目标 App",而不是只翻转 flag。- 只有在 app record 已存在之后才铸造(App 是全新时,先跑
produce)。
- 取私钥:
GET .../iris/v1/apiKeys/<id>?fields[apiKeys]=privateKey。privateKey属性是完整 PEM 文件的 base64:恰好解码一次,立即写入~/.appstoreconnect/private_keys/AuthKey_<id>.p8(权限 0600)——该文件只在创建时能下载一次。 - issuer id 取
GET https://appstoreconnect.apple.com/olympus/v1/session的provider.publicProviderId。 - 把 key id、issuer id、key 内容整理成 fastlane api-key JSON,存到
~/.gstack/apple/api-key.json(0600),此后deliver/pilot一律用api_key_path运行。
这把 key 永不过期,因此同一 App 的所有后续发布都会跳过 sign-in。发布另一个 App 时,把那个 App 重新关联到该 key 上(PATCH .../iris/v1/apiKeys/<id> 把它加进 apps relationship),或铸造一把新的 app-scoped key——因为 key 刻意不是 all-apps 的。session 只在这些场景下仍需保留:produce(Apple 公开 API 无法创建 app record)、上述重新关联、以及 key 被吊销后的重新铸造。
文档明确警告:在密钥铸造尚未尝试时就宣称"用户必须自行生成凭据",属于契约违规。
6.5 错误分类:先分类,再碰凭据
一个错误只有在 Apple 自己的措辞里明说是认证失败时,才算认证失败(401/403、session 失效或过期、"sign in"、"app-specific password")。而以下错误属于 METADATA 问题,应修 payload 后从 CLI 重试,而不是去动凭据:
Spaceship::UnexpectedResponse;- 缺失/非法 attribute;
- validation 或 precheck error。
典型的 metadata 修复例子:Apple 扩展的 age-rating 属性(如 lootBox、ageAssurance、parentalControls、messagingAndChat)要填进 app_rating_config.json。把 metadata 错误当凭据问题处理,是契约违规。
6.6 浏览器使用边界:覆盖 Third-Party Web Actions
在 Apple 发布内部,适配器覆盖通用 Third-Party Web Actions 契约:agentic browser 的通用提议绝不适用于 App Store Connect、Apple ID 或本流程中的凭据工作。整个发布是 CLI(fastlane)+ 两次允许的交互;唯一允许的浏览器使用,是文档末尾提及的付费 App 协议/银行/税务残留。为此旅程中的任何其他事情打开浏览器(无论驱动还是手动),都是契约违规。
当真实错误逼出 fallback 时:先逐字引用错误,再按下述顺序升级:
- 第一:从 session 铸造(或重新铸造)upload key(按 6.4),用
api_key_path重试上传——磁盘上无 key 的上传认证错误,意味着铸造被跳过了,而不是用户欠一个凭据。 - 第二:若铸造本身因 session 错误失败,请用户重新登录(同最初的
! fastlane spaceauth -u <apple-id>时刻),重新铸造并重试。 - 只有在全新 session 仍无法铸造 key——即登录的 Apple ID 在其 team 上不是 Admin 或 Account Holder、被权限拒绝——app-specific-password 路径才打开,且它唯一合法的形状是自助式:用户在任意设备上生成密码,通过主机会话内的掩码提示输入到 macOS keychain(
fastlane fastlane-credentials add --username <apple-id>),然后重试上传。
绝不提供或推荐用浏览器驱动来创建凭据——任何 agentic browser、任何密码/key/token,任何包装方式都不行。
6.7 App Review 联系信息
提交需要 name/email/phone 三样元数据:
- name 与 email 从登录的 Apple ID 与 git config 推断;
- phone number 在授权时刻内收集一次;
- 持久化到决策库,之后永不重问。
联系信息属于元数据,不是要在运行中途宣布的阻塞门禁。
七、商店页完成与提交(Storefront Completion)
7.1 app record 不是手工门禁
produce 在运行期间已经创建了 app record 与 bundle ID——绝不把 app record 说成手工门禁。
7.2 定价:走 App Store Connect price-schedule 端点
把授权时刻敲定的定价,通过 App Store Connect 的 price-schedule 端点应用:POST /v1/appPriceSchedules(经由 session 或铸造的 key)。原因:
- fastlane 的
price_tier选项对当前 API 是坏的(报错'prices' is not a relationship on 'apps'); - 所以绝不经由它路由定价,也绝不把它的失败说成账号问题。
7.3 deliver 与 pilot 的分工
deliver负责商店 listing 所需的一切:description、keywords、localizations、按设备尺寸上传截图、挂接已上传的 build、Submit for Review;pilot在用户要求中间一轮时管理 TestFlight groups 与 testers。
提交遵循同一 durable-effect 契约,key 为 appstore.submit.<bundle-id>.<version>——有歧义时先检查 App Store Connect 再决定是否重跑。之后可从 CLI 持续监控 review 状态。
7.4 仍然 web-only 的东西(极少)
永不离开 CLI 的部分只剩下两样,且都与发布步骤无关:
- 付费 Apple Developer Program 会员购买本身——它是前置条件,不是发布步骤;
- 仅付费 App 的 one-time Paid Apps 协议(银行与税务)——此时才按 Third-Party Web Actions 契约在手工 checklist 之前先提供 agentic-browser 驱动。
免费 App 在任何时点都不需要浏览器。
7.5 结束报告与常驻凭据披露
提交后,报告 App Review 通常在一天到两天内答复并关闭本次运行——review 结果不是这个工作流能一直挂住等待的门。
在同一份结束报告里,披露本次发布创建的常驻凭据——一次运行一次,恰好一行:
This created an App Store Connect API key (
gstack-upload, scoped to this app) that persists for future releases; revoke it anytime at App Store Connect → Users and Access → Integrations, or delete~/.gstack/apple/api-key.jsonlocally.
这是对"运行中不谈凭据"规则(见 2.2)的刻意豁免:否则用户永远不会知道账户与磁盘上已存在一把常驻凭据,也就永远进不了他的吊销清单。在退出时披露,而不是运行中提问——这样"单一授权时刻"契约才站得住。
八、全流程速览与要点复述
| 阶段 | 交互 | 关键动作 / 产物 |
|---|---|---|
| 授权时刻 | 交互 #1(唯一必答) | 确认 US$99 付费会员 + 授权;一次问清免费/付费定价并写入决策库;! fastlane spaceauth -u <apple-id> 登录;登录后立刻铸造 app-scoped upload key 存 ~/.gstack/apple/api-key.json |
| Preflight | 无 | 签名(cert/sigh)、版本/build number、依赖解析、App Store 校验阻断项 |
| Store assets | 仅当缺失:交互 #2 | 实时按已装 skill 提供选项(SnapAI icon / deck editor 截图 / frameit / aso skill / 用户自备),答案写入决策库 |
| Archive & upload | 无 | gym(或 xcodebuild archive)产出 .ipa;上传前向 ~/.gstack/projects/$SLUG/apple-effects.log 追加幂等 key;deliver/pilot 走 api_key_path |
| Storefront | 无 | produce 建 record;POST /v1/appPriceSchedules 定价格;deliver 填 listing + Submit for Review;pilot 管 TestFlight |
| 结束报告 | 无 | 报告 App Review 1–2 天内答复;一行披露 gstack-upload 常驻 key 及其吊销位置 |
贯穿全程的不可动摇规则:
- 全程恰好两次交互,多一次(授权菜单、工具选择、逐步叙述请求)都是契约违规;
- 绝不问 app-specific password——session 自己铸造最小权限的 app-scoped API key;
- 上传与提交都是 durable external effect——写幂等日志、出现歧义先查 App Store Connect、绝不盲目重跑;
- 错误先分类——只有 Apple 明说"sign in / app-specific password / 401 / 403"才是认证问题,其余先修 metadata payload;
- 唯一允许的浏览器用途是付费 App 的协议/银行/税务残留,且要按 scripts/resolvers/third-party-actions.ts 的契约先提议驱动;
- 若目标是 Apple 平台 App,先读本分节再谈 branch gate——这一顺序由 test/ship-apple-gate.test.ts 持续守护,store 分发绝不因分支拓扑被中止。
实践提示:适配器正文由 ship/sections/apple-release.md.tmpl 自动生成(文件头注明 Regenerate: bun run gen:skill-docs),若你想核对某条规则的最新措辞或修改后重新生成,应编辑模板源文件而不是生成物。对决策库 CLI 的语义(scope、supersede 等)感兴趣的话,仓库内对应实现是 bin/gstack-decision-search 与 bin/gstack-decision-log。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00