Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 75 additions & 25 deletions .github/workflows/build-terminal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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" |
Expand All @@ -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:
Expand Down Expand Up @@ -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 `
Expand Down
3 changes: 3 additions & 0 deletions docs/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand Down
35 changes: 20 additions & 15 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion scripts/MacOsPackagingCommon.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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, `
Expand All @@ -298,4 +312,5 @@ Export-ModuleMember -Function `
Get-Sha256Manifest, `
Set-MacOsReproducibleTimestamps, `
Get-MacOsTreeByteSize, `
Get-MacOsWritableDmgSizeMegabytes
Get-MacOsWritableDmgSizeMegabytes, `
Move-MacOsLegalNotices
14 changes: 5 additions & 9 deletions scripts/Stage-MacOsApp.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 22 additions & 0 deletions scripts/Test-MacOsCodeSigning.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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.'
}
Expand Down
59 changes: 59 additions & 0 deletions scripts/Test-MacOsLegalNotices.ps1
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions scripts/Test-MacOsPackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading