mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
1167b7cfd1
commit
259d9919ff
2
.github/workflows/build-desktop-dev.yml
vendored
2
.github/workflows/build-desktop-dev.yml
vendored
@ -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
|
||||
|
||||
28
.github/workflows/release-desktop.yml
vendored
28
.github/workflows/release-desktop.yml
vendored
@ -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
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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`)。
|
||||
|
||||
77
docs/desktop/10-release-auto-update.md
Normal file
77
docs/desktop/10-release-auto-update.md
Normal 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` 已经存在。
|
||||
@ -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。
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user