feat(release): sign Windows artifacts with SignPath

This commit is contained in:
Relakkes Yang
2026-08-23 18:14:52 +08:00
parent ae6e11eeac
commit af4454f38a
6 changed files with 504 additions and 24 deletions
+10
View File
@@ -0,0 +1,10 @@
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="Claude Code Haha.exe">
<authenticode-sign/>
</pe-file>
<pe-file path="claude-sidecar-*.exe">
<authenticode-sign/>
</pe-file>
</zip-file>
</artifact-configuration>
+7
View File
@@ -0,0 +1,7 @@
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="Claude-Code-Haha-*-win-*.exe">
<authenticode-sign/>
</pe-file>
</zip-file>
</artifact-configuration>
+178 -9
View File
@@ -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
+67 -15
View File
@@ -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('<pe-file path="Claude Code Haha.exe">')
expect(applicationConfiguration).toContain('<pe-file path="claude-sidecar-*.exe">')
expect(applicationConfiguration).not.toContain('rg.exe')
expect(installerConfiguration).toContain('<pe-file path="Claude-Code-Haha-*-win-*.exe">')
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')
@@ -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')
})
})
+167
View File
@@ -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<unknown>
}
function usage() {
return 'Usage: bun run scripts/refresh-windows-update-metadata.ts --installer <path> --metadata <path>'
}
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<void>((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<RefreshWindowsUpdateMetadataResult> {
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)
}
}