diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index da0b4a02..6fbf2135 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 }}" diff --git a/AGENTS.md b/AGENTS.md index e3af5850..f7bcb366 100644 --- a/AGENTS.md +++ b/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 --dry`, then run `bun run verify`, and run `bun run quality:gate --mode release --allow-live --provider-model ` 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 ` 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 `. 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 diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index 96a51cba..0c6e2b83 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -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) => { diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index 88199404..58e767fd 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -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 | null = null let downloadingProxyKey: string | null = null let startupCheckPromise: Promise | null = null +let relaunchWatchdog: ReturnType | 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((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((set, get) => ({ downloadUpdate: async () => { if (!getUpdateHost()) return + clearRelaunchWatchdog() let update = pendingUpdate if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) { @@ -437,8 +465,10 @@ export const useUpdateStore = create((set, get) => ({ progressPercent: 100, })) + scheduleRelaunchWatchdog(host) await host.updates.relaunch() } catch (error) { + clearRelaunchWatchdog() if (prepareInstallAttempted) { try { await host.updates.cancelInstall() diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index 91925d84..51917870 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -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) {