diff --git a/.github/signpath/windows-application.xml b/.github/signpath/windows-application.xml
new file mode 100644
index 00000000..8db1bb24
--- /dev/null
+++ b/.github/signpath/windows-application.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/signpath/windows-installer.xml b/.github/signpath/windows-installer.xml
new file mode 100644
index 00000000..efc872f5
--- /dev/null
+++ b/.github/signpath/windows-installer.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index baa16da2..54b7ce9b 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -17,6 +17,7 @@ on:
type: boolean
permissions:
+ actions: read
contents: write
concurrency:
@@ -28,6 +29,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
macos_signed: ${{ steps.validate.outputs.macos_signed }}
+ windows_signed: ${{ steps.validate.outputs.windows_signed }}
steps:
- name: Validate release signing and notarization secrets
id: validate
@@ -38,8 +40,12 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
- WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+ SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
+ SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ SIGNPATH_SIGNING_POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+ SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG }}
+ SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG }}
RELEASE_DRAFT: ${{ github.event_name == 'workflow_dispatch' && inputs.draft == true }}
run: |
# macOS signing + notarization is preferred: Squirrel.Mac auto-update and
@@ -61,14 +67,24 @@ jobs:
else
echo "macos_signed=true" >> "$GITHUB_OUTPUT"
fi
- # Windows signing is optional: an unsigned NSIS installer still auto-updates
- # (electron-updater), it only triggers SmartScreen warnings. Warn, do not block,
- # so releases can ship with an Apple Developer ID alone.
+ # Drafts may remain unsigned while SignPath onboarding is being tested. Tags and
+ # non-draft releases must have the full GitHub connector configuration available.
win_missing=()
- [ -n "$WIN_CSC_LINK" ] || win_missing+=("WINDOWS_CERTIFICATE")
- [ -n "$WIN_CSC_KEY_PASSWORD" ] || win_missing+=("WINDOWS_CERTIFICATE_PASSWORD")
+ [ -n "$SIGNPATH_API_TOKEN" ] || win_missing+=("SIGNPATH_API_TOKEN secret")
+ [ -n "$SIGNPATH_ORGANIZATION_ID" ] || win_missing+=("SIGNPATH_ORGANIZATION_ID variable")
+ [ -n "$SIGNPATH_PROJECT_SLUG" ] || win_missing+=("SIGNPATH_PROJECT_SLUG variable")
+ [ -n "$SIGNPATH_SIGNING_POLICY_SLUG" ] || win_missing+=("SIGNPATH_SIGNING_POLICY_SLUG variable")
+ [ -n "$SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG" ] || win_missing+=("SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG variable")
+ [ -n "$SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG" ] || win_missing+=("SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG variable")
if [ "${#win_missing[@]}" -gt 0 ]; then
- printf '::warning::Windows signing secrets missing (%s): the Windows build will be unsigned. Auto-update still works, but users will see SmartScreen warnings.\n' "${win_missing[*]}"
+ printf '::warning::SignPath configuration missing (%s): the Windows build will be unsigned.\n' "${win_missing[*]}"
+ echo "windows_signed=false" >> "$GITHUB_OUTPUT"
+ if [ "$RELEASE_DRAFT" != "true" ]; then
+ echo "::error::Refusing to publish a non-draft desktop release without SignPath Windows signing."
+ exit 1
+ fi
+ else
+ echo "windows_signed=true" >> "$GITHUB_OUTPUT"
fi
build:
@@ -107,12 +123,16 @@ jobs:
- platform: windows-latest
target_triple: x86_64-pc-windows-msvc
builder_args: --win nsis --x64
+ builder_arch_arg: --x64
+ unpacked_dir: win-unpacked
label: Windows-x64
smoke_platform: windows
arch: x64
- platform: windows-latest
target_triple: aarch64-pc-windows-msvc
builder_args: --win nsis --arm64
+ builder_arch_arg: --arm64
+ unpacked_dir: win-arm64-unpacked
label: Windows-ARM64
smoke_platform: windows
arch: arm64
@@ -384,7 +404,7 @@ jobs:
fi
- name: Build unsigned Electron release artifacts
- if: matrix.smoke_platform != 'macos' || needs.signing-preflight.outputs.macos_signed != 'true'
+ if: matrix.smoke_platform == 'linux' || (matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed != 'true') || (matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed != 'true')
working-directory: desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -394,6 +414,155 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never
+ - name: Build unsigned Windows application directory for SignPath
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ working-directory: desktop
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false'
+ run: node ./node_modules/electron-builder/out/cli/cli.js --win dir ${{ matrix.builder_arch_arg }} --publish never
+
+ - name: Stage project-owned Windows application executables
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ id: stage-signpath-application
+ shell: pwsh
+ run: |
+ $unpackedDir = Join-Path $PWD "desktop/build-artifacts/electron/${{ matrix.unpacked_dir }}"
+ $stageDir = Join-Path $env:RUNNER_TEMP "signpath-application-${{ matrix.arch }}"
+ $sidecarName = "claude-sidecar-${{ matrix.target_triple }}.exe"
+ $mainExecutable = Join-Path $unpackedDir "Claude Code Haha.exe"
+ $sidecarExecutable = Join-Path $unpackedDir "resources/app.asar.unpacked/src-tauri/binaries/$sidecarName"
+ New-Item -ItemType Directory -Path $stageDir | Out-Null
+ Copy-Item -LiteralPath $mainExecutable -Destination (Join-Path $stageDir "Claude Code Haha.exe")
+ Copy-Item -LiteralPath $sidecarExecutable -Destination (Join-Path $stageDir $sidecarName)
+ "stage_dir=$stageDir" >> $env:GITHUB_OUTPUT
+ "unpacked_dir=$unpackedDir" >> $env:GITHUB_OUTPUT
+ "sidecar_name=$sidecarName" >> $env:GITHUB_OUTPUT
+
+ - name: Upload unsigned Windows application executables
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ id: upload-unsigned-signpath-application
+ uses: actions/upload-artifact@v4
+ with:
+ name: signpath-unsigned-application-${{ matrix.arch }}
+ path: ${{ steps.stage-signpath-application.outputs.stage_dir }}
+ if-no-files-found: error
+
+ - name: Sign Windows application executables with SignPath
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ uses: signpath/github-action-submit-signing-request@v2
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+ artifact-configuration-slug: ${{ vars.SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG }}
+ github-artifact-id: ${{ steps.upload-unsigned-signpath-application.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: '3600'
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-application-${{ matrix.arch }}
+
+ - name: Restore and verify signed Windows application executables
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ shell: pwsh
+ env:
+ REQUIRE_TRUSTED_WINDOWS_SIGNATURE: ${{ github.event_name != 'workflow_dispatch' || inputs.draft == false }}
+ run: |
+ $signedDir = Join-Path $env:RUNNER_TEMP "signpath-signed-application-${{ matrix.arch }}"
+ $unpackedDir = "${{ steps.stage-signpath-application.outputs.unpacked_dir }}"
+ $sidecarName = "${{ steps.stage-signpath-application.outputs.sidecar_name }}"
+ $mainExecutable = Join-Path $unpackedDir "Claude Code Haha.exe"
+ $sidecarExecutable = Join-Path $unpackedDir "resources/app.asar.unpacked/src-tauri/binaries/$sidecarName"
+ Copy-Item -LiteralPath (Join-Path $signedDir "Claude Code Haha.exe") -Destination $mainExecutable -Force
+ Copy-Item -LiteralPath (Join-Path $signedDir $sidecarName) -Destination $sidecarExecutable -Force
+
+ function Assert-SignPathSignature([string] $Path) {
+ $signature = Get-AuthenticodeSignature -LiteralPath $Path
+ if ($null -eq $signature.SignerCertificate) {
+ throw "SignPath did not add an Authenticode signature to $Path"
+ }
+ if ($signature.Status -notin @('Valid', 'UnknownError')) {
+ throw "Authenticode verification failed for $Path with status $($signature.Status): $($signature.StatusMessage)"
+ }
+ if ($env:REQUIRE_TRUSTED_WINDOWS_SIGNATURE -eq 'true' -and $signature.Status -ne 'Valid') {
+ throw "A trusted production signature is required for $Path, but the status is $($signature.Status): $($signature.StatusMessage)"
+ }
+ Write-Host "Verified Authenticode signature on $Path from $($signature.SignerCertificate.Subject)"
+ }
+
+ Assert-SignPathSignature $mainExecutable
+ Assert-SignPathSignature $sidecarExecutable
+
+ - name: Package NSIS installer from signed Windows application
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ working-directory: desktop
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CSC_IDENTITY_AUTO_DISCOVERY: 'false'
+ run: node ./node_modules/electron-builder/out/cli/cli.js --win nsis ${{ matrix.builder_arch_arg }} --prepackaged "build-artifacts/electron/${{ matrix.unpacked_dir }}" --publish never
+
+ - name: Stage unsigned Windows installer
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ id: stage-signpath-installer
+ shell: pwsh
+ run: |
+ $installerName = "Claude-Code-Haha-${{ steps.version.outputs.value }}-win-${{ matrix.arch }}.exe"
+ $installerPath = Join-Path $PWD "desktop/build-artifacts/electron/$installerName"
+ $stageDir = Join-Path $env:RUNNER_TEMP "signpath-installer-${{ matrix.arch }}"
+ New-Item -ItemType Directory -Path $stageDir | Out-Null
+ Copy-Item -LiteralPath $installerPath -Destination (Join-Path $stageDir $installerName)
+ "stage_dir=$stageDir" >> $env:GITHUB_OUTPUT
+ "installer_name=$installerName" >> $env:GITHUB_OUTPUT
+ "installer_path=$installerPath" >> $env:GITHUB_OUTPUT
+
+ - name: Upload unsigned Windows installer
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ id: upload-unsigned-signpath-installer
+ uses: actions/upload-artifact@v4
+ with:
+ name: signpath-unsigned-installer-${{ matrix.arch }}
+ path: ${{ steps.stage-signpath-installer.outputs.stage_dir }}
+ if-no-files-found: error
+
+ - name: Sign Windows installer with SignPath
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ uses: signpath/github-action-submit-signing-request@v2
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+ artifact-configuration-slug: ${{ vars.SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG }}
+ github-artifact-id: ${{ steps.upload-unsigned-signpath-installer.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: '3600'
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-installer-${{ matrix.arch }}
+
+ - name: Restore and verify signed Windows installer
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ shell: pwsh
+ env:
+ REQUIRE_TRUSTED_WINDOWS_SIGNATURE: ${{ github.event_name != 'workflow_dispatch' || inputs.draft == false }}
+ run: |
+ $signedInstaller = Join-Path $env:RUNNER_TEMP "signpath-signed-installer-${{ matrix.arch }}/${{ steps.stage-signpath-installer.outputs.installer_name }}"
+ $installerPath = "${{ steps.stage-signpath-installer.outputs.installer_path }}"
+ Copy-Item -LiteralPath $signedInstaller -Destination $installerPath -Force
+ $signature = Get-AuthenticodeSignature -LiteralPath $installerPath
+ if ($null -eq $signature.SignerCertificate) {
+ throw "SignPath did not add an Authenticode signature to $installerPath"
+ }
+ if ($signature.Status -notin @('Valid', 'UnknownError')) {
+ throw "Authenticode verification failed for $installerPath with status $($signature.Status): $($signature.StatusMessage)"
+ }
+ if ($env:REQUIRE_TRUSTED_WINDOWS_SIGNATURE -eq 'true' -and $signature.Status -ne 'Valid') {
+ throw "A trusted production signature is required for $installerPath, but the status is $($signature.Status): $($signature.StatusMessage)"
+ }
+ Write-Host "Verified Authenticode signature on $installerPath from $($signature.SignerCertificate.Subject)"
+
+ - name: Refresh signed Windows blockmap and update metadata
+ if: matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed == 'true'
+ run: bun run scripts/refresh-windows-update-metadata.ts --installer "${{ steps.stage-signpath-installer.outputs.installer_path }}" --metadata desktop/build-artifacts/electron/latest.yml
+
- name: Verify Windows installer execution
if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'
timeout-minutes: 10
diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts
index e4f69982..7629d3c5 100644
--- a/scripts/pr/release-workflow.test.ts
+++ b/scripts/pr/release-workflow.test.ts
@@ -241,7 +241,7 @@ describe('release desktop workflow', () => {
expect(signedBuildStep).toContain('retrying after 120 seconds')
expect(signedBuildStep).toContain('node ./node_modules/electron-builder/out/cli/cli.js "${builder_args[@]}"')
- expect(unsignedBuildStep).toContain("if: matrix.smoke_platform != 'macos' || needs.signing-preflight.outputs.macos_signed != 'true'")
+ expect(unsignedBuildStep).toContain("if: matrix.smoke_platform == 'linux' || (matrix.smoke_platform == 'macos' && needs.signing-preflight.outputs.macos_signed != 'true') || (matrix.smoke_platform == 'windows' && needs.signing-preflight.outputs.windows_signed != 'true')")
expect(unsignedBuildStep).toContain("CSC_IDENTITY_AUTO_DISCOVERY: 'false'")
for (const envName of [
'CSC_LINK:',
@@ -257,7 +257,7 @@ describe('release desktop workflow', () => {
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', () => {
+ test('release workflow records macOS and SignPath signing state and blocks unsigned releases', () => {
const workflow = readReleaseWorkflow()
const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
@@ -267,6 +267,7 @@ describe('release desktop workflow', () => {
expect(signingJob).toContain('Validate release signing and notarization secrets')
expect(signingJob).toContain('outputs:')
expect(signingJob).toContain('macos_signed: ${{ steps.validate.outputs.macos_signed }}')
+ expect(signingJob).toContain('windows_signed: ${{ steps.validate.outputs.windows_signed }}')
for (const secret of [
'MACOS_CERTIFICATE',
'MACOS_CERTIFICATE_PASSWORD',
@@ -276,11 +277,15 @@ describe('release desktop workflow', () => {
]) {
expect(signingJob).toContain(secret)
}
- for (const secret of [
- 'WINDOWS_CERTIFICATE',
- 'WINDOWS_CERTIFICATE_PASSWORD',
+ for (const setting of [
+ 'SIGNPATH_API_TOKEN',
+ 'SIGNPATH_ORGANIZATION_ID',
+ 'SIGNPATH_PROJECT_SLUG',
+ 'SIGNPATH_SIGNING_POLICY_SLUG',
+ 'SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG',
+ 'SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG',
]) {
- expect(signingJob).toContain(secret)
+ expect(signingJob).toContain(setting)
}
expect(signingJob).toContain('Missing macOS signing/notarization secrets')
expect(signingJob).toContain('macOS artifacts will be unsigned')
@@ -289,24 +294,70 @@ describe('release desktop workflow', () => {
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')
- expect(signingJob).toContain('::warning::Windows signing secrets missing')
+ expect(signingJob).toContain('SignPath configuration missing')
+ expect(signingJob).toContain('windows_signed=false')
+ expect(signingJob).toContain('windows_signed=true')
+ expect(signingJob).toContain('Refusing to publish a non-draft desktop release without SignPath Windows signing.')
const macRequiredBlock = signingJob?.match(
- /missing=\(\)[\s\S]*?# Windows signing is optional:/,
- )?.[0]
- const windowsOptionalBlock = signingJob?.match(
- /win_missing=\(\)[\s\S]*?fi\n/,
+ /missing=\(\)[\s\S]*?# Drafts may remain unsigned/,
)?.[0]
expect(macRequiredBlock).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
expect(macRequiredBlock).toContain('exit 1')
- expect(windowsOptionalBlock).toContain('::warning::')
- expect(windowsOptionalBlock).not.toContain('exit 1')
+ expect(signingJob).toContain('if [ "$RELEASE_DRAFT" != "true" ]; then')
+ expect(signingJob).toContain('exit 1')
expect(buildJob).toContain('- signing-preflight')
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:'))
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
+ test('release workflow signs project-owned Windows binaries before packaging and repairs updater metadata', () => {
+ const workflow = readReleaseWorkflow()
+ const applicationConfiguration = readFileSync('.github/signpath/windows-application.xml', 'utf8')
+ const installerConfiguration = readFileSync('.github/signpath/windows-installer.xml', 'utf8')
+ const applicationBuildStep = extractStep(workflow, 'Build unsigned Windows application directory for SignPath')
+ const stageApplicationStep = extractStep(workflow, 'Stage project-owned Windows application executables')
+ const signApplicationStep = extractStep(workflow, 'Sign Windows application executables with SignPath')
+ const restoreApplicationStep = extractStep(workflow, 'Restore and verify signed Windows application executables')
+ const packageInstallerStep = extractStep(workflow, 'Package NSIS installer from signed Windows application')
+ const signInstallerStep = extractStep(workflow, 'Sign Windows installer with SignPath')
+ const restoreInstallerStep = extractStep(workflow, 'Restore and verify signed Windows installer')
+ const refreshMetadataStep = extractStep(workflow, 'Refresh signed Windows blockmap and update metadata')
+
+ expect(workflow).toContain('actions: read')
+ expect(workflow).toContain('builder_arch_arg: --x64')
+ expect(workflow).toContain('builder_arch_arg: --arm64')
+ expect(workflow).toContain('unpacked_dir: win-unpacked')
+ expect(workflow).toContain('unpacked_dir: win-arm64-unpacked')
+ expect(applicationBuildStep).toContain('--win dir ${{ matrix.builder_arch_arg }}')
+ expect(applicationBuildStep).toContain("CSC_IDENTITY_AUTO_DISCOVERY: 'false'")
+ expect(stageApplicationStep).toContain('Claude Code Haha.exe')
+ expect(stageApplicationStep).toContain('claude-sidecar-${{ matrix.target_triple }}.exe')
+ expect(stageApplicationStep).not.toContain('rg.exe')
+ expect(stageApplicationStep).not.toContain('node-pty')
+ expect(signApplicationStep).toContain('signpath/github-action-submit-signing-request@v2')
+ expect(signApplicationStep).toContain('SIGNPATH_APPLICATION_ARTIFACT_CONFIGURATION_SLUG')
+ expect(signApplicationStep).toContain('github-artifact-id: ${{ steps.upload-unsigned-signpath-application.outputs.artifact-id }}')
+ expect(restoreApplicationStep).toContain('Get-AuthenticodeSignature')
+ expect(restoreApplicationStep).toContain('REQUIRE_TRUSTED_WINDOWS_SIGNATURE')
+ expect(packageInstallerStep).toContain('--prepackaged "build-artifacts/electron/${{ matrix.unpacked_dir }}"')
+ expect(signInstallerStep).toContain('signpath/github-action-submit-signing-request@v2')
+ expect(signInstallerStep).toContain('SIGNPATH_INSTALLER_ARTIFACT_CONFIGURATION_SLUG')
+ expect(restoreInstallerStep).toContain('Get-AuthenticodeSignature')
+ expect(restoreInstallerStep).toContain('A trusted production signature is required')
+ expect(refreshMetadataStep).toContain('scripts/refresh-windows-update-metadata.ts')
+ expect(refreshMetadataStep).toContain('desktop/build-artifacts/electron/latest.yml')
+ expect(applicationConfiguration).toContain('')
+ expect(applicationConfiguration).toContain('')
+ expect(applicationConfiguration).not.toContain('rg.exe')
+ expect(installerConfiguration).toContain('')
+ expect(workflow).not.toContain('WINDOWS_CERTIFICATE')
+ expect(workflow).not.toContain('WINDOWS_CERTIFICATE_PASSWORD')
+ expect(workflow.indexOf('Restore and verify signed Windows application executables')).toBeLessThan(workflow.indexOf('Package NSIS installer from signed Windows application'))
+ expect(workflow.indexOf('Restore and verify signed Windows installer')).toBeLessThan(workflow.indexOf('Refresh signed Windows blockmap and update metadata'))
+ expect(workflow.indexOf('Refresh signed Windows blockmap and update metadata')).toBeLessThan(workflow.indexOf('Verify Windows installer execution'))
+ })
+
test('release workflow avoids same-name updater metadata uploads from matrix builds', () => {
const workflow = readReleaseWorkflow()
const namespaceStep = workflow.match(
@@ -567,6 +618,7 @@ describe('release desktop workflow', () => {
const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8')
const recoveryHelper = readFileSync('desktop/build/recover-legacy-install-data.ps1', 'utf8')
+ const normalizedRecoveryHelper = recoveryHelper.replace(/\r\n/g, '\n')
expect(installerHook).toContain('!macro customInit')
expect(installerHook).toContain('!macro customCheckAppRunning')
expect(installerHook).toContain('!macro customPageAfterChangeDir')
@@ -608,7 +660,7 @@ describe('release desktop workflow', () => {
expect(recoveryHelper).toContain('Active CLAUDE_CONFIG_DIR is managed outside Claude Code Haha')
expect(recoveryHelper).toContain('Test-LexicalPathAtOrBelow')
expect(recoveryHelper).toContain('-SharedInstallDirs @($PerMachineInstallDir)')
- expect(recoveryHelper).toContain("function Invoke-LegacyRecovery {\n param(\n [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]]$InstallDirs")
+ expect(normalizedRecoveryHelper).toContain("function Invoke-LegacyRecovery {\n param(\n [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]]$InstallDirs")
expect(recoveryHelper).toContain('$installDirInputs.Count -eq 0')
expect(recoveryHelper).toContain('$sharedInstallDirInputs.Count -gt 0')
expect(recoveryHelper).toContain('per-user default-mode reinstall scanned the packaged application tree')
diff --git a/scripts/refresh-windows-update-metadata.test.ts b/scripts/refresh-windows-update-metadata.test.ts
new file mode 100644
index 00000000..64523c03
--- /dev/null
+++ b/scripts/refresh-windows-update-metadata.test.ts
@@ -0,0 +1,75 @@
+import { createHash } from 'node:crypto'
+import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, test } from 'bun:test'
+import { parse } from 'yaml'
+import { refreshWindowsUpdateMetadata } from './refresh-windows-update-metadata'
+
+function tempDir() {
+ return mkdtempSync(join(tmpdir(), 'cc-haha-signed-windows-metadata-'))
+}
+
+describe('signed Windows update metadata refresh', () => {
+ test('replaces the unsigned installer checksum and size while preserving release metadata', async () => {
+ const dir = tempDir()
+ const installerName = 'Claude-Code-Haha-0.5.5-win-x64.exe'
+ const installerPath = join(dir, installerName)
+ const metadataPath = join(dir, 'latest.yml')
+ const installer = Buffer.from('signed installer bytes')
+ writeFileSync(installerPath, installer)
+ writeFileSync(metadataPath, `
+version: 0.5.5
+files:
+ - url: ${installerName}
+ sha512: unsigned-checksum
+ sha2: stale-sha256
+ size: 1
+path: ${installerName}
+sha512: unsigned-checksum
+sha2: stale-sha256
+releaseDate: '2026-08-23T00:00:00.000Z'
+`.trimStart())
+
+ const result = await refreshWindowsUpdateMetadata({ installerPath, metadataPath })
+ const expectedSha512 = createHash('sha512').update(installer).digest('base64')
+ const metadata = parse(readFileSync(metadataPath, 'utf8')) as {
+ files: Array<{ sha512: string, sha2?: string, size: number }>
+ sha512: string
+ sha2?: string
+ releaseDate: string
+ }
+
+ expect(result).toEqual({
+ installerName,
+ sha512: expectedSha512,
+ size: installer.length,
+ })
+ expect(metadata.files[0]).toMatchObject({
+ sha512: expectedSha512,
+ size: installer.length,
+ })
+ expect(metadata.files[0].sha2).toBeUndefined()
+ expect(metadata.sha512).toBe(expectedSha512)
+ expect(metadata.sha2).toBeUndefined()
+ expect(metadata.releaseDate).toBe('2026-08-23T00:00:00.000Z')
+ })
+
+ test('rejects metadata that does not point at the signed installer', async () => {
+ const dir = tempDir()
+ const installerPath = join(dir, 'Claude-Code-Haha-0.5.5-win-arm64.exe')
+ const metadataPath = join(dir, 'latest.yml')
+ writeFileSync(installerPath, 'signed')
+ writeFileSync(metadataPath, `
+version: 0.5.5
+files:
+ - url: different-installer.exe
+ sha512: old
+path: different-installer.exe
+sha512: old
+`.trimStart())
+
+ await expect(refreshWindowsUpdateMetadata({ installerPath, metadataPath }))
+ .rejects.toThrow('Expected exactly one update file')
+ })
+})
diff --git a/scripts/refresh-windows-update-metadata.ts b/scripts/refresh-windows-update-metadata.ts
new file mode 100644
index 00000000..acb572b3
--- /dev/null
+++ b/scripts/refresh-windows-update-metadata.ts
@@ -0,0 +1,167 @@
+#!/usr/bin/env bun
+
+import { createHash } from 'node:crypto'
+import { createReadStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { basename, relative, resolve } from 'node:path'
+import { parse, stringify } from 'yaml'
+
+type UpdateFileMetadata = {
+ url?: string
+ sha512?: string
+ sha2?: string
+ size?: number
+ [key: string]: unknown
+}
+
+type UpdateMetadata = {
+ files?: UpdateFileMetadata[]
+ path?: string
+ sha512?: string
+ sha2?: string
+ [key: string]: unknown
+}
+
+export type RefreshWindowsUpdateMetadataOptions = {
+ installerPath: string
+ metadataPath: string
+}
+
+export type RefreshWindowsUpdateMetadataResult = {
+ installerName: string
+ sha512: string
+ size: number
+}
+
+type AppBuilderModule = {
+ executeAppBuilderAsJson(args: string[]): Promise
+}
+
+function usage() {
+ return 'Usage: bun run scripts/refresh-windows-update-metadata.ts --installer --metadata '
+}
+
+function readArgValue(argv: string[], index: number, flag: string) {
+ const value = argv[index + 1]
+ if (!value || value.startsWith('--')) {
+ throw new Error(`Missing value for ${flag}\n${usage()}`)
+ }
+ return value
+}
+
+function parseArgs(argv: string[]): RefreshWindowsUpdateMetadataOptions {
+ let installerPath: string | undefined
+ let metadataPath: string | undefined
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index]
+ if (arg === '--installer') {
+ installerPath = readArgValue(argv, index, arg)
+ index += 1
+ continue
+ }
+ if (arg === '--metadata') {
+ metadataPath = readArgValue(argv, index, arg)
+ index += 1
+ }
+ }
+
+ if (!installerPath || !metadataPath) {
+ throw new Error(usage())
+ }
+
+ return { installerPath, metadataPath }
+}
+
+function fileNameFromUrl(url: string) {
+ return url.replace(/\\/g, '/').split('/').at(-1)
+}
+
+async function sha512File(filePath: string) {
+ const hash = createHash('sha512')
+ await new Promise((resolvePromise, reject) => {
+ const stream = createReadStream(filePath)
+ stream.on('data', chunk => hash.update(chunk))
+ stream.on('error', reject)
+ stream.on('end', resolvePromise)
+ })
+ return hash.digest('base64')
+}
+
+export async function rebuildWindowsInstallerBlockmap(installerPath: string) {
+ const resolvedInstallerPath = resolve(installerPath)
+ const moduleCandidates = [
+ resolve('desktop/node_modules/app-builder-lib/out/util/appBuilder.js'),
+ resolve('node_modules/app-builder-lib/out/util/appBuilder.js'),
+ ]
+ const modulePath = moduleCandidates.find(existsSync)
+ if (!modulePath) {
+ throw new Error('Cannot find app-builder-lib; install desktop dependencies before rebuilding the blockmap')
+ }
+
+ const require = createRequire(import.meta.url)
+ const { executeAppBuilderAsJson } = require(modulePath) as AppBuilderModule
+ await executeAppBuilderAsJson([
+ 'blockmap',
+ '--input',
+ resolvedInstallerPath,
+ '--output',
+ `${resolvedInstallerPath}.blockmap`,
+ ])
+}
+
+function readMetadata(filePath: string): UpdateMetadata {
+ const parsed = parse(readFileSync(filePath, 'utf8')) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`Update metadata must be a YAML object: ${filePath}`)
+ }
+ return parsed as UpdateMetadata
+}
+
+export async function refreshWindowsUpdateMetadata(
+ options: RefreshWindowsUpdateMetadataOptions,
+): Promise {
+ const installerPath = resolve(options.installerPath)
+ const metadataPath = resolve(options.metadataPath)
+ const installerName = basename(installerPath)
+ const metadata = readMetadata(metadataPath)
+ const matchingFiles = Array.isArray(metadata.files)
+ ? metadata.files.filter(file => file.url && fileNameFromUrl(file.url) === installerName)
+ : []
+
+ if (matchingFiles.length !== 1) {
+ throw new Error(
+ `Expected exactly one update file for ${installerName} in ${metadataPath}, found ${matchingFiles.length}`,
+ )
+ }
+ if (!metadata.path || fileNameFromUrl(metadata.path) !== installerName) {
+ throw new Error(`Primary update path does not reference ${installerName} in ${metadataPath}`)
+ }
+
+ const size = statSync(installerPath).size
+ const sha512 = await sha512File(installerPath)
+ const [file] = matchingFiles
+ file.sha512 = sha512
+ file.size = size
+ delete file.sha2
+ metadata.sha512 = sha512
+ delete metadata.sha2
+ writeFileSync(metadataPath, stringify(metadata))
+
+ return { installerName, sha512, size }
+}
+
+if (import.meta.main) {
+ try {
+ const options = parseArgs(process.argv.slice(2))
+ await rebuildWindowsInstallerBlockmap(options.installerPath)
+ const result = await refreshWindowsUpdateMetadata(options)
+ console.log(
+ `[refresh-windows-update-metadata] updated ${relative(process.cwd(), resolve(options.metadataPath))} for ${result.installerName} (${result.size} bytes)`,
+ )
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.error(message)
+ process.exit(1)
+ }
+}