使用 GitHub Actions 全自动发布 Safari 扩展

本文最后更新于:2026年7月20日 上午

背景

我之前写过两篇关于 Safari 扩展的博客:转换 Chrome Extension 为 Safari 版本,以及 发布 Safari 扩展到 iOS 应用商店。它们解决的是”第一次怎么做成”。但随着我的 Safari 扩展越来越多,每次发版的手动流程变得越来越难以忍受:打开 Xcode、改版本号、Archive、Validate、Distribute,再打开 App Store Connect 选 bundle、填 release note、点提交——每个扩展十分钟起步,而且不能出错。

这篇文章介绍我现在的发布流程:改一下 package.json 里的 version,push 到 GitHub,然后就没有然后了。构建、签名、上传、提交审核,全部在 GitHub Actions 里自动完成。

1784531579982.jpg

最终效果

先看终态。这是一个真实项目里 Safari 发布相关的完整 workflow 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
safari:
needs: version
runs-on: macos-26
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- run: pnpm install
# 构建扩展并转换为 Xcode 项目,输出到 .output/My Extension
- run: pnpm build:safari

- uses: rxliuli/safari-webext-publish-action@v2
with:
# build 模式:构建、签名、上传 bundle 到 App Store Connect,但不提交审核
mode: build
project-path: '.output/My Extension'
bundle-identifier: 'com.example.my-extension'
team-id: 'ABCDE12345'
app-signing-identity: '3rd Party Mac Developer Application: Your Name (ABCDE12345)'
installer-signing-identity: '3rd Party Mac Developer Installer: Your Name (ABCDE12345)'
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_MACOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_PROVISIONING_PROFILE_BASE64 }}
APPLE_MACOS_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
APPLE_IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_IOS_PROVISIONING_PROFILE_BASE64 }}
APPLE_IOS_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_IOS_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

safari-submit:
needs: [version, safari]
runs-on: ubuntu-latest
steps:
- uses: rxliuli/safari-webext-publish-action@v2
with:
# submit 模式:用刚上传的 bundle 创建新版本并提交审核,只需要 3 个 secrets
mode: submit
bundle-identifier: 'com.example.my-extension'
version: ${{ needs.version.outputs.version }}
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

配好这份 YAML 和它引用的 secrets,就是全部了。它用到了我为此写的两个工具:

为什么 Safari 需要专门的工具?Chrome/Firefox/Edge 的发布 WXT 已经做得很好了,一条命令的事。但 Safari 扩展本质上是一个 App——浏览器扩展的代码被嵌在一个 macOS/iOS 应用里,发布走的是 App Store,这意味着要面对 Xcode 项目、证书、签名、公证、provisioning profile 这一整套 Apple 的分发体系。复杂度的直观展示——这是一个全平台扩展需要的所有 secrets:

1784533468309.jpg

看起来吓人,但每一个都只需要配置一次。下面按顺序讲清楚每一步在做什么、每个 secret 从哪里来。

顺带一提:这套流程也是逐步演化出来的。最早我只用 wxt-module-safari-xcode 生成项目,仍然在 Xcode 里手动构建上传;后来 action 的 build 模式让我不再需要打开 Xcode;最后加上 submit 模式,才把最后”打开 App Store Connect 点两三分钟”的步骤也消灭掉。如果你想了解完整的手动流程(以及为什么它值得被自动化),见开头那两篇博客。

第一步:配置 WXT,把扩展转换为 Xcode 项目

Apple 官方提供了转换工具 xcrun safari-web-extension-converter,但它只做最基础的转换:生成的 Xcode 项目还需要手动设置版本号、App 分类、开发团队等。wxt-module-safari-xcode 把这些集成进了 WXT 的构建流程——运行 wxt build -b safari 时自动完成转换和全部配置注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
// wxt.config.ts
import { defineConfig } from 'wxt'

export default defineConfig({
modules: ['wxt-module-safari-xcode'],
safariXcode: {
projectName: 'Your Project Name',
appCategory: 'public.app-category.productivity',
bundleIdentifier: 'com.example.your-extension',
developmentTeam: 'ABC1234567',
},
// ... other configurations
})

四个配置项的来源:

  • projectName:扩展的显示名,包含大写字母或空格都没问题。
  • appCategory:App Store 的应用分类,可选值见 Apple 官方分类列表
  • bundleIdentifier:全局唯一标识,惯例是反转域名 + 应用名,全小写、不含空格。
  • developmentTeam:你的 Apple Developer Team ID,在 developer.apple.com/account 的 Membership 页面可以找到。

1784536060862.jpg

运行 pnpm wxt build -b safari 后,Xcode 项目会出现在 .output/<projectName>,版本号已经从 package.json 自动同步(模块会同时更新 MARKETING_VERSIONCFBundleVersion,后者按 major×10000 + minor×100 + patch 换算成递增的数字版本——Apple 要求每次上传的 build number 必须递增,这个换算规则让它跟着语义化版本自动满足)。

真实项目的配置示例:https://github.com/rxliuli/imp-translate/blob/main/wxt.config.ts

第二步:准备 secrets

这是整个流程中最繁琐的部分,但每项只做一次。分三组。

证书(APPLE_CERTIFICATE_BASE64 / APPLE_CERTIFICATE_PASSWORD)

CI 里签名的原理是:把你的分发证书(含私钥)导出成 .p12 文件,以 base64 形式存进 GitHub secrets,action 在运行时把它导入 runner 上的临时 keychain 供 Xcode 签名使用。步骤:

  1. developer.apple.com 的 Certificates 页面 创建三张分发证书:Mac App Distribution(对应 “3rd Party Mac Developer Application” 签名身份)、Mac Installer Distribution(对应 “3rd Party Mac Developer Installer”。看到”Installer”别疑惑——Mac App Store 规定的上传格式就是签过名的 .pkg,它只是提交给 Apple 时的运输容器,用户实际安装走的是 App Store 自己的机制,永远不会接触这个 .pkg。action 内部用 productbuild 打包时需要这张证书签名),以及 Apple Distribution——注意一个命名陷阱:创建页面上它叫 “Apple Distribution”,但创建后在证书列表里只显示为 “Distribution“(Platform 列为 “All”),是同一张证书。它是跨平台的,负责 iOS 分发签名。创建过程中需要用本机钥匙串生成 CSR(证书签名请求),按页面提示操作即可。这三张证书都是账户级的,不绑定任何具体扩展——配置一次,所有项目通用。
  2. 下载证书后双击导入本机钥匙串,然后打开「钥匙串访问」→「我的证书」,同时选中这三张证书(确认每张都能展开看到私钥),右键导出为单个 .p12 文件——action 支持一个 .p12 内包含全部签名身份,不需要分开传。导出时设置的密码就是 APPLE_CERTIFICATE_PASSWORD
  3. 把 .p12 转成 base64 存入 secrets:base64 -i Certificates.p12 | pbcopy,粘贴为 APPLE_CERTIFICATE_BASE64

1784542838372.jpg

Provisioning Profiles(4 个 *_PROVISIONING_PROFILE_BASE64)

Profiles 页面 为每个 target 创建 App Store 类型的 profile。转换器生成的项目里有 App 和 Extension 两个 target,各自有独立的 bundle id——Extension 的 id 是主 id 加 .Extension 后缀(例如 com.example.my-extension.Extension),两个 bundle id 都需要先在 Identifiers 页面注册。macOS 和 iOS 各一套,所以一共 4 个 profile。每个下载后同样 base64 编码存入对应的 secret。

1784542904113.jpg

注意:这一步是按扩展算的——每新增一个 Safari 扩展,都要为它创建一套新的 profile。这也是所有 secrets 里唯一不能跨项目复用的部分(证书和 API key 都是账户级的,配一次全部扩展通用)。

App Store Connect API(APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER)

App Store Connect 的 Integrations 页面 创建团队 API 密钥,角色选 App Manager 就足够(最小权限原则,不要用 Admin)。创建后:

  • 下载 .p8 私钥文件——只能下载这一次,妥善保存。用 base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy 转成 base64 后存入 APPLE_API_KEY
  • 页面上的 Key ID 存入 APPLE_API_KEY_ID
  • 页面顶部的 Issuer ID 存入 APPLE_API_ISSUER

这组密钥承担两个职责:build 模式用它上传 bundle(替代了 Xcode 的 Distribute 步骤),submit 模式用它调用 App Store Connect API 创建版本并提交审核。

1784542967542.jpg

找不到这些页面入口是正常的,Apple 的后台并不以易用著称。一个现代解法:让能操作浏览器的 LLM 帮你导航到对应页面,比自己翻文档快得多。

第三步:理解 workflow 的两个 job

回头看开头那份 YAML,Safari 发布被拆成了两个 job,这个拆分有讲究:

safari(build 模式) 跑在 macOS runner 上——只有这一步真正需要 macOS 和 Xcode(注意必须是 macos-26 及以上,action 会检查 Xcode 26 SDK)。它做四件事:导入证书到临时 keychain、用 xcodebuild 构建并签名(macOS 产出 x86_64+arm64 通用二进制)、打包、通过 API 上传到 App Store Connect。跑完之后,bundle 已经出现在 App Store Connect 的构建版本列表里,等价于你在 Xcode 里点完了 Archive → Distribute。一个省心的细节:action 会自动从 Xcode 项目的 scheme 检测目标平台——项目里只有 macOS scheme 就自动跳过 iOS 步骤,反之亦然,不需要额外配置。

safari-submit(submit 模式) 跑在 ubuntu runner 上——它只调用 App Store Connect API,不需要 macOS,用便宜的 runner 就好。它等价于你在网页上手动做的那套:轮询等待 bundle 处理完成、创建新版本、关联刚上传的 bundle、填写 What’s New、提交审核。也因此它只需要 API 三件套这 3 个 secrets。开头抱怨过的”每次都要手动填 release note”也在这里被解决了:通过 release-notes 输入传入,不传则默认 “Bug fixes and improvements.”。

1784543097189.jpg

总结

诚实地说:如果你只有一两个 Safari 扩展,Xcode 的手动流程是可以忍受的,它替你隐藏了证书和签名的大部分复杂性,为它配置这一整套 secrets 未必划算。但如果你有三个以上的 Safari 扩展,或者单个扩展发版频繁,这条流水线会彻底改变你的发版体验——一次性的配置成本,换来之后每次发版为零的心智负担。我的十几个 Safari 扩展现在全部走这条流水线,配置完成之后,我再也没有为发版打开过 Xcode。


使用 GitHub Actions 全自动发布 Safari 扩展
https://blog.rxliuli.com/p/5b274a53caa641d187ca4de9a41c8f1b/
作者
rxliuli
发布于
2026年7月20日
许可协议