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:
parent
d35ee5c38b
commit
a906f98baf
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue