mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
3e685eb6c2
commit
a7063e838b
19
.github/workflows/release-desktop.yml
vendored
19
.github/workflows/release-desktop.yml
vendored
@ -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 }}"
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user