首页
/ gstack /ship 的 Apple App Store / TestFlight 发布适配器:一次授权、全程 CLI 的商店发布工作流

gstack /ship 的 Apple App Store / TestFlight 发布适配器:一次授权、全程 CLI 的商店发布工作流

2026-09-06 18:14:42作者:董灵辛Dennis

导读

本文讲解 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.jsonship/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 把该顺序做成了字节级回归测试,防止未来重构悄悄改变行为:

测试还校验了分节正文中的关键"脊梁"锚点,包括 one authorization momentfastlane spaceauthiris/v1/apiKeysappPriceSchedules、"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 的 macos runner,执行同样的 gymdeliver/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 路径。此时才给出两条出路:

  1. 通过 Third-Party Web Actions 契约(gstack 自带的浏览器栈驱动、用户手动接手人机环节)引导用户在 developer.apple.com 自助完成 enrollment(这是一笔由用户自己完成的购买,激活可能需要一两天);
  2. 如实说明免费账号的天花板:只能在自有设备上做 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 不存在时,certsigh 会现场铸造。
  • 版本号:用户应看到的 marketing version,加上一个严格大于该版本任何已上传 build 的 build number。
  • 依赖xcodebuild -resolvePackageDependencies 必须成功;若存在 PodfileCartfile,其 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 的截图选项;已安装时按其工作流执行,而不是重新实现
用户自备文件 永远是合法答案;校验尺寸后继续 不适用

两条硬性纪律:

  1. 选项必须来自 ask-time 对已安装 skill 的实时检查,绝不凭记忆或更早会话的 turn 来拼装。只要 app-store-screenshots deck editor skill 已安装,其免费无 key 选项必须出现在列表里;漏掉它等同于谎称"截图需要 API key",属于契约违规。
  2. 已存在的素材直接跳过整个问题;退出时宣布本次生成了什么。

六、归档与上传(Archive and Upload)

6.1 归档与签名导出

gym 归档并导出签名的 Release build(它驱动 xcodebuild 与 preflight 铸好的签名)。有自定义归档需求的项目可直接降到 xcodebuild archive。两种路径的输出都是 App Store 签名的 .ipa

6.2 上传 = 外部持久效应(幂等日志)

上传是 external effect,按 durable-effect 契约执行:

  1. 执行前,把 key appstore.upload.<bundle-id>.<build> 追加~/.gstack/projects/$SLUG/apple-effects.log
  2. 若该 key 已存在(来自先前崩溃或重试),把本次上传视为 possibly-done绝不重跑
  3. 有歧义时绝不盲目重传——先到 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

铸造流程(文档原样继承了调用链,此处整理为分步操作):

  1. 通过 fastlane 内置的 spaceship 登录复用缓存 cookie(Spaceship::Tunes.login(<apple-id>) + raw client 请求)。
  2. 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 + 显式 apps relationship 是刻意的最小权限:一把 allAppsVisible:true 的 APP_MANAGER key 等于对整个 team 下所有 App 的常驻控制权,一旦机器失陷就是不必要的爆炸半径。
  • apps relationship 是必需项而非可选项:无 App 关联的 key 什么也看不见,上传会以权限错误失败——所以是"把 key 绑到目标 App",而不是只翻转 flag。
  • 只有在 app record 已存在之后才铸造(App 是全新时,先跑 produce)。
  1. 取私钥:GET .../iris/v1/apiKeys/<id>?fields[apiKeys]=privateKeyprivateKey 属性是完整 PEM 文件的 base64:恰好解码一次,立即写入 ~/.appstoreconnect/private_keys/AuthKey_<id>.p8(权限 0600)——该文件只在创建时能下载一次
  2. issuer id 取 GET https://appstoreconnect.apple.com/olympus/v1/sessionprovider.publicProviderId
  3. 把 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 属性(如 lootBoxageAssuranceparentalControlsmessagingAndChat)要填进 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 时:先逐字引用错误,再按下述顺序升级:

  1. 第一:从 session 铸造(或重新铸造)upload key(按 6.4),用 api_key_path 重试上传——磁盘上无 key 的上传认证错误,意味着铸造被跳过了,而不是用户欠一个凭据。
  2. 第二:若铸造本身因 session 错误失败,请用户重新登录(同最初的 ! fastlane spaceauth -u <apple-id> 时刻),重新铸造并重试。
  3. 只有在全新 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 的部分只剩下两样,且都与发布步骤无关:

  1. 付费 Apple Developer Program 会员购买本身——它是前置条件,不是发布步骤;
  2. 仅付费 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.json locally.

这是对"运行中不谈凭据"规则(见 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/pilotapi_key_path
Storefront produce 建 record;POST /v1/appPriceSchedules 定价格;deliver 填 listing + Submit for Review;pilot 管 TestFlight
结束报告 报告 App Review 1–2 天内答复;一行披露 gstack-upload 常驻 key 及其吊销位置

贯穿全程的不可动摇规则:

  1. 全程恰好两次交互,多一次(授权菜单、工具选择、逐步叙述请求)都是契约违规;
  2. 绝不问 app-specific password——session 自己铸造最小权限的 app-scoped API key;
  3. 上传与提交都是 durable external effect——写幂等日志、出现歧义先查 App Store Connect、绝不盲目重跑;
  4. 错误先分类——只有 Apple 明说"sign in / app-specific password / 401 / 403"才是认证问题,其余先修 metadata payload;
  5. 唯一允许的浏览器用途是付费 App 的协议/银行/税务残留,且要按 scripts/resolvers/third-party-actions.ts 的契约先提议驱动;
  6. 若目标是 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-searchbin/gstack-decision-log

登录后查看全文
热门项目推荐
相关项目推荐