diff --git a/.gitignore b/.gitignore index 679a2812..19bd95b2 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ IDEWorkspaceChecks.plist #### # New project build artifacts ShadowsocksX-NG2/.build/ +ShadowsocksX-NG2/dist/ ShadowsocksX-NG2/DerivedData/ #### diff --git a/ShadowsocksX-NG2/Taskfile.dist.yml b/ShadowsocksX-NG2/Taskfile.dist.yml index 986864d4..dad6afc8 100644 --- a/ShadowsocksX-NG2/Taskfile.dist.yml +++ b/ShadowsocksX-NG2/Taskfile.dist.yml @@ -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 diff --git a/ShadowsocksX-NG2/Taskfile.package.yml b/ShadowsocksX-NG2/Taskfile.package.yml new file mode 100644 index 00000000..ec3a88f0 --- /dev/null +++ b/ShadowsocksX-NG2/Taskfile.package.yml @@ -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}}' diff --git a/docs/distribution.md b/docs/distribution.md index 9265ab81..21470551 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -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=` 显式覆盖),两者都没有时该断言直接失败,不静默跳过。无证书的 ad-hoc Debug 产物会在此明确失败(`gate: hint` 会说明原因),这是预期行为。