fix(release): survive PSGallery outages in the Windows signing preflight

The Windows release job hard-failed in run 30125672117: every SignPath
module install attempt got 403 Forbidden from the gallery's OData API,
which is behind Azure Front Door and was also serving 502/504 at the
time. That step was the only hard-fail in an otherwise fail-open signing
chain, so a gallery incident blocked the whole release.

The gallery CDN that serves the nupkg is a separate origin and stayed
healthy throughout, so fall back to a pinned version fetched from it
after the normal install path is exhausted. The fallback verifies a
SHA-256 pin, since that route skips the gallery's own package
validation.

Extracted to a composite action so the release job and the signing
rehearsal cannot drift apart.
This commit is contained in:
Neil 2026-07-24 14:37:14 -07:00
parent d35ee5c38b
commit a906f98baf
5 changed files with 360 additions and 203 deletions

View File

@ -0,0 +1,197 @@
name: Install SignPath PowerShell module
description: >-
Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery,
falling back to a pinned, hash-verified nupkg from the gallery CDN when the
gallery's package API is unavailable.
inputs:
fallback-version:
description: Module version fetched directly from the CDN when the gallery API is unreachable.
required: false
default: 4.4.6
fallback-sha256:
description: >-
SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own
package validation, so this hash is the only integrity check on that route.
required: false
default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d
runs:
using: composite
steps:
- name: Install SignPath PowerShell module
shell: pwsh
env:
SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }}
SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }}
run: |
$ErrorActionPreference = 'Stop'
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
try {
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
} else {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
} catch {
# Why: repository registration also talks to the gallery, so a gallery
# outage can fail here before a single install is attempted. The CDN
# fallback below does not need a registered repository, so keep going.
Write-Warning "PSGallery repository registration failed: $_"
}
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
Where-Object {
if ([string]::IsNullOrWhiteSpace($_)) {
$false
} else {
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
}
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
function Test-SignPathModule {
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
}
function Remove-SignPathModuleDirectory {
if (Test-Path -LiteralPath $signPathModulePath) {
Write-Warning "Removing current-user SignPath module directory: $signPathModulePath"
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
}
}
$installed = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -eq 2) {
Start-Sleep -Seconds 15
} elseif ($attempt -eq 3) {
Start-Sleep -Seconds 30
}
try {
if ($useResourceGet) {
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
}
Test-SignPathModule
$installed = $true
break
} catch {
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
Remove-SignPathModuleDirectory
}
}
# Why: the gallery's package API (OData search + repository metadata) sits
# behind Azure Front Door and has returned 403/502/504 for every install
# attempt during gallery incidents, which hard-failed the whole Windows
# release job. The CDN that serves the nupkg itself is a separate origin
# and stays up through those incidents, so fall back to a pinned version
# fetched straight from it. The hash pin is mandatory: this route skips the
# gallery's package validation, so an unexpected payload must fail loudly.
if (-not $installed) {
$version = $env:SIGNPATH_FALLBACK_VERSION
$expectedHash = $env:SIGNPATH_FALLBACK_SHA256
Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN."
$nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip"
if (Test-Path -LiteralPath $nupkg) {
Remove-Item -LiteralPath $nupkg -Force
}
# Why two URLs: the /api/v2/package route 302s to the CDN and can serve
# while the OData search endpoint is failing; the CDN URL is the same
# redirect target reached directly when the api host is down entirely.
$sources = @(
"https://www.powershellgallery.com/api/v2/package/SignPath/$version",
"https://cdn.powershellgallery.com/packages/signpath.$version.nupkg"
)
$downloaded = $false
foreach ($source in $sources) {
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -gt 1) {
Start-Sleep -Seconds (10 * $attempt)
}
try {
Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop
$actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash
if ($actualHash -ne $expectedHash.ToUpperInvariant()) {
throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)."
}
$downloaded = $true
Write-Host "Downloaded and verified SignPath $version from $source"
break
} catch {
Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_"
if (Test-Path -LiteralPath $nupkg) {
Remove-Item -LiteralPath $nupkg -Force
}
}
}
if ($downloaded) {
break
}
}
if (-not $downloaded) {
throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source."
}
Remove-SignPathModuleDirectory
# Why a version-named subdirectory: PowerShell only treats a nested folder
# as a side-by-side module version when the name matches the manifest's
# ModuleVersion, which is what makes `Import-Module SignPath` resolve it.
$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version
New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null
Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force
# Why: strip nupkg packaging entries so only the module files remain.
foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) {
$path = Join-Path -Path $versionRoot -ChildPath $entry
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force
}
}
$manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1'
if (-not (Test-Path -LiteralPath $manifest)) {
throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot."
}
Test-SignPathModule
}

View File

@ -1183,82 +1183,7 @@ jobs:
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
$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
}
} else {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
Where-Object {
if ([string]::IsNullOrWhiteSpace($_)) {
$false
} else {
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
}
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -eq 2) {
Start-Sleep -Seconds 15
} elseif ($attempt -eq 3) {
Start-Sleep -Seconds 30
}
try {
if ($useResourceGet) {
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
break
} catch {
if ($attempt -eq 3) {
throw
}
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
if (Test-Path -LiteralPath $signPathModulePath) {
Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath"
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
}
}
}
uses: ./.github/actions/install-signpath-module
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE

View File

@ -141,23 +141,7 @@ jobs:
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
uses: ./.github/actions/install-signpath-module
- name: Submit inner binaries signing request
id: submit-inner-signing

View File

@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => {
expect(releaseMacWorkflowText).not.toContain('SIGNPATH_')
})
it('preflights SignPath module install before Windows signing side effects', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const installStepIndexes = stepNames.flatMap((name, index) =>
name === 'Install SignPath PowerShell module' ? [index] : []
)
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
expect(steps[verifyNodePtyIndex].run).toContain(
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
)
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
.map((step) => step.run ?? '')
.join('\n')
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
const installStep = steps[installStepIndexes[0]]
const installRun = installStep.run
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
([, seconds]) => seconds
)
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.shell).toBe('pwsh')
expect(installRun).toContain(
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
)
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
)
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
expect(sleepSeconds).toEqual(['15', '30'])
expect(installRun).toContain(
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
)
expect(installRun).toContain('Import-Module SignPath')
expect(installRun).toContain(
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
)
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
expect(installRun).not.toContain('SignPath*')
expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan(
installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath')
)
expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/)
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
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 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(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', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),

View File

@ -0,0 +1,161 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
describe('Windows signing workflow contract', () => {
it('preflights SignPath module install before Windows signing side effects', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const installStepIndexes = stepNames.flatMap((name, index) =>
name === 'Install SignPath PowerShell module' ? [index] : []
)
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
expect(steps[verifyNodePtyIndex].run).toContain(
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
)
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
.map((step) => step.run ?? '')
.join('\n')
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
const installStep = steps[installStepIndexes[0]]
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.uses).toBe('./.github/actions/install-signpath-module')
expect(installStep.run).toBeUndefined()
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
const actionStep = installAction.runs.steps[0]
const installRun = actionStep.run
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
([, seconds]) => seconds
)
expect(installAction.runs.using).toBe('composite')
expect(actionStep.shell).toBe('pwsh')
expect(installRun).toContain(
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
)
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
)
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
expect(sleepSeconds).toContain('15')
expect(sleepSeconds).toContain('30')
expect(installRun).toContain(
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
)
expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop')
expect(installRun).toContain(
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
)
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
expect(installRun).not.toContain('SignPath*')
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => {
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
const installRun = installAction.runs.steps[0].run
// Why: the gallery API 403s during Azure Front Door incidents while its CDN
// stays up, so a pinned nupkg is the fallback. The hash pin is the only
// integrity check on that route — losing it would let any payload install.
const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs
expect(version.default).toMatch(/^4\.\d+\.\d+$/)
expect(sha256.default).toMatch(/^[0-9a-f]{64}$/)
expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256')
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
expect(installRun).toContain(
'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg'
)
// The module only resolves by name when the folder matches its ModuleVersion.
expect(installRun).toContain(
'$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version'
)
// The fallback only runs after the gallery route is exhausted, and still
// fails the job when neither route produced a usable module.
expect(installRun.indexOf('$installed = $true')).toBeLessThan(
installRun.indexOf('if (-not $installed)')
)
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
// Why: the rehearsal exists to prove the real signing flow, so it must
// install the module exactly the way the release job does.
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe(
'./.github/actions/install-signpath-module'
)
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined()
expect(installIndex).toBeLessThan(
stepNames.indexOf('Download signed inner binaries from SignPath')
)
})
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
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 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(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)
}
})
})