给一个 macOS 小工具配上 push 即发版的 GitHub Action
Table of Contents
最近写了个菜单栏小工具,管理 git worktree 用的。功能慢慢成形之后,就遇到一个很现实的问题:怎么发版。
手动发版是能跑的——本地 xcodebuild 打个包,签个名,做成 dmg,再上传到 GitHub Release。问题是这套动作我每次都要重来一遍,而且总有哪一步会忘。打包忘了改版本号、签名漏了某个嵌套 framework、dmg 名字和上次对不上……这种事一旦靠记性,迟早出错。
我想要的其实很简单:改个版本号,推一下,剩下的别让我管。
这套「push 到 release 分支就自动出包」的流程,我其实之前在自己 fork 的 Ghostty(我自己魔改的 Ghostty++)里就搭过一套。那套要复杂得多——后面会专门说一下复杂在哪。这次我等于是把那套的骨架抠出来,砍掉它特有的、我这个小工具用不上的部分,重新拼一个最小可用版本。这篇就记一下这个过程,顺便把中间踩的一个坑写清楚。
整体思路
发布这件事,我希望它有一个明确的「触发点」。不是每次 push 都发版——那太吵了——而是专门留一个 release 分支,只有内容进到这个分支才发版。平时在 main 上随便改,想发了再把改动推进 release。
版本号我放在一个单独的 VERSION 文件里,就一行:
0.1.0
这样版本号有了唯一的来源。CI 读这个文件,校验格式,再把它盖进构建里。要发新版,改这一行就行,不用去翻 Xcode 工程设置。
整条流水线干的事情是这样的:
push 到 release 分支
↓
读 VERSION,算出 tag(v0.1.0)
↓
检查这个 tag 是不是已经发过了
↓
生成 Xcode 工程 → 编译 Release
↓
ad-hoc 签名 → 打成 dmg
↓
建 GitHub Release,把 dmg 挂上去
workflow 本体
触发部分很直接,盯着 release 分支,外加一个手动触发的口子方便调试:
on:
push:
branches:
- release
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-branch
cancel-in-progress: false
permissions: contents: write 是必须的,不然最后建 Release 那一步没权限。concurrency 是为了防止同时跑两次发版把产物搞乱——发布这种事,一次只能有一个在跑。
读版本号这一步顺便把几个后面要用的值都算出来:
- name: Read version
id: version
run: |
VERSION="$(tr -d '[:space:]' < VERSION)"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "VERSION must be X.Y.Z, got: $VERSION"
exit 1
fi
BUILD="$(git rev-list --count HEAD)"
TAG="v${VERSION}"
DMG_NAME="WorktreeBar-v${VERSION}.dmg"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "build=$BUILD" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "dmg_name=$DMG_NAME" >> "$GITHUB_OUTPUT"
BUILD 用 commit 总数来当 build number,简单可靠,每次都递增。正则那一行是个小保险:万一 VERSION 写错了格式,让它在这里就停下来,而不是跑到一半才发现。
然后是一个我觉得很值的步骤——发版前先检查这个 tag 是不是已经存在:
- name: Check release does not exist
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
run: |
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "Tag already exists: ${TAG}"
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "GitHub Release already exists: ${TAG}"
exit 1
fi
有了这一步,我重复 push release 分支也不怕——只要没改 VERSION,它会在这里直接失败,不会覆盖已有的 Release。这种「想犯错都犯不了」的设计,比事后小心要可靠得多。
版本号怎么盖进去
我这个项目用 XcodeGen,工程文件是 project.yml 生成出来的。所以版本号要进到 app 里,得先改 project.yml,再 generate:
- name: Install XcodeGen
run: brew install xcodegen
- name: Patch version into project.yml
env:
APP_VERSION: ${{ steps.version.outputs.version }}
APP_BUILD: ${{ steps.version.outputs.build }}
run: |
sed -i '' -E "s/MARKETING_VERSION: \"[^\"]*\"/MARKETING_VERSION: \"${APP_VERSION}\"/" project.yml
sed -i '' -E "s/CURRENT_PROJECT_VERSION: \"[^\"]*\"/CURRENT_PROJECT_VERSION: \"${APP_BUILD}\"/" project.yml
- name: Generate Xcode project
run: xcodegen generate
这里我特意先在本地拿副本试了一下 sed,确认它只改那两行、不会误伤别的内容,才敢放进 CI。sed -i '' 是 macOS 的写法(BSD sed 的 -i 后面要跟一个空字符串参数),这点和 Linux 不一样,写的时候别想当然。
签名和打包
我这个工具是个人用的,没有 Apple 开发者证书,所以走 ad-hoc 签名就行:
- name: Ad-hoc codesign app bundle
run: |
/usr/bin/codesign --force --deep --sign - "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
--sign - 就是 ad-hoc 签名的意思。我一开始照搬 Ghostty 那边的写法带了 --options runtime(hardened runtime),但我这个 app 本来就没开 hardened runtime,加上反而可能验证失败,就去掉了。签完跑一次 --verify 确认签名是好的,免得带着坏签名往下走。
打 dmg 用 create-dmg:
- name: Create DMG
env:
DMG_NAME: ${{ steps.version.outputs.dmg_name }}
run: |
npm install --global create-dmg
create-dmg --no-code-sign "$APP_PATH" ./ || true
mv ./Worktree*.dmg "./${DMG_NAME}"
test -f "./${DMG_NAME}"
这里有两个小细节是我本地试出来的,不试根本想不到:
一个是 create-dmg 在跳过签名的时候会返回非零退出码,哪怕 dmg 其实生成成功了。所以我加了 || true,不让它的退出码把整步搞挂,改成后面用 test -f 来确认文件在不在。
另一个更隐蔽:create-dmg 给文件起的名字是按 app 的显示名来的。我的 app 显示名是「Worktree Bar」(带空格),所以它生成的是 Worktree Bar 1.0.dmg,而不是我以为的 WorktreeBar-xxx.dmg。我原来的 mv WorktreeBar*.dmg 通配符根本匹配不到,得改成 Worktree*.dmg。这种东西不在本地真跑一遍,光看脚本是发现不了的。
最后建 Release,用现成的 action:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
target_commitish: ${{ github.sha }}
name: WorktreeBar ${{ steps.version.outputs.tag }}
files: |
${{ steps.version.outputs.dmg_name }}
make_latest: true
那个坑:未来的工程格式
写完之后我满怀信心地把 release 分支推上去,结果第一次就挂了。失败在「Build」这一步,跑了十几秒就报错:
xcodebuild: error: Unable to read project 'WorktreeBar.xcodeproj'.
Reason: The project 'WorktreeBar' cannot be opened because it is
in a future Xcode project file format (77).
「future Xcode project file format (77)」——工程文件的格式版本太新了,CI 上的 Xcode 读不了。
原因是我本地用的是 Xcode 16,XcodeGen 生成出来的工程是 objectVersion 77 这个新格式。而我 workflow 里写的是 runs-on: macos-14,macos-14 这个 runner 镜像带的是 Xcode 15,它不认这个格式。
本地一切正常,正是因为本地是新 Xcode;一搬到 CI 就露馅。这种「本地能跑、CI 跑不了」的问题,根子上往往就是环境版本对不上。
修起来很简单,把 runner 换成带 Xcode 16 的:
runs-on: macos-15
macos-15 镜像默认是 Xcode 16.4,能读这个格式。改完重新推,这次就一路绿灯跑完了,Release 也老老实实建好了,dmg 挂在上面。
对比一下我在 Ghostty++ 里那套
前面说了,这套流程的原型是我给自己魔改的 Ghostty(Ghostty++)加的发布流水线。同样是「push 到 release 分支自动发版」,但那套要重很多。简单说一下它多出来的部分,也算解释一下我这次砍掉了什么。
第一重复杂是构建本身。 Ghostty 的核心是 Zig 写的,Xcode 那层只是个壳。所以它不能直接 xcodebuild 完事,得先用 Nix 把构建环境拉起来,zig build 编出 GhosttyKit(核心库),再交给 Xcode 把 app 包出来。光是「准备环境 + 编核心」就是好几步,跑起来也慢。我这个纯 SwiftUI 的小工具没这一层,一句 xcodebuild 就出 app 了。
第二重、也是最麻烦的,是 app 内自动更新。 Ghostty++ 接了 Sparkle——就是很多 mac app 那种「发现新版本,点一下就更新」的机制。要支持它,发布流程里凭空多出一整条链路:
- 构建时把 Sparkle 框架塞进 app,往
Info.plist里写更新公钥; - 打完 dmg 之后,用一个私钥(存在仓库 secrets 里)给 dmg 签一个 EdDSA 签名;
- 再生成一个
appcast.xml——这是 Sparkle 的更新 feed,里面记着最新版本号、下载地址、签名。老用户的 app 就是去拉这个 xml 来判断要不要更新的。
这一串东西环环相扣,签名密钥还不能泄露,配置起来比单纯发个 dmg 麻烦得多。而我这个工具是给自己用的,根本没接自动更新,所以这整段我直接没要——发完 dmg、建完 Release 就结束,用户想更新就再下一次。
还有些零碎的差别,比如那套用的是带新版 Xcode 的特定 runner 镜像、依赖好几个 secret。但核心就是上面两点:它要编一个 Zig 核心,还要做 app 内自动更新。把这两块拿掉,剩下的骨架——盯 release 分支、读版本、防重复、签名、打包、建 Release——其实就是这篇这套了。
所以这次与其说是「从零搭」,不如说是「做减法」。有了那套复杂版打底,反而更清楚哪些是发布流程真正的骨干,哪些只是为某个具体需求(Zig、自动更新)服务的枝节。
现在发版是什么样
配好之后,我发新版就三步:
- 改
VERSION文件里那一行; - 提交到
main; git push origin main:release。
剩下的我不用管。CI 会自己编译、签名、打包、建 Release。要是我手贱重复推了同一个版本,它会在检查那一步直接拒绝,不会把已有的覆盖掉。
回头看,这套东西真正帮我省下来的,不是「自动化」本身那点时间,而是把一堆「容易忘、忘了就出错」的步骤从我脑子里挪了出去。发版这种偶尔才做一次的事,最怕的就是靠记性,而记性恰恰最不可靠。