mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(release): sign Windows artifacts with SignPath
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user