From 259d9919ff063807c4568d3a85fcaf4c61bd3a8a 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: Tue, 16 Jun 2026 23:58:40 +0800 Subject: [PATCH] fix(release): enable signed Electron release updates Tested: bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts scripts/quality-gate/package-smoke/index.test.ts Tested: bun run check:policy Tested: bun run check:docs Tested: workflow YAML parse and git diff --check Scope-risk: moderate --- .github/workflows/build-desktop-dev.yml | 2 +- .github/workflows/release-desktop.yml | 28 ++++++--- docs/.vitepress/config.mts | 1 + docs/desktop/04-installation.md | 8 ++- docs/desktop/10-release-auto-update.md | 77 +++++++++++++++++++++++++ docs/desktop/index.md | 4 ++ scripts/pr/release-workflow.test.ts | 66 ++++++++++++++++++++- scripts/release-update-metadata.test.ts | 58 +++++++++++++++++++ scripts/release-update-metadata.ts | 15 +++-- 9 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 docs/desktop/10-release-auto-update.md diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index f734005b..8c304050 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -110,7 +110,7 @@ jobs: working-directory: desktop env: CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: bunx electron-builder ${{ matrix.builder_args }} --publish never + run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never - name: Verify packaged app structure run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9ceff66e..928360be 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -158,17 +158,31 @@ jobs: bun run build bun run build:electron - - name: Build Electron release artifacts + - name: Build signed macOS Electron release artifacts + if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true' working-directory: desktop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # 未签名发布:故意不传 CSC_LINK / APPLE_* / WIN_CSC_* 这些 env。 - # 当对应 secret 缺失时它们会被渲染成空字符串,而 electron-builder 一旦看到 - # CSC_LINK 这个 key 就会进入证书签名流程,把空路径当成证书去加载,最终报 - # " not a file" 而失败。拿到 Apple Developer ID / Windows 证书后, - # 再把对应签名 env 加回来即可开启签名 + 公证。 + # Signed macOS releases require all of these secrets. Keep this step + # separate from the unsigned fallback so empty secrets are never passed + # to electron-builder as CSC_LINK / APPLE_* env vars. + CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never + + - name: Build unsigned Electron release artifacts + if: matrix.smoke_platform != 'macos' || needs.signing-preflight.outputs.macos_signed != 'true' + working-directory: desktop + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Unsigned fallback: do not pass CSC_LINK / APPLE_* / WIN_CSC_* here. + # Empty secrets are rendered as empty strings, and electron-builder + # treats an empty CSC_LINK key as an explicit certificate path. CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: bunx electron-builder ${{ matrix.builder_args }} --publish never + run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never - name: Verify packaged app structure run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 29e15d04..15478de1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -95,6 +95,7 @@ const zhSidebar = [ { text: 'Electron 迁移调研', link: '/desktop/07-electron-migration-research' }, { text: 'Electron 迁移任务', link: '/desktop/08-electron-migration-tasks' }, { text: 'Electron 迁移验证', link: '/desktop/09-electron-migration-validation-checklist' }, + { text: 'Electron 发布与更新', link: '/desktop/10-release-auto-update' }, ], }, { diff --git a/docs/desktop/04-installation.md b/docs/desktop/04-installation.md index 5cdecf1c..0a039de9 100644 --- a/docs/desktop/04-installation.md +++ b/docs/desktop/04-installation.md @@ -1,6 +1,6 @@ # 安装指南 -桌面端基于 **Electron**,提供 macOS / Windows / Linux 安装包。当前版本暂未进行 Apple / Windows 代码签名,首次安装需要手动放行一次(不是文件损坏,按下方步骤操作即可)。 +桌面端基于 **Electron**,提供 macOS / Windows / Linux 安装包。`v0.4.3` 起的正式 macOS Release 使用 Developer ID 签名和 notarization;更早版本或临时开发包仍可能需要手动放行。 ## 下载 @@ -18,7 +18,9 @@ ## macOS 安装 -双击 DMG 把应用拖入 `Applications`。首次打开如果提示**"已损坏"**或**"无法验证开发者"**,在终端执行: +双击 DMG 把应用拖入 `Applications`。`v0.4.3` 起的正式 Release 正常情况下只会出现 macOS 的标准下载来源确认,不需要执行 `xattr`。 + +如果安装的是旧版或 unsigned 临时包,首次打开可能提示**"已损坏"**或**"无法验证开发者"**,再在终端执行: ```bash xattr -cr /Applications/Claude\ Code\ Haha.app @@ -66,4 +68,4 @@ bun run dev --host 127.0.0.1 --port 2024 **Q: 这个版本会自动更新吗?** -暂时不会。在拿到苹果签名前,请每次到 [GitHub Releases](https://github.com/NanmiCoder/cc-haha/releases) 手动下载新版本覆盖安装。覆盖安装不会删除本地配置和会话数据(`~/.claude`)。 +`v0.4.3` 起的正式 Release 会通过 GitHub Releases 检查更新,并下载对应平台的更新包。首次迁移到 signed/notarized macOS 版本时,建议先手动下载安装 `v0.4.3`;后续版本可以走应用内更新。覆盖安装不会删除本地配置和会话数据(`~/.claude`)。 diff --git a/docs/desktop/10-release-auto-update.md b/docs/desktop/10-release-auto-update.md new file mode 100644 index 00000000..fb265497 --- /dev/null +++ b/docs/desktop/10-release-auto-update.md @@ -0,0 +1,77 @@ +# Electron 发布与自动更新 + +本页是维护者发版 runbook。桌面端版本来源是 `desktop/package.json`,正式发布必须让版本号、Git tag 和 `release-notes/vX.Y.Z.md` 严格一致。 + +## 更新链路 + +应用内更新由 `electron-updater` 驱动,发布产物托管在 GitHub Releases: + +| 平台 | 安装/更新目标 | Metadata | +|------|---------------|----------| +| macOS arm64/x64 | `dmg` 用于首次安装,`zip` 用于 Squirrel.Mac 更新 | `latest-mac.yml` | +| Windows x64 | NSIS `.exe` | `latest.yml` | +| Linux x64 | `.AppImage`,同时发布 `.deb` 供手动安装 | `latest-linux.yml` | +| Linux arm64 | `.AppImage`,同时发布 `.deb` 供手动安装 | `latest-linux-arm64.yml` | + +Release workflow 会先在各平台 matrix 中生成 `latest*.yml`,把同名 metadata 临时改名为 `latest-.yml`,最后由 `scripts/release-update-metadata.ts` 合并回 electron-updater 期望的标准文件名。不要改成 matrix job 直接发布 GitHub Release,否则 metadata 可能互相覆盖。 + +## Signing Secrets + +macOS signed/notarized release 依赖 GitHub Actions repository secrets: + +```text +MACOS_CERTIFICATE +MACOS_CERTIFICATE_PASSWORD +APPLE_ID +APPLE_APP_SPECIFIC_PASSWORD +APPLE_TEAM_ID +``` + +`MACOS_CERTIFICATE` 是 Developer ID Application `.p12` 的 base64 内容。当前项目不发布 `.pkg`,不需要 Developer ID Installer 证书。 + +Windows signing secrets 是可选项: + +```text +WINDOWS_CERTIFICATE +WINDOWS_CERTIFICATE_PASSWORD +``` + +缺少 Windows 签名时 NSIS 自动更新仍可工作,但用户可能看到 SmartScreen 提示。 + +## v0.4.3 首次切换 + +`v0.4.3` 是从旧 unsigned macOS 发布迁移到 Developer ID 签名/notarization 的第一个版本时,建议不要直接依赖旧版本自动更新: + +1. 用 release workflow 产出 signed/notarized `v0.4.3`。 +2. 在一台未安装开发证书的 macOS 机器上手动下载 `Claude-Code-Haha-0.4.3-mac-arm64.dmg`。 +3. 不执行 `xattr -d` 或 `xattr -cr`,直接安装并打开。 +4. 确认只出现标准下载来源确认,不出现无法验证开发者、文件损坏或 unidentified developer。 +5. 再把后续 `v0.4.4` 作为自动更新验证目标。 + +## v0.4.4 自动更新验证 + +发布 `v0.4.4` 时至少验证一条真实更新链路: + +1. 安装 GitHub Release 中的 `v0.4.3` 正式包。 +2. 发布 `v0.4.4` tag,让 `Release Desktop` workflow 完整通过。 +3. 打开 `v0.4.3` 应用,等待启动后自动检查,或进入 Settings 手动检查更新。 +4. 确认应用提示 `v0.4.4`,下载完成后点击安装并重启。 +5. 重启后确认 About/Settings 中版本为 `0.4.4`。 + +平台重点: + +- macOS:确认 Release job 使用 `Build signed macOS Electron release artifacts`,且 `Verify macOS launch policy` 通过。 +- Windows:确认 `latest.yml`、`.exe`、`.exe.blockmap` 都在 GitHub Release 中;未签名时 SmartScreen 不代表 updater 失败。 +- Linux:优先用 AppImage 验证自动更新;`.deb` 继续作为手动安装包发布。 + +## 发版前检查 + +发版前至少运行: + +```bash +bun run scripts/release.ts 0.4.3 --dry +bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts scripts/quality-gate/package-smoke/index.test.ts +bun run check:policy +``` + +正式调用 `bun run scripts/release.ts 0.4.3` 前,先确认对应 `release-notes/v0.4.3.md` 已经存在。 diff --git a/docs/desktop/index.md b/docs/desktop/index.md index 76337dfd..88cc6067 100644 --- a/docs/desktop/index.md +++ b/docs/desktop/index.md @@ -40,6 +40,10 @@ 迁移收口验证:自动化证据、Review 收口项、macOS Computer Use smoke、Windows/Linux 实机验收边界。 +### [Electron 发布与自动更新](./10-release-auto-update.md) + +维护者发版 runbook:Developer ID 签名、GitHub Release metadata、`v0.4.3 -> v0.4.4` 自动更新验证路径。 + ### [Electron 迁移交互式验收清单](/desktop/electron-migration-qa-checklist.html) 本地浏览器可直接打开的验收 checklist,支持勾选进度、记录问题、导出 Markdown。 diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index 62af25c3..952b1f56 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -12,6 +12,14 @@ describe('release desktop workflow', () => { )?.[0] } + function extractStep(workflow: string, stepName: string) { + return workflow.match( + new RegExp(`- name: ${stepName}[\\s\\S]*?(?:\\n\\s{6}- name:|$)`), + )?.[0] + } + + const electronBuilderCli = 'node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never' + test('release packaging does not run the PR-quality gate', () => { const workflow = readReleaseWorkflow() @@ -41,7 +49,13 @@ describe('release desktop workflow', () => { ) } - expect(workflow).toContain('Build Electron') + if (workflowPath === '.github/workflows/release-desktop.yml') { + expect(workflow).toContain('Build signed macOS Electron release artifacts') + expect(workflow).toContain('Build unsigned Electron release artifacts') + } else { + expect(workflow).toContain('Build Electron app') + expect(workflow).toContain(electronBuilderCli) + } expect(workflow).toContain('smoke_platform') expect(workflow).toContain('bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron') expect(workflow).not.toContain('tauri-apps/tauri-action@v0') @@ -103,6 +117,36 @@ describe('release desktop workflow', () => { expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish')) }) + test('release workflow signs and notarizes macOS builds only when signing preflight succeeds', () => { + const workflow = readReleaseWorkflow() + const signedBuildStep = extractStep(workflow, 'Build signed macOS Electron release artifacts') + const unsignedBuildStep = extractStep(workflow, 'Build unsigned Electron release artifacts') + + expect(signedBuildStep).toContain("if: matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed == 'true'") + expect(signedBuildStep).toContain('CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}') + expect(signedBuildStep).toContain('CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}') + expect(signedBuildStep).toContain('APPLE_ID: ${{ secrets.APPLE_ID }}') + expect(signedBuildStep).toContain('APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}') + expect(signedBuildStep).toContain('APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}') + expect(signedBuildStep).not.toContain('CSC_IDENTITY_AUTO_DISCOVERY') + expect(signedBuildStep).toContain(electronBuilderCli) + + expect(unsignedBuildStep).toContain("if: matrix.smoke_platform != 'macos' || needs.signing-preflight.outputs.macos_signed != 'true'") + expect(unsignedBuildStep).toContain("CSC_IDENTITY_AUTO_DISCOVERY: 'false'") + for (const envName of [ + 'CSC_LINK:', + 'CSC_KEY_PASSWORD:', + 'APPLE_ID:', + 'APPLE_APP_SPECIFIC_PASSWORD:', + 'APPLE_TEAM_ID:', + ]) { + expect(unsignedBuildStep).not.toContain(envName) + } + expect(unsignedBuildStep).toContain(electronBuilderCli) + expect(workflow.indexOf('Build signed macOS Electron release artifacts')).toBeLessThan(workflow.indexOf('Verify packaged app structure')) + expect(workflow.indexOf('Build unsigned Electron release artifacts')).toBeLessThan(workflow.indexOf('Verify packaged app structure')) + }) + test('release workflow records macOS signing state and warns for unsigned builds', () => { const workflow = readReleaseWorkflow() const signingJob = workflow.match( @@ -320,6 +364,26 @@ describe('release desktop workflow', () => { expect(desktopPackage.build.linux?.publish).toBeUndefined() }) + test('Electron Builder macOS config keeps the signed auto-update contract', () => { + const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as { + build: { + mac?: { + target?: string[] + hardenedRuntime?: boolean + notarize?: boolean + entitlements?: string + entitlementsInherit?: string + } + } + } + + expect(desktopPackage.build.mac?.target).toEqual(['dmg', 'zip']) + expect(desktopPackage.build.mac?.hardenedRuntime).toBe(true) + expect(desktopPackage.build.mac?.notarize).toBe(true) + expect(desktopPackage.build.mac?.entitlements).toBe('build/entitlements.mac.plist') + expect(desktopPackage.build.mac?.entitlementsInherit).toBe('build/entitlements.mac.inherit.plist') + }) + test('Windows NSIS installer lets users choose the install directory', () => { const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as { build: { diff --git a/scripts/release-update-metadata.test.ts b/scripts/release-update-metadata.test.ts index 0dcf5eda..f634e9e9 100644 --- a/scripts/release-update-metadata.test.ts +++ b/scripts/release-update-metadata.test.ts @@ -136,6 +136,64 @@ describe('release update metadata merge', () => { expect(arm64.path).toBe('Claude-Code-Haha-0.3.2-arm64.AppImage') }) + test('keeps Linux AppImage as primary update artifact when deb is also published', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-linux-Linux-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-linux-amd64.deb + sha512: linux-deb-checksum + size: 222 + - url: Claude-Code-Haha-0.3.2-linux-x86_64.AppImage + sha512: linux-appimage-checksum + size: 111 + path: Claude-Code-Haha-0.3.2-linux-amd64.deb + sha512: linux-deb-checksum + `) + + mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir }) + + const x64 = parse(readFileSync(join(outputDir, 'latest-linux.yml'), 'utf8')) as { + files: Array<{ url: string, sha512: string }> + path: string + sha512: string + } + expect(x64.files.map(file => file.url)).toEqual([ + 'Claude-Code-Haha-0.3.2-linux-x86_64.AppImage', + 'Claude-Code-Haha-0.3.2-linux-amd64.deb', + ]) + expect(x64.path).toBe('Claude-Code-Haha-0.3.2-linux-x86_64.AppImage') + expect(x64.sha512).toBe('linux-appimage-checksum') + }) + + test('restores standard Windows channel metadata after matrix namespacing', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-Windows-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-win-x64.exe + sha512: win-checksum + size: 333 + path: Claude-Code-Haha-0.3.2-win-x64.exe + sha512: win-checksum + `) + + mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir }) + + const windows = parse(readFileSync(join(outputDir, 'latest.yml'), 'utf8')) as { + files: Array<{ url: string, sha512: string }> + path: string + sha512: string + } + expect(windows.files.map(file => file.url)).toEqual(['Claude-Code-Haha-0.3.2-win-x64.exe']) + expect(windows.path).toBe('Claude-Code-Haha-0.3.2-win-x64.exe') + expect(windows.sha512).toBe('win-checksum') + }) + test('rejects metadata groups with mixed app versions', () => { const inputDir = tempDir() const outputDir = tempDir() diff --git a/scripts/release-update-metadata.ts b/scripts/release-update-metadata.ts index 297e9d14..4557eecf 100644 --- a/scripts/release-update-metadata.ts +++ b/scripts/release-update-metadata.ts @@ -154,17 +154,24 @@ function metadataFiles(metadata: UpdateMetadata, sourcePath: string) { function archRank(url: string) { const lowerUrl = url.toLowerCase() - if (/(^|[-_.])x64($|[-_.])/.test(lowerUrl)) return 0 + if (/(^|[-_.])(?:x64|x86_64|amd64)($|[-_.])/.test(lowerUrl)) return 0 if (/(^|[-_.])arm64($|[-_.])|(^|[-_.])aarch64($|[-_.])/.test(lowerUrl)) return 1 if (/(^|[-_.])ia32($|[-_.])/.test(lowerUrl)) return 2 return 3 } function artifactRank(canonicalName: string, url: string) { - if (canonicalName !== 'latest-mac.yml') return 0 const lowerUrl = url.toLowerCase() - if (lowerUrl.endsWith('.zip')) return 0 - if (lowerUrl.endsWith('.dmg')) return 1 + if (canonicalName === 'latest-mac.yml') { + if (lowerUrl.endsWith('.zip')) return 0 + if (lowerUrl.endsWith('.dmg')) return 1 + return 2 + } + if (canonicalName.startsWith('latest-linux')) { + if (lowerUrl.endsWith('.appimage')) return 0 + if (lowerUrl.endsWith('.deb')) return 1 + return 2 + } return 2 }