From 07b46889450c78e373b6f8a6bc7a77ff17b1b6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 10:08:25 -0400 Subject: [PATCH 1/2] fix: correct macOS and MSIX release signing Preserve published third-party notices in macOS bundle Resources. Use the exact Artifact Signing certificate publisher only for signed MSIX builds, retaining development identity and local signing behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-terminal.yml | 100 ++++++--- docs/macos.md | 3 + docs/release.md | 35 ++-- scripts/MacOsPackagingCommon.psm1 | 17 +- scripts/Stage-MacOsApp.ps1 | 14 +- scripts/Test-MacOsCodeSigning.ps1 | 22 ++ scripts/Test-MacOsLegalNotices.ps1 | 59 ++++++ scripts/Test-MacOsPackage.ps1 | 4 + scripts/Test-MacOsPackagingMetadata.ps1 | 1 + src/Devolutions.Terminal.Package/README.md | 16 +- .../Scripts/Build-Packages.ps1 | 7 + .../Scripts/Test-PackageBuild.ps1 | 194 ++++++++++++++++++ .../Scripts/Test-Packages.ps1 | 8 +- 13 files changed, 424 insertions(+), 56 deletions(-) create mode 100644 scripts/Test-MacOsLegalNotices.ps1 create mode 100644 src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 9272f4e..1a1d173 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -159,6 +159,9 @@ jobs: - name: Test run: dotnet test Devolutions.Terminal.slnx -c Release --no-build -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + - name: Test macOS legal notice layout + run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + nuget-pack: name: Pack Devolutions.Terminal.Control NuGet package runs-on: windows-latest @@ -313,6 +316,9 @@ jobs: - name: Test macOS code-signing topology run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsCodeSigning.ps1 + - name: Test macOS legal notice layout + run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + macos-native-aot: name: macOS NativeAOT ${{ matrix.rid }} runs-on: macos-26 @@ -706,6 +712,10 @@ jobs: with: version: v0.6.1 + - name: Test MSIX publisher and development signing + shell: pwsh + run: ./src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 + - name: Download x64 publish uses: actions/download-artifact@v4 with: @@ -718,14 +728,6 @@ jobs: name: DevolutionsTerminal-win-arm64 path: artifacts/msix/layout/win-arm64 - - name: Build unsigned MSIX packages - shell: pwsh - run: > - ./src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 - -SkipPublish - -OutputDirectory ./artifacts/msix - -Version "${{ needs.release-metadata.outputs.msix_version }}" - - name: Resolve signing mode id: signing-mode shell: pwsh @@ -737,6 +739,7 @@ jobs: TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} + REQUESTED_PUBLISHER: ${{ vars.TRUSTED_SIGNING_PUBLISHER }} run: | # Ordinary (non-release) CI runs never attempt MSIX signing: they are not gated on # signing secrets being configured, so they must not fail when those secrets are absent. @@ -774,7 +777,40 @@ jobs: throw "Missing Azure Artifact Signing secrets: $($missing -join ', ')" } - "should_sign=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + # Resolve after the selected GitHub Environment's variables are available. + $publisher = $env:REQUESTED_PUBLISHER + if ([string]::IsNullOrWhiteSpace($publisher)) { + $publisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA" + } + if ($publisher -match '[\r\n]') { + throw "TRUSTED_SIGNING_PUBLISHER must be a single-line certificate subject." + } + @( + "should_sign=true" + "publisher=$publisher" + ) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Build unsigned MSIX packages + id: build-msix + shell: pwsh + env: + SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }} + MSIX_VERSION: ${{ needs.release-metadata.outputs.msix_version }} + MSIX_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} + run: | + $arguments = @{ + SkipPublish = $true + OutputDirectory = "./artifacts/msix" + Version = $env:MSIX_VERSION + } + if ($env:SHOULD_SIGN -eq "true") { + $arguments["Publisher"] = $env:MSIX_PUBLISHER + } + ./src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 @arguments + + New-Item -ItemType Directory -Force -Path ./artifacts/msix/unsigned | Out-Null + Get-ChildItem ./artifacts/msix/packages -Filter "*.msix" -File | + Copy-Item -Destination ./artifacts/msix/unsigned - name: Azure login for Trusted Signing if: steps.signing-mode.outputs.should_sign == 'true' @@ -792,15 +828,8 @@ jobs: TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} run: | - # MSIX packages are signed here, on a real Windows runner, using WinApp CLI's - # native `az-sign` command (real Win32 SignerSignEx3 / AppxSip.dll) rather than - # psign-tool's cross-platform "--mode portable" MSIX signing path used for the - # .msi container and bundled binaries elsewhere in this workflow. Portable-mode - # MSIX signing has been found to corrupt the package's central directory relative - # to the AXCD digest embedded in its own AppxSignature.p7x, which Windows rejects - # at install time with HRESULT 0x80080205 ("The Appx package's block map is - # invalid") even though the package is otherwise well-formed. Native signing on - # Windows uses the real OS AppX signing component and does not have this bug. + # Keep MSIX signing and verification on Windows with the native AppX SIP. + # psign-tool signs only the MSI container and bundled binaries in other jobs. $metadata = [ordered]@{ Endpoint = $env:TRUSTED_SIGNING_ENDPOINT CodeSigningAccountName = $env:TRUSTED_SIGNING_ACCOUNT_NAME @@ -821,6 +850,7 @@ jobs: shell: pwsh env: SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }} + MSIX_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} run: | $packagePaths = Get-ChildItem ./artifacts/msix/packages -File | Where-Object Extension -eq ".msix" | @@ -831,10 +861,35 @@ jobs: } if ($env:SHOULD_SIGN -eq "true") { $arguments["RequireSignature"] = $true + $arguments["ExpectedPublisher"] = $env:MSIX_PUBLISHER } ./src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 @arguments + - name: Collect MSIX failure diagnostics + if: failure() && steps.build-msix.outcome == 'success' + shell: pwsh + run: | + $diagnostics = "./artifacts/msix/diagnostics" + New-Item -ItemType Directory -Force -Path $diagnostics | Out-Null + Copy-Item ./artifacts/msix/metadata/Package.appxmanifest -Destination $diagnostics + wevtutil qe Microsoft-Windows-AppxPackaging/Operational /rd:true /c:30 /f:xml | + Set-Content "$diagnostics/AppxPackaging-events.xml" -Encoding utf8 + if ($LASTEXITCODE -ne 0) { + Write-Host "::warning::AppxPackaging event collection failed with exit code $LASTEXITCODE." + } + + - name: Upload failed MSIX inputs and diagnostics + if: failure() && steps.build-msix.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-MSIX-failure-diagnostics + path: | + artifacts/msix/unsigned + artifacts/msix/diagnostics + if-no-files-found: warn + retention-days: 7 + - name: Upload MSIX artifacts uses: actions/upload-artifact@v4 with: @@ -1336,13 +1391,8 @@ jobs: throw "Failed to acquire an Azure Trusted Signing access token." } - # MSIX packages are already signed on windows-latest in the msix job (via - # WinApp CLI's native `az-sign`), not here. psign-tool's cross-platform - # "--mode portable" MSIX signing (which is what this ubuntu-latest job would - # otherwise use) has been found to corrupt the package's central directory - # relative to the AXCD digest embedded in its own AppxSignature.p7x, which - # Windows rejects at install time with HRESULT 0x80080205 ("The Appx - # package's block map is invalid"). Only the .msi container is signed here. + # MSIX packages are already signed and verified by the Windows msix job. + # Only the MSI container is signed in this Linux release job. ./src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1 ` -PackageDirectory ./artifacts/msi-packages ` -Version $env:MSIX_VERSION ` diff --git a/docs/macos.md b/docs/macos.md index 4f887b3..54b98cf 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -63,6 +63,9 @@ an additional SVG renderer. Xcode compiles that same master through remains the fallback on earlier releases. The script then ad-hoc signs the bundle and writes a zip plus SHA-256 manifest. +Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency notices) are preserved in `Contents/Resources` alongside `LICENSE`, not in the code-only `Contents/MacOS` directory. +`Test-MacOsLegalNotices.ps1` checks this layout without requiring Apple signing credentials. + ```bash open "artifacts/packages/Devolutions Terminal.app" ``` diff --git a/docs/release.md b/docs/release.md index 16acc80..708def0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -226,14 +226,13 @@ credentials are unavailable. ## GitHub Release automation -The release workflow in `.github/workflows/build-terminal.yml` publishes signed -Windows packages and the corresponding platform archives directly to GitHub -Releases without staging them in OneDrive. It builds unsigned per-architecture -MSIX and MSI packages for Windows x64 and ARM64, then signs them on the Linux -release runner with Devolutions `psign-tool` and Azure Artifact Signing -(Trusted Signing). The private key never lands on the runner. Signed Windows -packages are uploaded alongside Linux and macOS archives. The workflow is -intended for tag-based releases and for manual dispatch. +The release workflow in `.github/workflows/build-terminal.yml` publishes signed Windows packages and the corresponding platform archives directly to GitHub Releases without staging them in OneDrive. +It builds unsigned per-architecture MSIX and MSI packages for Windows x64 and ARM64. +MSIX uses WinApp CLI's native `az-sign` command and Windows SDK verification on the Windows packaging runner. +MSI containers and their application binaries use Devolutions `psign-tool` with Azure Artifact Signing (Trusted Signing). +The production private key never lands on the runner. +Signed Windows packages are uploaded alongside Linux and macOS archives. +The workflow is intended for tag-based releases and for manual dispatch. Manual dispatch provides these inputs: @@ -244,9 +243,8 @@ Manual dispatch provides these inputs: - `dry_run` — build, package, and validate artifacts without creating or updating a GitHub Release. The combined release assets are uploaded as a workflow artifact. -- `sign_dry_run` — with `dry_run`, sign Windows packages using the selected - signing environment when all signing secrets are available. This validates - the real `psign-tool` and Azure Artifact Signing path without publishing. +- `sign_dry_run` — with `dry_run`, sign Windows packages using the selected signing environment when all signing secrets are available. + This validates the real WinApp CLI, `psign-tool`, and Azure Artifact Signing paths without publishing. Without this option, dry-run assets remain unsigned. - `github-env` — selects the GitHub Environment containing signing credentials: `test`, `prod`, or `auto`. `auto` selects `publish-prod` for `master` and tag @@ -279,13 +277,20 @@ Required environment secrets: - `APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD` — Developer ID certificate password - `APPLE_BOT_PASSWORD` — app-specific password for macOS notarization -Optional environment or repository variable: +Optional GitHub Environment or repository configuration variables: - `TRUSTED_SIGNING_TIMESTAMP_SERVER` (defaults to `http://timestamp.acs.microsoft.com/`) +- `TRUSTED_SIGNING_PUBLISHER` — full certificate subject for the selected signing profile. + Defaults to `CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA`; override it if the profile has a different subject. -`psign-tool` portable Artifact Signing signs the per-architecture `.msix` and -`.msi` files. The MSIX `Publisher` identity in `Package.appxmanifest` must -match the Artifact Signing certificate subject. +The signed MSIX `Identity.Publisher` must exactly match the Artifact Signing certificate subject, including all DN fields, punctuation, and accents. +The workflow resolves whether signing will occur before building the package and supplies this publisher to `Build-Packages.ps1`; unsigned CI/dry runs retain the checked-in development publisher. +`Test-Packages.ps1` checks both the generated identity and the signer against the selected publisher. +Development certificate generation still uses the unchanged source manifest. +Changing publishers changes the package family, so a signed production package is not an in-place upgrade of a development package. + +If MSIX signing or validation fails after packaging, the workflow retains the pre-signing unsigned packages, generated manifest, and recent AppxPackaging events in `DevolutionsTerminal-MSIX-failure-diagnostics` for seven days. +HRESULT `0x8007000B` alone is not diagnostic: AppxPackaging events distinguish publisher mismatch (150), hash mismatch (151), and block-map mismatch (152). The release job always publishes `Devolutions.Terminal.App` and `Devolutions.Terminal.Control` to NuGet.org through OIDC trusted publishing diff --git a/scripts/MacOsPackagingCommon.psm1 b/scripts/MacOsPackagingCommon.psm1 index 5d47caa..09e5cd5 100644 --- a/scripts/MacOsPackagingCommon.psm1 +++ b/scripts/MacOsPackagingCommon.psm1 @@ -286,6 +286,20 @@ function Get-MacOsWritableDmgSizeMegabytes { return [Math]::Max([long]64, $sourceMegabytes + $extraMegabytes) } +function Move-MacOsLegalNotices { + param( + [Parameter(Mandatory)] + [string]$MacOsDirectory, + [Parameter(Mandatory)] + [string]$ResourcesDirectory + ) + + # Publish output can acquire new notices from transitive dependencies. + # Keep them out of the bundle's code-only directory without dropping licenses. + Get-ChildItem -LiteralPath $MacOsDirectory -File -Filter 'THIRD-PARTY-NOTICES*.txt' | + Move-Item -Destination $ResourcesDirectory -Force +} + Export-ModuleMember -Function ` Import-MacOsPackageEnv, ` Get-MacOsSourceDateEpoch, ` @@ -298,4 +312,5 @@ Export-ModuleMember -Function ` Get-Sha256Manifest, ` Set-MacOsReproducibleTimestamps, ` Get-MacOsTreeByteSize, ` - Get-MacOsWritableDmgSizeMegabytes + Get-MacOsWritableDmgSizeMegabytes, ` + Move-MacOsLegalNotices diff --git a/scripts/Stage-MacOsApp.ps1 b/scripts/Stage-MacOsApp.ps1 index 8bab75d..c2ecce4 100644 --- a/scripts/Stage-MacOsApp.ps1 +++ b/scripts/Stage-MacOsApp.ps1 @@ -143,16 +143,12 @@ finally { } Copy-Item -LiteralPath (Join-Path $repoRoot 'LICENSE') -Destination (Join-Path $resources 'LICENSE') -Force -Move-Item -LiteralPath (Join-Path $macosDir 'THIRD-PARTY-NOTICES-GHOSTTY.txt') ` - -Destination (Join-Path $resources 'THIRD-PARTY-NOTICES-GHOSTTY.txt') -Force -Move-Item -LiteralPath (Join-Path $macosDir 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') ` - -Destination (Join-Path $resources 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') -Force -Invoke-Native -FilePath chmod -ArgumentList @( - '0644', - (Join-Path $resources 'LICENSE'), - (Join-Path $resources 'THIRD-PARTY-NOTICES-GHOSTTY.txt'), - (Join-Path $resources 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') +Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources +$legalPaths = @((Join-Path $resources 'LICENSE')) + @( + Get-ChildItem -LiteralPath $resources -File -Filter 'THIRD-PARTY-NOTICES*.txt' | + ForEach-Object FullName ) +Invoke-Native -FilePath chmod -ArgumentList (@('0644') + $legalPaths) Invoke-Native -FilePath chmod -ArgumentList @( '0755', diff --git a/scripts/Test-MacOsCodeSigning.ps1 b/scripts/Test-MacOsCodeSigning.ps1 index 228addc..56717e0 100644 --- a/scripts/Test-MacOsCodeSigning.ps1 +++ b/scripts/Test-MacOsCodeSigning.ps1 @@ -80,6 +80,25 @@ try { } Remove-Item -LiteralPath (Join-Path $macosDir 'HelperTool.runtimeconfig.json') -Force + $noticeName = 'THIRD-PARTY-NOTICES-CONPTY.txt' + $noticeText = 'ConPTY fixture license must survive bundle signing.' + Set-Content -LiteralPath (Join-Path $macosDir $noticeName) -Value $noticeText -NoNewline -Encoding utf8 + $noticeRejected = $false + try { + & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' + } + catch { + if ($_.Exception.Message -notmatch "Contents/MacOS may contain only Mach-O code.*$([regex]::Escape($noticeName))") { + throw + } + $noticeRejected = $true + } + if (-not $noticeRejected) { + throw 'Sign-MacOsPackage.ps1 accepted a third-party notice in Contents/MacOS.' + } + $resources = Join-Path $contents 'Resources' + New-Item -ItemType Directory -Path $resources | Out-Null + Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' if ($LASTEXITCODE -ne 0) { throw "Sign-MacOsPackage.ps1 failed with exit code $LASTEXITCODE." @@ -90,6 +109,9 @@ try { $isolatedHelper = Join-Path $work 'HelperTool' Invoke-Native -FilePath cp -ArgumentList (Join-Path $macosDir 'HelperTool'), $isolatedHelper Assert-MacOsCodeSignature -Path $isolatedHelper -RequireHardenedRuntime + if ((Get-Content -LiteralPath (Join-Path $resources $noticeName) -Raw) -cne $noticeText) { + throw 'Bundle signing did not preserve the relocated ConPTY legal notice.' + } Write-Host 'macOS standalone auxiliary-code signing regression test passed.' } diff --git a/scripts/Test-MacOsLegalNotices.ps1 b/scripts/Test-MacOsLegalNotices.ps1 new file mode 100644 index 0000000..b928a37 --- /dev/null +++ b/scripts/Test-MacOsLegalNotices.ps1 @@ -0,0 +1,59 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force +$work = Join-Path ([IO.Path]::GetTempPath()) "macos-notices-test-$([guid]::NewGuid().ToString('N'))" + +try { + foreach ($rid in @('osx-x64', 'osx-arm64')) { + $contents = Join-Path $work "$rid/Notice Test.app/Contents" + $macos = Join-Path $contents 'MacOS' + $resources = Join-Path $contents 'Resources' + New-Item -ItemType Directory -Path $macos, $resources -Force | Out-Null + $codePath = Join-Path $macos 'dt' + [IO.File]::WriteAllBytes($codePath, [byte[]](0xCF, 0xFA, 0xED, 0xFE)) + $codeHash = (Get-FileHash -LiteralPath $codePath).Hash + $notices = @( + 'THIRD-PARTY-NOTICES-GHOSTTY.txt', + 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt', + 'THIRD-PARTY-NOTICES-CONPTY.txt', + 'THIRD-PARTY-NOTICES-FUTURE-DEPENDENCY.txt' + ) + foreach ($notice in $notices) { + [IO.File]::WriteAllText((Join-Path $macos $notice), "License content for $notice") + } + + Move-MacOsLegalNotices -MacOsDirectory $macos -ResourcesDirectory $resources + foreach ($notice in $notices) { + $resourcePath = Join-Path $resources $notice + if (-not (Test-Path -LiteralPath $resourcePath -PathType Leaf) -or + [IO.File]::ReadAllText($resourcePath) -cne "License content for $notice") { + throw "$rid did not preserve the exact contents of $notice in Resources." + } + if (Test-Path -LiteralPath (Join-Path $macos $notice)) { + throw "$rid left $notice in the code-only MacOS directory." + } + } + if (@(Get-ChildItem -LiteralPath $resources -File).Count -ne $notices.Count -or + @(Get-ChildItem -LiteralPath $macos -File).Count -ne 1 -or + (Get-FileHash -LiteralPath $codePath).Hash -cne $codeHash) { + throw "$rid notice relocation changed the code or produced an unexpected layout." + } + Write-Host "Move-MacOsLegalNotices_AllPublishedNoticesPreserved ($rid) passed." + + Move-MacOsLegalNotices -MacOsDirectory $macos -ResourcesDirectory $resources + if (@(Get-ChildItem -LiteralPath $resources -File).Count -ne $notices.Count -or + (Get-FileHash -LiteralPath $codePath).Hash -cne $codeHash) { + throw "$rid notice-free relocation changed the existing layout." + } + Write-Host "Move-MacOsLegalNotices_NoNoticesLeavesCodeUntouched ($rid) passed." + } +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force +} diff --git a/scripts/Test-MacOsPackage.ps1 b/scripts/Test-MacOsPackage.ps1 index 8124598..eb73006 100644 --- a/scripts/Test-MacOsPackage.ps1 +++ b/scripts/Test-MacOsPackage.ps1 @@ -163,6 +163,10 @@ function Test-MacOsAppBundle { if ($runtimeConfig) { throw "$label contains NativeAOT runtime configuration in Contents/MacOS." } + $misplacedNotices = Get-ChildItem -LiteralPath $macosDir -File -Filter 'THIRD-PARTY-NOTICES*.txt' + if ($misplacedNotices) { + throw "$label contains third-party legal notices in Contents/MacOS instead of Contents/Resources." + } } try { diff --git a/scripts/Test-MacOsPackagingMetadata.ps1 b/scripts/Test-MacOsPackagingMetadata.ps1 index fa830fd..c0a8738 100644 --- a/scripts/Test-MacOsPackagingMetadata.ps1 +++ b/scripts/Test-MacOsPackagingMetadata.ps1 @@ -25,6 +25,7 @@ $scripts = @( 'Test-MacOsPackage.ps1', 'Test-MacOsRuntime.ps1', 'Test-MacOsCodeSigning.ps1', + 'Test-MacOsLegalNotices.ps1', 'Sign-MacOsPackage.ps1', 'Build-MacOsDmg.ps1', 'Notarize-MacOsPackage.ps1', diff --git a/src/Devolutions.Terminal.Package/README.md b/src/Devolutions.Terminal.Package/README.md index f66cfe1..45796d1 100644 --- a/src/Devolutions.Terminal.Package/README.md +++ b/src/Devolutions.Terminal.Package/README.md @@ -31,15 +31,19 @@ registration. | Field | Value | | --- | --- | | NuGet distribution package | `Devolutions.Terminal.App` | -| Publisher | `CN=Devolutions Inc.` | +| Development publisher | `CN=Devolutions Inc.` | | Application ID | `Terminal` | | Execution aliases | `dt.exe`, `Devolutions.Terminal.exe` | | Protocol | `dterm:` | | Minimum Windows | Windows 10, version 2004 (`10.0.19041.0`) | -The identity is stable across local and CI builds so package-scoped data can -survive upgrades. Production/Store onboarding can replace the identity and -publisher in a separate manifest without changing the unpackaged host. +The checked-in identity is stable for development and unsigned CI builds. +Signed releases override the generated manifest's publisher with the complete Artifact Signing certificate subject: `CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA`. +The workflow's optional `TRUSTED_SIGNING_PUBLISHER` GitHub configuration variable supports profiles with a different subject. +The source manifest and development certificate generation remain unchanged. + +A different publisher produces a different package family and is not an in-place upgrade of the development package; package-scoped data is separate. +This distinction does not affect the unpackaged host or MSI. The package is a medium-integrity, full-trust desktop package. It declares only `runFullTrust`; it does not request broad file-system or network capabilities. @@ -69,6 +73,10 @@ To package NativeAOT outputs produced elsewhere, place them in helpers. `-SkipNativeBuild` is only valid when matching helper outputs already exist under `artifacts\msix\native-shell\`. +For an external signing profile, pass its exact certificate subject through `Build-Packages.ps1 -Publisher` before packaging, and use the same value with `Test-Packages.ps1 -ExpectedPublisher`. +Do not modify a built MSIX manifest: that would invalidate its block map. +Omitting these parameters retains the development identity and its validation. + ## Development signing and installation Private keys and generated package artifacts live under `dotnet\artifacts`, diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 index 4b2dbeb..ac2ded1 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 @@ -15,6 +15,10 @@ param( [switch] $SkipNativeBuild, + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $Publisher, + [string] $CertificatePath, [securestring] $CertificatePassword @@ -89,6 +93,9 @@ function Write-VersionedManifest { [xml] $manifest = Get-Content -LiteralPath $sourceManifest $manifest.Package.Identity.Version = $Version + if (-not [string]::IsNullOrWhiteSpace($Publisher)) { + $manifest.Package.Identity.Publisher = $Publisher + } $settings = [Xml.XmlWriterSettings]::new() $settings.Encoding = [Text.UTF8Encoding]::new($false) diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 new file mode 100644 index 0000000..fea20e1 --- /dev/null +++ b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 @@ -0,0 +1,194 @@ +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "MSIX build and local signing regression tests require Windows." +} +if (-not (Get-Command winapp -ErrorAction SilentlyContinue)) { + throw "MSIX build regression tests require WinApp CLI." +} + +$packageRoot = Split-Path -Parent $PSScriptRoot +$sourceManifest = Join-Path $packageRoot "Package.appxmanifest" +$sourceHash = (Get-FileHash -LiteralPath $sourceManifest).Hash +$work = Join-Path ([IO.Path]::GetTempPath()) "terminal-msix-test-$([guid]::NewGuid().ToString('N'))" +$productionPublisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Qu$([char]0xE9)bec, C=CA" +$password = ConvertTo-SecureString "ephemeral-fixture-password" -AsPlainText -Force +$wrongPublisherPackage = Join-Path $work "wrong-publisher.msix" + +function Assert-ExpectedFailure { + param( + [scriptblock] $Action, + [string] $MessagePattern, + [string] $Name + ) + + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw + } + Write-Host "$Name passed." + return + } + throw "$Name did not reject the invalid input." +} + +function New-PackageFixture { + param([string] $OutputDirectory) + + foreach ($architecture in @("x64", "arm64")) { + $layout = Join-Path $OutputDirectory "layout\win-$architecture" + $native = Join-Path $OutputDirectory "native-shell\$architecture" + New-Item -ItemType Directory -Path $layout, $native -Force | Out-Null + # Non-runnable PE headers are sufficient for architecture detection and packing. + $image = [byte[]]::new(1024) + $image[0] = 0x4D + $image[1] = 0x5A + $image[0x3C] = 0x80 + $image[0x80] = 0x50 + $image[0x81] = 0x45 + $image[0x86] = 1 + $image[0x94] = 0xF0 + $image[0x96] = 0x22 + $image[0x98] = 0x0B + $image[0x99] = 0x02 + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x9C) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xA8) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xAC) + [BitConverter]::GetBytes([uint64]0x140000000).CopyTo($image, 0xB0) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xB8) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0xBC) + $image[0xC0] = 6 + $image[0xC8] = 6 + [BitConverter]::GetBytes([uint32]8192).CopyTo($image, 0xD0) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0xD4) + $image[0xDC] = 3 + [BitConverter]::GetBytes([uint64]1MB).CopyTo($image, 0xE0) + [BitConverter]::GetBytes([uint64]4096).CopyTo($image, 0xE8) + [BitConverter]::GetBytes([uint64]1MB).CopyTo($image, 0xF0) + [BitConverter]::GetBytes([uint64]4096).CopyTo($image, 0xF8) + $image[0x104] = 16 + [Text.Encoding]::ASCII.GetBytes(".text").CopyTo($image, 0x188) + [BitConverter]::GetBytes([uint32]4).CopyTo($image, 0x190) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0x194) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x198) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x19C) + [BitConverter]::GetBytes([uint32]0x60000020).CopyTo($image, 0x1AC) + $machine = if ($architecture -eq "arm64") { [uint16]0xAA64 } else { [uint16]0x8664 } + [BitConverter]::GetBytes($machine).CopyTo($image, 0x84) + foreach ($name in @("Devolutions.Terminal.exe", "dt.exe", "ghostty-vt.dll")) { + [IO.File]::WriteAllBytes((Join-Path $layout $name), $image) + } + foreach ($name in @("Devolutions.Terminal.ShellExt.dll", "dt-shell-integration.exe")) { + [IO.File]::WriteAllBytes((Join-Path $native $name), $image) + } + [IO.File]::WriteAllText((Join-Path $layout "THIRD-PARTY-NOTICES-GHOSTTY.txt"), "Fixture license") + } +} + +try { + foreach ($case in @( + @{ Name = "Development"; Publisher = "CN=Devolutions Inc."; Override = $false }, + @{ Name = "Production"; Publisher = $productionPublisher; Override = $true }, + @{ Name = "XmlEscaping"; Publisher = "CN=Fixture & Company"; Override = $true } + )) { + $output = Join-Path $work $case.Name + New-PackageFixture -OutputDirectory $output + $buildArguments = @{ + OutputDirectory = $output + SkipPublish = $true + SkipNativeBuild = $true + Version = "2026.3.0.0" + } + if ($case.Override) { + $buildArguments.Publisher = $case.Publisher + } + $packages = @(& (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments) + if ($packages.Count -ne 2) { + throw "$($case.Name) build did not produce both architecture packages." + } + $generatedManifest = Join-Path $output "metadata\Package.appxmanifest" + [xml] $manifest = Get-Content -LiteralPath $generatedManifest -Raw -Encoding utf8 + if ($manifest.Package.Identity.Publisher -cne $case.Publisher -or + $manifest.Package.Identity.Version -cne "2026.3.0.0") { + throw "$($case.Name) build did not write the exact publisher and version." + } + $testArguments = @{ PackagePath = @($packages.FullName) } + if ($case.Override) { + $testArguments.ExpectedPublisher = $case.Publisher + } + $results = @(& (Join-Path $PSScriptRoot "Test-Packages.ps1") @testArguments) + if ($results.Count -ne 2 -or + ($results.Architecture | Sort-Object) -join "|" -cne "arm64|x64" -or + @($results | Where-Object { $_.Version -cne "2026.3.0.0" -or $_.Signed }).Count -ne 0) { + throw "$($case.Name) packages did not pass exact unsigned architecture/version validation." + } + Write-Host "Build-Packages_$($case.Name)PublisherRoundTripsBothArchitectures passed." + + if ($case.Override) { + Assert-ExpectedFailure -Name "Test-Packages_$($case.Name)RejectsDevelopmentPublisher" ` + -MessagePattern "Unexpected package publisher" -Action { + & (Join-Path $PSScriptRoot "Test-Packages.ps1") -PackagePath $packages[0].FullName + } + } + if ($case.Name -eq "XmlEscaping") { + continue + } + + $certificateDirectory = Join-Path $output "certificates" + $certificatePath = Join-Path $certificateDirectory "Devolutions.Terminal.pfx" + if ($case.Name -eq "Development") { + Copy-Item -LiteralPath $packages[0].FullName -Destination $wrongPublisherPackage + & (Join-Path $PSScriptRoot "New-DevelopmentCertificate.ps1") ` + -OutputDirectory $certificateDirectory -Password $password | Out-Null + } + else { + New-Item -ItemType Directory -Path $certificateDirectory | Out-Null + winapp cert generate --manifest $generatedManifest --output $certificatePath ` + --password "ephemeral-fixture-password" --valid-days 1 --quiet + if ($LASTEXITCODE -ne 0) { + throw "Production-subject fixture certificate generation failed." + } + $mismatchOutput = & winapp sign $wrongPublisherPackage $certificatePath ` + --password "ephemeral-fixture-password" --quiet 2>&1 + if ($LASTEXITCODE -eq 0 -or ($mismatchOutput -join "`n") -notmatch '0x8007000B') { + throw "Native signing did not reject the development publisher with the production-subject fixture certificate: $mismatchOutput" + } + Write-Host "Build-Packages_OldPublisherReproducesNativeSigningFailure passed." + } + $buildArguments.CertificatePath = $certificatePath + $buildArguments.CertificatePassword = $password + $signedPackages = @(& (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments) + $testArguments.PackagePath = @($signedPackages.FullName) + $testArguments.RequireSignature = $true + $testArguments.AllowUntrustedRoot = $true + $signedResults = @(& (Join-Path $PSScriptRoot "Test-Packages.ps1") @testArguments) + if ($signedResults.Count -ne 2 -or @($signedResults | Where-Object { -not $_.Signed }).Count -ne 0) { + throw "$($case.Name) packages did not pass local signature and publisher validation." + } + Write-Host "Build-Packages_$($case.Name)LocalCertificateMatchesPublisher passed." + } + + foreach ($invalid in @("", " ", "CN=Fixture`nO=Company")) { + Assert-ExpectedFailure -Name "Build-Packages_RejectsInvalidPublisher" -MessagePattern "Publisher" -Action { + & (Join-Path $PSScriptRoot "Build-Packages.ps1") -Publisher $invalid ` + -OutputDirectory (Join-Path $work "invalid") -SkipPublish -SkipNativeBuild + } + } + if ((Get-FileHash -LiteralPath $sourceManifest).Hash -cne $sourceHash) { + throw "Package build tests changed the checked-in development manifest." + } + Write-Host "Build-Packages_SourceDevelopmentIdentityUnchanged passed." +} +finally { + if (Test-Path -LiteralPath $work) { + Remove-Item -LiteralPath $work -Recurse -Force + } +} diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 index 250390f..f7849bc 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 @@ -11,6 +11,10 @@ param( # Useful right after binaries are signed but before the final MSI container is signed. [switch] $RequireBinarySignature, + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $ExpectedPublisher = "CN=Devolutions Inc.", + [switch] $AllowUntrustedRoot ) @@ -88,7 +92,7 @@ begin { Assert-Condition ($null -ne $signature.SignerCertificate) "No signature was found on '$Path'." if ($RequirePublisherSubject) { Assert-Condition ( - $signature.SignerCertificate.Subject -eq "CN=Devolutions Inc." + $signature.SignerCertificate.Subject -ceq $ExpectedPublisher ) "The signer for '$Path' does not match the package publisher." } @@ -140,7 +144,7 @@ begin { $identity = $manifest.SelectSingleNode("/f:Package/f:Identity", $namespace) Assert-Condition ($identity.Name -eq "Devolutions.Terminal") "Unexpected package identity name." - Assert-Condition ($identity.Publisher -eq "CN=Devolutions Inc.") "Unexpected package publisher." + Assert-Condition ($identity.Publisher -ceq $ExpectedPublisher) "Unexpected package publisher '$($identity.Publisher)'; expected '$ExpectedPublisher'." Assert-Condition ($identity.ProcessorArchitecture -in @("x64", "arm64")) "Unexpected package architecture '$($identity.ProcessorArchitecture)'." $expectedMachine = if ($identity.ProcessorArchitecture -eq "arm64") { 0xAA64 } else { 0x8664 } $ghosttyMachine = Get-PeMachine $ghosttyPath From 0624fe58266b306c381e04f9cacb6aa68fec2f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 10:32:23 -0400 Subject: [PATCH 2/2] fix: count MSIX artifacts independently of tool output Keep WinApp setup and status messages visible without treating them as package results. Discover the unsigned and signed fixture MSIX files from the build output directory so fresh CI runners retain the exact artifact-count assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Scripts/Test-PackageBuild.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 index fea20e1..454cb7b 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 @@ -110,7 +110,9 @@ try { if ($case.Override) { $buildArguments.Publisher = $case.Publisher } - $packages = @(& (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments) + & (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments | Out-Host + # First-run WinApp setup can write status messages to the success stream. + $packages = @(Get-ChildItem -LiteralPath (Join-Path $output "packages") -File -Filter "*.msix") if ($packages.Count -ne 2) { throw "$($case.Name) build did not produce both architecture packages." } @@ -165,7 +167,8 @@ try { } $buildArguments.CertificatePath = $certificatePath $buildArguments.CertificatePassword = $password - $signedPackages = @(& (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments) + & (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments | Out-Host + $signedPackages = @(Get-ChildItem -LiteralPath (Join-Path $output "packages") -File -Filter "*.msix") $testArguments.PackagePath = @($signedPackages.FullName) $testArguments.RequireSignature = $true $testArguments.AllowUntrustedRoot = $true