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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-16 23:58:40 +08:00
parent 1167b7cfd1
commit 259d9919ff
9 changed files with 243 additions and 16 deletions

View File

@ -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

View File

@ -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 就会进入证书签名流程,把空路径当成证书去加载,最终报
# "<workdir> 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

View File

@ -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' },
],
},
{

View File

@ -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`)。

View File

@ -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-<platform>.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` 已经存在。

View File

@ -40,6 +40,10 @@
迁移收口验证自动化证据、Review 收口项、macOS Computer Use smoke、Windows/Linux 实机验收边界。
### [Electron 发布与自动更新](./10-release-auto-update.md)
维护者发版 runbookDeveloper ID 签名、GitHub Release metadata、`v0.4.3 -> v0.4.4` 自动更新验证路径。
### [Electron 迁移交互式验收清单](/desktop/electron-migration-qa-checklist.html)
本地浏览器可直接打开的验收 checklist支持勾选进度、记录问题、导出 Markdown。

View File

@ -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: {

View File

@ -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()

View File

@ -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
}