Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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

Expand All @@ -124,6 +126,49 @@ 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
echo "DMG notarization status: Accepted"

xcrun stapler staple -v "$dmg_path"
xcrun stapler validate "$dmg_path"
spctl --assess --type open -vv --context context:primary-signature "$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() {
Expand All @@ -132,6 +177,9 @@ jobs:
}
trap cleanup EXIT

codesign --verify --verbose=2 "$dmg_path"
xcrun stapler validate "$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
Expand All @@ -140,7 +188,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
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -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.
Expand Down
19 changes: 17 additions & 2 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
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 -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

- Publishing account: `santosli`
Expand Down Expand Up @@ -64,8 +71,9 @@ The command:
- runs the `Release` workflow once through `workflow_dispatch` as a no-publish
dry run;
- creates and pushes the `v<version>` 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`;
Expand All @@ -92,6 +100,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_<version>_universal.dmg
spctl --assess --type open -vv --context context:primary-signature SkillBox_<version>_universal.dmg
```

- Verify Gatekeeper accepts the app:

```sh
Expand Down
3 changes: 2 additions & 1 deletion docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dmg>` 和带 `context:primary-signature` 的 `spctl --assess --type open -vv --context context:primary-signature <dmg>`,再进入发布与 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。

失败与回滚:

Expand Down
19 changes: 19 additions & 0 deletions scripts/release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ 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 --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 \}\}/);
assert.match(workflow, /DMG_PATH: \$\{\{ steps\.verify_dmg\.outputs\.dmg_path \}\}/);
});

test('inserts and extracts changelog release notes', () => {
const changelog = [
'# Changelog',
Expand Down