Files
PowerToys/.pipelines/signSparsePackages.ps1
Gleb Khmyznikov bea1b8e247 [UITests][PowerRename] Migrate to new .Next and add more UI tests (#50096)
## Summary of the Pull Request

Adds a `PowerRename.UITests.Next` suite powered by winappcli and
automates all 18 scenarios from #40663. The suite covers PowerRename
settings, search and replace behavior, regular expressions, formatting
and filtering options, file-list interactions, and both classic and
Windows 11 context-menu workflows.

The PR also stabilizes shared `UITestAutomation.Next` runner lifetimes
and settings restoration, adds automation IDs for the original and
renamed counters, and prepares unsigned CI builds for PowerRename shell
testing. CI now signs the sparse context-menu MSIX and the
runner/Settings IPC companions with a disposable machine-trusted test
identity.

## PR Checklist

- [x] Closes: #40663
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [x] **New binaries:** Not applicable; no new shipped product binaries
are added
   - [x] JSON for signing: Not applicable
   - [x] WXS for installer: Not applicable
- [x] YML for CI pipeline: The new UI-test project is discovered through
the existing `*UITest*.csproj` pipeline flow and is registered in
`PowerToys.slnx`
   - [x] YML for signed pipeline: Not applicable
- [x] **Documentation updated:** Not applicable; there are no
user-facing behavior or documentation changes

## Detailed Description of the Pull Request / Additional comments

### PowerRename UI tests

- Adds a 25-case `PowerRename.UITests.Next` executable covering all 18
checklist items from #40663.
- Exercises classic context-menu registration on Windows 10 and Windows
11.
- Exercises the signed Windows 11 tier-1 context menu, including icon
visibility and real invocation with an Explorer selection.
- Covers search/replace preview and application, text formatting,
file/folder/subfolder inclusion, filename/extension scope, enumeration,
case sensitivity, match-all behavior, regular expressions, file
timestamps, Boost syntax, MRU autocomplete, persisted values, and
file-list selection/filtering.
- Preserves the existing legacy tests.

### Test reliability and automation hooks

- Reuses one runner/Settings lifetime across the complete PowerRename
suite to avoid repeated cold launches on constrained agents.
- Retains and restores global settings for the full class lifetime and
verifies that both Settings and the runner remain healthy.
- Propagates scope and PowerRename cleanup failures instead of silently
leaking process or profile state.
- Adds `OriginalCount` and `RenamedCount` automation IDs. These are
automation-only metadata and do not change the visible UI.
- Uses stable preview samples, exact count targeting, authoritative
Explorer selection, readable classic-menu inventories, and live UIA
visibility for popup items.

### Unsigned CI build support

- Extends the existing sparse-package test signer with required
Authenticode companion files.
- PowerRename jobs sign `PowerToys.exe` and `PowerToys.Settings.exe`
with the same disposable machine-trusted test identity used for sparse
MSIX packages.
- This preserves Release IPC authentication while allowing Settings
module-toggle commands to work on unsigned PR builds.
- Windows 11 and ARM64 PowerRename jobs require a validly signed
`PowerRenameContextMenuPackage.msix` before tests start.

## Validation Steps Performed

### Builds and discovery

- `PowerRename.UITests.Next` Debug x64 build: passed
- `PowerRename.UITests.Next` Debug ARM64 cross-build: passed
- `PowerRenameUI` Release x64 build: passed
- `UITestAutomation.Next.UnitTests` Debug x64 build: passed
- `UITestAutomation.Next.UnitTests`: 16/16 passed
- Microsoft.Testing.Platform discovery: 25 unique PowerRename test cases

### Local VM matrix

All runs used the complete unfiltered `TestCategory=PowerRename` suite
and restored the standard user's settings file byte-for-byte.

| Guest | Profile | Result |
|---|---|---:|
| Windows 10 x64 | Default, 4 vCPU / 8 GB | 25/25 | 
| Windows 10 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | 
| Windows 11 x64 | Default, 4 vCPU / 8 GB | 25/25 | 
| Windows 11 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | 

### Azure DevOps UI Test Automation

- Final build: [155646235 /
20260824.1](https://dev.azure.com/microsoft/Dart/_build/results?buildId=155646235)
- Source revision: `4496683104aea622b30179909bd6e95a17d5500f`
- ARM64: 25/25 passed
- Windows 10 x64: 25/25 passed
- Windows 11 x64: 25/25 passed
- Total: 75/75 passed, with zero failed, skipped, not-executed, or
unanalyzed results
- ARM64 and x64 Release product builds succeeded and published their
normal artifacts
- Signing steps verified the PowerRename sparse MSIX where applicable
and both IPC companion executables on every PowerRename test job

<img width="372" height="314" alt="image"
src="https://github.com/user-attachments/assets/bc303673-0325-4f85-8d70-ef93110baf5b"
/>
2026-08-26 00:26:10 +02:00

485 lines
19 KiB
PowerShell

<#
.SYNOPSIS
Self-sign PowerToys sparse MSIX shell-extension packages with a machine-trusted TEST certificate so
they register on unsigned CI builds, letting UI tests exercise the real end-user workflow (the modern
Windows 11 context menu) instead of signing-free fallbacks.
.DESCRIPTION
CI PR-validation builds are produced with codeSign:false, so every sparse-MSIX shell extension
(ImageResizer / PowerRename / FileLocksmith / NewPlus context menus, the CmdPal PowerToysSparse
package) ships UNSIGNED. PowerToys registers these at module-enable time via
PackageManager.AddPackageByUriAsync, which requires a signature that chains to a trusted root, so on
CI they fail with 0x800B0100 (TRUST_E_NOSIGNATURE) and the modern context menu never appears.
Run this on the test agent AFTER the build is downloaded/installed and BEFORE PowerToys enables the
module. For every package it:
1. reads the manifest Publisher subject,
2. ensures a self-signed code-signing certificate with that exact subject exists,
3. force-trusts that certificate (LocalMachine + CurrentUser Root and TrustedPeople), and
4. signs the package with signtool.
This asserts NO security -- it is a test-only trust anchor for validating normal app usage. It only
signs packages that are not already validly signed (unless -Force), so real framework packages
(VCLibs, WindowsAppSDK) are left untouched.
.PARAMETER PackageRoot
One or more folders to search recursively for sparse packages. Missing folders are skipped, so you
can pass both the run-in-place build tree and the installed location:
-PackageRoot "$(Pipeline.Workspace)\build-x64-Release", "$env:ProgramFiles\PowerToys"
.PARAMETER Include
Filename patterns to sign. Defaults to *.msix and *.appx.
.PARAMETER RequiredPackage
Filename patterns that must be found and end with a Valid signature. Missing, unsigned, or untrusted
matches make the script fail after attempting all packages.
.PARAMETER RequiredAuthenticodeFile
Filename patterns for unpackaged companion binaries that must be found and signed with the same
machine-trusted TEST identity. This is used for authenticated PowerToys IPC on unsigned CI builds.
.PARAMETER AuthenticodePublisher
Certificate subject used to sign RequiredAuthenticodeFile matches. The default matches the
Microsoft publisher identity required by PowerToys' Release IPC caller authentication.
.PARAMETER CertificateMarkerPath
Optional durable text file that receives each test certificate thumbprint used by this invocation.
CI uses the marker before signing and from an <c>always()</c> cleanup step to remove the trust anchor
and private key, including after an interrupted prior job.
.PARAMETER Force
Re-sign even packages that already carry a valid signature.
.PARAMETER SkipLocalTrust
Sign without importing the test certificate into this machine's trust stores. Use when signing on a
build host and registering the package somewhere else (for example packaging a UI-test payload on the
host and running it in a VM), so the build machine never gains a test trust anchor.
.PARAMETER ExportCertificatePath
Write the public certificate to this path so the machine that registers the package can trust it.
.EXAMPLE
.\signSparsePackages.ps1 -PackageRoot "$env:ProgramFiles\PowerToys" `
-RequiredPackage ImageResizerContextMenuPackage.msix
.EXAMPLE
# Local sideloading into a UI-test VM runtime:
.\signSparsePackages.ps1 -PackageRoot "C:\PowerToysUiTestRun\PowerToys"
.EXAMPLE
# Sign a payload on the host, trust it only inside the VM that will register it:
.\signSparsePackages.ps1 -PackageRoot "X:\payload\product" -SkipLocalTrust `
-ExportCertificatePath "X:\payload\pt-test-signer.cer"
#>
param(
[Parameter(Mandatory = $true)]
[string[]]$PackageRoot,
[Parameter()]
[string[]]$Include = @('*.msix', '*.appx'),
[Parameter()]
[string[]]$RequiredPackage = @(),
[Parameter()]
[string[]]$RequiredAuthenticodeFile = @(),
[Parameter()]
[string]$AuthenticodePublisher = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US',
[Parameter()]
[string]$CertificateMarkerPath,
[switch]$Force,
[switch]$SkipLocalTrust,
[Parameter()]
[string]$ExportCertificatePath
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$testCertificateFriendlyName = 'PowerToys UI Test Signing'
function Select-SignToolByArch {
param([string[]]$Paths)
$paths = @($Paths | Where-Object { $_ } | Select-Object -Unique)
if (-not $paths) { return $null }
$archPref = @($env:PROCESSOR_ARCHITECTURE, 'x64', 'x86', 'arm64') |
ForEach-Object { $_.ToLower() } | Select-Object -Unique
foreach ($arch in $archPref) {
$match = $paths | Where-Object { $_ -match "\\$arch\\" } | Select-Object -First 1
if ($match) { return $match }
}
return $paths[0]
}
# Locate signtool.exe on the agent: PATH, then any Windows Kits install (all versions/layouts,
# including the App Certification Kit). The pinned, signature-verified NuGet fallback is separate.
function Find-SignTool {
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$found = @()
$kitRoots = @(
"${env:ProgramFiles(x86)}\Windows Kits",
"$env:ProgramFiles\Windows Kits",
"$env:ProgramW6432\Windows Kits"
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($root in $kitRoots) {
# Scope to bin\ and the App Certification Kit so the huge Include\ / Lib\ trees are skipped.
$scopes = Get-ChildItem -Path $root -Directory -Recurse -Depth 1 -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq 'bin' -or $_.Name -eq 'App Certification Kit' } |
Select-Object -ExpandProperty FullName
foreach ($scope in $scopes) {
$found += Get-ChildItem -Path $scope -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
}
}
return Select-SignToolByArch -Paths $found
}
# Last resort when the agent has no Windows SDK: fetch signtool from the public
# Microsoft.Windows.SDK.BuildTools NuGet package. Best-effort.
function Get-SignToolFromNuget {
$nupkg = $null
$archive = $null
try {
[xml]$centralPackages = Get-Content (Join-Path $PSScriptRoot '..\Directory.Packages.props') -Raw
$versionNode = $centralPackages.SelectSingleNode(
"/Project/ItemGroup/PackageVersion[@Include='Microsoft.Windows.SDK.BuildTools']")
$version = if ($versionNode) { $versionNode.GetAttribute('Version') } else { $null }
if ($version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw 'Microsoft.Windows.SDK.BuildTools must have a concrete four-part version in Directory.Packages.props.'
}
$dest = Join-Path $env:TEMP "pt-sdk-buildtools-$version"
$nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.nupkg"
$archive = Join-Path $env:TEMP "sdk-buildtools-$version.zip"
$packageFileName = "microsoft.windows.sdk.buildtools.$version.nupkg"
$cachedPackage = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) |
Where-Object { $_ } |
ForEach-Object { Join-Path $_ "microsoft.windows.sdk.buildtools\$version\$packageFileName" } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if ($cachedPackage) {
Write-Host "signtool not found on the agent; verifying cached Windows SDK BuildTools $version."
Copy-Item -LiteralPath $cachedPackage -Destination $nupkg -Force
}
else {
Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet."
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/$packageFileName" -OutFile $nupkg -UseBasicParsing
}
$verificationOutput = @(& dotnet nuget verify $nupkg --all 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "NuGet signature verification failed for Microsoft.Windows.SDK.BuildTools ${version}: $($verificationOutput -join [Environment]::NewLine)"
}
$verificationOutput | ForEach-Object { Write-Host $_ }
Copy-Item $nupkg $archive -Force
Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $archive -DestinationPath $dest -Force
$paths = Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
return Select-SignToolByArch -Paths $paths
}
catch {
Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)"
return $null
}
finally {
@($nupkg, $archive) | Where-Object { $_ } | ForEach-Object {
Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue
}
}
}
function Get-VerifiedSignTool {
$path = Find-SignTool
if (-not $path) { $path = Get-SignToolFromNuget }
if (-not $path) {
throw 'signtool.exe not found and could not be fetched from NuGet. Install the Windows SDK.'
}
$signature = Get-AuthenticodeSignature -FilePath $path
if ($signature.Status -ne 'Valid' -or
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch '(^|,\s*)O=Microsoft Corporation(,|$)') {
throw "signtool.exe is not validly signed by Microsoft: $path"
}
return $path
}
# Read <Identity Publisher="..."> straight out of the .msix/.appx (a zip) without extracting it.
function Get-PackagePublisher {
param([string]$PackagePath)
$zip = [System.IO.Compression.ZipFile]::OpenRead($PackagePath)
try {
$entry = $zip.GetEntry('AppxManifest.xml')
if (-not $entry) { return $null }
$reader = New-Object System.IO.StreamReader($entry.Open())
try { $xml = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() }
return $xml.Package.Identity.Publisher
}
finally {
$zip.Dispose()
}
}
function Import-CertTrust {
param(
[string]$CerPath,
[string]$Thumbprint,
[string]$StorePath,
[switch]$Optional
)
if (Get-ChildItem $StorePath -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $Thumbprint }) {
return $true
}
try {
Import-Certificate -FilePath $CerPath -CertStoreLocation $StorePath -ErrorAction Stop | Out-Null
return $true
}
catch {
if ($Optional) {
Write-Warning "Could not import test cert into $StorePath (admin may be required): $($_.Exception.Message)"
return $false
}
throw
}
}
$certCache = @{}
function Get-TrustedSigningCert {
param([string]$Subject)
if ($certCache.ContainsKey($Subject)) { return $certCache[$Subject] }
$cert = Get-ChildItem Cert:\CurrentUser\My |
Where-Object {
$_.Subject -eq $Subject -and
$_.HasPrivateKey -and
$_.FriendlyName -eq $testCertificateFriendlyName
} |
Sort-Object NotAfter -Descending | Select-Object -First 1
if (-not $cert) {
Write-Host "Creating self-signed test certificate for: $Subject"
$cert = New-SelfSignedCertificate -Subject $Subject `
-CertStoreLocation Cert:\CurrentUser\My `
-FriendlyName $testCertificateFriendlyName `
-KeyAlgorithm RSA -KeyLength 2048 `
-Type CodeSigningCert -HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(1)
}
if ($CertificateMarkerPath) {
$markerParent = Split-Path $CertificateMarkerPath -Parent
if ($markerParent -and -not (Test-Path $markerParent)) {
New-Item $markerParent -ItemType Directory -Force | Out-Null
}
$recordedThumbprints = if (Test-Path $CertificateMarkerPath) {
@(Get-Content $CertificateMarkerPath | Where-Object { $_ })
} else {
@()
}
if ($recordedThumbprints -notcontains $cert.Thumbprint) {
Add-Content -Path $CertificateMarkerPath -Value $cert.Thumbprint -Encoding ascii
}
}
# Force-trust so AddPackageByUriAsync accepts the signature. A self-signed cert is its own root,
# so it must live in a Root store (chain) and TrustedPeople (AppX sideload allow-list). Use the
# LocalMachine stores: they import silently and the elevated CI test agent can write them.
# CurrentUser\Root is deliberately NOT used -- importing into the user Root store raises a CryptoAPI
# consent dialog that fails non-interactively ("UI is not allowed in this operation"), even elevated.
$cerPath = Join-Path $env:TEMP ("pt-test-signer-{0}.cer" -f $cert.Thumbprint)
Export-Certificate -Cert $cert -FilePath $cerPath -Force | Out-Null
if ($ExportCertificatePath) {
$exportParent = Split-Path $ExportCertificatePath -Parent
if ($exportParent -and -not (Test-Path $exportParent)) {
New-Item $exportParent -ItemType Directory -Force | Out-Null
}
Copy-Item $cerPath $ExportCertificatePath -Force
Write-Host "Exported public certificate to: $ExportCertificatePath"
}
if ($SkipLocalTrust) {
Write-Host "Skipping local trust for '$Subject'; trust the exported certificate where the package is registered."
$certCache[$Subject] = $cert
return $cert
}
$rootTrusted = Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\Root' -Optional
Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\TrustedPeople' -Optional | Out-Null
Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\CurrentUser\TrustedPeople' -Optional | Out-Null
if (-not $rootTrusted) {
Write-Warning "Could not establish machine root trust for '$Subject' (run elevated). Signed packages may not register."
}
$certCache[$Subject] = $cert
return $cert
}
$packages = @()
foreach ($root in $PackageRoot) {
if (-not (Test-Path $root)) {
Write-Host "Skipping missing package root: $root"
continue
}
$packages += Get-ChildItem -Path $root -Recurse -File -Include $Include -ErrorAction SilentlyContinue
}
$packages = $packages | Sort-Object FullName -Unique
$requiredAuthenticodeFiles = @()
foreach ($pattern in ($RequiredAuthenticodeFile | Where-Object { $_ } | Select-Object -Unique)) {
$matches = @()
foreach ($root in $PackageRoot) {
if (Test-Path $root) {
$matches += Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
}
}
$matches = @($matches | Sort-Object FullName -Unique)
if ($matches.Count -eq 0) {
throw "Required Authenticode file '$pattern' was not found under: $($PackageRoot -join ', ')."
}
$requiredAuthenticodeFiles += $matches
}
$requiredAuthenticodeFiles = @($requiredAuthenticodeFiles | Sort-Object FullName -Unique)
if (-not $packages -and $RequiredPackage.Count -gt 0) {
throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')."
}
if (-not $packages -and $requiredAuthenticodeFiles.Count -eq 0) {
Write-Host "No packages or required Authenticode files found under: $($PackageRoot -join ', ')"
return
}
$requiredPackages = @()
foreach ($pattern in ($RequiredPackage | Where-Object { $_ } | Select-Object -Unique)) {
$matches = @($packages | Where-Object { $_.Name -like $pattern })
if ($matches.Count -eq 0) {
throw "Required sparse package '$pattern' was not found under: $($PackageRoot -join ', ')."
}
$requiredPackages += $matches
}
$requiredPackages = @($requiredPackages | Sort-Object FullName -Unique)
$signed = 0
$signtool = $null
foreach ($pkg in $packages) {
if (-not $Force) {
$existing = Get-AuthenticodeSignature -FilePath $pkg.FullName
if ($existing.Status -eq 'Valid') {
Write-Host "Already validly signed, skipping: $($pkg.Name)"
continue
}
}
$publisher = $null
try { $publisher = Get-PackagePublisher -PackagePath $pkg.FullName } catch { }
if (-not $publisher) {
Write-Host "No manifest publisher, skipping: $($pkg.Name)"
continue
}
if (-not $signtool) {
$signtool = Get-VerifiedSignTool
Write-Host "Using signtool: $signtool"
}
$cert = Get-TrustedSigningCert -Subject $publisher
Write-Host "Signing $($pkg.Name) (Publisher: $publisher)"
& $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $pkg.FullName
if ($LASTEXITCODE -ne 0) {
Write-Warning "signtool failed for $($pkg.Name) (exit $LASTEXITCODE)"
continue
}
$verify = Get-AuthenticodeSignature -FilePath $pkg.FullName
if ($verify.Status -eq 'Valid') {
$signed++
}
else {
Write-Warning "Signature not Valid after signing $($pkg.Name): $($verify.Status)"
}
}
if ($requiredPackages.Count -gt 0) {
$invalidRequiredPackages = @($requiredPackages | Where-Object {
(Get-AuthenticodeSignature -FilePath $_.FullName).Status -ne 'Valid'
})
if ($invalidRequiredPackages.Count -gt 0) {
throw "Required sparse package(s) are not validly signed and trusted: $($invalidRequiredPackages.FullName -join ', ')."
}
Write-Host "Verified required sparse package(s): $($requiredPackages.FullName -join ', ')"
}
if ($requiredAuthenticodeFiles.Count -gt 0) {
if (-not $signtool) {
$signtool = Get-VerifiedSignTool
Write-Host "Using signtool: $signtool"
}
$filesToSign = @($requiredAuthenticodeFiles | Where-Object {
$existing = Get-AuthenticodeSignature -FilePath $_.FullName
$Force -or $existing.Status -ne 'Valid'
})
$testSignedPaths = @{}
$cert = if ($filesToSign.Count -gt 0) {
Get-TrustedSigningCert -Subject $AuthenticodePublisher
} else {
$null
}
foreach ($file in $filesToSign) {
Write-Host "Signing companion binary: $($file.FullName)"
& $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $file.FullName
if ($LASTEXITCODE -ne 0) {
throw "signtool failed for required Authenticode file '$($file.FullName)' (exit $LASTEXITCODE)."
}
$testSignedPaths[$file.FullName] = $true
}
$invalidAuthenticodeFiles = @($requiredAuthenticodeFiles | Where-Object {
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
$signerName = if ($signature.SignerCertificate) {
$signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)
} else {
$null
}
if ($testSignedPaths.ContainsKey($_.FullName)) {
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Thumbprint -ne $cert.Thumbprint -or
(-not $SkipLocalTrust -and $signature.Status -ne 'Valid')
} else {
-not $signature.SignerCertificate -or
$signerName -ne 'Microsoft Corporation' -or
$signature.Status -ne 'Valid'
}
})
if ($invalidAuthenticodeFiles.Count -gt 0) {
throw "Required Authenticode file(s) are not signed with the trusted test identity: $($invalidAuthenticodeFiles.FullName -join ', ')."
}
Write-Host "Verified required Authenticode file(s): $($requiredAuthenticodeFiles.FullName -join ', ')"
}
Write-Host "Signed $signed package(s) with a trusted test certificate."