我的 App 支持 15 种语言,App Store 页面每种语言有标题、副标题、促销文本、描述、关键词、版本更新说明 6 个字段。每次发版,登录 App Store Connect,选语言、粘贴、换语言、再粘贴……一次发版 90 次粘贴操作,而且文案在本地 Markdown 和 ASC 后台各有一份,永远不知道哪份是最新。

直到我把 fastlanedeliver 搭起来,现在整个流程是:改一下 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:

  1. 打开 App Store Connect → 用户和访问 → 集成
  2. "+" 生成密钥,下载 .p8 文件(只有一次下载机会)
  3. 记下 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,内容必须写成 UTILITIESPRODUCTIVITY 这种大写枚举,写成小写 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:三个连环坑

  1. 走错认证:CLI 子命令不会用 Fastfile 里的 api_key 逻辑,会去走 Apple ID 登录,然后卡在双重认证的六位验证码上。解法:把 API Key 写成临时 JSON 文件,通过 --api_key_path 传入。
  2. 静默不下载:本地 metadata 目录非空时,CLI 会问"要不要覆盖",非交互环境下它不报错、直接什么都不做退出,显示 finished successfully 🎉,极具迷惑性。必须加 --force true
  3. 工作目录:在 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 种语言。

本文同步发布于掘金。有问题欢迎留言,新的坑会持续更新到这篇。