fix(release): prepare unsigned macOS v0.4.0 install path

Bump the Electron desktop package version to 0.4.0, publish a macOS unsigned install helper, and let the release workflow continue when Developer ID signing is not configured.

Tested: bash -n desktop/scripts/install-macos-unsigned.sh
Tested: bun test scripts/pr/release-workflow.test.ts
Tested: bun run scripts/release.ts 0.4.0 --dry
Tested: git diff --check
Tested: bun run check:docs
Tested: bun run check:policy
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 22:03:27 +08:00
parent 5cf4480d22
commit 9e4893ac36
6 changed files with 152 additions and 68 deletions

View File

@ -126,6 +126,9 @@ jobs:
| while read -r artifact; do
cp -f "$artifact" "$STAGING/"
done
if [ "${{ matrix.smoke_platform }}" = "macos" ]; then
cp -f desktop/scripts/install-macos-unsigned.sh "$STAGING/"
fi
echo "staging_dir=$STAGING" >> "$GITHUB_OUTPUT"
ls -lh "$STAGING/" 2>/dev/null || true

View File

@ -51,8 +51,11 @@ jobs:
signing-preflight:
runs-on: ubuntu-latest
outputs:
macos_signed: ${{ steps.validate.outputs.macos_signed }}
steps:
- name: Validate release signing and notarization secrets
id: validate
shell: bash
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
@ -63,8 +66,9 @@ jobs:
WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
# macOS signing + notarization is mandatory: Squirrel.Mac auto-update and
# first-launch Gatekeeper approval both require a notarized Developer ID build.
# macOS signing + notarization is preferred: Squirrel.Mac auto-update and
# first-launch Gatekeeper approval work best with a notarized Developer ID build.
# A migration release may still ship unsigned while Apple Developer ID setup is pending.
missing=()
[ -n "$CSC_LINK" ] || missing+=("MACOS_CERTIFICATE")
[ -n "$CSC_KEY_PASSWORD" ] || missing+=("MACOS_CERTIFICATE_PASSWORD")
@ -72,8 +76,10 @@ jobs:
[ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] || missing+=("APPLE_APP_SPECIFIC_PASSWORD")
[ -n "$APPLE_TEAM_ID" ] || missing+=("APPLE_TEAM_ID")
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing required macOS signing/notarization secrets: %s\n' "${missing[*]}"
exit 1
printf '::warning::Missing macOS signing/notarization secrets (%s): macOS artifacts will be unsigned and users must use install-macos-unsigned.sh.\n' "${missing[*]}"
echo "macos_signed=false" >> "$GITHUB_OUTPUT"
else
echo "macos_signed=true" >> "$GITHUB_OUTPUT"
fi
# Windows signing is optional: an unsigned NSIS installer still auto-updates
# (electron-updater), it only triggers SmartScreen warnings. Warn, do not block,
@ -187,6 +193,7 @@ jobs:
working-directory: desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@ -200,9 +207,13 @@ jobs:
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
- name: Verify macOS launch policy
if: matrix.smoke_platform == 'macos'
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'
run: bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper
- name: Warn unsigned macOS launch policy skipped
if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed != 'true'
run: echo "::warning::Skipping macOS Gatekeeper package-smoke because this release is unsigned. Ship install-macos-unsigned.sh with the DMG."
- name: Validate matrix release asset set
shell: bash
working-directory: desktop/build-artifacts/electron
@ -426,3 +437,4 @@ jobs:
artifacts/release-assets/**/*.deb
artifacts/release-assets/**/*.blockmap
artifacts/update-metadata-standard/*.yml
desktop/scripts/install-macos-unsigned.sh

View File

@ -1,7 +1,7 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.3.2",
"version": "0.4.0",
"description": "Desktop coding agent workbench for Claude Code Haha.",
"homepage": "https://github.com/NanmiCoder/cc-haha",
"author": {

View File

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="Claude Code Haha.app"
APP_PATH="/Applications/${APP_NAME}"
script_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1
pwd
}
find_dmg() {
local dir="$1"
local arch
arch="$(uname -m)"
shopt -s nullglob
local matches=("${dir}"/Claude-Code-Haha-*-mac-*.dmg)
shopt -u nullglob
if [ "${#matches[@]}" -eq 0 ]; then
return 1
fi
if [ "${#matches[@]}" -eq 1 ]; then
printf '%s\n' "${matches[0]}"
return 0
fi
local wanted="mac-x64"
if [ "$arch" = "arm64" ]; then
wanted="mac-arm64"
fi
local dmg
for dmg in "${matches[@]}"; do
if [[ "$(basename "$dmg")" == *"${wanted}.dmg" ]]; then
printf '%s\n' "$dmg"
return 0
fi
done
printf '%s\n' "${matches[0]}"
}
main() {
local base_dir
base_dir="$(script_dir)"
local dmg="${1:-}"
if [ -z "$dmg" ]; then
if ! dmg="$(find_dmg "$base_dir")"; then
echo "No Claude Code Haha macOS DMG found next to this script."
echo "Usage: bash install-macos-unsigned.sh /path/to/Claude-Code-Haha-0.4.0-mac-arm64.dmg"
exit 1
fi
fi
if [ ! -f "$dmg" ]; then
echo "DMG not found: $dmg"
exit 1
fi
echo "Using DMG: $dmg"
xattr -dr com.apple.quarantine "$dmg" 2>/dev/null || true
local mount_output volume app_in_volume
mount_output="$(hdiutil attach "$dmg" -nobrowse -readonly)"
volume="$(printf '%s\n' "$mount_output" | sed -n 's#^.*\(/Volumes/.*\)$#\1#p' | head -n 1)"
if [ -z "$volume" ] || [ ! -d "$volume" ]; then
echo "Could not find mounted DMG volume."
exit 1
fi
cleanup() {
hdiutil detach "$volume" >/dev/null 2>&1 || true
}
trap cleanup EXIT
app_in_volume="${volume}/${APP_NAME}"
if [ ! -d "$app_in_volume" ]; then
echo "Could not find ${APP_NAME} in mounted DMG: $volume"
exit 1
fi
osascript -e 'quit app "Claude Code Haha"' >/dev/null 2>&1 || true
if [ -d "$APP_PATH" ]; then
local backup
backup="${HOME}/.Trash/Claude Code Haha.$(date +%Y%m%d%H%M%S).app"
echo "Moving existing app to: $backup"
mv "$APP_PATH" "$backup"
fi
echo "Installing to: $APP_PATH"
ditto "$app_in_volume" "$APP_PATH"
xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true
echo "Opening Claude Code Haha..."
open "$APP_PATH"
}
main "$@"

View File

@ -40,55 +40,6 @@
| Linux x64 | `Claude-Code-Haha-0.4.0-linux-x86_64.AppImage``Claude-Code-Haha-0.4.0-linux-amd64.deb` |
| Linux ARM64 | `Claude-Code-Haha-0.4.0-linux-arm64.AppImage``Claude-Code-Haha-0.4.0-linux-arm64.deb` |
### macOS
正式签名并公证的 DMG
1. 下载与你 Mac 架构匹配的 DMG。
2. 打开 DMG`Claude Code Haha.app` 拖到 `Applications`
3. 从 `Applications` 打开应用。
如果本次 0.4.0 因 Apple Developer ID 还未准备好只能先发布未签名构建macOS 可能提示 DMG "已损坏"或"无法验证开发者"。这时不要直接双击反复尝试,按下面命令安装。
Apple Silicon 示例:
```bash
ARTIFACT_DIR="$HOME/Downloads/Claude-Code-Haha-v0.4.0-macos-arm64"
DMG="$ARTIFACT_DIR/Claude-Code-Haha-0.4.0-mac-arm64.dmg"
APP="/Applications/Claude Code Haha.app"
xattr -dr com.apple.quarantine "$ARTIFACT_DIR" 2>/dev/null || true
xattr -dr com.apple.quarantine "$DMG" 2>/dev/null || true
MOUNT_OUTPUT="$(hdiutil attach "$DMG" -nobrowse -readonly)"
VOLUME="$(printf '%s\n' "$MOUNT_OUTPUT" | sed -n 's#^.*\(/Volumes/.*\)$#\1#p' | head -n 1)"
if [ -z "$VOLUME" ]; then
echo "未找到 DMG 挂载目录"
exit 1
fi
osascript -e 'quit app "Claude Code Haha"' >/dev/null 2>&1 || true
if [ -d "$APP" ]; then
BACKUP="$HOME/.Trash/Claude Code Haha.$(date +%Y%m%d%H%M%S).app"
mv "$APP" "$BACKUP"
fi
ditto "$VOLUME/Claude Code Haha.app" "$APP"
hdiutil detach "$VOLUME"
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
open "$APP"
```
如果你是直接从 GitHub Release 下载 DMG而不是从 GitHub Actions 的 artifact 外层 ZIP 解压出来,把前两行改成:
```bash
ARTIFACT_DIR="$HOME/Downloads"
DMG="$ARTIFACT_DIR/Claude-Code-Haha-0.4.0-mac-arm64.dmg"
```
Intel Mac 把 `macos-arm64` / `mac-arm64` 替换为 `macos-x64` / `mac-x64`
### Windows
下载 `Claude-Code-Haha-0.4.0-win-x64.exe` 后双击安装。如果 Windows SmartScreen 提示未知发布者,选择"更多信息"再选择"仍要运行"。
@ -110,20 +61,21 @@ sudo apt install ./Claude-Code-Haha-0.4.0-linux-amd64.deb
ARM64 Linux 对应把文件名替换为 `Claude-Code-Haha-0.4.0-linux-arm64.AppImage``Claude-Code-Haha-0.4.0-linux-arm64.deb`
### macOS
正式签名并公证的 DMG下载与你 Mac 架构匹配的 DMG打开后把 `Claude Code Haha.app` 拖到 `Applications`,再从 `Applications` 打开。
如果本次 0.4.0 因 Apple Developer ID 还未准备好只能先发布未签名构建macOS 可能提示 DMG "已损坏"或"无法验证开发者"。这种情况下请下载同一个 Release 里的 `install-macos-unsigned.sh`,和 DMG 放在同一目录后运行文末命令。M 系列和 Intel 都用同一个脚本;脚本会自动选择同目录里匹配当前机器架构的 DMG也可以手动传入 DMG 路径。
## 发布者备注
- GitHub Release 正文继续以 `release-notes/v0.4.0.md` 作为来源。
- 发布前请确认 `desktop/package.json` 版本、tag `v0.4.0` 和本文件名完全一致。
- 正式 release workflow 目前要求 macOS Developer ID 签名和 notarization secrets如果缺少 Apple ID / team / app-specific password正式 signed release 会被 preflight 阻止。
- 如果需要在签名就绪前先给用户一个临时迁移包,请在 GitHub Release 文案中明确这是 unsigned build并保留上面的 macOS 命令安装步骤。
- 如果 macOS Developer ID / notarization secrets 已配置release workflow 会额外跑 Gatekeeper 验证;如果暂时没有 Apple ID本版本会以 unsigned macOS 构建发布,并依赖 `install-macos-unsigned.sh` 完成首次安装。
- 不要只上传 DMG 后删除 ZIP、blockmap 或 `latest*.yml`。DMG 是手动安装入口ZIP 和 metadata 是 Electron auto-update 链路需要的资产。
发布前建议先运行
macOS 未签名构建安装命令
```bash
bun run scripts/release.ts 0.4.0 --dry
bun run verify
bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>
bash ~/Downloads/install-macos-unsigned.sh
```
如果 live provider、Apple Developer ID 或平台实机不可用,请在 release 记录里把 blocker 写清楚。

View File

@ -57,6 +57,8 @@ describe('release desktop workflow', () => {
expect(collectStep).toContain('*.zip')
expect(collectStep).toContain('*.blockmap')
expect(collectStep).toContain('*.yml')
expect(collectStep).toContain('install-macos-unsigned.sh')
expect(collectStep).toContain('[ "${{ matrix.smoke_platform }}" = "macos" ]')
expect(collectStep).not.toContain('-type d -name "*.app"')
})
@ -82,18 +84,23 @@ describe('release desktop workflow', () => {
expect(desktopPackage.build?.linux?.maintainer).toBe('NanmiCoder <relakkes@gmail.com>')
})
test('release workflow requires macOS Gatekeeper launch approval before upload', () => {
test('release workflow requires macOS Gatekeeper launch approval for signed builds', () => {
const workflow = readReleaseWorkflow()
const gatekeeperStep = workflow.match(
/- name: Verify macOS launch policy[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
const unsignedWarningStep = workflow.match(
/- name: Warn unsigned macOS launch policy skipped[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos'")
expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'")
expect(gatekeeperStep).toContain('bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper')
expect(unsignedWarningStep).toContain("if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed != 'true'")
expect(unsignedWarningStep).toContain('install-macos-unsigned.sh')
expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow blocks on macOS signing/notarization secrets and warns for Windows signing', () => {
test('release workflow records macOS signing state and warns for unsigned builds', () => {
const workflow = readReleaseWorkflow()
const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
@ -101,6 +108,8 @@ describe('release desktop workflow', () => {
const buildJob = extractJob(workflow, 'build')
expect(signingJob).toContain('Validate release signing and notarization secrets')
expect(signingJob).toContain('outputs:')
expect(signingJob).toContain('macos_signed: ${{ steps.validate.outputs.macos_signed }}')
for (const secret of [
'MACOS_CERTIFICATE',
'MACOS_CERTIFICATE_PASSWORD',
@ -116,7 +125,11 @@ describe('release desktop workflow', () => {
]) {
expect(signingJob).toContain(secret)
}
expect(signingJob).toContain('Missing required macOS signing/notarization secrets')
expect(signingJob).toContain('Missing macOS signing/notarization secrets')
expect(signingJob).toContain('macOS artifacts will be unsigned')
expect(signingJob).toContain('install-macos-unsigned.sh')
expect(signingJob).toContain('macos_signed=false')
expect(signingJob).toContain('macos_signed=true')
expect(signingJob).toContain('Windows signing secrets missing')
expect(signingJob).toContain('::warning::Windows signing secrets missing')
@ -126,7 +139,7 @@ describe('release desktop workflow', () => {
const windowsOptionalBlock = signingJob?.match(
/win_missing=\(\)[\s\S]*?fi\n/,
)?.[0]
expect(macRequiredBlock).toContain('exit 1')
expect(macRequiredBlock).not.toContain('exit 1')
expect(windowsOptionalBlock).toContain('::warning::')
expect(windowsOptionalBlock).not.toContain('exit 1')
expect(buildJob).toContain('- quality-preflight')
@ -179,6 +192,7 @@ describe('release desktop workflow', () => {
expect(publishJob).toContain('artifacts/release-assets/**/*.exe')
expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
expect(publishJob).toContain('desktop/scripts/install-macos-unsigned.sh')
expect(publishJob).toContain('fail_on_unmatched_files: true')
expect(publishJob).toContain('Load release notes')
expect(workflow.indexOf('publish-release:')).toBeGreaterThan(workflow.indexOf('build:'))