上大学的时候,我靠接零活,从一个月的收入大约 10 万日元,一路攒到 60 万日元。后来我丢了工作,一夜之间掉回零收入。半年之后,我围绕一个自主运行的 Claude Code 环境,把一切重新搭起来,现在月收入已经超过 120 万日元。今天我想展开讲一讲这个环境里的一块拼图:它能让 App 进入 App Store 审核流程,而全程没有一个人需要登录 App Store Connect。
这套打法为什么划算
你一旦开始批量发布 iOS App,瓶颈就不再是开发,而是提交。打开 Xcode、点 Archive、登录 App Store Connect、等着 2FA 短信、挑一个构建、再点「Submit for Review」。单个 App 这么干没什么,可在你同时管着五到十个的时候,这套流程就变成了每周都不变的纯体力活。
还有一个更根本的问题:只要依赖 2FA,就没办法交给机器人。fastlane 的 deliver 确实方便,可每次会话 cookie 一过期,就会弹出一个交互式认证窗口。放在 CI 上,这就是死路。
App Store Connect API 密钥(那个 .p8 文件)能从根上消掉这个问题。你只需要签发一次密钥,之后就能不经过两步验证直接调 API。它也没有过期时间,除非你主动吊销,否则永远有效。这意味着,在存在这个密钥的环境里,Claude Code 能在凌晨两点自主执行「提交审核」。
我现在管着 12 个 App,其中一些在同一天就会发布新版本。一个人能坐在屏幕前的时间是有限的,但 API 可以并行调用。只要跑起这样一个循环:for app_id in $(cat app_ids.txt); do python3 ~/.appstoreconnect/asc.py submit "$app_id"; done,每个 App 就都能在我喝咖啡的时候完成提交。
「任务」和「环境」
「每次都打开 Xcode」是任务。「任何持有 API 密钥的人(或程序)都能提交」是环境。
靠一件件苦撑任务,你的收入上限就是你自己有多少小时。把环境搭起来,系统就能在你睡觉的时候运转。我的收入是大学时代 12 倍,主要原因并不是我加大了自己的工作量,而是我增加了一批替我干活的东西。App Store Connect 的 API 密钥就是其中一个很有代表性的例子。
为什么我把它叫作「坑」
「有了 API 密钥,你只要生成一个 JWT 去调 API 就行了」,这话在技术上没错。可实现上只要错一个步骤,你就会永远卡在 401。Apple 的 ES256 JWT 要求的是 RFC 7518 定义的裸 r+s 编码。Python 的加密库默认返回 DER 格式,你直接拿来用,就必然产出一个坏掉的 JWT。第一次遇到它的时候,原因完全看不出来,因为返回的是「401 Unauthorized」,而不是「Invalid signature」。
下一节我会具体讲讲这个坑到底是什么,以及我真正在用的代码。
整体流程
先从大图景说起。从二进制文件生成,到提交 App Store 审核,我的环境分成三层。
┌─────────────────────────────────────────────────────────┐
│ 第一层:二进制文件生成 │
│ xcodebuild archive (tools/archive.sh) │
│ 或 eas build --local (Expo 系列 App) │
└──────────────────┬──────────────────────────────────────┘
│ .ipa
▼
┌─────────────────────────────────────────────────────────┐
│ 第二层:二进制文件上传 │
│ eas submit (相当于 Transporter,不消耗云端存储额度) │
└──────────────────┬──────────────────────────────────────┘
│ processingState: VALID
▼
┌─────────────────────────────────────────────────────────┐
│ 第三层:状态确认 / 元数据编辑 / 提交审核 │
│ python3 ~/.appstoreconnect/asc.py {apps|status|submit}│
│ 无需 2FA,JWT 认证,可跨账号使用 │
└─────────────────────────────────────────────────────────┘
这里讲的重点是第三层。asc.py 只有 272 行,却覆盖了审核生命周期里几乎所有的操作。
# 列出全部 App
python3 ~/.appstoreconnect/asc.py apps
# 查看指定 App 的审核状态、构建状态
python3 ~/.appstoreconnect/asc.py status <app_id>
# 提交审核
python3 ~/.appstoreconnect/asc.py submit <app_id>
下面我们看看,它为什么能在不经过 2FA 的情况下运行,实现上又是怎么做的。
API 密钥放在哪里,配置文件怎么组织
App Store Connect 的 API 密钥,我把它分成两个文件,放在 ~/.appstoreconnect/ 下。
~/.appstoreconnect/keys.json
{
"key_id": "XXXXXXXXXX",
"issuer_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"key_path": "~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8"
}
**~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8**(私钥本体,从 ASC 只能下载一次)
在 asc.py 的开头加载 keys.json,把这三个值存成常量。
CFG = json.load(open(os.path.expanduser("~/.appstoreconnect/keys.json")))
KEY_ID, ISSUER = CFG["key_id"], CFG["issuer_id"]
P8 = os.path.expanduser(CFG["key_path"])
真正的秘密只有那一个 .p8 文件。keys.json 里只有 key ID 和 issuer ID。这样的分工很关键:就算 keys.json 不小心进了 Git 仓库(我不建议这么干),也不会造成泄漏事故。只要严格管好 .p8 就够了。在我的环境里,.p8 放在 ~/.appstoreconnect/,整个目录都用 chmod 700 设了权限。要上 CI/CD 的话,就在运行时从密钥存储里把文件写出来。
ES256 JWT 的坑:要裸 r+s,不要 DER
JWT 认证的核心是 _jwt() 函数。下面是真实代码,一字不差。
def _b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
def _jwt():
h = _b64(json.dumps({"alg":"ES256","kid":KEY_ID,"typ":"JWT"},
separators=(",",":")).encode())
p = _b64(json.dumps({"iss":ISSUER,
"iat":int(time.time())-30,
"exp":int(time.time())+900,
"aud":"appstoreconnect-v1"},
separators=(",",":")).encode())
signing = h + b"." + p
key = serialization.load_pem_private_key(open(P8,"rb").read(), password=None)
der = key.sign(signing, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der)
return (signing + b"." + _b64(r.to_bytes(32,"big") + s.to_bytes(32,"big"))).decode()
坑就藏在最后两行。
key.sign() 返回的是 DER 格式的 ECDSA 签名。DER 带着 ASN.1 结构,形式是 30 xx 02 xx [r-bytes] 02 xx [s-bytes]。Apple 不接受这种 DER。
Apple 的 ES256 JWT 需要的,是 RFC 7518 第 3.4 节定义的「固定 64 字节裸编码」。也就是 r 占 32 字节、s 占 32 字节,按大端序拼在一起,总共 64 字节,再做 Base64URL 编码。
# 错误:直接把 DER 转 Base64URL,还是会永久 401
_b64(der)
# 正确:解码 DER,取出 r、s,再用裸的 32 字节拼接
r, s = decode_dss_signature(der)
_b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
decode_dss_signature 是 cryptography 库里的函数,它把 DER 格式的签名转换成 Python 的整数元组 (r, s)。再各用 to_bytes(32, "big") 变成 32 字节序列,拼接起来做 Base64URL,这就是正确的步骤。
为什么是 32 字节?因为 ES256 用的是 NIST P-256 曲线,这条曲线的阶正好能装在 32 字节(256 位)里。即使 r 或 s 恰好是个很小的数(最高位是 0),也仍然要补零补满 32 字节。r.to_bytes(32, "big") 会自动处理这个。
只要实现错了,Apple 返回的就永远是 401 Unauthorized。它其实是一次签名校验失败,但返回的却是「认证失败」而不是「签名错误」,所以要花上一阵子你才会意识到,问题出在 JWT 的结构上。
为什么 iat 要往回调 30 秒
还有一个不大但很关键的点。
"iat": int(time.time()) - 30,
iat(签发时间)被设成当前时间之前 30 秒。原因是时钟偏移。如果 Apple 的 API 服务器和你的机器时间对不齐,JWT 就可能被判定为「还没有生效」而遭拒绝。我确实为这件事损失过几十分钟。留出 30 秒的余量,几乎能吞掉所有环境差异。
过期时间设成从现在起 900 秒(15 分钟)。对一个用完一次就扔的 JWT 来说,足够宽裕了。
命令背后的设计思路
下面是 asc.py 提供的全部子命令。
|
|
|
|---|---|
apps |
|
status <id> |
|
submit <id> |
|
make-version <id> <ver> |
|
attach-build <id> <ver> |
|
whatsnew <id> <text> |
|
release <id> <ver> <whatsnew> |
|
reject <id> |
|
dedup <id> [--apply] |
|
add-tester <id> |
|
设计主轴是幂等。举个例子,提交审核之前,submit 会先查一下是不是已经存在 READY_FOR_REVIEW 状态的 reviewSubmission,有的话就直接复用。
def submit(app_id, platform="IOS"):
_, rs = call("GET",
f"/v1/reviewSubmissions?filter[app]={app_id}&filter[state]=READY_FOR_REVIEW&limit=1")
sub = (rs.get("data") or [None])[0]
if not sub:
# 新建
st, r = call("POST", "/v1/reviewSubmissions", {...})
...
sid = sub["id"]
# 把版本作为 item 添加
...
# 用 submitted=true 提交
st, r = call("PATCH", f"/v1/reviewSubmissions/{sid}",
{"data": {"type": "reviewSubmissions", "id": sid,
"attributes": {"submitted": True}}})
对一个自动化脚本来说,「同一个命令跑两遍也不会出问题」是硬性要求。Claude Code 会重试,网络出错也会重跑,但绝不能因此出现重复提交和重复报错。所以操作之前,先用 GET 查当前状态。这个「先查再动」的模式,我对每个写操作都无例外地应用。
Xcode 归档这一侧(archive.sh)
再来看负责产出二进制的 tools/archive.sh。
xcodegen generate
rm -rf build/Auraly.xcarchive build/export
xcodebuild archive \
-project Auraly.xcodeproj \
-scheme Auraly \
-configuration Release \
-archivePath build/Auraly.xcarchive \
-destination 'generic/platform=iOS' \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" \
-allowProvisioningUpdates
xcodebuild -exportArchive \
-archivePath build/Auraly.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/export
关键点是 CODE_SIGN_STYLE=Manual。用 Automatic Signing 的话,Xcode 会自己去管理描述文件,在无人值守跑的时候可能弹出认证对话框。改成 Manual,并按名字指定描述文件,无界面的构建就能稳定下来。
描述文件本身则由 tools/setup_signing.py 通过 ASC API 自动生成并安装。它用 /v1/profiles API 创建一份 App Store 分发描述文件,直接写进 ~/Library/MobileDevice/Provisioning Profiles/,这样完全不用打开 Xcode,签名环境就绪了。证书匹配用的是 SHA1 指纹:
LOCAL_SHA1 = "EC06777A693874E920CECFE390D467670552CCCE".lower()
...
der = base64.b64decode(content)
sha1 = hashlib.sha1(der).hexdigest()
if sha1 == LOCAL_SHA1:
return c["id"]
这就把本地的分发证书和 ASC 上的证书对上了,人不必再坐在屏幕前纠结「到底该用钥匙串里的哪张证书」。
在下一篇文章(第二部分)里,我会详细讲怎么把 asc.py 接进 Claude Code 的自主循环,那条管理 12 个 App 的完整流水线,还有当提交一直因为 INVALID_BINARY 被拒时,我的排查和突破流程。
实现细节
call():一个零外部库的 HTTP 层
asc.py 只有一个依赖,就是 cryptography 库。HTTP 部分用的是 urllib.request。
def call(method, path, body=None):
url = path if path.startswith("http") else BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Authorization":"Bearer "+_jwt(), "Content-Type":"application/json"})
try:
r = urllib.request.urlopen(req); raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b"{}")
不用 requests 的原因很简单:一个只靠 Python 标准库就能跑的脚本,可以无条件地带进任何环境。配一台新 Mac,往 CI 里部署,去掉这一步 pip install requests,难易程度的差别是实实在在的。
还有一点:call() 每次都会调用 _jwt() 重新生成 fresh 的 JWT。JWT 有 900 秒(15 分钟)的有效期,同一次脚本执行里缓存同一个 JWT 本来也无妨。但我刻意不缓存。原因是要避免那种半死不活的失败模式:JWT 在长批量处理的中途过期,只有后面的请求返回 401。每次都重新生成,慢是慢一点点,但成本只是每个请求多签一次 ECDSA,微秒级别。哪怕一次跑完 12 个 App,也感觉不出差别。
HTTPError 的处理也保持在最小。它把状态码和响应体原样返回,调用方统一用「if st >= 400: ... return」这种写法。用异常来控制流程分支会混进堆栈信息,损害可读性,所以我始终用「按数字判断,提前返回」的风格。
_build_for_version():按营销版本号匹配构建
我最初实现它时最头疼的部分,是「按营销版本号(也就是展示版本,比如 0.3.2)去识别一个处理完成的构建」。
/v1/builds 接口的响应里有构建号(整数),但没有营销版本号字符串。营销版本号存在另一个叫 preReleaseVersion 的资源上,除非你在查询里显式传 include=preReleaseVersion,否则它不会返回。
def _build_for_version(app_id, version_string):
_, b = call("GET", f"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate"
f"&include=preReleaseVersion")
incl = {i["id"]: i for i in b.get("included", []) if i["type"] == "preReleaseVersions"}
for x in b.get("data", []):
if x["attributes"].get("processingState") != "VALID":
continue
pr = x.get("relationships", {}).get("preReleaseVersion", {}).get("data")
ver = incl.get(pr["id"], {}).get("attributes", {}).get("version") if pr else None
if ver == version_string:
return x["id"], x["attributes"].get("version")
return None
加上 include=preReleaseVersion 之后,类型为 preReleaseVersions 的对象就会进入响应的 included 数组。把它整理成以 ID 为键的字典(incl),再通过每个构建的 relationships.preReleaseVersion.data.id 去查,这就是这段代码的关窍。
跳过 processingState != "VALID" 这一点也很重要。二进制文件在 Apple 那边还没处理完的构建,会处于 PROCESSING 或 INVALID 状态。把一个这种状态的构建挂到版本上,会返回 409。只保留 VALID,就能自动排除那些刚上传还在「处理中」的构建。
dedup_screenshots():提交前清理重复截图
如果你用 sync_screenshots: false 多次运行 fastlane deliver,截图每次都会被追加进去。第一次能放对 5 张截图,跑第二次就变成 10 张,跑第三次变成 15 张。App Store 校验对「每个尺寸超过 10 张」会直接拒绝,所以这成了提交失败的隐藏原因。
dedup_screenshots() 用 sourceFileChecksum 和 fileName 组合起来当键,来解决这个问题。
def dedup_screenshots(app_id, apply=False):
...
for sh in shots.get("data", []):
key = (sh["attributes"].get("sourceFileChecksum"),
sh["attributes"].get("fileName"))
if key in seen:
if apply:
st, _ = call("DELETE", f"/v1/appScreenshots/{sh['id']}")
print(f" [{locale}/...] DELETE {sh['id']} -> {st}")
else:
print(f" [{locale}/...] dup {sh['id']} (dry-run)")
total += 1
else:
seen.add(key)
重要的是,apply=False 让 dry-run 成为默认行为。只跑 asc.py dedup <app_id>,它只会打印存在多少重复;真正删除,只有在传 --apply 的时候才会发生。你要同时管 12 个 App,真有可能发生「哎呀我把截图全删了」的事故,所以我把设计做成两段式。
实际运行里,我总是在 submit 前立刻插一句 dedup --apply。它只是批处理脚本里的一个顺序调用,人根本不用去想。
setup_signing.py:把描述文件管理全自动
tools/setup_signing.py 就是那个「不打开 Xcode,就把证书、Bundle ID、描述文件这个铁三角准备好」的脚本。
证书匹配用 SHA1 指纹(前面提过的地方)。原因是:ASC 上的证书不会直接告诉你它对应当地钥匙串里的哪把私钥。先在本地钥匙串记下分发证书的 SHA1,再从 ASC API 拉取全部证书,用「DER 解码 + 算 SHA1」逐一比对,这是最可靠的做法。
描述文件管理走的是「先删旧的,再重建」的模式。
def ensure_profile(cert_id, bundle_internal_id):
_, d = asc.call("GET", "/v1/profiles?limit=200&filter[profileType]=IOS_APP_STORE")
for p in d.get("data", []):
if p["attributes"].get("name") == PROFILE_NAME:
asc.call("DELETE", f"/v1/profiles/{p['id']}")
print("deleted stale profile", p["id"])
st, r = asc.call("POST", "/v1/profiles", {...})
...
uuid = attrs["uuid"]
content = base64.b64decode(attrs["profileContent"])
dest_dir = os.path.expanduser("~/Library/MobileDevice/Provisioning Profiles")
dest = os.path.join(dest_dir, f"{uuid}.mobileprovision")
with open(dest, "wb") as f:
f.write(content)
为什么先删掉同名的那份描述文件:当你续期了证书,残留下来的旧描述文件会造成「描述文件还在,证书却过期」这种不一致。每次都删掉重建,就很容易保证「幂等,而且永远在正确状态」。
直接以 UUID 作为文件名写进 ~/Library/MobileDevice/Provisioning Profiles/ 也很重要,这样 xcodebuild 才能按名字解析 PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore"。不用去点 Xcode 的下载按钮。
add_tester():幂等添加内部测试员
提交审核之后,我做的第一件事,就是把我的 iCloud 邮箱加进 TestFlight 当内部测试员。这也是一条命令:asc.py add-tester <app_id>。
代码靠末尾的地方,有一段这样的注释:
def add_tester(app_id, email=DEFAULT_TESTER_EMAIL, first="Lily", last="Tester"):
"""...
注意:直接连外部测试组,或用 `betaGroups/{id}/relationships/betaTesters` 直连,
都会返回 409 STATE_ERROR(Tester cannot be assigned)。
只有 create-with-group 这一种方式能通过。"""
这段注释就是一次失败的记录(下一节会细讲)。正确做法是在 body 里带上 relationships.betaGroups,通过 POST /v1/betaTesters 一条请求创建。
st, r = call("POST", "/v1/betaTesters",
{"data": {"type": "betaTesters",
"attributes": {"email": email, "firstName": first, "lastName": last},
"relationships": {"betaGroups": {"data": [{"type": "betaGroups", "id": gid}]}}}})
「创建测试员」和「分配组成员」在同一个请求里完成,就不会和 Apple 的状态管理撞车。
另外,_has_tester() 会先做一次存在性检查,防止重复注册。要是没有这道幂等检查,你批量跑所有 App 时,就会用同一个邮箱注册 12 次,然后收回 12 个 409。
我在哪里卡过壳
搭自动化环境的时候,我反复撞上一种模式:「实现很快就写完了,时间全花在消灭诡异的 401 和诡异的 409 上。」下面是我实际踩过的失败,都按「症状 → 原因 → 修复」的顺序写。
失败一:永远 401,看不出原因
第一次实现 asc.py 的那个晚上,我把 JWT 组装好,一调 API,返回 401 Unauthorized。
Python 的 key.sign(signing, ec.ECDSA(hashes.SHA256())) 看起来是正常工作的。header 和 payload 的 Base64URL 也拼得很顺。肉眼看 JWT 的结构,哪儿都不像有问题。可就是每一个调用都返回 401。
看错误响应体,你只能拿到这个:{"errors":[{"status":"401","code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."}]},一句信息量基本为零的提示。它不会告诉你「你的签名坏了」。
我反复核对 header 格式、aud 值和 exp 算法,折腾了大约两个小时,才偶然去查 RFC 7518 第 3.4 节,看到了关键:ES256 JWT 的签名必须是「固定 64 字节:r(32 字节)+ s(32 字节)」。
而 key.sign() 返回的是可变长度的 DER 字节串。它开头是 30 xx 02 xx... 这样的 ASN.1 编码,r 和 s 的长度都不固定。我当时直接拿它去做了 Base64URL。
# 我当时写的代码(错误)
sig_b64 = _b64(der)
# 正确的代码
r, s = decode_dss_signature(der)
sig_b64 = _b64(r.to_bytes(32, "big") + s.to_bytes(32, "big"))
用 decode_dss_signature 把 DER 转回 Python 整数 (r, s),各变成 32 位大端字节再拼接。就这一步,把 401 变成了 200。一个「认证失败」的错误码,掩盖的其实是签名算法里的编码格式问题。要是一开始就懂这个,我就能省回那两个小时。
失败二:JWT 时好时坏
解决掉 DER 问题之后一段时间,一个新的现象出现了:「早上能用,到了下午早段就不好使了。」复现率很低,过几个小时再试又一切正常。
如果 Apple 的 API 服务器和本地机器的时钟差了几秒到十几秒,iat(签发时间)就可能被判定为「来自未来的 JWT」而遭拒绝。Mac 的系统时钟通常走 NTP 同步,但刚睡醒、或者机器负载很高的时候,还是可能偏出去几秒。
修复很简单:把 iat 设成当前时间往前 30 秒。
"iat": int(time.time()) - 30,
这就声明「这个 JWT 是 30 秒前签发的」,从 Apple API 服务器的角度看,它就是一个「签发得足够久之前」的 JWT,可以被接受。只是从 900 秒的有效期里扣掉 30 秒,实际没有任何代价。
失败三:betaGroups/{id}/relationships/betaTesters → 409 STATE_ERROR
添加 TestFlight 测试员的时候,我一开始的做法是「先取到内部测试组,再把测试员加进那个组」。
# 失败的做法
asc.call("POST", f"/v1/betaGroups/{gid}/relationships/betaTesters",
{"data": [{"type": "betaTesters", "id": tester_id}]})
这返回 409 STATE_ERROR: Tester cannot be assigned。在 Apple 的状态机里,把外部测试员直接挂到内部测试组上,被当作不允许的转移。
正确的方法是,在创建测试员的那一刻就把测试组指定好。在 POST /v1/betaTesters 的请求体里带上 relationships.betaGroups。这一发请求,原子化地同时完成「创建测试员」和「分配组成员」。
从外部看,Apple 的 API 像是一个「什么都能做的通用 REST API」,但实际上它是把审核生命周期的状态机包装成了 API,所以很多操作不按正确的转移顺序走,就会报错。这个坑很多都没写进文档,只能靠试错去摸。
失败四:截图每次都翻倍,卡住提交
用 fastlane deliver 上传截图,如果你传了 sync_screenshots: false(或者干脆用默认,也就是不替换这个设置),它会把截图追加进去,而不是替换。
第一次提交没问题。可当你在被拒之后修了东西想重新提交,再跑一次 deliver 截图就翻倍了,第二次就变成 8 张。8 张刚好压在 App Store 的 10 张限制之下,很难察觉;再跑一次到了 12 张,你就收到 METADATA_ERROR: Too many screenshots。
单看症状,还以为「是元数据坏了」,我来回怀疑图片尺寸、格式问题,白耗了大约一小时。真实原因就是「同一张截图出现了好几次」。
dedup_screenshots() 就是为此而写的。它把 sourceFileChecksum 和 fileName 都匹配的记录,从第二份开始删掉。只用 sourceFileChecksum 一个键,会有小概率出现「不同文件恰好共用同一个校验和」,所以文件名是第二个键。
支持不加 --apply 先 dry-run 预览,也是从这次失败里得来的。我算是明白了「一次性全删掉,就再也找不回来」的道理。
失败五:明明有 VALID 构建,却报「没有 VALID 构建」
attach_build() 里挂构建的时候,我第一版只打了 /v1/builds?filter[app]=xxx&limit=20&sort=-uploadedDate。App Store Connect 界面里明明显示有一个 VALID 构建,脚本却说 no VALID build for version 0.3.2。
原因在于:响应里没有营销版本号(0.3.2)。/v1/builds 响应只返回构建对象,一个构建对应的营销版本绑在另一个独立的资源 preReleaseVersion 上,你不 include 它,它就不会出现在响应里。
# 修正后:加上 include=preReleaseVersion
_, b = call("GET", f"/v1/builds?filter[app]={app_id}&limit=20&sort=-uploadedDate"
f"&include=preReleaseVersion")
incl = {i["id"]: i for i in b.get("included", [])
if i["type"] == "preReleaseVersions"}
加上 include,类型为 preReleaseVersions 的对象就会进入响应的 included 数组。把它整理成以 ID 为键的字典,再通过每个构建的 relationships.preReleaseVersion.data.id 去查,就能拿到营销版本号字符串。
App Store Connect API 文档解释了 include 这个参数,但没有一张详尽表格告诉你「哪个接口能 include 哪个资源」,你不试就不知道。我当时试着用 preReleaseVersion,是因为在 ASC 界面里看到过构建和版本号是关联显示的,于是猜测「这个关联关系一定存在」。
这些失败有个共同点:「看起来对、却跑不起来」这种状态会持续很久。401 和 409 表面很相似,但各自有各自的原因。没有银弹,唯一靠谱的办法,就是用 json.dumps(r)[:800] 把 Apple API 响应的整个 body 都打印出来,仔细读。就这么简单。
在下一篇(第二部分),我会讲怎么把 asc.py 接进 Claude Code 的自主循环,以及并行管理 12 个 App 的完整流水线设计。
踩坑索引
上面已经把「我为什么这样写」的理由讲得差不多了。可一旦你真开始实现,「认不出这个症状对应那个原因」才是真正的麻烦。下面是一份完整的索引,按「症状 → 原因 → 对策」的形式整理,遇到同样的症状可以直接查。
① 返回了 401,但错误 body 里完全没有「signature」字样
{"code":"NOT_AUTHORIZED","title":"Authentication credentials are missing or invalid."} 在 ES256 签名编码错误时同样会被返回。最典型的情况,就是把 key.sign() 返回的 DER 直接喂给 _b64(der)。从 Apple 那边看,这永远只是「认证失败」。正确做法是用 cryptography.hazmat.primitives.asymmetric.utils 里的 decode_dss_signature(der) 转成 Python 整数 (r, s),再把拼接结果 r.to_bytes(32,"big") + s.to_bytes(32,"big") 传去做 Base64URL。这个导入路径本身就很容易写错,要留神。
② 早上能用,下午早段就 401(复现率很低)
原因是 Mac 的 NTP 漂移,或者刚从睡眠唤醒时时钟同步滞后。如果你让 iat 保持简单的 int(time.time()),和 Apple 服务器时间差出几秒,就会判定为「还没生效」。asc.py 用 int(time.time()) - 30 往回减 30 秒。Apple 的容差大致在正负 60 秒,所以这个值留了很足的空间。
③ 409 STATE_ERROR: Tester cannot be assigned(betaGroups 直连)
把已有的测试员 ID 传给 POST /v1/betaGroups/{id}/relationships/betaTesters,在 Apple 的状态机里会被当作「不允许的转移」而拒绝。错误信息不会告诉你正确姿势。正如 asc.py 里那段注释(272 行代码里的 注意: 块)所说,唯一能通过的写法,是在 POST /v1/betaTesters 的 body 里带上 relationships.betaGroups,让测试员创建和组成员分配在同一次请求里完成。
④ 重跑 fastlane deliver 之后报 METADATA_ERROR: Too many screenshots
用 sync_screenshots: false(或省略这个设置用默认)多次跑 deliver,截图是追加而非删除。一旦超过 App Store 每尺寸 10 张的限制,提交就过不去。用 asc.py dedup <app_id> 检查重复,用 --apply 删除。只用 sourceFileChecksum 一个键去重,偶尔会误删,所以用 (checksum, fileName) 两个一起当键(见 asc.py 里的 key = (sh["attributes"].get("sourceFileChecksum"), sh["attributes"].get("fileName")))。
⑤ 界面里明明有 VALID 构建,却报 no VALID build for version X.X.X
默认的 /v1/builds 响应里不含营销版本号字符串(0.3.2 之类)。不加 &include=preReleaseVersion,included 数组就是空的,也就没法按版本号匹配。这正是 asc.py 里的 _build_for_version() 会把那个查询参数写全的原因。另外,如果在很短的时间内上传了大量构建,导致 limit=20 不够用,也会出现同样的症状。对构建很多的 App,要提高 limit= 的值,或者留意 sort=-uploadedDate 的排序。
⑥ 提交前报错 version is WAITING_FOR_REVIEW (locked)
处于 WAITING_FOR_REVIEW 或 IN_REVIEW 状态时,既不能编辑 appStoreVersion,也不能添加新的 reviewSubmissionItem。asc.py 只允许在 _EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED", "INVALID_BINARY"} 这五个状态下写入,否则会返回一个保护性提示。想用 Developer Reject 撤回审核再重新提交,就用 asc.py reject <app_id>(UNRESOLVED_ISSUES 也被那个函数覆盖)。
⑦ 我以为 INVALID_BINARY 就必须重新建版本
INVALID_BINARY 在 _EDITABLE 里。换句话说,即使是在「Apple 处理二进制时拒绝了」的状态下,你也可以在同一个版本下换掉二进制重新提交。只要把构建号加一、重新上传,用 asc.py attach-build <app_id> <ver> 覆盖,再调 submit 就行。省去了重建版本的麻烦。
⑧ setup_signing.py 报 no ASC cert matches local SHA1 退出
setup_signing.py 开头有个常量 LOCAL_SHA1 = "EC06777A...".lower(),那是你本地钥匙串里分发证书的 SHA1 指纹。Apple Distribution 证书一年到期,续期会生成不同的 SHA1。如果每年续期后你没有更新这个常量就运行 setup_signing.py,find_cert() 会遍历每一项,找不到匹配,最终 sys.exit(1)。续期证书之后,一定要到钥匙串工具里确认 SHA1,改掉这个常量。
⑨ archive.sh 因为解析不到描述文件而构建失败
archive.sh 里的 PROVISIONING_PROFILE_SPECIFIER="Auraly AppStore" 是靠精确字符串匹配,来对应 setup_signing.py 里的 PROFILE_NAME = "Auraly AppStore"。在没运行过 setup_signing.py 的环境里,或者证书续期后没有重跑它的环境里,残留的旧描述文件会让 xcodebuild 签订失败,报「找不到匹配的描述文件」或「证书不匹配」。请把 setup_signing.py → archive.sh 的顺序固定在流水线开头。另外,archive.sh 第 6 行是 xcodegen generate,如果你改过 project.yml,这一步会重新生成 .xcodeproj。跳过它、拿旧的 .xcodeproj 去构建,可能因为 scheme 配置对不上而归档失败。
⑩ 调用了 release,却什么都没提交
asc.py release <app_id> <ver> "<whatsnew>" 会把「准备版本 → 挂构建 → 设置新内容」这三步一起跑掉,但它不会提交。正如代码末尾注释所说,意图是「确认之后再 submit」。设计是:先用 release 准备好,去 ASC 界面确认一切无误,再单独调 asc.py submit <app_id>。在自动化末尾顺手调 release、却误以为已经「提交了」,很容易犯,要注意。
⑪ 用 dedup --apply 删掉的截图又出现了
dedup_screenshots() 会扫描 _latest_version() 当前返回的那个版本的每个地区、每个尺寸。当那个版本处于 _EDITABLE 之外的状态(比如 WAITING_FOR_REVIEW)时,即使 apply=True 也会提前返回、只打印一条消息、什么也不删(这是 asc.py 第 186 行的守卫)。在这种状态下反复 --apply 是没有效果的,得先用 reject 把它退回到可编辑状态。
最佳实践
下面这些,是实际运营 12 个 App 后沉淀下来的规矩。
1. 用 chmod 700 隔离整个目录,保护 .p8
建好 ~/.appstoreconnect/ 之后跑一句 chmod 700 ~/.appstoreconnect,就能阻止同一台机器上的其他用户读取它。因为 keys.json 里只有 key_id 和 issuer_id,就算它泄漏,没有私钥本身也调不了 API。上 CI 的时候,在运行时从密钥存储里把文件写成 ~/.appstoreconnect/AuthKey_XXXXXXXXXX.p8,同时永远确保提交历史里没有 .p8。
2. 每个请求都重新生成 JWT(不要缓存)
call() 每次都调 _jwt()。明明有 900 秒的有效期却次次重新生成,看起来低效,但它能防住「JWT 在批量中途过期、只有后面的请求返回 401」这个问题。每请求做一次 ECDSA 签名只有微秒级开销,哪怕跑完 12 个 App 的批量也感知不到差别。选「无论在哪里坏掉,行为都一致」而不是「缓存它,稍微快一点」,是稳定 API 客户端的立身之本。
3. 永远要验证 decode_dss_signature → to_bytes(32, "big") 的拼接
实现完 ES256 JWT 签名之后,把生成出的 JWT 字符串解码一次,确认签名片段是 64 字节(Base64URL 解码之后)。DER 是可变的(通常 70 到 72 字节)。如果 len(base64.urlsafe_b64decode(jwt.split(".")[2] + "==")) 返回 64,说明你的 r+s 编码是对的。如果只停留在「好像能用」,那一旦 r 或 s 碰巧是小数、缺了补零,签名立刻就会坏掉。
4. iat 永远往回调 30 秒
哪怕生产机器是走 NTP 同步的,刚从睡眠唤醒、或者负载很高时,仍可能偏移几秒。Apple 的容差推测在正负 60 秒左右,30 秒的余量是不花钱就得到的保险。900 秒的有效期只是变成 870 秒,没有任何实际代价。
5. 每次写操作,都要先 GET 当前状态再 POST/PATCH
submit() 会先用 GET /v1/reviewSubmissions?filter[state]=READY_FOR_REVIEW 查有没有已存在的提交,有就复用。add_tester() 会先通过 _has_tester() 做存在性检查。把「先查再做」这套严谨地套在每个写命令上,不管 Claude Code 重试,还是你手滑跑两遍,都不会出现重复提交和重复报错。幂等,是 API 客户端最重要的一个设计原则。
6. 牢牢记下那五个 _EDITABLE 状态
只有在 PREPARE_FOR_SUBMISSION、DEVELOPER_REJECTED、REJECTED、METADATA_REJECTED、INVALID_BINARY 这五个状态下,才能修改版本属性、换构建、删截图。在任何其他状态下尝试这些操作,都会返回 409 或 422。审核流程卡住的时候,先用 asc.py status <app_id> 看当前 appStoreState;如果不在 _EDITABLE,要么决定先 reject,要么在不需要 reject 的情况下等审核走完。
7. 在每次 submit 前都插一句 dedup --apply
无论 fastlane deliver 跑过多少遍,都养成习惯,在 submit 之前执行 asc.py dedup <app_id> --apply。就算 dry-run 显示为零,执行成本也很低,还能提前挡掉重复截图引起的 METADATA_ERROR。批处理脚本里可以用一行串起来保证:for app_id in ...; do asc.py dedup "$app_id" --apply && asc.py submit "$app_id"; done。
8. 描述文件走「先删后重建」的循环
setup_signing.py 里的 ensure_profile() 会先删掉所有同名描述文件,再新建一个。用的是「重建」,不是「更新」。续期证书时,旧的、绑定在旧证书 ID 上的描述文件会造成「描述文件在,但证书不匹配」的不一致。用重建循环,就永远存在恰好一份、证书 ID 正确的描述文件,xcodebuild 也能正确解析它。
9. 用 CODE_SIGN_STYLE=Manual 稳住无人值守构建
archive.sh 指定 CODE_SIGN_STYLE=Manual,是因为用 Automatic Signing 时,Xcode 会自己去管理描述文件,无人值守跑的时候可能弹出钥匙串访问的认证对话框,或者试图访问 Apple Developer API。改成 Manual 并按名字固定 Profile Specifier 之后,就只剩 xcodebuild 按名字解析 setup_signing.py 写进 ~/Library/MobileDevice/Provisioning Profiles/ 的那个描述文件,全程没有任何 GUI 操作。
10. 外部依赖只留 cryptography 一个
asc.py 用 urllib.request 做 HTTP,不把 requests 当依赖。配一台新 Mac、第一次跑进 CI 环境,去掉一步 pip install requests,真实感知到的麻烦会小很多。cryptography 是标准库替代不了的,所以作为最小依赖被忍痛留下。每次把脚本带到另一个项目时,都不用在 pip install -r requirements.txt 上卡壳,这正是这个设计决定的直接收益。
11. API 报错时,用 json.dumps(r)[:800] 把整个响应体打出来
asc.py 里每个函数都用统一的 if st >= 400: print(json.dumps(r)[:800]); return。Apple 的错误响应里,errors 数组内有个 detail 字段,那里藏着原因的线索。如果只打印 status、不把 detail 显示出来,409、401、422 全都长成一个「错误」,没法区分。养成把整个 body 打印出来的习惯,定位根因的时间能省一半以上。
12. release 和 submit 永远分成两步
release <app_id> <ver> "<whatsnew>" 把版本准备、构建挂载、新内容设置打包在一起,但刻意不提交。release 之后用 asc.py status <app_id> 确认元数据设置正确,然后才调 submit,就能避免「提交 → 发现弄错 → 再 Developer Reject」这种浪费。就算要接进自动化流水线,我也会设计成让 release 的输出以 Claude Code 能读的形式留在日志里,没有问题才进行 submit。
13. 证书续期时,永远重写 SHA1 指纹
setup_signing.py 里的 LOCAL_SHA1 是分发证书的 SHA1,而 Apple Distribution 证书一年就过期。续期之后,在钥匙串工具里选中新分发证书,去「显示简介 → 指纹 SHA1」那一项确认值,改写这个常量。忘了改的话,find_cert() 会扫完全部 200 张证书、找不到匹配,然后 sys.exit(1)。稳妥的做法,是把证书续期记进日历,续期当天把 setup_signing.py 改掉、再做一次验证运行,当作一个整体来完成。
总结
「有了 API 密钥,你只要生成一个 JWT 去调 API 就行了」,这话没错。但那个 JWT 必须是 ES256 的裸 r+s 编码,iat 要留 30 秒的时钟偏移余量,忘了 include=preReleaseVersion 构建就看不见,测试员不用 create-with-group 就会 409。在把这些坑一个个亲自踩过之前,你是到不了「不经过 2FA 直接调 API」那种状态的。
这里介绍的 asc.py 有 272 行,apps / status / submit 这三个命令是它的核心。但它们的背后,坐着上面列出的每一个坑。看完这些再上手实现,DER 那个花了我两小时的坑,还有吃掉几十分钟的时钟偏移问题,你都能一次性绕开。
最后回头看看整体结构。第一层产出二进制(archive.sh,Manual Signing),第二层上传它(eas submit),第三层通过 ASC API 提交审核(asc.py submit)。只有三层都就位,你才拥有一个「我在睡觉时,12 个 App 的审核提交自己跑完」的环境。代码就在眼前,剩下的事就是去跑它。
在下一篇(第二部分),我会讲怎么把 asc.py 接进 Claude Code 的自主循环,INVALID_BINARY 反复被拒时的突破流程,以及并行管理 12 个 App 的流水线设计。
来源
-
原文标题:Two Hours Lost to a Silent 401: Submitting 12 iOS Apps to the App Store With No Human in the Loop (Part 1) -
作者:Lily(bokuwalily) -
发布平台:DEV Community(dev.to) -
原文链接:https://dev.to/bokuwalily/two-hours-lost-to-a-silent-401-submitting-12-ios-apps-to-the-app-store-with-no-human-in-the-loop-1op5
相关链接
-
作者的付费笔记《Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート》(日文):https://note.com/bokuwalily/n/n849b3a07784a -
作者主页:https://bokuwalily.com -
作者的 X(Twitter):https://x.com/bokuwalily -
作者的 GitHub:https://github.com/bokuwalily
相关阅读:
AI Agent 如何组合工具完成任务?5 个没有预先设计的真实案例

