LeakCanary 仓库中的 Shark Explorer 桌面应用发布流程版本约束、CI 编排与 macOS 签名实战【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanaryShark Explorer 是 LeakCanary 仓库里一个独立于 LeakCanary 库发布的桌面应用用于可视化打开并分析 hprof 堆转储文件它走的是自己的版本线、自己的 tag、自己的 GitHub Release 工作流。本文以仓库文档 docs/releasing-shark-explorer.md 为骨架结合 .github/workflows/release-shark-explorer.yml、.github/workflows/promote-shark-explorer.yml、UpdateCheck.kt 与 entitlements.plist 等源码证据完整还原一次 Shark Explorer 发布的全过程从版本号约束、打 tag 触发 CI到发布后另行通知的 promote 机制再到 macOS 签名/公证的细节与踩坑记录。一、两条发布线Shark Explorer 与 LeakCanary 各走各的Shark Explorer 是一个桌面应用而不是库因此它与 LeakCanary 分开发布。两者唯一的共同点是共享同一个仓库。这份发布说明docs/releasing-shark-explorer.md对应的是 Shark Explorer 这条线LeakCanary 库自身的发布流程记录在 docs/releasing.md两条线互不共享任何机制。两条发布线的对比总结如下LeakCanaryShark ExplorerTagsv3.0-alpha-10shark-explorer-1.0.0版本存放位置VERSION_NAMESHARK_EXPLORER_VERSION发布去向Maven CentralGitHub Release发布工作流publish-release.ymlrelease-shark-explorer.yml变更日志docs/changelog.mddocs/shark-explorer-changelog.md两条发布线意味着两份变更日志。Shark Explorer 的变更永远不会写进 LeakCanary 的变更日志反之亦然——因为读者各自只关心其中一条发布线。从当前仓库状态看gradle.properties中VERSION_NAME3.0-alpha-10-SNAPSHOT与SHARK_EXPLORER_VERSION1.0.0同时存在正好是两套版本体系的实例docs/shark-explorer-changelog.md 也明确写道该应用按自己的节奏发布有自己的变更日志且复用 LeakCanary 变更日志的条目标记⚠️ 破坏性变更、 行为变更、 崩溃修复、 缺陷修复、✨ 新功能、 改进只是去掉了新识别的库泄漏那一条。二、版本号不能说alpha所以由 Release 来说SHARK_EXPLORER_VERSION只能由三个整数组成不能多也不能少。原因在于Shark Explorer 的每个安装包格式都会校验版本号而各格式的约束交集只允许MAJOR.MINOR.PATCH这种形态MAJOR 必须介于 1 到 255 之间macOS 拒绝 MAJOR 为 0MSI 拒绝任何大于 255 的值MINOR 最大 255PATCH 最大 65535。因此没有任何数字能表达1.0 之前0.1.0不行macOS 拒绝 MAJOR 0日历版本不行-alpha-1之类的限定符也不行。这一点在 gradle.properties 的注释中有完整记录——What those formats accept, measured by building each通过实际构建各安装包测得的约束并且说明这正是3.0-alpha-10无法用于构建 DMG 的原因。由此得出的推论是这是 alpha这句话由 Release 说出来而不是由版本号说出来。.github/workflows/release-shark-explorer.yml 会把每个发布都标记为prerelease并把 Release 标题写成Shark Explorer version (alpha)。当应用不再处于 alpha 阶段时才需要去掉这个标记和标题中的(alpha)。三、打一个发布版本设版本 → 写变更日志 → 打 tag → 等 CI3.1 顺序很重要先改版本再打 tag发布流程的起点是设置版本号并打 tag其余交给 CI。关键是顺序工作流会拒绝执行 tag 与SHARK_EXPLORER_VERSION不一致的构建所以必须先改版本、提交合并再打 tag。文档给出的完整命令如下printf Version being released (e.g. 1.0.1): read NEW_VERSION git checkout main git pull \ git checkout -b shark_explorer_$NEW_VERSION \ sed -i s/SHARK_EXPLORER_VERSION.*/SHARK_EXPLORER_VERSION$NEW_VERSION/ gradle.properties这段命令从main拉取最新代码、新建名为shark_explorer_版本的分支并把 gradle.properties 里的SHARK_EXPLORER_VERSION替换为新版本号macOS/BSD 风格的sed -i 。3.2 更新变更日志并提交然后把 docs/shark-explorer-changelog.md 中的## Unreleased标题改名为## Version $NEW_VERSION (日期)检查它是否完整列出了上次发布以来的所有变更随后提交${EDITOR:-vi} docs/shark-explorer-changelog.md \ git commit -am Release Shark Explorer $NEW_VERSION3.3 合并、打 tag、推送、盯工作流将该分支合并回main然后打 tag 并推送最后用ghCLI 实时跟踪发布工作流的执行直到退出成功git tag shark-explorer-$NEW_VERSION \ git push origin shark-explorer-$NEW_VERSION \ gh run watch $(gh run list --workflowrelease-shark-explorer.yml --limit 1 --json databaseId --jq .[].databaseId) --exit-status3.4 没有-SNAPSHOT舞蹈与 LeakCanary 不同Shark Explorer 发布不涉及任何-SNAPSHOT版本操作没有任何东西把该版本当作依赖来消费所以main分支在两次发布之间一直带着上一个已发布版本号也毫无代价。3.5 CI 究竟构建什么.github/workflows/release-shark-explorer.yml 的构建产物分两路macOS arm64 与 x64 两个 DMG签名并公证。为什么是两个构建而不是一个 universal 包因为 jpackage 只会为它当前运行的架构生成瘦二进制没有 universal 选项所以 Apple Silicon 与 Intel 必须分别在macos-15和macos-15-intel两个 runner 上构建。工作流注释还特别提醒macos-15-intel是 GitHub 提供的最后一个 x86_64 macOS 镜像将在 2027 年 8 月下线届时要么移除矩阵中的 Intel 条目要么 Intel 用户停留在最后一个版本。Windows.msi与 Linux.deb不签名。工作流开头的versionjob 会先单独校验 tag 与版本一致性——之所以单独成 job是为了让不一致只浪费一个快速 job而不是等四个打包 job 跑完、构建出一个错误版本的 Release 之后才发现。构建使用packageDmg/packageMsi/packageDeb注意不是packageReleaseDmg后者会把产物交给 R8 混淆而 Shark 的对象检查器按字段名读取字段混淆是需要单独测试的变更不适合与首次发布捆绑在一起另外所有 job 都显式禁用了 Actions 缓存cache-disabled: true因为缓存条目在发布产物会被公众下载macOS 产物还以 Block 名义签名的场景下属于不可信输入而发布一年只跑几次不值得为此冒险。四、发布 ≠ 通知promote 是独立的一步发布一个 Release 并不会把它提供给任何人。应用只检查一个文件——滚动 Releaseshark-explorer-latest上的latest.properties资产——而只有 .github/workflows/promote-shark-explorer.yml 会写它。发布完成后需要手动运行gh workflow run promote-shark-explorer.yml -f version$NEW_VERSION因此正确的做法是先安装这个 Release 并用它打开一个堆转储文件做验证然后再执行 promote。这样即使某个 Release 事后被发现是坏的它也只是一个没人被告知过的版本而不是一个必须撤回的版本——发布与告知被刻意拆成了两个动作。promote 工作流还做了两件防御性的事先检查目标 Release 确实存在且带有两个 macOS DMG 资产Shark-Explorer-$VERSION-macos-arm64.dmg与Shark-Explorer-$VERSION-macos-x64.dmg没有就报错拒绝推进写完 manifest 后再从应用实际拉取的 URL 反复重试最多 10 次、每次间隔 15 秒确认 CDN 已切换到新版本——因为 CDN 在上传后的短暂时间内仍会服务旧资产。4.1 为什么用 release 资产而不是 GitHub API这个机制里有两处看起来像事故、其实是有意为之的设计它不是 GitHub API。releases/latest返回的是两条发布线中最新的那个 Release而这个仓库同时以v*tag 发布 LeakCanary所以该端点通常答非所问——返回的往往完全是错误的那个 Release。另外未认证 API 每小时每 IP 只有 60 次请求配额一个共享的企业出口 IP 很容易被耗尽而 release 资产是普通的、不限量的 CDN 下载。应用只负责报告。它只显示一条条提示新版本名称的横幅并附上链接不会自行下载或安装任何东西。见 UpdateCheck.kt 的类注释一个 JVM 应用无法在运行中替换自己除非借助原生 helper而让应用重写自己已签名的 bundle 远比放一个链接复杂得多。4.2 应用侧的更新检查实现从源码看更新检查的完整链路非常清晰UpdateCheck.kt当前版本来自SharkExplorerVersion.current如果运行的是一个不知道自己版本号的开发构建UNKNOWN_VERSION则直接跳过检查避免把每次开发构建都当成待更新拉取 manifest 失败离线、代理、GitHub 宕机只记日志不弹窗parseReleaseManifest()用java.util.Properties解析.properties格式的 manifest——刻意不用 JSON因为那样会引入一个应用别处用不到的依赖HTML 404 页面也能被 Properties 成功加载但读不到version自然返回 nullisNewerVersion()按MAJOR.MINOR.PATCH逐段比较缺失段按 0 计所以0.2大于0.1.9任何非数字内容返回 false 而不是抛异常——读不懂的 manifest 不是告诉用户应用过期的理由请求超时设为 10 秒因为检查更新发生在一个用户在等待窗口的时候一个永远不结束的检查等于从未触发。4.3 Release 说明与站点部署Release 的说明文字会链接到变更日志页面而该页面要等站点部署完成才生效rm -rf docs/api ./gradlew siteDokka mkdocs gh-deploy这一步与 releasing.md 中的 LeakCanary 发布共享两点理由也相同其一siteDokka不是可选的——虽然 Shark Explorer 的发布完全不涉及 API 参考文档但docs/api是生成且被 git 忽略的目录不先运行siteDokka就直接gh-deploy发布的站点上 API 页面会全部 404其二这条命令从你的本地 checkout 部署整个站点所以务必在main分支上运行而不是在带着无关文档工作的分支上运行。五、macOS 签名与公证5.1 谁在签名Block 内部签名服务 OIDCmacOS 签名由block/apple-codesign-actionv1.1.0在 release-shark-explorer.yml 中引用处理通过 Block 的内部签名服务以Developer ID Application: Block, Inc.身份完成签名与公证。本仓库里不存放任何证书或 Apple 凭据工作流通过 OIDC 认证签名发生在仓库之外——这正是在公共仓库里做签名也是安全的的原因。工作流需要两个仓库 secretsOSX_CODESIGN_ROLECODESIGN_S3_BUCKET这两个 secret 需要向#mdx-ios团队申请配置——该团队在 Block 负责 macOS 桌面应用以及 iOS 应用的 Apple 代码签名block/qrgo、block/buzz等应用的签名也走这条通道。OIDC 体现在工作流的permissions上macOS job 声明了id-token: write让签名服务可以直接认证该工作流而不是通过一个可能泄漏的密钥。5.2 关于签名结果要知道的两件事DMG 容器本身是未签名的。签名服务对.app签名然后在其外围重建 DMGGatekeeper 评估的是应用本体——这才是必须通过的部分。工作流在 Release 前会对 DMG 内的应用依次运行codesign --verify、stapler validate和spctl --assess并打印输出务必阅读这些输出尤其是首次发布时。entitlements.plist 不是可选的。其中的每一项都是 JVM 运行时需要、而 hardened runtime 默认禁止的能力一个缺少它们的公证构建会启动后立即死亡。该文件包含四个键对应关系如下键为什么 JVM 需要它com.apple.security.cs.allow-jitHotSpot 会把字节码编译进随后执行的内存com.apple.security.cs.allow-unsigned-executable-memory同样是 JIT 编译执行代码所需com.apple.security.cs.allow-dyld-environment-variablesjpackage 生成的启动器会把自己的 dyld 路径传给 JVMcom.apple.security.cs.disable-library-validation应用会加载 Skia 与 JDK 的 dylib它们携带的 team id 与 bundle 自身的签名不同5.3 签名与公证的验证步骤来自 CI 本身工作流对返回的 DMG 做了四道独立检查release-shark-explorer.ymlcodesign --verify --deep --strict验证签名、codesign -dvv打印证书 Authority/TeamIdentifier/flags、spctl --assess评估 Gatekeeper、xcrun stapler validate验证公证票据。其中stapler validate失败是致命的——因为签名服务签了、但 Apple 没有公证的应用不会启动实测在 macOS 26.5 上表现为在 dyld 中挂起无输出无日志而不是被拒绝而且它往往是唯一能暴露该问题的检查codesign对此满意spctl --assess对Apple 没有公证记录的构建也回答 accepted因为本地没有任何东西携带触发 Gatekeeper 检查的 quarantine 属性只有stapler会失败。因此这条检查是绿色 Release与打开就挂死的 DMG之间的分水岭。5.4 Windows 与 LinuxWindows 产物未签名——签名它需要 Azure Trusted Signing那是另一套服务、另一个申请流程。Linux.deb不需要签名。工作流注释对此的表述很直白构建它们是因为构建脚本本来就已经覆盖了这两个平台未签名构建好过没有构建。六、Managed Software Center仅 Block 员工这一步是可选的且只为了可发现性而做——应用内更新检查已经覆盖了更新推送。做法是在go/cpeticket提交一个 ticket附上仓库信息、bundle IDcom.squareup.leakcanary.shark-explorer、一个 release 资产 URL安装类型选择 optional。CPE 团队会自行搭建 AutoPkg 流水线和 Munki recipes每天自动拾取新的 GitHub Release。一个值得注意的细节Munki 使用CFBundleShortVersionString比较版本而该字段的值就是SHARK_EXPLORER_VERSION——这再次说明为什么这个字段不能钉死在一个与 Release 不联动的东西上。七、首次签名运行踩过的坑三个互相掩盖的故障以下内容是文档记录的真实排障经历在 2026-08-04 对着真实签名服务实测使用的 tag 对应的 Release 被强制设为 draft 后删除。一共发现三个独立故障而且它们互相掩盖Apple 拒绝了应用、签名服务把拒绝报成了成功、应用名里的一个空格又破坏了本应揭示真相的回执。7.1 故障一Apple 因一个任何签名者都看不见的 dylib拒绝整个应用此问题已在仓库内修复修复位置是 shark-explorer-app/build.gradle.kts在打包阶段把应用镜像中残留的 dylib 删除。但了解它仍然值得因为失败的表象完全不指向原因。机理是这样的skiko-awt-runtime-macos-arm64这个包同时携带两种架构的 dylib各 21 与 22 MB。Compose 会把当前打包架构的那一份解压到应用目录中——启动器通过-Dskiko.library.path$APPDIR确保加载的是这一份——而把另一架构的 dylib 留在 jar 里。没人加载那一份签名器也够不到它签名器遍历的是文件而那是 zip 里的一个条目。但Apple 的公证服务会打开 jar。于是公证服务因为skiko-awt-runtime-macos-arm64-*.jar/libskiko-macos-x64.dylib拒绝了整个应用报错The binary is not signed with a valid Developer ID certificate——而签名器可见的全部 32 个文件都签名正确、都带安全时间戳嵌套的Contents/runtimebundle 也封签完好。这也是为什么任何本地检查都发现不了它codesign --verify --deep --strict在返回的 DMG 上通过spctl --assess接受四个 entitlements 也都在。它付出的代价是整个应用一个 Apple 没有公证记录的应用不会启动但也不会报错失败——它在dyld里挂住无输出、无会话日志而同一 bundle 用 ad hoc 重新签名后两秒就能启动。所以签名过的 DMG 打开后挂死意味着公证问题而不是 entitlements 问题。修复实现build.gradle.kts做的是挂钩createDistributable任务遍历应用镜像中每个 jar列出其中的.dylib与.dylib.sha256条目.sha256一并删除因为 skiko 校验的是它实际加载的那份解压副本的哈希删除前先确认解压副本确实存在于 jar 旁边否则抛GradleException——防止误删唯一副本。改动作用在应用镜像而非模块解析到的 jar 上是因为run和测试还要从 jar 加载 skiko而镜像才是只剩单一架构的第一个时点所有安装包格式都由该镜像渲染在这里清理即可同时覆盖 DMG。7.2 故障二一次拒绝被报成了成功apple-codesign/lib/notarize.sh位于squareup/mdx-ios-codesign-helper中的notarize()函数读取的是xcrun notarytool submit --wait的退出状态而不是它请求的 JSON 里的status字段——于是一次拒绝被记成了 Notarization complete流水线交回的是一个已签名但未公证的 DMG。它还丢弃了那份 JSON而提交 id 就在里面——由于产物上不携带任何痕迹提交 id 是拿到 Apple 拒绝理由的唯一抓手。两半问题都在squareup/mdx-ios-codesign-helper#20中修复那次合并之后的运行在同一个 bundle 上失败了而此前它通过过这恰恰说明修复生效了。签名失败的原因只有在 Buildkite 里才读得到。lambda 会把任何失败构建折叠成Poll request failed with status 400所以那个 PR 新增的notarytool log输出永远到不了 GitHub Actions 日志。要看它得打开buildkite.com/runway/mdx-ios-codesign-helper上的构建——这是 Block 内部系统所以预期需要找有权限的人。7.3 故障三应用名里的空格破坏了回执而非签名本身服务正确地给Shark Explorer.app签了名——Buildkite job 通过并上传了签名 zip——然后 lambda 在确定自己把文件放哪了这一环节失败bad URI(is not URI?): s3://…/Shark Explorer.app.zip此时 mac worker 已经做完了所有工作。原因是global/lambdas/codesign_helper.rb中的destination_url用 Ruby 的URI()解析该 S3 URL 以在扩展名前插入-signed而空格不是合法的 URI 字符。squareup/tf-mobuild-workers#1365修复了它于是名字里又可以有空格了。合并 PR 不等于上线——这与上面的notarize.sh修复不同而那一点最值得记住Buildkite 在构建时从 git checkout 里读取脚本所以前一个修复合并即生效而这个 lambda 是 Ruby由 terraform 打包合并后会继续服务已部署的 zip。PR 上的每个检查都只是plan。合并后的一次带 tag 构建仍然以同样的bad URI失败真正让它上线的是mobuild-workers-rollout这个 CodePipelinestaging 自动应用production 停在 AWS 控制台的一道审批上而只有mobuild-workers-human-role组能给出审批。所以对那个仓库里的任何东西而言要等的是 rollout 而不是 merge并且需要该组成员在场。7.4 结论现在的位置两个 macOS 构建都能以 Block 身份签名、公证并加贴票据返回spctl显示sourceNotarized Developer IDstapler validate返回The validate action worked!——首次实测于构建 1622 与 1623。这两行输出就是一个 Release 必须呈现的结果工作流宁可失败也不会发布任何达不到此标准的东西。这一结论也传导到了下载者得到的产物从 Release 下载、带有浏览器下载所加的com.apple.quarantine属性的 DMG正是它让 Gatekeeper 坚持要公证票据能够被接受应用能正常打开堆转储文件。所以 macOS 已经达到可发布状态。在复测前还须知道一件事因为它看起来和上面的公证挂起一模一样一个带 quarantine 属性的应用首次启动会等待屏幕解锁——无输出、无日志文件、无 CPU 占用。同一 bundle、同一条命令、同一个 quarantine 属性面对锁屏超过五分钟毫无反应解锁后四秒完成。Gatekeeper 要在首次启动时看到真人所以请在键盘前验证 Release。八、发布检查清单可操作总结综合上述流程一次完整的 Shark Explorer 发布可以收敛为以下步骤新建分支把 gradle.properties 中SHARK_EXPLORER_VERSION改为新版本三个整数MAJOR 1–255将 docs/shark-explorer-changelog.md 的## Unreleased改为## Version X.Y.Z (日期)并核对条目提交合并回main打 tagshark-explorer-X.Y.Z并推送用gh run watch观察 release-shark-explorer.yml 直到成功——它会构建并验证 macOS arm64/x64 签名公证产物以及未签名的 Windows.msi、Linux.deb从 Release 下载 DMG 实测在解锁屏幕前、在键盘前确认能打开堆转储验证通过后再运行gh workflow run promote-shark-explorer.yml -f versionX.Y.Z向所有运行中的实例推送更新提示在main上运行rm -rf docs/api ./gradlew siteDokka mkdocs gh-deploy部署站点否则 Release 说明里的变更日志链接会 404。整个机制的核心设计可归纳为一句话构建出什么由release-shark-explorer.yml负责告诉谁由promote-shark-explorer.yml负责版本能不能这么说由安装包格式说了算——三者解耦才让一个 alpha 桌面应用可以在公共仓库里安全、可回退地持续发布。【免费下载链接】leakcanaryA memory leak detection library for Android.项目地址: https://gitcode.com/gh_mirrors/le/leakcanary创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
