From 9e4893ac360e52d0b9695eace135ffc3c338c9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 22:03:27 +0800 Subject: [PATCH] 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 --- .github/workflows/build-desktop-dev.yml | 3 + .github/workflows/release-desktop.yml | 22 +++-- desktop/package.json | 2 +- desktop/scripts/install-macos-unsigned.sh | 103 ++++++++++++++++++++++ release-notes/v0.4.0.md | 66 ++------------ scripts/pr/release-workflow.test.ts | 24 +++-- 6 files changed, 152 insertions(+), 68 deletions(-) create mode 100755 desktop/scripts/install-macos-unsigned.sh diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 8eea4fd3..f1fc33c9 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -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 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8ec05f55..f89f0889 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/desktop/package.json b/desktop/package.json index efe8a1ae..d7c0df34 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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": { diff --git a/desktop/scripts/install-macos-unsigned.sh b/desktop/scripts/install-macos-unsigned.sh new file mode 100755 index 00000000..7035083c --- /dev/null +++ b/desktop/scripts/install-macos-unsigned.sh @@ -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 "$@" diff --git a/release-notes/v0.4.0.md b/release-notes/v0.4.0.md index f14d215d..e3e6a246 100644 --- a/release-notes/v0.4.0.md +++ b/release-notes/v0.4.0.md @@ -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 +bash ~/Downloads/install-macos-unsigned.sh ``` - -如果 live provider、Apple Developer ID 或平台实机不可用,请在 release 记录里把 blocker 写清楚。 diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index ddf17247..05af2274 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -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 ') }) - 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:'))