fix(release): sign Windows installers with SignPath

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
OrcaWin
2026-06-19 14:06:21 -07:00
committed by GitHub
co-authored by Neil
parent 1a7c579df0
commit 4ea550bbef
3 changed files with 176 additions and 19 deletions
+166 -7
View File
@@ -715,7 +715,7 @@ jobs:
~/Library/Caches/electron-builder
- os: windows-latest
platform: win
release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always'
release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never'
eb_cache_path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
@@ -729,6 +729,7 @@ jobs:
runs-on: ${{ matrix.os }}
permissions:
actions: read
contents: write
steps:
@@ -890,8 +891,8 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Publish release artifacts
if: matrix.platform != 'mac'
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux'
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
@@ -901,6 +902,164 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
# upload only after the production-signed installer has been returned.
- name: Build Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
max_attempts: 3
retry_wait_seconds: 30
command: ${{ matrix.release_command }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-unsigned-${{ needs.cut.outputs.tag }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
# Why: SignPath Foundation production certificates require manual review,
# so the release job waits while the signing request is approved in UI.
- name: Submit Windows installer signing request
id: submit-signing-request
if: matrix.platform == 'win'
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: release-signing
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Notify Slack that Windows signing is waiting for approval
if: matrix.platform == 'win'
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
}
$requestUrl = $env:SIGNPATH_REQUEST_URL
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = $message
}
}
)
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name SignPath -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force
- name: Download signed Windows installer from SignPath
if: matrix.platform == 'win'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
-ApiToken $env:SIGNPATH_API_TOKEN `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
- name: Stage signed Windows release assets
if: matrix.platform == 'win'
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) {
$sha512.Dispose()
}
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
- name: Verify signed Windows installer
if: matrix.platform == 'win'
shell: pwsh
run: |
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
if ($signature.Status -ne 'Valid') {
throw ($signature | Format-List * | Out-String)
}
if ($signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*') {
throw "Unexpected Windows signer: $($signature.SignerCertificate.Subject)"
}
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: gh release upload "${{ needs.cut.outputs.tag }}" "dist/orca-windows-setup.exe" "dist/orca-windows-setup.exe.blockmap" "dist/latest.yml" --clobber --repo "${{ github.repository }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify release remains draft after artifact upload
# Why: the build matrix must never be the actor that exposes a partial
# release. If an uploader or GitHub transition flips draft early, fail
@@ -925,10 +1084,10 @@ jobs:
exit 1
fi
# Why post-publish (not pre-publish): electron-builder packs and
# uploads in a single `--publish always` invocation, so there is no
# cheap insertion point between pack and upload without splitting
# that step. Running verify last still blocks the bad release: the
# Why post-publish for macOS/Linux: electron-builder packs and uploads
# in a single `--publish always` invocation, so there is no cheap
# insertion point between pack and upload without splitting those steps.
# Running verify last still blocks the bad release: the
# binary is uploaded to the draft, but a failed matrix job blocks
# the `publish-release` job (which depends on `build`) from flipping
# the release from draft → published, so users never see it. A human
+5
View File
@@ -142,6 +142,11 @@ module.exports = {
},
win: {
executableName: 'Orca',
// Why: Windows installers are signed after electron-builder packaging by
// SignPath, so the packager cannot infer the updater publisherName.
signtoolOptions: {
publisherName: 'SignPath Foundation'
},
extraResources: [
...commonExtraResources,
winSpeechNativeResource,
+5 -12
View File
@@ -914,19 +914,12 @@ export function setupAutoUpdater(
debug: (m: unknown) => console.debug('[autoUpdater]', m)
} as never
// Why: no Windows Authenticode certificate exists for this project.
// electron-builder embeds the code-signing publisherName into the app's
// bundled app-update.yml at build time. Versions that were incorrectly
// signed with the macOS Apple Developer ID cert (issue #631) baked in a
// publisherName whose chain Windows cannot validate, and even after the
// CI fix the installed app's app-update.yml still contains the stale
// publisherName. Skip Windows code signing verification — update
// integrity is still guaranteed by the SHA-512 hash check in latest.yml.
// Why: older Windows installs either have no publisherName or have the
// stale macOS Apple Developer ID publisherName from issue #631. Keep the
// migration path open while SignPath-signed builds roll out.
//
// TODO: remove this override once a Windows Authenticode certificate is
// purchased and WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD are added to CI.
// At that point electron-builder will embed the correct publisherName
// and the default verification should be re-enabled.
// TODO: re-enable after SignPath-signed builds with the explicit Windows
// publisherName have been the minimum supported updater source for a while.
if (process.platform === 'win32') {
;(autoUpdater as NsisUpdater).verifyUpdateCodeSignature = () => Promise.resolve(null)
}