From da5653a0ed5e14b1d121132e817a136227e16795 Mon Sep 17 00:00:00 2001 From: Santos Date: Mon, 3 Aug 2026 23:10:05 +0800 Subject: [PATCH 1/2] ci(github): verify notarized release DMGs --- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++++--- CONTRIBUTING.md | 3 ++ docs/release.md | 17 ++++++++-- docs/workflows.md | 3 +- scripts/release.test.js | 18 +++++++++++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecb8089..d38afa2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain security find-identity -v -p codesigning build.keychain - - name: Build, sign, and notarize DMG + - name: Build and sign app/DMG uses: tauri-apps/tauri-action@v1.0.0 env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} @@ -108,10 +108,12 @@ jobs: uploadUpdaterJson: false args: --target universal-apple-darwin --bundles app,dmg - - name: Verify signed DMG - id: verify_dmg + - name: Notarize and staple DMG + id: notarize_dmg env: - RELEASE_VERSION: ${{ steps.release_version.outputs.version }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail @@ -124,6 +126,48 @@ jobs: fi dmg_path="$(printf '%s\n' "$dmg_list" | sed -n '1p')" + notary_result="$(mktemp)" + cleanup() { + rm -f "$notary_result" + } + trap cleanup EXIT + + xcrun notarytool submit "$dmg_path" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > "$notary_result" + notary_status="$(node -e ' + const fs = require("node:fs"); + const result = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(result.status || ""); + ' "$notary_result")" + if [ "$notary_status" != "Accepted" ]; then + echo "DMG notarization did not reach Accepted status (status: ${notary_status:-unknown})." >&2 + exit 1 + fi + + xcrun stapler staple -v "$dmg_path" + xcrun stapler validate "$dmg_path" + spctl --assess --type open -vv "$dmg_path" + + echo "dmg_path=$dmg_path" >> "$GITHUB_OUTPUT" + + - name: Verify signed DMG + id: verify_dmg + env: + RELEASE_VERSION: ${{ steps.release_version.outputs.version }} + DMG_PATH: ${{ steps.notarize_dmg.outputs.dmg_path }} + run: | + set -euo pipefail + + if [ ! -f "$DMG_PATH" ]; then + echo "The notarized DMG path is missing: $DMG_PATH" >&2 + exit 1 + fi + + dmg_path="$DMG_PATH" asset_name="SkillBox_${RELEASE_VERSION}_universal.dmg" mount_dir="$(mktemp -d)" cleanup() { @@ -132,6 +176,9 @@ jobs: } trap cleanup EXIT + codesign --verify --verbose=2 "$dmg_path" + xcrun stapler validate "$dmg_path" + spctl --assess --type open -vv "$dmg_path" hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" app_path="$mount_dir/SkillBox.app" if [ ! -d "$app_path" ]; then @@ -140,7 +187,8 @@ jobs: fi codesign --verify --deep --strict --verbose=2 "$app_path" - spctl -a -vv --type exec "$app_path" + xcrun stapler validate "$app_path" + spctl --assess --type exec -vv "$app_path" app_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app_path/Contents/Info.plist")" if [ "$app_version" != "$RELEASE_VERSION" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7385689..aca3e43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,6 +254,9 @@ Public releases must be: - tagged as `v*`; - built as universal macOS DMGs; - signed and notarized before direct install instructions are published; +- stapled and independently accepted by `xcrun stapler validate` and + `spctl --assess --type open` at the DMG level, with the mounted app checked + separately; - accompanied by `SHA256SUMS` and a DMG-specific `.sha256` asset; - compatible with the Homebrew tap cask; - clear that `~/.skillbox` is user data and is not removed by normal uninstall. diff --git a/docs/release.md b/docs/release.md index 9991210..912af2b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -3,6 +3,11 @@ SkillBox releases target macOS 14+ and publish a signed, notarized, universal DMG plus Tauri updater artifacts through GitHub Releases. +The DMG itself is a release artifact and must be submitted to Apple notarization, +reach `Accepted`, be stapled, and pass both `xcrun stapler validate` and +`spctl --assess --type open` before it can be published. App-level signing and +mounted-app checks do not replace these DMG-level checks. + ## Release Identity - Publishing account: `santosli` @@ -64,8 +69,9 @@ The command: - runs the `Release` workflow once through `workflow_dispatch` as a no-publish dry run; - creates and pushes the `v` tag; -- waits for the tag-triggered Release workflow to build, notarize, mount, - verify, publish, upload updater artifacts, and upload checksums; +- waits for the tag-triggered Release workflow to build, submit the DMG to + notarization, wait for `Accepted`, staple and validate the DMG, mount and + verify the app, publish, upload updater artifacts, and upload checksums; - reads the published DMG checksum from GitHub Releases; - verifies the published release includes the DMG, updater archive, updater signature, and `latest.json`; @@ -92,6 +98,13 @@ GitHub release labels. ## Smoke Test - Install the DMG on a fresh macOS user profile. +- Verify the downloaded DMG itself before opening it: + + ```sh + xcrun stapler validate SkillBox__universal.dmg + spctl --assess --type open -vv SkillBox__universal.dmg + ``` + - Verify Gatekeeper accepts the app: ```sh diff --git a/docs/workflows.md b/docs/workflows.md index c8015b0..024edb1 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -937,7 +937,8 @@ Apply fast-forward: - 有可用更新时,Tauri 保存最近一次成功 metadata check 得到的进程内 pending update;侧边栏显示 Update,Settings 显示版本、notes 和安装按钮。 - 点击安装时先执行一次 force check,确认版本仍然可用,再调用 `install_app_update` 下载、验证、安装并重启。进程内 pending 缺失时,Rust 也必须重新检查,不能从 SQLite cache 构造 URL 或安装对象。 - 自动检查只检查 metadata;没有用户点击时不下载、不安装、不重启。 -- Release workflow 必须上传 DMG、updater `.app.tar.gz`、`.sig` 和 `latest.json`;`latest.json` 同时包含 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 universal updater archive。 +- Release workflow 必须先对生成的 DMG 本体执行 `xcrun notarytool submit --wait`,确认状态为 `Accepted` 后执行 `xcrun stapler staple`;然后通过 `xcrun stapler validate ` 和 `spctl --assess --type open -vv `,再进入发布与 checksum 阶段。仅 app 已签名/notarized 不足以通过 DMG 发布门禁。 +- Release workflow 必须上传通过 DMG-level gates 的 DMG、updater `.app.tar.gz`、`.sig` 和 `latest.json`;`latest.json` 同时包含 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 universal updater archive。挂载后仍需验证 `SkillBox.app` 的 codesign、stapler、Gatekeeper、版本和 bundle id。 失败与回滚: diff --git a/scripts/release.test.js b/scripts/release.test.js index fa8a8cf..349c984 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -134,6 +134,24 @@ test('release workflow builds app and dmg bundles for updater artifacts', () => assert.doesNotMatch(workflow, /\$UPDATER_PATH#\$UPDATER_ASSET_NAME/); }); +test('release workflow notarizes and staples the DMG before verification and upload', () => { + const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); + const notarizeIndex = workflow.indexOf('id: notarize_dmg'); + const verifyIndex = workflow.indexOf('id: verify_dmg'); + + assert.ok(notarizeIndex >= 0, 'DMG notarization step should be present'); + assert.ok(verifyIndex > notarizeIndex, 'DMG verification must follow notarization'); + assert.match(workflow, /xcrun notarytool submit "\$dmg_path"[\s\S]*--wait/); + assert.match(workflow, /notary_status[\s\S]*Accepted/); + assert.match(workflow, /xcrun stapler staple -v "\$dmg_path"/); + assert.match(workflow, /xcrun stapler validate "\$dmg_path"/); + assert.match(workflow, /spctl --assess --type open -vv "\$dmg_path"/); + assert.match(workflow, /codesign --verify --verbose=2 "\$dmg_path"/); + assert.match(workflow, /xcrun stapler validate "\$app_path"/); + assert.match(workflow, /DMG_PATH: \$\{\{ steps\.notarize_dmg\.outputs\.dmg_path \}\}/); + assert.match(workflow, /DMG_PATH: \$\{\{ steps\.verify_dmg\.outputs\.dmg_path \}\}/); +}); + test('inserts and extracts changelog release notes', () => { const changelog = [ '# Changelog', From f556ba267d898b888e09b5f940b8e280d4c46f0e Mon Sep 17 00:00:00 2001 From: Santos Date: Mon, 3 Aug 2026 23:17:14 +0800 Subject: [PATCH 2/2] fix(github): use primary signature context for DMGs --- .github/workflows/release.yml | 5 +++-- CONTRIBUTING.md | 4 ++-- docs/release.md | 8 +++++--- docs/workflows.md | 2 +- scripts/release.test.js | 3 ++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d38afa2..e553d37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,10 +147,11 @@ jobs: echo "DMG notarization did not reach Accepted status (status: ${notary_status:-unknown})." >&2 exit 1 fi + echo "DMG notarization status: Accepted" xcrun stapler staple -v "$dmg_path" xcrun stapler validate "$dmg_path" - spctl --assess --type open -vv "$dmg_path" + spctl --assess --type open -vv --context context:primary-signature "$dmg_path" echo "dmg_path=$dmg_path" >> "$GITHUB_OUTPUT" @@ -178,7 +179,7 @@ jobs: codesign --verify --verbose=2 "$dmg_path" xcrun stapler validate "$dmg_path" - spctl --assess --type open -vv "$dmg_path" + spctl --assess --type open -vv --context context:primary-signature "$dmg_path" hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" app_path="$mount_dir/SkillBox.app" if [ ! -d "$app_path" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aca3e43..1a4f68b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -255,8 +255,8 @@ Public releases must be: - built as universal macOS DMGs; - signed and notarized before direct install instructions are published; - stapled and independently accepted by `xcrun stapler validate` and - `spctl --assess --type open` at the DMG level, with the mounted app checked - separately; + `spctl --assess --type open -vv --context context:primary-signature` at the + DMG level, with the mounted app checked separately; - accompanied by `SHA256SUMS` and a DMG-specific `.sha256` asset; - compatible with the Homebrew tap cask; - clear that `~/.skillbox` is user data and is not removed by normal uninstall. diff --git a/docs/release.md b/docs/release.md index 912af2b..f85426d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -5,8 +5,10 @@ DMG plus Tauri updater artifacts through GitHub Releases. The DMG itself is a release artifact and must be submitted to Apple notarization, reach `Accepted`, be stapled, and pass both `xcrun stapler validate` and -`spctl --assess --type open` before it can be published. App-level signing and -mounted-app checks do not replace these DMG-level checks. +`spctl --assess --type open -vv --context context:primary-signature` before it +can be published. The primary-signature context is required for a disk-image +Gatekeeper assessment. App-level signing and mounted-app checks do not replace +these DMG-level checks. ## Release Identity @@ -102,7 +104,7 @@ GitHub release labels. ```sh xcrun stapler validate SkillBox__universal.dmg - spctl --assess --type open -vv SkillBox__universal.dmg + spctl --assess --type open -vv --context context:primary-signature SkillBox__universal.dmg ``` - Verify Gatekeeper accepts the app: diff --git a/docs/workflows.md b/docs/workflows.md index 024edb1..7bdea45 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -937,7 +937,7 @@ Apply fast-forward: - 有可用更新时,Tauri 保存最近一次成功 metadata check 得到的进程内 pending update;侧边栏显示 Update,Settings 显示版本、notes 和安装按钮。 - 点击安装时先执行一次 force check,确认版本仍然可用,再调用 `install_app_update` 下载、验证、安装并重启。进程内 pending 缺失时,Rust 也必须重新检查,不能从 SQLite cache 构造 URL 或安装对象。 - 自动检查只检查 metadata;没有用户点击时不下载、不安装、不重启。 -- Release workflow 必须先对生成的 DMG 本体执行 `xcrun notarytool submit --wait`,确认状态为 `Accepted` 后执行 `xcrun stapler staple`;然后通过 `xcrun stapler validate ` 和 `spctl --assess --type open -vv `,再进入发布与 checksum 阶段。仅 app 已签名/notarized 不足以通过 DMG 发布门禁。 +- Release workflow 必须先对生成的 DMG 本体执行 `xcrun notarytool submit --wait`,确认状态为 `Accepted` 后执行 `xcrun stapler staple`;然后通过 `xcrun stapler validate ` 和带 `context:primary-signature` 的 `spctl --assess --type open -vv --context context:primary-signature `,再进入发布与 checksum 阶段。仅 app 已签名/notarized 不足以通过 DMG 发布门禁。 - Release workflow 必须上传通过 DMG-level gates 的 DMG、updater `.app.tar.gz`、`.sig` 和 `latest.json`;`latest.json` 同时包含 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 universal updater archive。挂载后仍需验证 `SkillBox.app` 的 codesign、stapler、Gatekeeper、版本和 bundle id。 失败与回滚: diff --git a/scripts/release.test.js b/scripts/release.test.js index 349c984..c5ccdd0 100644 --- a/scripts/release.test.js +++ b/scripts/release.test.js @@ -145,7 +145,8 @@ test('release workflow notarizes and staples the DMG before verification and upl assert.match(workflow, /notary_status[\s\S]*Accepted/); assert.match(workflow, /xcrun stapler staple -v "\$dmg_path"/); assert.match(workflow, /xcrun stapler validate "\$dmg_path"/); - assert.match(workflow, /spctl --assess --type open -vv "\$dmg_path"/); + assert.match(workflow, /spctl --assess --type open -vv --context context:primary-signature "\$dmg_path"/); + assert.match(workflow, /DMG notarization status: Accepted/); assert.match(workflow, /codesign --verify --verbose=2 "\$dmg_path"/); assert.match(workflow, /xcrun stapler validate "\$app_path"/); assert.match(workflow, /DMG_PATH: \$\{\{ steps\.notarize_dmg\.outputs\.dmg_path \}\}/);