mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 01:46:47 +08:00
[feat] add SignPath release signing and manual Windows tests
[skip ci]
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
name: Sign Windows artifact
|
||||
description: Submit one raw EXE or ZIP to SignPath and download the signed result.
|
||||
inputs:
|
||||
api-token:
|
||||
description: SignPath submitter token.
|
||||
required: true
|
||||
organization-id:
|
||||
description: SignPath organization ID.
|
||||
required: true
|
||||
signing-policy:
|
||||
description: test-signing or release-signing.
|
||||
required: true
|
||||
artifact-configuration:
|
||||
description: Artifact configuration slug in the crossdesk project.
|
||||
required: true
|
||||
artifact:
|
||||
description: Absolute path to one EXE or ZIP file.
|
||||
required: true
|
||||
output-directory:
|
||||
description: Absolute directory for the downloaded signed file.
|
||||
required: true
|
||||
version:
|
||||
description: CrossDesk ProductVersion and FileVersion.
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Upload unsigned artifact
|
||||
id: unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ${{ inputs.artifact }}
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Submit signing request and wait for approval
|
||||
id: sign
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ inputs.api-token }}
|
||||
organization-id: ${{ inputs.organization-id }}
|
||||
project-slug: crossdesk
|
||||
signing-policy-slug: ${{ inputs.signing-policy }}
|
||||
artifact-configuration-slug: ${{ inputs.artifact-configuration }}
|
||||
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: ${{ inputs.output-directory }}
|
||||
skip-decompress: true
|
||||
parameters: |
|
||||
version: ${{ toJSON(inputs.version) }}
|
||||
|
||||
- name: Record signing request
|
||||
if: always() && steps.sign.outputs.signing-request-web-url != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_REQUEST_URL: ${{ steps.sign.outputs.signing-request-web-url }}
|
||||
SIGNING_CONFIGURATION: ${{ inputs.artifact-configuration }}
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy }}
|
||||
run: |
|
||||
"- $env:SIGNING_CONFIGURATION ($env:SIGNING_POLICY): $env:SIGNING_REQUEST_URL" >> $env:GITHUB_STEP_SUMMARY
|
||||
+114
-41
@@ -14,7 +14,17 @@ on:
|
||||
required: false
|
||||
default: "0"
|
||||
sign_windows_test:
|
||||
description: "Submit the Windows installer to SignPath using test-signing."
|
||||
description: "Test-sign Windows binaries and installer (Actions artifacts only)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
sign_windows_release:
|
||||
description: "Test release-signing with the production certificate (Actions artifacts only)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
windows_only:
|
||||
description: "Build only Windows, for signing tests."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -28,6 +38,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
|
||||
name: Build Linux (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
@@ -176,6 +187,7 @@ jobs:
|
||||
|
||||
# macOS
|
||||
build-macos:
|
||||
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
|
||||
name: Build on macOS
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
@@ -308,6 +320,7 @@ jobs:
|
||||
|
||||
# iOS
|
||||
build-ios:
|
||||
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
|
||||
name: Build on iOS (arm64)
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 120
|
||||
@@ -504,9 +517,37 @@ jobs:
|
||||
build-windows-x64:
|
||||
name: Build on Windows x64
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 240
|
||||
outputs:
|
||||
signing-policy: ${{ steps.signing.outputs.policy }}
|
||||
env:
|
||||
XMAKE_GLOBALDIR: D:\xmake_global
|
||||
steps:
|
||||
- name: Select Windows signing policy
|
||||
id: signing
|
||||
shell: pwsh
|
||||
env:
|
||||
TEST_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_test }}
|
||||
RELEASE_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_release }}
|
||||
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
run: |
|
||||
if ($env:TEST_SIGNING -eq 'true' -and $env:RELEASE_SIGNING -eq 'true') {
|
||||
throw 'Select only one of sign_windows_test and sign_windows_release.'
|
||||
}
|
||||
$policy = ''
|
||||
if ($env:PUBLISH_RELEASE -eq 'true' -or $env:RELEASE_SIGNING -eq 'true') {
|
||||
$policy = 'release-signing'
|
||||
} elseif ($env:TEST_SIGNING -eq 'true') {
|
||||
$policy = 'test-signing'
|
||||
}
|
||||
if ($policy -and (!$env:SIGNPATH_API_TOKEN -or !$env:SIGNPATH_ORGANIZATION_ID)) {
|
||||
throw 'Signing requires SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID.'
|
||||
}
|
||||
"policy=$policy" >> $env:GITHUB_OUTPUT
|
||||
"Windows signing policy: $(if ($policy) { $policy } else { 'unsigned development build' })" >> $env:GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Extract version number
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -611,67 +652,99 @@ jobs:
|
||||
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
|
||||
copy "${{ github.workspace }}\apps\desktop\scripts\windows\nsProcess.dll" $nsisPluginDir
|
||||
|
||||
- name: Test Windows signing helpers
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows_signing_test.ps1
|
||||
|
||||
- name: Build CrossDesk
|
||||
run: |
|
||||
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true -y
|
||||
xmake b -vy crossdesk
|
||||
|
||||
- name: Prepare installer binaries for signing
|
||||
if: steps.signing.outputs.policy != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/installer-binaries.zip
|
||||
|
||||
- name: Sign installer binaries
|
||||
if: steps.signing.outputs.policy != ''
|
||||
uses: ./.github/actions/sign-windows
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
signing-policy: ${{ steps.signing.outputs.policy }}
|
||||
artifact-configuration: windows-binaries
|
||||
artifact: ${{ github.workspace }}/signpath/unsigned/installer-binaries.zip
|
||||
output-directory: ${{ github.workspace }}/signpath/signed/installer-binaries
|
||||
version: ${{ env.VERSION_NUM }}
|
||||
|
||||
- name: Verify and restore signed installer binaries
|
||||
if: steps.signing.outputs.policy != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
|
||||
run: |
|
||||
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/installer-binaries/installer-binaries.zip -SigningPolicy $env:SIGNING_POLICY
|
||||
|
||||
- name: Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "${{ github.workspace }}\apps\desktop\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
|
||||
|
||||
- name: Upload unsigned Windows installer for test signing
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
|
||||
id: upload-unsigned-windows-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign Windows installer with SignPath test certificate
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
- name: Sign Windows installer
|
||||
if: steps.signing.outputs.policy != ''
|
||||
uses: ./.github/actions/sign-windows
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: crossdesk
|
||||
signing-policy-slug: test-signing
|
||||
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: ${{ github.workspace }}\signpath\signed-installer
|
||||
skip-decompress: true
|
||||
parameters: |
|
||||
version: ${{ toJSON(env.VERSION_NUM) }}
|
||||
signing-policy: ${{ steps.signing.outputs.policy }}
|
||||
artifact-configuration: initial
|
||||
artifact: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
|
||||
output-directory: ${{ github.workspace }}/signpath/signed/installer
|
||||
version: ${{ env.VERSION_NUM }}
|
||||
|
||||
- name: Verify and use test-signed Windows installer
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
|
||||
- name: Verify and use signed Windows installer
|
||||
if: steps.signing.outputs.policy != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
|
||||
run: |
|
||||
$installerName = "crossdesk-win-x64-${{ env.VERSION_NUM }}.exe"
|
||||
$signedInstaller = Join-Path "${{ github.workspace }}\signpath\signed-installer" $installerName
|
||||
$destination = Join-Path "${{ github.workspace }}\apps\desktop\scripts\windows" $installerName
|
||||
|
||||
if (!(Test-Path $signedInstaller)) {
|
||||
throw "SignPath did not return the signed installer: $signedInstaller"
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature $signedInstaller
|
||||
if (!$signature.SignerCertificate -or $signature.Status -eq "NotSigned") {
|
||||
throw "SignPath returned an installer without an Authenticode signature."
|
||||
}
|
||||
|
||||
Write-Host "Signer: $($signature.SignerCertificate.Subject)"
|
||||
Write-Host "Signature status: $($signature.Status)"
|
||||
Copy-Item $signedInstaller $destination -Force
|
||||
$installerName = "crossdesk-win-x64-$env:VERSION_NUM.exe"
|
||||
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation VerifyInstaller -Artifact "./signpath/signed/installer/$installerName" -SigningPolicy $env:SIGNING_POLICY
|
||||
Copy-Item "./signpath/signed/installer/$installerName" ./apps/desktop/scripts/windows/ -Force
|
||||
|
||||
- name: Build Portable CrossDesk
|
||||
run: |
|
||||
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y
|
||||
xmake b -vy crossdesk
|
||||
|
||||
- name: Prepare portable binaries for signing
|
||||
if: steps.signing.outputs.policy != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/portable-binaries.zip
|
||||
|
||||
- name: Sign portable binaries
|
||||
if: steps.signing.outputs.policy != ''
|
||||
uses: ./.github/actions/sign-windows
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
signing-policy: ${{ steps.signing.outputs.policy }}
|
||||
artifact-configuration: windows-binaries
|
||||
artifact: ${{ github.workspace }}/signpath/unsigned/portable-binaries.zip
|
||||
output-directory: ${{ github.workspace }}/signpath/signed/portable-binaries
|
||||
version: ${{ env.VERSION_NUM }}
|
||||
|
||||
- name: Verify and restore signed portable binaries
|
||||
if: steps.signing.outputs.policy != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
|
||||
run: |
|
||||
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/portable-binaries/portable-binaries.zip -SigningPolicy $env:SIGNING_POLICY
|
||||
|
||||
- name: Package Portable
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -719,7 +792,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Publish Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.build-windows-x64.outputs.signing-policy == 'release-signing'
|
||||
needs: [build-linux, build-macos, build-ios, build-windows-x64]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('PrepareBinaries', 'RestoreBinaries', 'VerifyInstaller')]
|
||||
[string]$Operation,
|
||||
[Parameter(Mandatory)] [string]$Artifact,
|
||||
[string]$Directory,
|
||||
[ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
|
||||
)
|
||||
|
||||
. "$PSScriptRoot/signpath-common.ps1"
|
||||
|
||||
if ($Operation -ne 'PrepareBinaries' -and !$SigningPolicy) {
|
||||
throw 'SigningPolicy is required when verifying signed artifacts.'
|
||||
}
|
||||
if ($Operation -eq 'VerifyInstaller') {
|
||||
Assert-CrossDeskSignature -Path $Artifact -SigningPolicy $SigningPolicy
|
||||
return
|
||||
}
|
||||
if (!$Directory -or !(Test-Path -LiteralPath $Directory -PathType Container)) {
|
||||
throw "Binaries directory does not exist: $Directory"
|
||||
}
|
||||
|
||||
$files = @(Get-CrossDeskSigningFiles)
|
||||
$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-$([guid]::NewGuid())"
|
||||
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
|
||||
try {
|
||||
if ($Operation -eq 'PrepareBinaries') {
|
||||
foreach ($name in $files) {
|
||||
$source = Join-Path $Directory $name
|
||||
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||
throw "Missing CrossDesk binary: $source"
|
||||
}
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $temporaryDirectory $name)
|
||||
}
|
||||
$artifactPath = [IO.Path]::GetFullPath($Artifact)
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $artifactPath) -Force | Out-Null
|
||||
Compress-Archive -Path "$temporaryDirectory/*" -DestinationPath $artifactPath -Force
|
||||
return
|
||||
}
|
||||
|
||||
# Inspect the ZIP before extraction: reject missing, duplicate or unexpected
|
||||
# entries (including paths) so only the four intended files can be replaced.
|
||||
$archive = [IO.Compression.ZipFile]::OpenRead([IO.Path]::GetFullPath($Artifact))
|
||||
try {
|
||||
$entries = @($archive.Entries | ForEach-Object { $_.FullName })
|
||||
$uniqueEntries = @($entries | Sort-Object -Unique)
|
||||
if ($entries.Count -ne $files.Count -or $uniqueEntries.Count -ne $files.Count -or
|
||||
@(Compare-Object $files $entries).Count -ne 0) {
|
||||
throw "Signed archive must contain exactly: $($files -join ', ')"
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
Expand-Archive -LiteralPath $Artifact -DestinationPath $temporaryDirectory
|
||||
|
||||
# Verify every file before replacing any build output.
|
||||
foreach ($name in $files) {
|
||||
Assert-CrossDeskSignature -Path (Join-Path $temporaryDirectory $name) -SigningPolicy $SigningPolicy
|
||||
}
|
||||
foreach ($name in $files) {
|
||||
Copy-Item -LiteralPath (Join-Path $temporaryDirectory $name) -Destination (Join-Path $Directory $name) -Force
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<parameters>
|
||||
<parameter name="version" required="true" />
|
||||
</parameters>
|
||||
<zip-file>
|
||||
<pe-file-set product-name="CrossDesk"
|
||||
product-version="${version}"
|
||||
file-version="${version}"
|
||||
company-name="CrossDesk"
|
||||
copyright="Copyright (C) CrossDesk contributors">
|
||||
<include path="crossdesk.exe" original-filename="CrossDesk.exe" />
|
||||
<include path="crossdesk_service.exe" original-filename="crossdesk_service.exe" />
|
||||
<include path="crossdesk_session_helper.exe" original-filename="crossdesk_session_helper.exe" />
|
||||
<include path="wgc_plugin.dll" original-filename="wgc_plugin.dll" />
|
||||
<for-each>
|
||||
<authenticode-sign />
|
||||
</for-each>
|
||||
</pe-file-set>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -0,0 +1,57 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-CrossDeskSigningFiles {
|
||||
# Only binaries built from this project's sources. Never include third-party DLLs.
|
||||
return @('crossdesk.exe', 'crossdesk_service.exe', 'crossdesk_session_helper.exe', 'wgc_plugin.dll')
|
||||
}
|
||||
|
||||
function Find-CrossDeskSignTool {
|
||||
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if ($command) {
|
||||
return $command.Source
|
||||
}
|
||||
$sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
|
||||
$tool = Get-ChildItem "$sdk/*/x64/signtool.exe" |
|
||||
Sort-Object { [version]$_.Directory.Parent.Name } |
|
||||
Select-Object -Last 1
|
||||
if (!$tool) {
|
||||
throw 'SignTool from the Windows SDK is required to verify release signatures.'
|
||||
}
|
||||
return $tool.FullName
|
||||
}
|
||||
|
||||
function Assert-CrossDeskSignature {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Path,
|
||||
[Parameter(Mandatory)] [ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
|
||||
)
|
||||
|
||||
if (!(Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Missing signed file: $Path"
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
||||
if (!$signature.SignerCertificate) {
|
||||
throw "File has no Authenticode signing certificate: $Path"
|
||||
}
|
||||
Write-Host "Signature: $Path | $($signature.Status) | $($signature.SignerCertificate.Subject)"
|
||||
if ($SigningPolicy -eq 'test-signing') {
|
||||
# Self-signed test certificates are not trusted by stock GitHub runners.
|
||||
# HashMismatch, NotSigned, UnknownError, etc. must still stop the build.
|
||||
if ($signature.Status -notin @('Valid', 'NotTrusted')) {
|
||||
throw "Invalid test signature on ${Path}: $($signature.Status)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($signature.Status -ne 'Valid') {
|
||||
throw "Release signature is not trusted on ${Path}: $($signature.Status)"
|
||||
}
|
||||
if (!$signature.TimeStamperCertificate) {
|
||||
throw "Release signature has no timestamp: $Path"
|
||||
}
|
||||
$signTool = Find-CrossDeskSignTool
|
||||
& $signTool verify /pa /all /v $Path
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "SignTool verification failed for $Path (exit $LASTEXITCODE)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# Portable tests for artifact handling and signature validation decisions.
|
||||
# Get-AuthenticodeSignature and SignTool are simulated; CI validates real files.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$scripts = Join-Path $PSScriptRoot '../scripts/windows'
|
||||
. "$scripts/signpath-common.ps1"
|
||||
$artifactScript = Join-Path $scripts 'signpath-artifact.ps1'
|
||||
$testDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-test-$([guid]::NewGuid())"
|
||||
New-Item -ItemType Directory -Path $testDirectory | Out-Null
|
||||
$checks = 0
|
||||
|
||||
function Assert-True([bool]$Condition, [string]$Message) {
|
||||
if (!$Condition) { throw $Message }
|
||||
$script:checks++
|
||||
}
|
||||
|
||||
function Assert-Throws([scriptblock]$Action, [string]$Expected) {
|
||||
try { & $Action } catch {
|
||||
if ($_.Exception.Message -notlike "*$Expected*") { throw }
|
||||
$script:checks++
|
||||
return
|
||||
}
|
||||
throw "Expected an error containing: $Expected"
|
||||
}
|
||||
|
||||
function New-TestZip([string]$Path, [string[]]$Names) {
|
||||
$zip = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in $Names) {
|
||||
$writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open())
|
||||
try { $writer.Write("signed $name") } finally { $writer.Dispose() }
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
}
|
||||
|
||||
$mockStatus = 'NotTrusted'
|
||||
$mockBadFile = ''
|
||||
$mockCertificate = [pscustomobject]@{ Subject = "CN=Test certificate for 'CrossDesk [OSS]'" }
|
||||
$mockTimestamp = $null
|
||||
function Get-AuthenticodeSignature {
|
||||
param([string]$LiteralPath)
|
||||
$status = if ([IO.Path]::GetFileName($LiteralPath) -eq $mockBadFile) { 'HashMismatch' } else { $mockStatus }
|
||||
return [pscustomobject]@{
|
||||
Status = $status
|
||||
SignerCertificate = $mockCertificate
|
||||
TimeStamperCertificate = $mockTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$files = @(Get-CrossDeskSigningFiles)
|
||||
$build = Join-Path $testDirectory 'build'
|
||||
New-Item -ItemType Directory -Path $build | Out-Null
|
||||
foreach ($name in $files) { Set-Content -LiteralPath (Join-Path $build $name) -Value "original $name" }
|
||||
Set-Content -LiteralPath (Join-Path $build 'third-party.dll') -Value 'upstream'
|
||||
$unsigned = Join-Path $testDirectory 'unsigned.zip'
|
||||
& $artifactScript -Operation PrepareBinaries -Directory $build -Artifact $unsigned
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($unsigned)
|
||||
try {
|
||||
$entries = @($zip.Entries | ForEach-Object { $_.FullName })
|
||||
Assert-True ($entries.Count -eq 4 -and @(Compare-Object $files $entries).Count -eq 0) 'Signing input must include only the four project binaries.'
|
||||
} finally { $zip.Dispose() }
|
||||
|
||||
[xml]$config = Get-Content -Raw (Join-Path $scripts 'signpath-binaries.xml')
|
||||
$configured = @($config.SelectNodes('//*[local-name()="include"]') | ForEach-Object { $_.path })
|
||||
Assert-True (@(Compare-Object $files $configured).Count -eq 0) 'SignPath XML must cover exactly the files in the submitted ZIP.'
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $build 'wgc_plugin.dll')
|
||||
Assert-Throws { & $artifactScript -Operation PrepareBinaries -Directory $build -Artifact (Join-Path $testDirectory 'missing.zip') } 'Missing CrossDesk binary'
|
||||
Set-Content -LiteralPath (Join-Path $build 'wgc_plugin.dll') -Value 'original wgc_plugin.dll'
|
||||
|
||||
$signed = Join-Path $testDirectory 'signed.zip'
|
||||
New-TestZip $signed $files
|
||||
$mockBadFile = 'wgc_plugin.dll'
|
||||
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing } 'HashMismatch'
|
||||
foreach ($name in $files) {
|
||||
Assert-True ((Get-Content -Raw (Join-Path $build $name)).Trim() -eq "original $name") 'Failed verification must not replace any output.'
|
||||
}
|
||||
$mockBadFile = ''
|
||||
& $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing
|
||||
foreach ($name in $files) {
|
||||
Assert-True ((Get-Content -Raw (Join-Path $build $name)) -eq "signed $name") 'Verified signed files must replace the unsigned build output.'
|
||||
}
|
||||
Assert-True ((Get-Content -Raw (Join-Path $build 'third-party.dll')).Trim() -eq 'upstream') 'Third-party libraries must remain untouched.'
|
||||
|
||||
$badArchives = @(
|
||||
@($files[0], $files[1], $files[2]),
|
||||
@($files[0], $files[1], $files[2], '../wgc_plugin.dll'),
|
||||
@($files[0], $files[1], $files[2], $files[0]),
|
||||
@($files[0], $files[1], $files[2], $files[3], 'third-party.dll')
|
||||
)
|
||||
for ($i = 0; $i -lt $badArchives.Count; $i++) {
|
||||
$badZip = Join-Path $testDirectory "bad-$i.zip"
|
||||
New-TestZip $badZip $badArchives[$i]
|
||||
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $badZip -SigningPolicy test-signing } 'Signed archive must contain exactly'
|
||||
}
|
||||
|
||||
$file = Join-Path $build 'crossdesk.exe'
|
||||
foreach ($mockStatus in @('NotSigned', 'HashMismatch', 'UnknownError')) {
|
||||
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'Invalid test signature'
|
||||
}
|
||||
$mockStatus = 'Valid'
|
||||
$mockCertificate = $null
|
||||
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'no Authenticode signing certificate'
|
||||
$mockCertificate = [pscustomobject]@{ Subject = 'CN=SignPath Foundation' }
|
||||
$mockStatus = 'NotTrusted'
|
||||
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'Release signature is not trusted'
|
||||
$mockStatus = 'Valid'
|
||||
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'no timestamp'
|
||||
$mockTimestamp = [pscustomobject]@{ Subject = 'CN=Timestamp authority' }
|
||||
|
||||
# Only the real SignTool process is replaced; exercise the actual exit-code check.
|
||||
$fakeSignTool = Join-Path $testDirectory 'signtool.ps1'
|
||||
function Find-CrossDeskSignTool { return $fakeSignTool }
|
||||
Set-Content $fakeSignTool '$global:LASTEXITCODE = 1'
|
||||
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'SignTool verification failed'
|
||||
Set-Content $fakeSignTool '$global:LASTEXITCODE = 0'
|
||||
Assert-CrossDeskSignature $file release-signing
|
||||
$checks++
|
||||
|
||||
Write-Host "Passed $checks Windows signing checks."
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $testDirectory -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Windows 签名与 Release 测试
|
||||
|
||||
GitHub Actions 的 `Build and Release` 工作流构建 CrossDesk,调用 SignPath 签名,再发布已验证的 Windows 文件。
|
||||
|
||||
## 触发方式
|
||||
|
||||
| 触发方式 | 签名策略 | 输出 |
|
||||
| --- | --- | --- |
|
||||
| 普通分支 push,或未勾选签名的手动运行 | 无 | 开发构建工件 |
|
||||
| 手动运行,勾选 `sign_windows_test` | `test-signing` | 测试证书签名的 Actions 工件 |
|
||||
| 手动运行,勾选 `sign_windows_release` | `release-signing` | 正式证书签名的 Actions 工件 |
|
||||
| push `v*` tag | `release-signing` | 验证通过后发布 GitHub Release 和下载站 |
|
||||
|
||||
两种手动签名选项不能同时启用。手动运行可勾选 `windows_only` 跳过其他平台;即使选择 tag 手动运行,也不会发布 Release、更新 `latest` 或同步下载站。
|
||||
|
||||
## SignPath 配置
|
||||
|
||||
GitHub 仓库需要 Secret `SIGNPATH_API_TOKEN` 和 Variable `SIGNPATH_ORGANIZATION_ID`。SignPath 项目 slug 为 `crossdesk`,已关联 GitHub.com Trusted Build System。
|
||||
|
||||
该工作流明确使用以下 Artifact Configuration:
|
||||
|
||||
| Slug | XML 文件 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `initial` | `apps/desktop/scripts/windows/signpath-installer.xml` | 现有安装包配置,只签外层 NSIS EXE |
|
||||
| `windows-binaries` | `apps/desktop/scripts/windows/signpath-binaries.xml` | 签 ZIP 内四个 CrossDesk 自有二进制 |
|
||||
|
||||
`crossdesk` 后台已添加并验证 `windows-binaries`。新环境需要在项目的 Artifact Configurations 页面创建同名配置,使用仓库内对应的完整 XML。保留 `initial` 的默认配置和现有审批、来源验证要求。XML 文件仅提交到 GitHub 不会自动同步到 SignPath 后台。
|
||||
|
||||
两种策略都必须允许提交这些配置。正式策略需要有效的 Release 证书、提交者权限和审批人。若证书仍为 `CSR PENDING`,需要 SignPath 完成签发/导入;切换工作流参数不会使证书生效。后台的来源限制也必须允许当前测试分支,若不允许,应按项目原有发布审批流程处理。
|
||||
|
||||
## 签名顺序
|
||||
|
||||
1. 构建安装版,将 `crossdesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe`、`wgc_plugin.dll` 打成 `installer-binaries.zip`。
|
||||
2. 使用 `windows-binaries` 签名,验证四个文件后替换构建输出。
|
||||
3. NSIS 打包已签名的文件,使用 `initial` 签名安装器,验证后替换安装器。
|
||||
4. 构建便携版,将四个文件打成 `portable-binaries.zip`,同样签名并验证,然后生成包含已签名文件的便携 ZIP。
|
||||
5. 上传最终安装器与便携 ZIP。tag 发布等待 Windows 签名和其他平台构建全部成功。
|
||||
|
||||
第三方 DLL 原样打包,不提交签名。外层 NSIS 签名不为内部文件添加独立签名;本流程在打包前签名自有文件。NSIS 生成的 `uninstall.exe` 尚未配置独立签名。
|
||||
|
||||
每次运行会按上述顺序提交三个签名请求。正式签名需要在 SignPath 中逐个审批,每个请求最多等待 60 分钟,整个 Windows job 最多 240 分钟。请求链接由官方 Action 输出,并在可用时写入运行摘要。
|
||||
|
||||
## 启动正式证书测试
|
||||
|
||||
在 GitHub → Actions → Build and Release → Run workflow 中选择测试分支:
|
||||
|
||||
- `sign_windows_release`: true
|
||||
- `sign_windows_test`: false
|
||||
- `windows_only`: true
|
||||
|
||||
也可在分支推送后运行:
|
||||
|
||||
```sh
|
||||
gh workflow run build.yml --ref signpath-release-signing \
|
||||
-f sign_windows_release=true -f sign_windows_test=false -f windows_only=true
|
||||
```
|
||||
|
||||
正式验证要求 Authenticode 状态为 `Valid`、存在时间戳,并且 `signtool verify /pa /all /v` 返回成功。测试证书允许 `NotTrusted`,但拒绝无签名、哈希不匹配等状态。任一签名或验证失败均停止后续发布;不会回退到未签名发布。
|
||||
|
||||
原始签名输入保留为中间 Actions 工件。手动测试成功后,下载名为 `crossdesk-win-x64-...` 和 `crossdesk-win-x64-portable-...` 的最终工件,不要使用 `installer-binaries.zip`、`portable-binaries.zip` 等原始签名输入。
|
||||
|
||||
## 本地校验
|
||||
|
||||
```sh
|
||||
pwsh -NoProfile -File apps/desktop/tests/windows_signing_test.ps1
|
||||
actionlint .github/workflows/build.yml
|
||||
```
|
||||
|
||||
PowerShell 测试使用模拟证书状态检查归档范围、还原顺序及失败行为,不能替代 Windows 上的真实 Authenticode 验证。
|
||||
|
||||
参考:[SignPath GitHub 集成](https://docs.signpath.io/trusted-build-systems/github)、[Artifact Configuration](https://docs.signpath.io/artifact-configuration/examples)、[SignTool 验证](https://learn.microsoft.com/en-us/windows/win32/seccrypto/using-signtool-to-verify-a-file-signature)。
|
||||
Reference in New Issue
Block a user