fix(desktop): harden updater release flow (#797)

Prevent repeated update checks from clearing the pending Electron update, recover the UI when relaunch does not exit, and keep public releases draft until all updater assets are uploaded.

Tested: bun test scripts/release-update-metadata.test.ts scripts/pr/release-workflow.test.ts
Tested: cd desktop && bun run test -- src/stores/updateStore.test.ts --run
Tested: cd desktop && bun run test -- electron/services/updater.test.ts --run
Tested: git diff --check
Tested: bun run check:policy
Tested: bun run check:desktop
Not-tested: bun run check:native
Not-tested: bun run check:coverage
Not-tested: bun run verify
Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:13:19 +08:00
parent 3e685eb6c2
commit a7063e838b
5 changed files with 184 additions and 15 deletions

View File

@ -40,10 +40,11 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
RELEASE_DRAFT: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}
run: |
# 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.
# Drafts may still build unsigned while Apple Developer ID setup is being tested.
missing=()
[ -n "$CSC_LINK" ] || missing+=("MACOS_CERTIFICATE")
[ -n "$CSC_KEY_PASSWORD" ] || missing+=("MACOS_CERTIFICATE_PASSWORD")
@ -53,6 +54,10 @@ jobs:
if [ "${#missing[@]}" -gt 0 ]; then
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"
if [ "$RELEASE_DRAFT" != "true" ]; then
echo "::error::Refusing to publish a non-draft desktop release without macOS signing/notarization secrets."
exit 1
fi
else
echo "macos_signed=true" >> "$GITHUB_OUTPUT"
fi
@ -607,7 +612,7 @@ jobs:
tag_name: v${{ steps.version.outputs.value }}
name: Claude Code Haha v${{ steps.version.outputs.value }}
body: ${{ steps.release_notes.outputs.body }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}
draft: true
prerelease: false
fail_on_unmatched_files: true
files: |
@ -620,8 +625,14 @@ jobs:
artifacts/update-metadata-standard/*.yml
desktop/scripts/install-macos-unsigned.sh
- name: Ensure workflow-dispatch release remains draft
- name: Publish GitHub release after complete upload
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.draft == false)
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "v${{ steps.version.outputs.value }}" --draft=false --repo "${{ github.repository }}"
- name: Keep workflow-dispatch release as draft
if: github.event_name == 'workflow_dispatch' && inputs.draft == true
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "v${{ steps.version.outputs.value }}" --draft --repo "${{ github.repository }}"
run: gh release view "v${{ steps.version.outputs.value }}" --json isDraft --jq 'if .isDraft then "release remains draft" else error("workflow-dispatch release was published unexpectedly") end' --repo "${{ github.repository }}"

View File

@ -99,11 +99,23 @@ Every feature, bugfix, and behavior change must ship with proof that matches the
## Release Workflow
- Desktop releases are built remotely by GitHub Actions from tags matching `v*.*.*`; do not upload local build artifacts as the release source of truth.
- The release workflow `.github/workflows/release-desktop.yml` runs a non-live PR-quality preflight, validates that the tag matches `desktop/package.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, and packages the Electron desktop app across the matrix.
- The hosted tag workflow is not a substitute for local release verification. Before tagging or calling a release ready, run `bun run scripts/release.ts <version> --dry`, then run `bun run verify`, and run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` when live provider access is available.
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename, app version, and tag aligned exactly.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates Electron desktop version files, requires the matching release-notes file, commits it, and creates the annotated tag.
- The normal release push is `git push origin main --tags`. If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-release blocker explicitly.
- The release workflow `.github/workflows/release-desktop.yml` runs a non-live PR-quality preflight, validates that the tag matches `desktop/package.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, packages the Electron desktop app across the matrix, uploads updater metadata, and only publishes the GitHub Release after all assets are uploaded.
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename, app version in `desktop/package.json`, and tag aligned exactly.
- Before cutting a release, commit the product/workflow changes that should ship in the release onto `main`. The release script creates a separate `release: vX.Y.Z` commit; it is not a substitute for committing the bugfix or feature work first.
- Release notes must exist before the release script is run, for example `release-notes/v0.4.6.md`. Keep them user-facing and include install/update caveats when the release changes packaging, signing, updater behavior, or platform support.
- Required local release runbook for `vX.Y.Z`:
1. Confirm the worktree is on `main` and only intended release changes are staged or committed: `git status --short`.
2. Create or update `release-notes/vX.Y.Z.md`.
3. Run `bun run scripts/release.ts X.Y.Z --dry` and verify the printed tag, version, and notes path.
4. Run the relevant local gates before tagging. At minimum for desktop/native/release changes: `bun run check:policy`, `bun run check:desktop`, and `bun run check:native`. Before calling the release ready, run `bun run verify`.
5. When live provider access exists, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`. If no provider key/quota is available, run the non-live gates anyway and report the live-release blocker explicitly.
6. Run `bun run scripts/release.ts X.Y.Z` to update `desktop/package.json`, stage the release notes, create the `release: vX.Y.Z` commit, and create the annotated `vX.Y.Z` tag.
7. Push with `git push origin main --tags`.
8. Watch the `Release Desktop` workflow for the pushed tag. Do not announce the release until the workflow succeeds and `gh release view vX.Y.Z --json isDraft,url,assets` shows a public release with the expected macOS, Windows, Linux, and updater metadata assets.
- Do not manually create or move a release tag unless recovering from a failed release with maintainer intent. The normal path is `bun run scripts/release.ts X.Y.Z`; if a tag must be repaired, document the old tag target, the new target, and why the repair is safe.
- Non-draft desktop releases require macOS signing and notarization secrets: `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`. Missing macOS secrets must block publishing, because auto-update and Gatekeeper require a signed, notarized release.
- Windows signing secrets, `WINDOWS_CERTIFICATE` and `WINDOWS_CERTIFICATE_PASSWORD`, are recommended but not currently release-blocking. Without them, Windows auto-update can still work, but users may see SmartScreen warnings.
- Manual `workflow_dispatch` runs are for draft/testing builds by default. A draft run may be unsigned while signing setup is being tested; it must not be treated as the public update source.
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, with outputs under `desktop/build-artifacts/macos-arm64/`.
## Docs Workflow Notes

View File

@ -223,6 +223,39 @@ describe('updateStore', () => {
expect(useUpdateStore.getState().status).toBe('downloaded')
})
it('does not cancel the freshly checked update when replacing an older wrapper', async () => {
const oldClose = vi.fn().mockResolvedValue(undefined)
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
check
.mockResolvedValueOnce({
version: '0.2.0',
body: 'Notes',
download: vi.fn().mockResolvedValue(undefined),
close: oldClose,
})
.mockResolvedValueOnce({
version: '0.2.0',
body: 'Notes',
download,
close: vi.fn().mockResolvedValue(undefined),
})
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates({ autoDownload: false })
await useUpdateStore.getState().checkForUpdates({ autoDownload: false })
await useUpdateStore.getState().downloadUpdate()
expect(oldClose).not.toHaveBeenCalled()
expect(download).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloaded')
})
it('passes the configured manual update proxy to update checks', async () => {
const update = {
version: '0.2.0',
@ -429,6 +462,65 @@ describe('updateStore', () => {
expect(relaunch).toHaveBeenCalledTimes(1)
})
it('recovers if the relaunch request returns but the app stays alive', async () => {
vi.useFakeTimers()
try {
const download = vi.fn(async (onEvent?: (event: unknown) => void) => {
onEvent?.({ event: 'Started', data: { contentLength: 100 } })
onEvent?.({ event: 'Finished' })
})
const install = vi.fn().mockResolvedValue(undefined)
const prepareInstall = vi.fn().mockResolvedValue(undefined)
const cancelInstall = vi.fn().mockResolvedValue(undefined)
const relaunchHost = vi.fn().mockResolvedValue(undefined)
const getServerUrl = vi.fn().mockResolvedValue('http://127.0.0.1:3456')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
updates: true,
},
runtime: {
getServerUrl,
},
updates: {
...browserHost.updates,
check: vi.fn().mockResolvedValue({
version: '0.4.0',
body: 'Electron host update',
download,
install,
close: vi.fn().mockResolvedValue(undefined),
}),
prepareInstall,
cancelInstall,
relaunch: relaunchHost,
},
}
vi.resetModules()
const { useUpdateStore } = await import('./updateStore')
await useUpdateStore.getState().checkForUpdates()
await useUpdateStore.getState().installUpdate()
expect(useUpdateStore.getState().status).toBe('restarting')
await vi.advanceTimersByTimeAsync(15_000)
expect(relaunchHost).toHaveBeenCalledTimes(1)
expect(cancelInstall).toHaveBeenCalledTimes(1)
expect(getServerUrl).toHaveBeenCalledTimes(1)
expect(useUpdateStore.getState().status).toBe('downloaded')
expect(useUpdateStore.getState().error).toContain('Restart did not start automatically')
expect(useUpdateStore.getState().shouldPrompt).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('refreshes the pending update when the proxy changes before install', async () => {
const staleClose = vi.fn().mockResolvedValue(undefined)
const freshDownload = vi.fn(async (onEvent?: (event: unknown) => void) => {

View File

@ -21,6 +21,7 @@ type CheckOptions = {
}
const DISMISSED_UPDATE_VERSION_KEY = 'cc-haha-dismissed-update-version'
const RELAUNCH_WATCHDOG_MS = 15_000
type UpdateStore = {
status: UpdateStatus
@ -45,6 +46,31 @@ let pendingUpdateDownloaded = false
let downloadPromise: Promise<void> | null = null
let downloadingProxyKey: string | null = null
let startupCheckPromise: Promise<void> | null = null
let relaunchWatchdog: ReturnType<typeof setTimeout> | null = null
function clearRelaunchWatchdog() {
if (!relaunchWatchdog) return
clearTimeout(relaunchWatchdog)
relaunchWatchdog = null
}
function scheduleRelaunchWatchdog(host: DesktopHost) {
clearRelaunchWatchdog()
relaunchWatchdog = setTimeout(() => {
relaunchWatchdog = null
if (useUpdateStore.getState().status !== 'restarting') return
void host.updates.cancelInstall().catch(() => undefined)
void host.runtime.getServerUrl().catch(() => undefined)
useUpdateStore.setState((state) => ({
...state,
status: 'downloaded',
error: 'Restart did not start automatically. Restart the app manually to finish installing the update.',
shouldPrompt: true,
progressPercent: 100,
}))
}, RELAUNCH_WATCHDOG_MS)
}
function readDismissedUpdateVersion(): string | null {
if (typeof window === 'undefined') return null
@ -100,7 +126,7 @@ async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | n
downloadingProxyKey = null
}
if (previous && previous !== next) {
if (previous && !next) {
try {
await previous.close()
} catch {
@ -185,6 +211,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
const host = getUpdateHost()
if (!host) return null
if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate
clearRelaunchWatchdog()
set((state) => ({
...state,
@ -281,6 +308,7 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
downloadUpdate: async () => {
if (!getUpdateHost()) return
clearRelaunchWatchdog()
let update = pendingUpdate
if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) {
@ -437,8 +465,10 @@ export const useUpdateStore = create<UpdateStore>((set, get) => ({
progressPercent: 100,
}))
scheduleRelaunchWatchdog(host)
await host.updates.relaunch()
} catch (error) {
clearRelaunchWatchdog()
if (prepareInstallAttempted) {
try {
await host.updates.cancelInstall()

View File

@ -218,6 +218,8 @@ describe('release desktop workflow', () => {
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("RELEASE_DRAFT: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}")
expect(signingJob).toContain('Refusing to publish a non-draft desktop release without macOS signing/notarization secrets.')
expect(signingJob).toContain('macos_signed=false')
expect(signingJob).toContain('macos_signed=true')
expect(signingJob).toContain('Windows signing secrets missing')
@ -229,7 +231,8 @@ describe('release desktop workflow', () => {
const windowsOptionalBlock = signingJob?.match(
/win_missing=\(\)[\s\S]*?fi\n/,
)?.[0]
expect(macRequiredBlock).not.toContain('exit 1')
expect(macRequiredBlock).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
expect(macRequiredBlock).toContain('exit 1')
expect(windowsOptionalBlock).toContain('::warning::')
expect(windowsOptionalBlock).not.toContain('exit 1')
expect(buildJob).toContain('- signing-preflight')
@ -288,15 +291,31 @@ describe('release desktop workflow', () => {
expect(publishJob).toContain('artifacts/release-assets/**/*.blockmap')
expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
expect(publishJob).toContain('desktop/scripts/install-macos-unsigned.sh')
expect(publishJob).toContain("draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}")
expect(publishJob).toContain('Ensure workflow-dispatch release remains draft')
expect(publishJob).toContain('draft: true')
expect(publishJob).toContain('Publish GitHub release after complete upload')
expect(publishJob).toContain("if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.draft == false)")
expect(publishJob).toContain('gh release edit "v${{ steps.version.outputs.value }}" --draft=false --repo "${{ github.repository }}"')
expect(publishJob).toContain('Keep workflow-dispatch release as draft')
expect(publishJob).toContain("if: github.event_name == 'workflow_dispatch' && inputs.draft == true")
expect(publishJob).toContain('gh release edit "v${{ steps.version.outputs.value }}" --draft --repo "${{ github.repository }}"')
expect(publishJob).toContain('release remains draft')
expect(publishJob).toContain('fail_on_unmatched_files: true')
expect(publishJob).toContain('Load release notes')
expect(publishJob.indexOf('Publish complete GitHub release')).toBeLessThan(publishJob.indexOf('Publish GitHub release after complete upload'))
expect(workflow.indexOf('publish-release:')).toBeGreaterThan(workflow.indexOf('build:'))
})
test('release workflow keeps updater-visible releases draft until every asset is uploaded', () => {
const workflow = readReleaseWorkflow()
const publishJob = extractJob(workflow, 'publish-release')
expect(publishJob).toContain('draft: true')
expect(publishJob).toContain('fail_on_unmatched_files: true')
expect(publishJob).toContain('Publish GitHub release after complete upload')
expect(publishJob).toContain('--draft=false')
expect(publishJob.indexOf('Publish complete GitHub release')).toBeLessThan(publishJob.indexOf('Publish GitHub release after complete upload'))
expect(publishJob.indexOf('Validate standard update metadata set')).toBeLessThan(publishJob.indexOf('Publish complete GitHub release'))
})
test('release matrix asset basenames remain unique when final artifacts are flattened', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
version: string
@ -322,6 +341,8 @@ describe('release desktop workflow', () => {
`Claude-Code-Haha-${version}-linux-arm64.deb`,
`Claude-Code-Haha-${version}-win-x64.exe`,
`Claude-Code-Haha-${version}-win-x64.exe.blockmap`,
`Claude-Code-Haha-${version}-win-arm64.exe`,
`Claude-Code-Haha-${version}-win-arm64.exe.blockmap`,
]
const namespacedMetadata = [
'latest-mac-macOS-ARM64.yml',
@ -347,7 +368,7 @@ describe('release desktop workflow', () => {
expect(expectedReleaseAssets.filter((name) => name.endsWith('.zip')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.AppImage')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.deb')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.exe')).length).toBe(1)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.exe')).length).toBe(2)
expect(expectedReleaseAssets.some((name) => name.includes('-linux-') && name.endsWith('.blockmap'))).toBe(false)
for (const platform of ['mac', 'linux', 'win']) {
expect(expectedReleaseAssets.some((name) => name.includes(`-${platform}-`))).toBe(true)
@ -374,6 +395,9 @@ describe('release desktop workflow', () => {
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage',
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb',
'Claude-Code-Haha-${APP_VERSION}-win-x64.exe',
'Claude-Code-Haha-${APP_VERSION}-win-x64.exe.blockmap',
'Claude-Code-Haha-${APP_VERSION}-win-arm64.exe',
'Claude-Code-Haha-${APP_VERSION}-win-arm64.exe.blockmap',
]
for (const file of expectedFiles) {