# 外部二进制、签名与分发 [English](distribution.md) | [返回工程首页](../ShadowsocksX-NG2/README.zh.md) 除特别说明外,命令从 `ShadowsocksX-NG2/` 执行,文中工程路径也相对于该目录。 ## 外部二进制供应链 外部二进制只用固定版本的上游 release 构建产物:[`sslocal` 清单](../ShadowsocksX-NG2/Vendor/sslocal/manifest.json)(装于 `Contents/Helpers/`)、[`v2ray-plugin` 清单](../ShadowsocksX-NG2/Vendor/v2ray-plugin/manifest.json) 与 [`ck-client` 清单](../ShadowsocksX-NG2/Vendor/ck-client/manifest.json)(两个插件均装于 `Contents/Helpers/Plugins/`)。清单记录各 release 与哈希;构建链如下(spec #21 D6/D10): 1. **fetch** — `Scripts/fetch-external-binaries.sh` 获取清单固定 URL 的资产、逐字节校验 `archiveSHA256`,按 `assetFormat` 处理 tar 归档(默认,提取 `archiveMember`)或直接二进制(`binary`),并确认 arm64,落盘 `Vendor//` 并记录戳。资产哈希不符 → 立即失败;本地二进制缓存漂移 → 自动重新下载复验。CI 可设置 `EXTERNAL_BINARY_CACHE_DIR` 复用已下载归档;每个缓存归档使用前都会按清单哈希校验,缓存损坏时会删除并重新下载。 2. **generate** — `xcodegen generate` 把清单对应二进制作为 Copy Files 项装入 `Contents/Helpers[/Plugins]`。注意顺序:**先 fetch 再 generate**,文件在生成时必须已存在,否则 Copy Files 阶段会被静默丢弃(generate 也会直接报缺文件)。 3. **verify on every build** — 预构建脚本阶段重跑 fetch 脚本(缓存命中时只对本地戳快速复验),哈希或清单漂移使构建即失败。 4. **sign** — `Scripts/sign-embedded-helpers.sh`(post-build 阶段)对每个二进制 Developer ID 重签:`--options runtime --timestamp --identifier <清单 signIdentifier>`(插件形如 `.plugin.`);随后 Xcode 完成 app 外层签名,封存重签后的嵌套代码。 5. **gate** — 构建产物必须过 `Scripts/packaging-gate.sh `:嵌套代码签名严格校验、主应用 DevID + Hardened Runtime 且无 App Sandbox entitlement、逐二进制清单覆盖(Helpers 下每个 Mach-O 都在清单内且 DevID 重签通过)。篡改任一二进制会因签名失效被断言发现。 ## 签名与运行形态基线 - bundle id `com.qiuyuzhou.ShadowsocksX-NG2`,与 Legacy(`com.qiuyuzhou.ShadowsocksX-NG`)区分;运行时数据落 `~/Library/Application Support/ShadowsocksX-NG2/`。macOS 15+ / arm64。 - Hardened Runtime(DevID 发布路径;免证书的 ad-hoc Debug 例外,详见下面的配置说明);不启用 App Sandbox,entitlements 为空 dict;签名样式固定 `Manual`。 - **证书与 Team 都不入库**(开源仓库):`project.yml` 不含 `CODE_SIGN_IDENTITY` / `DEVELOPMENT_TEAM`,两个键由 project xcconfig 按 config 在构建期注入: - `Configs/Signing-Debug.xcconfig`(入库)基线是 **ad-hoc(`-`)**:新 clone 与 CI 不需要任何证书就能 `task build` / `task test`。arm64 要求代码至少有 ad-hoc 签名才会被加载,所以「免证书」不是「不签名」。ad-hoc 产物不带 Hardened Runtime(Xcode 对 ad-hoc 签名不传 `-o runtime`,实测;`Scripts/sign-embedded-helpers.sh` 的 ad-hoc 分支同样不加),Hardened Runtime 由 DevID 发布路径与打包门槛断言。 - `Configs/Signing-Release.xcconfig`(入库)基线是 **Developer ID Application**:发布产物必须真签名,这里不给免证书路径。 - 两者都可选包含 `Configs/Signing.local.xcconfig`(gitignore)。有证书的人(含维护者)用它升级本机身份,例如让 Debug 也走 Developer ID: ```bash cat > Configs/Signing.local.xcconfig <<'EOF' DEVELOPMENT_TEAM = YOUR_TEAM_ID CODE_SIGN_IDENTITY = Developer ID Application EOF ``` - 该文件缺失时 Debug 构建为 ad-hoc(可跑单测),Release 构建以 Xcode 的「Signing for … requires selecting either a development team or a provisioning profile」明确失败,不静默降级。 - 测试 target(单测与冒烟)身份随同一基线:去宿主化(ADR-0021)后 xctest 包不再注入宿主 app,ad-hoc 也能加载执行(实测),因此测试无需证书。 - 注意 `project.yml` 一旦重新设置这两个键,pbxproj 构建设置会盖掉 xcconfig(`Tests/Architecture/SigningConfigurationTests.swift` 守卫该约束)。 - Release 配置关闭 `CODE_SIGN_INJECT_BASE_ENTITLEMENTS`,保证发布产物 entitlements 只来自项目文件。 - Release 配置通过 `OTHER_CODE_SIGN_FLAGS = $(inherited) --timestamp` 为 Xcode 签名的主程序和两个原生工具请求安全时间戳。打包门槛在上传公证前检查这些时间戳;DMG 的时间戳不能替代包内可执行文件的时间戳。 ## 发布门槛 ### 签名并公证的 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 ``` `task package`、`task build:release` 和 `task i:release` 必须提供非空的 `MARKETING_VERSION` 与 `CURRENT_PROJECT_VERSION`,不使用工程默认版本。参数可以通过命令行、环境变量或本机 `.env` 提供,只覆盖本次 Release 构建,不改写 `project.yml`。`MARKETING_VERSION` 接受三个以点分隔的数字、可选的 `v` 前缀和以 `-` 开头的预发布后缀。后缀中的标识符使用 ASCII 字母、数字或连字符,以点分隔,例如 `v2.0.0-alpha-1`、`2.0.0-beta.2` 和 `v2.0.0-rc.1`。`CURRENT_PROJECT_VERSION` 接受一至三个以点分隔的数字。`package:notarize` 复用已有 DMG,不要求重新提供版本参数。 预发布构建示例: ```bash task package NOTARY_PROFILE=shadowsocks-release \ MARKETING_VERSION=v2.0.0-alpha-1 CURRENT_PROJECT_VERSION=22 ``` DMG 文件名保留完整发布版本:`ShadowsocksX-NG2-v2.0.0-alpha-1-22-arm64.dmg`。Task 用计算变量提取 `2.0.0` 传给 Xcode,因此 App 的 `CFBundleShortVersionString` 仍符合 Apple 的三段数字格式。预发布后缀不写入这个字段;App 内的预发布构建用递增的 `CURRENT_PROJECT_VERSION` 区分。不同 alpha、beta、rc 和正式构建都应继续递增构建号。 流程由 `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`。名称保留传入的完整发布版本。构建号始终从 App 的 Info.plist 读取。打包前校验发布版本的数字部分与 App 版本一致,防止文件名与包内版本不符。已有同名发布产物时明确失败,不覆盖文件。 `package` 根据必填版本参数,在工具检查、公证凭据检查、冒烟和构建之前检查同名产物。制盘、公证和最终发布时仍保留覆盖保护,防止并发打包产生覆盖。 每次构建使用独立的 `.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/`。仓库忽略这两类产物。`task package` 完成后使用下面的独立入口上传 GitHub Release。 工具用法见 Apple 的[公证流程](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow)与[分发打包说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution)。 ### 创建 GitHub Release 草稿 打包完成后,从干净且与 `origin/develop` 同步的 `develop` 分支执行。需要 Git、Task 3.47+(使用命令级 `if`)和已登录的 GitHub CLI(`gh auth login`)。发布目标取自 `origin`,其 fetch 与 push URL 必须指向同一个 GitHub 仓库。 ```bash task publish MARKETING_VERSION=2.1.0 CURRENT_PROJECT_VERSION=21 \ RELEASE_NOTES_FILE=/absolute/path/to/release-notes.md ``` 两个版本参数必须与 package 时完全相同。入口只接受对应的 `dist/ShadowsocksX-NG2-{MARKETING_VERSION}-{CURRENT_PROJECT_VERSION}-arm64.dmg`,不会重新打包或猜测最新文件。Release note 文件可省略: ```bash task publish MARKETING_VERSION=v2.1.0-rc.1 CURRENT_PROJECT_VERSION=22 ``` 创建新草稿且省略 `RELEASE_NOTES_FILE` 时,Task 的 `prompt` 会在任何推送之前询问是否使用 GitHub 自动生成的说明。拒绝则取消;无终端时确认失败。显式使用 `task --yes publish ...` 会自动确认,符合 Task 的标准行为。提供文件或复用已有 Release 时不再询问。 流程由 `Taskfile.publish.yml` 编排,按顺序执行内部任务:核对工作区、分支、tag、既有 Release 与 DMG → 在 `develop` 提交上创建并推送 tag → 将 `develop` 以 `--no-ff` 合并到 `master` 并推送 → 使用 `gh release create --draft` 创建草稿并上传 DMG → 打开浏览器审核。成功后回到 `develop`。本地没有 `master` 时从 `origin/master` 创建跟踪分支;已有本地 `master` 必须与远端同步,或恰好是本流程在推送失败后留下的待推送合并提交。 Tag 始终只有一个 `v` 前缀,不包含 build number,例如 `v2.1.0`、`v2.1.0-beta.2`、`v2.1.0-rc.1`。任何预发布后缀都会设置 GitHub prerelease 标记。草稿说明来自显式文件或 `gh --generate-notes`;浏览器打开后,维护者审核说明、DMG 和预发布标记,再手动正式发布。 Task 用 `preconditions` 检查冲突、`status` 跳过已完成步骤、`if` 选择需要执行的命令、`defer` 清理下载目录及发布锁并恢复分支。中途失败可以使用相同参数重跑。指向本次 `develop` 提交的 tag、已经包含该提交的远端 `master` 和目标一致的 draft 可复用。已有同名 DMG 会下载到临时目录并用 `cmp` 逐字节比较;仅缺少 DMG 时补传,保留草稿中已编辑的说明。同 tag 的正式 Release 若目标与资产都匹配,报告已完成。Tag 指向、Release 目标、预发布标记或 DMG 不匹配时停止,不覆盖资产或强推历史。合并冲突会保留在 `master`,需先解决冲突或执行 `git merge --abort`,再回到 `develop` 重跑。 发布锁位于 Git 公共目录下的 `ng2-publish.lock/`。若进程被强制终止而未执行清理,确认没有其他发布进程后,删除这个空目录即可重跑。 ### 单独检查 App 已有构建产物时,可以单独运行发布门槛检查: ```bash # 发布门槛(必须全绿):真实进程冒烟 + 打包门槛 task test:smoke task build:release MARKETING_VERSION=2.1.0 CURRENT_PROJECT_VERSION=21 Scripts/packaging-gate.sh \ .build/derivedData/Build/Products/Release/ShadowsocksX-NG2.app ``` 打包门槛只接受 Developer ID 签名的产物:Team 期望值取自 `Configs/Signing.local.xcconfig`(可用 `EXPECTED_TEAM_ID=` 显式覆盖),两者都没有时该断言直接失败,不静默跳过。无证书的 ad-hoc Debug 产物会在此明确失败(`gate: hint` 会说明原因),这是预期行为。 ## 依赖升级 升级任一外部二进制是显式供应链动作:人工复验新 release(下载归档、静态观测哈希/架构/签名,不执行),随后在一次 PR 中同时改 `Vendor//manifest.json` 的 release/asset/url/archiveSHA256 与对应测试锚点。不接受 `latest`,不在仓库内构建,不做应用内更新通道(升级仅随 app 发版)。