From a906f98baf1d57102731eeb481444928f8826ff9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:37:14 -0700 Subject: [PATCH] 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. --- .../install-signpath-module/action.yml | 197 ++++++++++++++++++ .github/workflows/release-cut.yml | 77 +------ .../workflows/windows-signing-rehearsal.yml | 18 +- ...package-electron-runtime-contract.test.mjs | 110 ---------- ...windows-signing-workflow-contract.test.mjs | 161 ++++++++++++++ 5 files changed, 360 insertions(+), 203 deletions(-) create mode 100644 .github/actions/install-signpath-module/action.yml create mode 100644 config/scripts/windows-signing-workflow-contract.test.mjs diff --git a/.github/actions/install-signpath-module/action.yml b/.github/actions/install-signpath-module/action.yml new file mode 100644 index 000000000..3f41daca3 --- /dev/null +++ b/.github/actions/install-signpath-module/action.yml @@ -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 + } diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 39d170f01..1840514e5 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 73d66e466..508ac8557 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -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 diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 74ad245ec..516aac21a 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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'), diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs new file mode 100644 index 000000000..fc2087379 --- /dev/null +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -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) + } + }) +})