build: add signed and notarized DMG packaging

This commit is contained in:
Qiu Yuzhou
2026-10-22 14:32:47 +08:00
parent 17c9f15928
commit 9cca9f9a0a
4 changed files with 260 additions and 7 deletions
+1
View File
@@ -77,6 +77,7 @@ IDEWorkspaceChecks.plist
####
# New project build artifacts
ShadowsocksX-NG2/.build/
ShadowsocksX-NG2/dist/
ShadowsocksX-NG2/DerivedData/
####
+17 -5
View File
@@ -1,10 +1,15 @@
# https://taskfile.dev/
version: '3'
includes:
packaging:
taskfile: ./Taskfile.package.yml
flatten: true
vars:
PROJECT_NAME: "ShadowsocksX-NG2"
BUILD_DIR: "{{ .TASKFILE_DIR }}/.build"
DERIVED_DATA_DIR: "{{ .BUILD_DIR }}/derivedData"
DERIVED_DATA_DIR: '{{default (printf "%s/derivedData" .BUILD_DIR) .DERIVED_DATA_DIR}}'
TEST_TIMEOUT_ARGS: '-test-timeouts-enabled YES -default-test-execution-time-allowance 120 -maximum-test-execution-time-allowance 300'
tasks:
@@ -121,12 +126,17 @@ tasks:
-scheme ShadowsocksX-NG2
-configuration Debug
-destination 'platform=macOS,arch=arm64'
-derivedDataPath {{ .DERIVED_DATA_DIR }}
-derivedDataPath {{.DERIVED_DATA_DIR | shellQuote}}
{{if .RESULT_BUNDLE_PATH}}-resultBundlePath {{.RESULT_BUNDLE_PATH | shellQuote}}{{end}}
build
build:release:
desc: Build the ShadowsocksX-NG2 macOS application with Release Config
preconditions:
- sh: '{{if or (empty .MARKETING_VERSION) (regexMatch "^[0-9]+\\.[0-9]+\\.[0-9]+$" .MARKETING_VERSION)}}true{{else}}false{{end}}'
msg: 'MARKETING_VERSION must have three numeric components (for example, 2.1.0).'
- sh: '{{if or (empty .CURRENT_PROJECT_VERSION) (regexMatch "^[0-9]+(\\.[0-9]+){0,2}$" .CURRENT_PROJECT_VERSION)}}true{{else}}false{{end}}'
msg: 'CURRENT_PROJECT_VERSION must have one to three numeric components.'
deps:
- gen:prj
cmds:
@@ -136,7 +146,9 @@ tasks:
-scheme ShadowsocksX-NG2
-configuration Release
-destination 'platform=macOS,arch=arm64'
-derivedDataPath {{ .DERIVED_DATA_DIR }}
-derivedDataPath {{.DERIVED_DATA_DIR | shellQuote}}
{{if .MARKETING_VERSION}}{{printf "MARKETING_VERSION=%s" .MARKETING_VERSION | shellQuote}}{{end}}
{{if .CURRENT_PROJECT_VERSION}}{{printf "CURRENT_PROJECT_VERSION=%s" .CURRENT_PROJECT_VERSION | shellQuote}}{{end}}
build
format:
@@ -166,7 +178,7 @@ tasks:
-project ShadowsocksX-NG2.xcodeproj
-scheme ShadowsocksX-NG2
-destination 'platform=macOS,arch=arm64'
-derivedDataPath {{ .DERIVED_DATA_DIR }}
-derivedDataPath {{.DERIVED_DATA_DIR | shellQuote}}
{{if .RESULT_BUNDLE_PATH}}-resultBundlePath {{.RESULT_BUNDLE_PATH | shellQuote}}{{end}}
{{.TEST_TIMEOUT_ARGS}}
test
@@ -192,7 +204,7 @@ tasks:
-project ShadowsocksX-NG2.xcodeproj
-scheme ShadowsocksX-NG2Smoke
-destination 'platform=macOS,arch=arm64'
-derivedDataPath {{ .DERIVED_DATA_DIR }}
-derivedDataPath {{.DERIVED_DATA_DIR | shellQuote}}
{{if .RESULT_BUNDLE_PATH}}-resultBundlePath {{.RESULT_BUNDLE_PATH | shellQuote}}{{end}}
{{.TEST_TIMEOUT_ARGS}}
test
+191
View File
@@ -0,0 +1,191 @@
# https://taskfile.dev/
version: '3'
vars:
DIST_DIR: '{{.TASKFILE_DIR}}/dist'
DMG_REQUIREMENT: '=anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists'
tasks:
package:
desc: Build, sign, notarize and publish an arm64 Release DMG
dir: '{{.TASKFILE_DIR}}'
requires:
vars: [NOTARY_PROFILE]
preconditions:
- sh: 'test -n {{.NOTARY_PROFILE | shellQuote}}'
msg: 'NOTARY_PROFILE must name credentials stored by notarytool store-credentials.'
- sh: '{{if or (empty .MARKETING_VERSION) (regexMatch "^[0-9]+\\.[0-9]+\\.[0-9]+$" .MARKETING_VERSION)}}true{{else}}false{{end}}'
msg: 'MARKETING_VERSION must have three numeric components (for example, 2.1.0).'
- sh: '{{if or (empty .CURRENT_PROJECT_VERSION) (regexMatch "^[0-9]+(\\.[0-9]+){0,2}$" .CURRENT_PROJECT_VERSION)}}true{{else}}false{{end}}'
msg: 'CURRENT_PROJECT_VERSION must have one to three numeric components.'
vars:
RUN_DIR:
sh: |
mkdir -p {{.BUILD_DIR | shellQuote}}/packaging
mktemp -d {{.BUILD_DIR | shellQuote}}/packaging/run-XXXXXX
cmds:
- 'printf "Packaging workspace: %s\n" {{.RUN_DIR | shellQuote}}'
- for: [xcodebuild, xcodegen, hdiutil, codesign, ditto, plutil, shasum, spctl, link]
cmd: 'command -v {{.ITEM}} > /dev/null'
- xcrun --find stapler
- >-
xcrun notarytool history --keychain-profile {{.NOTARY_PROFILE | shellQuote}}
--output-format json > {{.RUN_DIR | shellQuote}}/notary-history.json
- Scripts/fetch-external-binaries.sh
- task: test:smoke
vars:
DERIVED_DATA_DIR: '{{.RUN_DIR}}/derivedData'
RESULT_BUNDLE_PATH: '{{.RUN_DIR}}/smoke.xcresult'
- task: build:release
vars:
DERIVED_DATA_DIR: '{{.RUN_DIR}}/derivedData'
- task: package:image
vars:
RUN_DIR: '{{.RUN_DIR}}'
APP_PATH: '{{.RUN_DIR}}/derivedData/Build/Products/Release/ShadowsocksX-NG2.app'
package:image:
internal: true
dir: '{{.TASKFILE_DIR}}'
vars:
APP_VERSION:
sh: plutil -extract CFBundleShortVersionString raw -expect string -o - {{.APP_PATH | shellQuote}}/Contents/Info.plist
APP_BUILD:
sh: plutil -extract CFBundleVersion raw -expect string -o - {{.APP_PATH | shellQuote}}/Contents/Info.plist
IMAGE_NAME: 'ShadowsocksX-NG2-{{.APP_VERSION}}-{{.APP_BUILD}}-arm64.dmg'
IMAGE_PATH: '{{.RUN_DIR}}/{{.IMAGE_NAME}}'
preconditions:
- sh: '{{if regexMatch "^[0-9]+\\.[0-9]+\\.[0-9]+$" .APP_VERSION}}true{{else}}false{{end}}'
msg: 'Built App has an invalid CFBundleShortVersionString.'
- sh: '{{if regexMatch "^[0-9]+(\\.[0-9]+){0,2}$" .APP_BUILD}}true{{else}}false{{end}}'
msg: 'Built App has an invalid CFBundleVersion.'
- sh: 'test ! -e {{printf "%s/%s" .DIST_DIR .IMAGE_NAME | shellQuote}} && test ! -L {{printf "%s/%s" .DIST_DIR .IMAGE_NAME | shellQuote}}'
msg: 'This release already exists in dist; existing releases are never overwritten.'
cmds:
- Scripts/packaging-gate.sh {{.APP_PATH | shellQuote}}
- mkdir {{.RUN_DIR | shellQuote}}/stage
- ditto {{.APP_PATH | shellQuote}} {{.RUN_DIR | shellQuote}}/stage/ShadowsocksX-NG2.app
- ln -s /Applications {{.RUN_DIR | shellQuote}}/stage/Applications
- >-
hdiutil create -volname ShadowsocksX-NG2 -format UDZO
-srcfolder {{.RUN_DIR | shellQuote}}/stage {{.IMAGE_PATH | shellQuote}}
- codesign --display {{printf "--extract-certificates=%s/signing-" .RUN_DIR | shellQuote}} {{.APP_PATH | shellQuote}}
- task: package:sign
vars:
RUN_DIR: '{{.RUN_DIR}}'
DMG_PATH: '{{.IMAGE_PATH}}'
- task: package:notarize
vars:
DMG_PATH: '{{.IMAGE_PATH}}'
package:sign:
internal: true
vars:
CERTIFICATE_HASH:
sh: shasum -a 1 {{.RUN_DIR | shellQuote}}/signing-0
IDENTITY: '{{first (splitList " " .CERTIFICATE_HASH)}}'
preconditions:
- sh: '{{if regexMatch "^[0-9a-fA-F]{40}$" .IDENTITY}}true{{else}}false{{end}}'
msg: 'Cannot identify the certificate that signed the App.'
cmds:
- >-
codesign --sign {{.IDENTITY | shellQuote}} --timestamp
--identifier com.qiuyuzhou.ShadowsocksX-NG2.dmg {{.DMG_PATH | shellQuote}}
package:notarize:
desc: Notarize a signed DMG, staple its ticket and publish after verification
dir: '{{.TASKFILE_DIR}}'
requires:
vars: [DMG_PATH, NOTARY_PROFILE]
preconditions:
- sh: 'test -n {{.NOTARY_PROFILE | shellQuote}}'
msg: 'NOTARY_PROFILE must name credentials stored by notarytool store-credentials.'
- sh: '{{if and (hasPrefix "/" .DMG_PATH) (hasSuffix ".dmg" .DMG_PATH)}}true{{else}}false{{end}}'
msg: 'DMG_PATH must be an absolute path ending in .dmg.'
- sh: 'test -f {{.DMG_PATH | shellQuote}}'
msg: 'DMG_PATH does not name a file.'
- sh: 'test ! -e {{printf "%s/%s" .DIST_DIR (base .DMG_PATH) | shellQuote}} && test ! -L {{printf "%s/%s" .DIST_DIR (base .DMG_PATH) | shellQuote}}'
msg: 'This release already exists in dist; existing releases are never overwritten.'
vars:
LOG_DIR:
sh: |
mkdir -p {{.BUILD_DIR | shellQuote}}/notarization
mktemp -d {{.BUILD_DIR | shellQuote}}/notarization/run-XXXXXX
cmds:
- 'printf "Notarization evidence: %s\n" {{.LOG_DIR | shellQuote}}'
- codesign --verify --strict -R {{.DMG_REQUIREMENT | shellQuote}} {{.DMG_PATH | shellQuote}}
- shasum -a 256 {{.DMG_PATH | shellQuote}} > {{.LOG_DIR | shellQuote}}/input.sha256
- >-
xcrun notarytool submit {{.DMG_PATH | shellQuote}}
--keychain-profile {{.NOTARY_PROFILE | shellQuote}} --output-format json
> {{.LOG_DIR | shellQuote}}/submission.json
2> {{.LOG_DIR | shellQuote}}/submission.stderr.log
- task: package:wait
vars:
LOG_DIR: '{{.LOG_DIR}}'
- xcrun stapler staple {{.DMG_PATH | shellQuote}}
- xcrun stapler validate {{.DMG_PATH | shellQuote}}
- codesign --verify --strict -R {{.DMG_REQUIREMENT | shellQuote}} {{.DMG_PATH | shellQuote}}
- spctl --assess --type open --context context:primary-signature --verbose {{.DMG_PATH | shellQuote}}
- task: package:publish
vars:
DMG_PATH: '{{.DMG_PATH}}'
package:wait:
internal: true
vars:
SUBMISSION_ID:
sh: plutil -extract id raw -expect string -o - {{.LOG_DIR | shellQuote}}/submission.json
preconditions:
- sh: '{{if regexMatch "^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$" .SUBMISSION_ID}}true{{else}}false{{end}}'
msg: 'No valid notarization submission ID; inspect submission.json and submission.stderr.log.'
cmds:
- defer:
task: package:notary-log
vars:
SUBMISSION_ID: '{{.SUBMISSION_ID}}'
LOG_DIR: '{{.LOG_DIR}}'
- >-
xcrun notarytool wait {{.SUBMISSION_ID | shellQuote}} --timeout 30m
--keychain-profile {{.NOTARY_PROFILE | shellQuote}} --output-format json
> {{.LOG_DIR | shellQuote}}/result.json
2> {{.LOG_DIR | shellQuote}}/wait.stderr.log
- task: package:accepted
vars:
LOG_DIR: '{{.LOG_DIR}}'
package:accepted:
internal: true
vars:
NOTARY_STATUS:
sh: plutil -extract status raw -expect string -o - {{.LOG_DIR | shellQuote}}/result.json
preconditions:
- sh: '{{if eq .NOTARY_STATUS "Accepted"}}true{{else}}false{{end}}'
msg: 'Apple did not accept this submission; inspect result.json and notary-log.json.'
cmds:
- 'printf "Notarization status: %s\n" {{.NOTARY_STATUS | shellQuote}}'
package:notary-log:
internal: true
cmds:
- cmd: >-
xcrun notarytool log {{.SUBMISSION_ID | shellQuote}}
--keychain-profile {{.NOTARY_PROFILE | shellQuote}}
{{.LOG_DIR | shellQuote}}/notary-log.json
2> {{.LOG_DIR | shellQuote}}/notary-log.stderr.log
ignore_error: true
package:publish:
internal: true
vars:
PUBLISH_DIR:
sh: |
mkdir -p {{.DIST_DIR | shellQuote}}
mktemp -d {{.DIST_DIR | shellQuote}}/.package-XXXXXX
OUTPUT_PATH: '{{.DIST_DIR}}/{{base .DMG_PATH}}'
cmds:
- defer: 'rm -rf {{.PUBLISH_DIR | shellQuote}}'
- ditto {{.DMG_PATH | shellQuote}} {{.PUBLISH_DIR | shellQuote}}/release.dmg
# Linking within dist publishes a complete file atomically and refuses replacement.
- link {{.PUBLISH_DIR | shellQuote}}/release.dmg {{.OUTPUT_PATH | shellQuote}}
- 'printf "Release DMG: %s\n" {{.OUTPUT_PATH | shellQuote}}'
+51 -2
View File
@@ -37,13 +37,62 @@
## 发布门槛
发布前运行发布门槛检查:
### 签名并公证的 DMG
从 `ShadowsocksX-NG2/` 执行。首次准备外部二进制及 Developer ID 签名配置的方法见上文。钥匙串中必须有对应证书及私钥。
先用 Apple 的工具保存公证凭据。此命令交互输入 App 专用密码;凭据保存在钥匙串中,不写入仓库或 Taskfile:
```bash
xcrun notarytool store-credentials shadowsocks-release \
--apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID
```
完整发布入口:
```bash
task package NOTARY_PROFILE=shadowsocks-release \
MARKETING_VERSION=2.1.0 CURRENT_PROJECT_VERSION=21
```
`MARKETING_VERSION` 和 `CURRENT_PROJECT_VERSION` 分别可选。未传的参数使用工程默认值;传入的参数只覆盖本次 Release 构建,不改写 `project.yml`。`MARKETING_VERSION` 接受三个以点分隔的数字,`CURRENT_PROJECT_VERSION` 接受一至三个以点分隔的数字。单独运行 `task build:release` 也支持这两个覆盖参数。
流程由 `Taskfile.package.yml` 编排:检查公证凭据 → 获取外部二进制 → 真实进程冒烟 → Release 构建 → App 打包门槛 → 制作压缩 DMG → DMG 签名 → Apple 公证 → 装订票据 → 签名、票据及 Gatekeeper 检查 → 发布到 `dist/`。构建只支持 arm64。DMG 只包含 App 和指向 `/Applications` 的快捷方式,不增加背景图或 Finder 自动化依赖。
DMG 复用 App 实际签名证书的 SHA-1 指纹选择签名身份,使用安全时间戳。公证仅接收 `NOTARY_PROFILE` 指定的钥匙串配置名称。发布产物示例为 `dist/ShadowsocksX-NG2-2.1.0-21-arm64.dmg`,名称中的版本从构建产物的 Info.plist 读取。已有同名发布产物时明确失败,不覆盖文件。
每次构建使用独立的 `.build/packaging/run-*/`,其中保留签名 DMG、构建产物与冒烟结果。每次公证在 `.build/notarization/run-*/` 保存提交响应、提交编号、等待结果与 Apple 诊断日志。控制台打印这两个目录。提交和等待分别执行,等待中断时仍保留提交编号。公证等待上限为 30 分钟;本地等待结束后 Apple 仍可能继续处理。
公证失败后,可以直接重跑保留的签名 DMG,无须重新构建:
```bash
task package:notarize \
DMG_PATH=/absolute/path/to/ShadowsocksX-NG2-2.1.0-21-arm64.dmg \
NOTARY_PROFILE=shadowsocks-release
```
此入口会重新提交公证。若此前仅等待超时,可以先用保留的提交编号查询 Apple 状态,避免重复提交:
```bash
xcrun notarytool info SUBMISSION_ID --keychain-profile shadowsocks-release
```
`package:notarize` 仅在 Apple 返回 `Accepted` 后装订 DMG 票据。签名、票据和 Gatekeeper 检查全部通过后,才发布完整文件到 `dist/`。失败时不发布新文件。此流程只装订 DMG 票据,不另外分发独立 App。公证重跑入口不代替完整入口中的冒烟和 App 打包门槛;它接收此前完整入口生成的签名 DMG。
`task clean` 删除 `.build/` 中间产物及日志,保留 `dist/`。仓库忽略这两类产物。此流程不上传 GitHub Release。
工具用法见 Apple 的[公证流程](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow)与[分发打包说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution)。
### 单独检查 App
已有构建产物时,可以单独运行发布门槛检查:
```bash
# 发布门槛(必须全绿):真实进程冒烟 + 打包门槛
task test:smoke
task build:release
Scripts/packaging-gate.sh \
.build/derivedData/Build/Products/Debug/ShadowsocksX-NG2.app
.build/derivedData/Build/Products/Release/ShadowsocksX-NG2.app
```
打包门槛只接受 Developer ID 签名的产物:Team 期望值取自 `Configs/Signing.local.xcconfig`(可用 `EXPECTED_TEAM_ID=<Team ID>` 显式覆盖),两者都没有时该断言直接失败,不静默跳过。无证书的 ad-hoc Debug 产物会在此明确失败(`gate: hint` 会说明原因),这是预期行为。