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) + } +}