Sign Windows inner binaries during release (fail-open two-request SignPath flow) (#7866)
This commit is contained in:
parent
ed529a011b
commit
da0f03fc2a
|
|
@ -719,6 +719,10 @@ jobs:
|
|||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
|
||||
# (1h inner + 4h installer) are budgeted to fit under this with the
|
||||
# build itself, so a slow approval can't kill the job mid-flow.
|
||||
timeout-minutes: 360
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
|
|
@ -958,6 +962,244 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
||||
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
|
||||
# request, then the installer is rebuilt from the signed tree before the
|
||||
# existing installer signing request below. Every step in this chain is
|
||||
# fail-open (continue-on-error + outcome gating): any failure ships the
|
||||
# original installer with unsigned inner binaries, exactly like releases
|
||||
# did before this chain existed. Rehearsed end to end in run 28988432001
|
||||
# (.github/workflows/windows-signing-rehearsal.yml).
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
||||
- name: Stage unsigned inner PE files for signing
|
||||
id: stage-inner
|
||||
if: matrix.platform == 'win'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
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: windows-inner-binaries-zip
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Notify Slack that inner-binary signing is waiting for approval
|
||||
id: notify-inner-signing
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.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 inner-binaries signing request (1 of 2) 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
|
||||
|
||||
# Why gate on the notify outcome too: if nobody was told to approve,
|
||||
# don't hold the release for the approval window — fall through and
|
||||
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
|
||||
# both waits plus the build inside the 360-minute job cap; missing it
|
||||
# falls through to today's unsigned-inner flow rather than blocking.
|
||||
- name: Download signed inner binaries from SignPath
|
||||
id: download-signed-inner
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.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-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 3600
|
||||
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly (into fail-open) instead of silently
|
||||
# shipping a mix of signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
id: restore-signed-inner
|
||||
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
|
||||
# pristine elevate.exe from its download cache over resources\elevate.exe
|
||||
# on EVERY nsis pack — including the --prepackaged rebuild below — which
|
||||
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
|
||||
# way to disable just the copy, so we overwrite the cache's copy with our
|
||||
# signed one (identical bytes plus signature) so the clobber becomes a
|
||||
# no-op. Known quirk: the cache persists across releases via actions/cache,
|
||||
# so later runs may see elevate.exe as already signed and skip staging it —
|
||||
# that is fine (the signature is timestamped) and the evidence gate checks
|
||||
# elevate.exe in the shipped installer unconditionally. If this ever causes
|
||||
# trouble, delete this step; the only effect is elevate.exe shipping
|
||||
# unsigned again, which the evidence gate will flag.
|
||||
- name: Replace cached elevate.exe with the signed copy
|
||||
id: sign-elevate-cache
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signed = 'dist/win-unpacked/resources/elevate.exe'
|
||||
if (-not (Test-Path $signed)) {
|
||||
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
|
||||
exit 0
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||||
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
|
||||
exit 0
|
||||
}
|
||||
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
|
||||
if ($cached.Count -eq 0) {
|
||||
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
|
||||
exit 0
|
||||
}
|
||||
foreach ($file in $cached) {
|
||||
Copy-Item -Path $signed -Destination $file.FullName -Force
|
||||
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
|
||||
}
|
||||
|
||||
- name: Rebuild NSIS installer from signed unpacked app
|
||||
id: rebuild-nsis-signed
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
|
||||
# back to shipping them unchanged (fail-open).
|
||||
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
|
||||
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
|
||||
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Roll back to original installer after failed rebuild
|
||||
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
|
||||
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
|
|
@ -992,6 +1234,7 @@ jobs:
|
|||
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 }}
|
||||
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||||
|
|
@ -1002,7 +1245,9 @@ jobs:
|
|||
$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>"
|
||||
# Why: releases where inner signing fell through have only this one request.
|
||||
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
||||
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
|
|
@ -1085,6 +1330,90 @@ jobs:
|
|||
}
|
||||
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
||||
|
||||
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
|
||||
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
|
||||
# by extracting the shipped installer). Warn-only until the flow has been
|
||||
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
|
||||
# to 'true' so unsigned inner binaries block the release.
|
||||
- name: Verify Windows inner binary signatures
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
|
||||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||||
run: |
|
||||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||||
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Why try/catch: while the gate is warn-only, even an unexpected
|
||||
# script error (extraction hiccup, missing file) must not block
|
||||
# the release — only the flip to required makes failures fatal.
|
||||
try {
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Why: verify the files a user actually gets on disk, not the build
|
||||
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'inner-evidence-extract'
|
||||
# Why elevate.exe is always appended: staging skips already-signed
|
||||
# files, and the persisted electron-builder cache can carry a
|
||||
# previously signed elevate.exe — so it may be absent from the list
|
||||
# in some runs, yet it is the file most at risk of losing its
|
||||
# signature in the NSIS rebuild. Verify it in every release.
|
||||
$targets = @(Get-Content 'inner-signing-list.txt')
|
||||
if ($targets -notcontains 'resources\elevate.exe') {
|
||||
$targets += 'resources\elevate.exe'
|
||||
}
|
||||
foreach ($relative in $targets) {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
|
||||
$report.Add($line)
|
||||
Write-Host $line
|
||||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
} else {
|
||||
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
}
|
||||
} catch {
|
||||
if ($required) { throw }
|
||||
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
||||
}
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
||||
path: |
|
||||
inner-signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish signed Windows release artifacts
|
||||
if: matrix.platform == 'win'
|
||||
uses: nick-fields/retry@v4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,360 @@
|
|||
# Windows inner-binary signing rehearsal.
|
||||
#
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
|
||||
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
|
||||
# a two-request flow: sign the unpacked PE files first, then build the NSIS
|
||||
# installer from the signed tree, then sign the installer. This workflow
|
||||
# rehearses that entire flow from a branch, end to end, without publishing
|
||||
# anything — so the release pipeline on main is never at risk while we verify.
|
||||
#
|
||||
# Runs only via manual dispatch. Use the test-signing policy for iteration
|
||||
# (auto-approved test certificate) and release-signing to rehearse the
|
||||
# production flow (requires manual SignPath approvals). Rehearsed successfully
|
||||
# with test-signing (run 28987534795) and release-signing (run 28988432001).
|
||||
name: Windows signing rehearsal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
signing-policy-slug:
|
||||
description: SignPath signing policy
|
||||
type: choice
|
||||
default: test-signing
|
||||
options:
|
||||
- test-signing
|
||||
- release-signing
|
||||
inner-artifact-configuration-slug:
|
||||
description: SignPath artifact configuration for the inner-binaries zip
|
||||
type: string
|
||||
default: windows-inner-binaries-zip
|
||||
|
||||
jobs:
|
||||
rehearse:
|
||||
# Why: SignPath origin verification requires GitHub-hosted runners, and
|
||||
# windows-2022 matches the real release job's build environment.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: nothing here pushes; keep the token out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-win-
|
||||
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: rehearsal builds are never published, so the official-build
|
||||
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
- name: Package unpacked Windows app
|
||||
shell: pwsh
|
||||
run: |
|
||||
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 --dir --publish never
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
|
||||
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
|
||||
}
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
|
||||
# keep their original signer, so they are excluded from the request.
|
||||
- name: Stage unsigned PE files for inner signing
|
||||
id: stage-inner
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ github.run_id }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
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: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed inner binaries from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 3600
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly instead of silently shipping a mix of
|
||||
# signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
- name: Build NSIS installer from signed unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
id: upload-unsigned-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-installer-unsigned-${{ github.run_id }}
|
||||
path: dist/orca-windows-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit Windows installer signing request
|
||||
id: submit-installer-signing
|
||||
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: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: github-actions-windows-installer
|
||||
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed Windows installer from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-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
|
||||
|
||||
# Why: signing changes installer bytes, so the updater metadata must be
|
||||
# regenerated exactly like the release job's staging step does.
|
||||
- name: Stage signed Windows installer and regenerate updater metadata
|
||||
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'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$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
|
||||
|
||||
# Why: this is the pass/fail heart of the rehearsal — the same evidence
|
||||
# check that will later gate releases (see PR #7170). Test certificates
|
||||
# do not chain to a trusted root, so Status=Valid is only required for
|
||||
# release-signing runs; test-signing runs require a present signature.
|
||||
- name: Verify signatures end to end
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
run: |
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
|
||||
|
||||
function Test-Signature([string]$label, [string]$path) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
|
||||
$script:report.Add($line)
|
||||
Write-Host $line
|
||||
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
|
||||
$script:failures.Add("unsigned: $label")
|
||||
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
|
||||
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
|
||||
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$script:failures.Add("unexpected signer: $label ($subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
|
||||
|
||||
# Extract the signed installer and verify the files a user actually
|
||||
# gets on disk — including the exact file from issue #7785.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'extracted-app'
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
Test-Signature "installed: $relative" $path
|
||||
}
|
||||
|
||||
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signing rehearsal failed with $($failures.Count) problems."
|
||||
}
|
||||
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
|
||||
|
||||
- name: Upload rehearsal evidence and installer
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
|
||||
path: |
|
||||
signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
dist/orca-windows-setup.exe
|
||||
dist/orca-windows-setup.exe.blockmap
|
||||
dist/latest.yml
|
||||
retention-days: 7
|
||||
|
|
@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => {
|
|||
expect(installRun).not.toMatch(/throw\s+\$_/)
|
||||
})
|
||||
|
||||
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
|
||||
it('verifies Windows inner binary signatures fail-open before publishing', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
|
|
@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => {
|
|||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
|
||||
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
|
||||
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
|
||||
|
||||
expect(outerVerifyIndex).toBeGreaterThan(-1)
|
||||
expect(innerVerifyIndex).toBe(-1)
|
||||
expect(publishIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
|
||||
expect(publishIndex).toBe(evidenceIndex + 1)
|
||||
|
||||
// Why fail-open: unsigned inner binaries must warn, not block, until the
|
||||
// flow is proven on a real release (issue #7785). Flip this to 'true'
|
||||
// together with the workflow env to make the gate required.
|
||||
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
|
||||
|
||||
// Why: every step in the inner-signing chain must be unable to fail the
|
||||
// release — a SignPath outage or timeout falls through to today's
|
||||
// unsigned-inner flow instead of blocking the cut.
|
||||
const innerChainStepNames = [
|
||||
'Stage unsigned inner PE files for signing',
|
||||
'Upload unsigned inner binaries for SignPath',
|
||||
'Submit inner binaries signing request',
|
||||
'Notify Slack that inner-binary signing is waiting for approval',
|
||||
'Download signed inner binaries from SignPath',
|
||||
'Restore signed inner binaries into unpacked app',
|
||||
'Replace cached elevate.exe with the signed copy',
|
||||
'Rebuild NSIS installer from signed unpacked app'
|
||||
]
|
||||
for (const stepName of innerChainStepNames) {
|
||||
const step = steps[stepNames.indexOf(stepName)]
|
||||
expect(step, stepName).toBeDefined()
|
||||
expect(step['continue-on-error'], stepName).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes both Linux release matrix entries', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue