我的 App 支持 15 种语言,App Store 页面每种语言有标题、副标题、促销文本、描述、关键词、版本更新说明 6 个字段。每次发版,登录 App Store Connect,选语言、粘贴、换语言、再粘贴……一次发版 90 次粘贴操作,而且文案在本地 Markdown 和 ASC 后台各有一份,永远不知道哪份是最新。
直到我把 fastlane 的 deliver 搭起来,现在整个流程是:改一下 Markdown,跑一条命令,15 种语言全部同步到 App Store Connect。
概念本身很简单,真正花时间的是踩坑——7 个,其中好几个的报错信息和真实原因相距十万八千里。这篇文章把方案和坑都写全,你可以直接抄。
方案架构:Markdown 是唯一数据源
docs/AppStore/ ← 唯一维护入口,一种语言一个 .md 文件
├── English.md
├── Chinese.md
└── screenshots/ ← 各语言截图
scripts/build-appstore-metadata.mjs ← 解析 Markdown,生成 deliver 格式
fastlane/
├── Fastfile ← 推送/拉取/截图三个 lane
├── Appfile ← bundle id 等应用信息
├── metadata/ ← 生成物(gitignore)
└── screenshots/ ← 生成物(gitignore)
日常发版只做一件事:改 Markdown,跑一条命令。生成物目录全部 gitignore,不存在两份数据打架的问题。
安装:从一个崩溃开始的坑
macOS 上推荐用 Bundler 锁定 fastlane 版本,项目根目录建 Gemfile:
source "https://rubygems.org"
gem "fastlane"
bundle install
坑 1:gem 镜像导致非交互环境崩溃
国内开发者普遍把 gem 源换成了镜像。fastlane 每次启动会做版本更新检查,检测到 gem 源里没有 rubygems.org 时会尝试交互式询问,在 CI 或任何非交互终端里直接崩溃:
RubyGems is not listed as your Gem source
Could not retrieve response as fastlane runs in non-interactive mode
解法:在 .env 里加两行,永久跳过更新检查:
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
认证:App Store Connect API Key
不要用 Apple ID + 密码的方式(会撞上双重认证的交互提示),直接用 API Key:
- 打开 App Store Connect → 用户和访问 → 集成
- 点 "+" 生成密钥,下载
.p8文件(只有一次下载机会) - 记下 Key ID 和 Issuer ID
坑 2:角色不够,读得了写不了
我一开始用了一把 Developer 角色的 Key,拉取线上元数据一切正常,推送时却报:
This request is forbidden for security reasons - The API key in use does not allow this request
ASC API Key 的角色在创建时固定,不能修改。Developer 角色对元数据是只读的,要写元数据至少需要 App Manager。这个 403 报错完全不提"角色"二字,非常难排查。记住:读 OK 写 403 = 角色不够,直接去建一把新 Key。
环境变量:
APPLE_API_KEY=你的KeyID
APPLE_API_ISSUER=你的IssuerID
APPLE_API_KEY_PATH=/绝对路径/AuthKey_XXXX.p8 # 建议放在项目外,避免误提交
完整 Fastfile(能直接用的版本)
这份 Fastfile 已经内置了下文所有坑的修复,也可以直接用文末的开源模板:
default_platform(:ios)
# 项目根目录。fastlane 加载 Fastfile 时会把工作目录切到 fastlane/,
# 所有相对路径都会因此解析错位,必须基于 __FILE__ 定位(见坑 4)
def project_root
File.expand_path("../..", __FILE__)
end
def asc_api_key
key_id = ENV["APPLE_API_KEY"]
issuer_id = ENV["APPLE_API_ISSUER"]
return nil if key_id.nil? || issuer_id.nil?
key_path = ENV["APPLE_API_KEY_PATH"]
if key_path && File.exist?(key_path)
# 注意:参数名是 filepath,不是 key_filepath(见坑 3)
{ key_id: key_id, issuer_id: issuer_id, filepath: key_path }
end
end
platform :ios do
desc "推送全部语言的元数据到 App Store Connect"
lane :sync_metadata do
Dir.chdir(project_root) { sh("node", "scripts/build-appstore-metadata.mjs") }
deliver(
api_key: asc_api_key,
platform: "ios", # macOS 应用用 "osx"
app_version: ENV["ASC_CREATE_VERSION"], # 不设置就完全不动版本
metadata_path: File.join(project_root, "fastlane", "metadata"),
skip_screenshots: true,
skip_binary_upload: true,
run_precheck_before_submit: false,
submit_for_review: false,
force: true # 跳过 HTML 预览确认,CI 友好
)
end
end
对应的 npm script:
{
"scripts": {
"appstore:metadata": "bundle exec fastlane sync_metadata"
}
}
坑 3:一个参数名,静默崩溃
fastlane 的 Spaceship::ConnectAPI::Token.create 签名里,密钥文件参数叫 filepath:。而官方文档里另一个 action(app_store_connect_api_key)的参数叫 key_filepath。如果把 key_filepath: 传进 deliver 的 api_key 哈希,不匹配的键会被 Ruby 静默吞掉,然后:
no implicit conversion of nil into String (TypeError)
因为 File.binread(nil)。这个坑阴间在:如果你用的是"密钥内容"方式(key: 参数,名字恰好是对的),一切正常;哪天换成文件路径方式,就崩给你看。
坑 4:Fastfile 里的相对路径全部是错的
fastlane 解析 Fastfile 时会执行 Dir.chdir(fastlane目录),lane 里所有相对路径的基准都是 fastlane/ 而不是项目根。我调用外部脚本时传了相对路径,结果文件被写到了 fastlane/fastlane/metadata/ 这种套娃目录里。
更坑的是,fastlane 自带的 FastlaneCore::Helper.fastlane_enabled_folder_path 在这个场景下返回的是 fastlane/ 目录本身而不是项目根。唯一可靠的是基于 __FILE__:
def project_root
File.expand_path("../..", __FILE__) # Fastfile 位于 <root>/fastlane/Fastfile
end
Markdown → deliver 的生成脚本
deliver 要求的格式是 fastlane/metadata/<locale>/<field>.txt。语言目录名必须是 ASC 的 locale 代码而不是 ISO 语言码:
| 你的语言 | ASC locale | 你的语言 | ASC locale |
|---|---|---|---|
| 英文 | en-US | 韩文 | ko |
| 简体中文 | zh-Hans | 荷兰文 | nl-NL |
| 西班牙文 | es-ES | 波兰文 | pl |
| 法文 | fr-FR | 葡萄牙文 | pt-BR |
| 德文 | de-DE | 俄文 | ru |
| 日文 | ja | 瑞典文 | sv |
| 土耳其文 | tr |
生成脚本(完整版见文末仓库)做的事:解析 Markdown → 写出各语言字段文件 → 字符限制校验(name/subtitle ≤ 30、keywords ≤ 100、promo ≤ 170、description ≤ 4000)。校验很有价值:标题 30 字符的限制,中英文不一样容易超,脚本在本地就把超长拦下,不用等 ASC 后台飘红。
坑 5:类别值必须是大写枚举
如果元数据里有 primary_category.txt,内容必须写成 UTILITIES、PRODUCTIVITY 这种大写枚举,写成小写 utilities 会报:
The provided entity includes a relationship with an invalid value
fastlane 内部的"显示名 → 枚举"映射表键是首字母大写的 Utilities,小写映射不上,原样发出去就被 API 拒了。类别是一次性设置,最省事的做法是根本不生成类别文件,让线上已有的设置保持不动。
反向同步:从线上拉元数据也全是坑
想看线上现在是什么文案?deliver 的 action 只有上传模式,下载必须走 CLI 子命令:
bundle exec fastlane deliver download_metadata \
--metadata_path fastlane/metadata --platform ios --force true
坑 6:三个连环坑
- 走错认证:CLI 子命令不会用 Fastfile 里的
api_key逻辑,会去走 Apple ID 登录,然后卡在双重认证的六位验证码上。解法:把 API Key 写成临时 JSON 文件,通过--api_key_path传入。 - 静默不下载:本地 metadata 目录非空时,CLI 会问"要不要覆盖",非交互环境下它不报错、直接什么都不做退出,显示
finished successfully 🎉,极具迷惑性。必须加--force true。 - 工作目录:在 lane 里用
sh调这个 CLI 时,记得套Dir.chdir(project_root),原因见坑 4。
版本行为:不会自动建版本
高频问题:推送元数据需要先建版本吗?
- 线上已有可编辑版本(准备提交/审核中/被拒)→ 直接写入,什么都不用做
- 上个版本已发布、还没开新版本 → 报错
Cannot find edit app store version,不会自动创建
deliver 有自动建版本的能力(app_version 参数),但有个危险副作用:如果线上已有可编辑版本且版本号和你传的不一致,它会把那个版本改成你传的版本号。所以我把它做成显式 opt-in(见上面 Fastfile 里的 ASC_CREATE_VERSION),不设置就完全不动版本号。发版时:
ASC_CREATE_VERSION=1.0.7 npm run appstore:metadata
坑 7:你的 .env 可能正在被 git 跟踪
把密钥放进 .env 之前,先检查它有没有被提交过:
git ls-files --error-unmatch .env && echo "被跟踪了!"
我第二个项目的 .env 是被 git 跟踪的(历史提交里就有),直接往里加 Key 就把密钥写进版本历史了。解法:用 fastlane 官方的多环境文件机制,建一个独立的 .env.fastlane(gitignore 掉),命令里带 --env fastlane 加载,和项目自己的 .env 完全隔离。
版本更新说明(What’s New)
逐语言的 release_notes.txt 也可以放进 Markdown 工作流:每种语言的 md 里加一个 What’s New 章节,生成脚本会一并产出并推送;某语言没写这个章节,推送时就不会改动该语言线上的版本说明,很安全。注意各语言要写本地化文案,别全用英文 “Bug fixes”(这是我踩的第 8 个非技术坑 😅)。
开源模板
以上全部(生成脚本 + Fastfile + 双格式 Markdown 解析 + 截图映射)整理成了一个开箱即用的模板仓库,clone 下来填上自己的 Markdown 和密钥就能跑:
→ github.com/Pulset/fastlane-appstore-metadata
- 列表式 / 编号式两种 Markdown 格式自动识别,可混用
- 内置字符限制校验,超长在本地就拦下
- 支持 iOS / macOS(
ASC_PLATFORM一键切换),20 种语言映射 - 本文所有坑都已在模板里修好
更新记录
- 2026-08-24:首发。7 个坑,两个上架 App(macOS + iOS)验证,15 种语言。
本文同步发布于掘金。有问题欢迎留言,新的坑会持续更新到这篇。