diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml
index 792b6e3..1d8956c 100644
--- a/.github/workflows/build-terminal.yml
+++ b/.github/workflows/build-terminal.yml
@@ -4,11 +4,14 @@ on:
push:
branches:
- "**"
+ tags:
+ - "v*"
paths-ignore:
- ".github/workflows/build-ghostty.yml"
pull_request:
paths-ignore:
- ".github/workflows/build-ghostty.yml"
+ workflow_dispatch:
permissions:
contents: read
@@ -385,7 +388,7 @@ jobs:
name: DevolutionsTerminal-win-arm64
path: artifacts/msix/layout/win-arm64
- - name: Build unsigned MSIX packages and bundle
+ - name: Build unsigned MSIX packages
shell: pwsh
run: >
./src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1
@@ -398,7 +401,7 @@ jobs:
run: >
./src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
-PackagePath (Get-ChildItem ./artifacts/msix/packages -File |
- Where-Object Extension -In ".msix", ".msixbundle" |
+ Where-Object Extension -eq ".msix" |
ForEach-Object FullName)
- name: Upload unsigned MSIX artifacts
@@ -407,3 +410,197 @@ jobs:
name: DevolutionsTerminal-MSIX-unsigned
path: artifacts/msix/packages
if-no-files-found: error
+
+ msi:
+ name: MSI packages
+ needs: native-aot
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Download x64 publish
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-win-x64
+ path: artifacts/msi/layout/win-x64
+
+ - name: Download arm64 publish
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-win-arm64
+ path: artifacts/msi/layout/win-arm64
+
+ - name: Build MSI packages
+ shell: pwsh
+ run: >
+ ./src/Devolutions.Terminal.Package/Scripts/Build-Msi.ps1
+ -SkipPublish
+ -OutputDirectory ./artifacts/msi
+ -Version "0.1.${{ github.run_number }}.0"
+
+ - name: Upload unsigned MSI artifacts
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: DevolutionsTerminal-MSI-unsigned
+ path: artifacts/msi/packages
+ if-no-files-found: error
+
+ release:
+ name: Publish GitHub release
+ if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
+ needs:
+ - build
+ - native-aot
+ - linux-managed
+ - macos-managed
+ - macos-native-aot
+ - linux-packages
+ - linux-arm64-hardware
+ - msix
+ - msi
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ env:
+ RELEASE_VERSION: 0.1.${{ github.run_number }}
+ MSIX_VERSION: 0.1.${{ github.run_number }}.0
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Download Windows unsigned MSIX
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-MSIX-unsigned
+ path: artifacts/msix-packages
+
+ - name: Download Windows unsigned MSI
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-MSI-unsigned
+ path: artifacts/msi-packages
+
+ - name: Download Linux x64 packages
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-linux-x64-packages
+ path: artifacts/linux-x64
+
+ - name: Download Linux arm64 packages
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-linux-arm64-packages
+ path: artifacts/linux-arm64
+
+ - name: Download macOS package artifacts
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: DevolutionsTerminal-osx-arm64-packages
+ path: artifacts/macos-packages
+
+ - name: Install Linux psign-tool
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $toolRoot = Join-Path $env:RUNNER_TEMP "psign-tool"
+ $extractRoot = Join-Path $toolRoot "expanded"
+ if (Test-Path -LiteralPath $toolRoot) {
+ Remove-Item -LiteralPath $toolRoot -Recurse -Force
+ }
+
+ New-Item -Path $extractRoot -ItemType Directory -Force | Out-Null
+ gh release download --repo Devolutions/psign --pattern "psign-tool-linux-x64.zip" --dir $toolRoot --clobber
+
+ $toolArchivePath = Join-Path $toolRoot "psign-tool-linux-x64.zip"
+ if (-not (Test-Path -LiteralPath $toolArchivePath -PathType Leaf)) {
+ throw "psign-tool archive was not found at $toolArchivePath"
+ }
+
+ Expand-Archive -Path $toolArchivePath -DestinationPath $extractRoot -Force
+ $toolPath = Join-Path $extractRoot "psign-tool"
+ if (-not (Test-Path -LiteralPath $toolPath -PathType Leaf)) {
+ throw "psign-tool executable was not found at $toolPath"
+ }
+
+ chmod +x $toolPath
+ $extractRoot | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
+ & $toolPath --version
+
+ - name: Sign Windows packages with Azure Artifact Signing
+ shell: pwsh
+ env:
+ ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
+ ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
+ ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }}
+ CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }}
+ CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}
+ run: |
+ $required = @(
+ "ARTIFACT_SIGNING_ENDPOINT",
+ "ARTIFACT_SIGNING_ACCOUNT_NAME",
+ "ARTIFACT_SIGNING_PROFILE_NAME",
+ "AZURE_TENANT_ID",
+ "CODE_SIGNING_CLIENT_ID",
+ "CODE_SIGNING_CLIENT_SECRET"
+ )
+ $missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) })
+ if ($missing.Count -gt 0) {
+ throw "Missing Azure Artifact Signing secrets: $($missing -join ', ')"
+ }
+
+ $timestampServer = $env:CODE_SIGNING_TIMESTAMP_SERVER
+ if ([string]::IsNullOrWhiteSpace($timestampServer)) {
+ $timestampServer = "http://timestamp.acs.microsoft.com/"
+ }
+
+ ./src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1 `
+ -PackageDirectory ./artifacts/msix-packages `
+ -Version $env:MSIX_VERSION `
+ -ArtifactSigningEndpoint $env:ARTIFACT_SIGNING_ENDPOINT `
+ -ArtifactSigningAccountName $env:ARTIFACT_SIGNING_ACCOUNT_NAME `
+ -ArtifactSigningProfileName $env:ARTIFACT_SIGNING_PROFILE_NAME `
+ -AzureTenantId $env:AZURE_TENANT_ID `
+ -ClientId $env:CODE_SIGNING_CLIENT_ID `
+ -ClientSecret $env:CODE_SIGNING_CLIENT_SECRET `
+ -TimestampServer $timestampServer
+
+ ./src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1 `
+ -PackageDirectory ./artifacts/msi-packages `
+ -Version $env:MSIX_VERSION `
+ -ArtifactSigningEndpoint $env:ARTIFACT_SIGNING_ENDPOINT `
+ -ArtifactSigningAccountName $env:ARTIFACT_SIGNING_ACCOUNT_NAME `
+ -ArtifactSigningProfileName $env:ARTIFACT_SIGNING_PROFILE_NAME `
+ -AzureTenantId $env:AZURE_TENANT_ID `
+ -ClientId $env:CODE_SIGNING_CLIENT_ID `
+ -ClientSecret $env:CODE_SIGNING_CLIENT_SECRET `
+ -TimestampServer $timestampServer
+
+ - name: Stage release assets
+ shell: bash
+ run: |
+ mkdir -p artifacts/release
+ cp -f artifacts/msix-packages/* artifacts/release/
+ cp -f artifacts/msi-packages/* artifacts/release/
+ cp -f artifacts/linux-x64/* artifacts/release/
+ cp -f artifacts/linux-arm64/* artifacts/release/
+ cp -f artifacts/macos-packages/* artifacts/release/
+ ls -1 artifacts/release
+
+ - name: Publish release to GitHub Releases
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ gh release view "v${RELEASE_VERSION}" >/dev/null 2>&1 || \
+ gh release create "v${RELEASE_VERSION}" \
+ --title "Devolutions Terminal ${RELEASE_VERSION}" \
+ --generate-notes \
+ --target "${{ github.sha }}"
+ gh release upload "v${RELEASE_VERSION}" artifacts/release/* --clobber
+
+
+
diff --git a/.gitignore b/.gitignore
index 64d7388..ed49128 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ TestResults/
*.msixbundle
*.appx
*.appxbundle
+src/Devolutions.Terminal.Installer/GeneratedProductComponents.wxs
## Local secrets / certs
*.pfx
diff --git a/docs/release.md b/docs/release.md
index df9d25d..3ae93aa 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -163,12 +163,11 @@ whitespace while retaining unknown/local-layer data. Runtime state is stored in
## MSIX
-Create unsigned x64/ARM64 packages and a bundle:
+Create unsigned x64 and ARM64 packages:
```powershell
.\src\Devolutions.Terminal.Package\Scripts\Build-Packages.ps1 -Version 0.1.0.0
-$packages = Get-ChildItem .\artifacts\msix\packages\*.msix,
- .\artifacts\msix\packages\*.msixbundle
+$packages = Get-ChildItem .\artifacts\msix\packages\*.msix
.\src\Devolutions.Terminal.Package\Scripts\Test-Packages.ps1 `
-PackagePath $packages.FullName
```
@@ -178,26 +177,67 @@ Development signing:
```powershell
$password = Read-Host "Certificate password" -AsSecureString
.\src\Devolutions.Terminal.Package\Scripts\New-DevelopmentCertificate.ps1 `
- -OutputDirectory .\artifacts\msix\certificates -Password $password
+ -OutputDirectory .\artifacts\msix\certificates
.\src\Devolutions.Terminal.Package\Scripts\Sign-Packages.ps1 `
-PackageDirectory .\artifacts\msix\packages `
-CertificatePath .\artifacts\msix\certificates\Devolutions.Terminal.pfx `
- -Password $password -Version 0.1.0.0
+ -Version 0.1.0.0
```
+## MSI
+
+Build a WiX-based MSI package for the same published Windows outputs:
+
+```powershell
+.\src\Devolutions.Terminal.Package\Scripts\Build-Msi.ps1 `
+ -Architectures x64,arm64 `
+ -Version 0.1.0.0 `
+ -OutputDirectory .\artifacts\msi
+```
+
+The MSI project is in `src/Devolutions.Terminal.Installer` and uses a fixed
+`UpgradeCode` with per-machine install scope under `ProgramFiles6432Folder`.
+
Never commit PFX files, passwords, certificate private keys, or signed internal
artifacts. CI produces unsigned packages unless a protected release environment
injects signing credentials.
+## 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.
+
+Required secrets:
+
+- `ARTIFACT_SIGNING_ENDPOINT`
+- `ARTIFACT_SIGNING_ACCOUNT_NAME`
+- `ARTIFACT_SIGNING_PROFILE_NAME`
+- `AZURE_TENANT_ID`
+- `CODE_SIGNING_CLIENT_ID`
+- `CODE_SIGNING_CLIENT_SECRET`
+
+Optional repository variable:
+
+- `CODE_SIGNING_TIMESTAMP_SERVER` (defaults to `http://timestamp.acs.microsoft.com/`)
+
+`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.
+
## Release gates
1. Regenerate `compat/windows-terminal.json` and review inventory changes.
2. Run the full Release solution tests with no failures or unconditional skips.
3. Publish and launch-smoke NativeAOT x64.
4. Cross-publish NativeAOT ARM64.
-5. Build and structurally validate both MSIX packages and the bundle, including
- shell-helper PE architecture, SHA-256 manifests, COM/Explorer extensions,
- and notices.
+5. Build and structurally validate both MSIX packages, including shell-helper
+ PE architecture, SHA-256 manifests, COM/Explorer extensions, and notices.
6. Sign and verify package publisher/identity/version in a protected environment.
7. Install, launch `Devolutions.Terminal.exe`, invoke `dt.exe`, upgrade, and uninstall
on clean x64 and ARM64 Windows VMs.
diff --git a/src/Devolutions.Terminal.Installer/Devolutions.Terminal.Installer.wixproj b/src/Devolutions.Terminal.Installer/Devolutions.Terminal.Installer.wixproj
new file mode 100644
index 0000000..477186f
--- /dev/null
+++ b/src/Devolutions.Terminal.Installer/Devolutions.Terminal.Installer.wixproj
@@ -0,0 +1,12 @@
+
+
+ Package
+ Devolutions.Terminal
+ x64
+ 0.1.0.0
+ ProductVersion=$(ProductVersion)
+ Devolutions.Terminal_$(ProductVersion)_$(Platform)
+ bin\$(Platform)\$(Configuration)\
+ obj\$(Platform)\$(Configuration)\
+
+
diff --git a/src/Devolutions.Terminal.Installer/Package.wxs b/src/Devolutions.Terminal.Installer/Package.wxs
new file mode 100644
index 0000000..a18cebf
--- /dev/null
+++ b/src/Devolutions.Terminal.Installer/Package.wxs
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Devolutions.Terminal.Package/README.md b/src/Devolutions.Terminal.Package/README.md
index 07b6059..7e700a8 100644
--- a/src/Devolutions.Terminal.Package/README.md
+++ b/src/Devolutions.Terminal.Package/README.md
@@ -1,7 +1,7 @@
# Devolutions Terminal MSIX packaging
This project owns the development package identity and the scripts that turn the
-`win-x64` and `win-arm64` NativeAOT publishes into MSIX packages and a bundle.
+`win-x64` and `win-arm64` NativeAOT publishes into per-architecture MSIX packages.
Direct `dotnet run` and `dotnet publish` remain unpackaged and do not require
registration.
@@ -36,8 +36,7 @@ Install [winapp CLI](https://learn.microsoft.com/windows/apps/dev-tools/winapp-c
```powershell
.\src\Devolutions.Terminal.Package\Scripts\Build-Packages.ps1
.\src\Devolutions.Terminal.Package\Scripts\Test-Packages.ps1 `
- -PackagePath .\artifacts\msix\packages\*.msix, `
- .\artifacts\msix\packages\*.msixbundle
+ -PackagePath .\artifacts\msix\packages\*.msix
```
Unsigned packages are the default so CI can publish artifacts for a trusted
@@ -60,7 +59,6 @@ $password = Read-Host "Certificate password" -AsSecureString
.\src\Devolutions.Terminal.Package\Scripts\New-DevelopmentCertificate.ps1 `
-Password $password
-# Rebuild the bundle from the newly signed architecture packages, then sign it.
.\src\Devolutions.Terminal.Package\Scripts\Sign-Packages.ps1 `
-PackageDirectory .\artifacts\msix\packages `
-CertificatePath .\artifacts\msix\certificates\Devolutions.Terminal.pfx `
@@ -79,9 +77,9 @@ terminal:
Install, launch, validate, and uninstall:
```powershell
-$bundle = ".\artifacts\msix\packages\Devolutions.Terminal_0.1.0.0_x64_arm64.msixbundle"
-.\src\Devolutions.Terminal.Package\Scripts\Test-Packages.ps1 -PackagePath $bundle -RequireSignature
-.\src\Devolutions.Terminal.Package\Scripts\Install-Package.ps1 -PackagePath $bundle -Launch
+$package = ".\artifacts\msix\packages\Devolutions.Terminal_0.1.0.0_x64.msix"
+.\src\Devolutions.Terminal.Package\Scripts\Test-Packages.ps1 -PackagePath $package -RequireSignature
+.\src\Devolutions.Terminal.Package\Scripts\Install-Package.ps1 -PackagePath $package -Launch
dt.exe
Start-Process "dterm:"
.\src\Devolutions.Terminal.Package\Scripts\Uninstall-Package.ps1
diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-Msi.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-Msi.ps1
new file mode 100644
index 0000000..883c51a
--- /dev/null
+++ b/src/Devolutions.Terminal.Package/Scripts/Build-Msi.ps1
@@ -0,0 +1,104 @@
+[CmdletBinding()]
+param(
+ [ValidateSet("x64", "arm64")]
+ [string[]] $Architectures = @("x64", "arm64"),
+
+ [ValidatePattern("^\d{1,5}\.\d{1,5}\.\d{1,5}\.\d{1,5}$")]
+ [string] $Version = "0.1.0.0",
+
+ [ValidateSet("Debug", "Release")]
+ [string] $Configuration = "Release",
+
+ [string] $OutputDirectory,
+
+ [switch] $SkipPublish
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$packageRoot = Split-Path -Parent $PSScriptRoot
+$dotnetRoot = [IO.Path]::GetFullPath((Join-Path $packageRoot "..\.."))
+$hostProject = Join-Path $dotnetRoot "src\Devolutions.Terminal\Devolutions.Terminal.csproj"
+$installerProject = Join-Path $dotnetRoot "src\Devolutions.Terminal.Installer\Devolutions.Terminal.Installer.wixproj"
+
+if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
+ $OutputDirectory = Join-Path $dotnetRoot "artifacts\msi"
+}
+
+$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
+$layoutRoot = Join-Path $OutputDirectory "layout"
+$packageOutput = Join-Path $OutputDirectory "packages"
+New-Item -ItemType Directory -Force -Path $layoutRoot, $packageOutput | Out-Null
+
+function Invoke-Checked {
+ param(
+ [Parameter(Mandatory)]
+ [string] $FilePath,
+
+ [Parameter(ValueFromRemainingArguments)]
+ [string[]] $ArgumentList
+ )
+
+ & $FilePath @ArgumentList
+ if ($LASTEXITCODE -ne 0) {
+ throw "'$FilePath' failed with exit code $LASTEXITCODE."
+ }
+}
+
+foreach ($architecture in $Architectures) {
+ $runtimeIdentifier = "win-$architecture"
+ $layout = Join-Path $layoutRoot $runtimeIdentifier
+ if (-not $SkipPublish) {
+ if (Test-Path -LiteralPath $layout) {
+ Remove-Item -Recurse -Force -LiteralPath $layout
+ }
+
+ New-Item -ItemType Directory -Force -Path $layout | Out-Null
+ Invoke-Checked -FilePath dotnet -ArgumentList @(
+ "publish",
+ $hostProject,
+ "-c", $Configuration,
+ "-r", $runtimeIdentifier,
+ "--self-contained",
+ "-o", $layout
+ )
+ }
+ elseif (-not (Test-Path -LiteralPath (Join-Path $layout "Devolutions.Terminal.exe"))) {
+ throw "Published output for '$runtimeIdentifier' was not found at '$layout'."
+ }
+
+ $buildDirectory = [IO.Path]::GetFullPath((Join-Path $packageOutput $architecture))
+ New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null
+
+ $generatedComponents = Join-Path $dotnetRoot "src\Devolutions.Terminal.Installer\GeneratedProductComponents.wxs"
+ & (Join-Path $PSScriptRoot "Write-MsiComponents.ps1") -PublishDir $layout -OutputFile $generatedComponents
+ if (-not (Test-Path -LiteralPath $generatedComponents -PathType Leaf)) {
+ throw "MSI component list was not written to '$generatedComponents'."
+ }
+
+ $platform = if ($architecture -eq "arm64") { "ARM64" } else { "x64" }
+ Invoke-Checked -FilePath dotnet -ArgumentList @(
+ "build",
+ $installerProject,
+ "-c", $Configuration,
+ "-p:Platform=$platform",
+ "-p:ProductVersion=$Version",
+ "-p:OutputPath=$buildDirectory\\",
+ "-p:OutputName=Devolutions.Terminal_${Version}_${architecture}"
+ )
+
+ $generatedMsi = Join-Path $buildDirectory "Devolutions.Terminal_${Version}_${architecture}.msi"
+ if (-not (Test-Path -LiteralPath $generatedMsi -PathType Leaf)) {
+ $generatedMsi = (Get-ChildItem -LiteralPath $buildDirectory -Filter "*.msi" -File | Sort-Object Name | Select-Object -First 1).FullName
+ }
+
+ if (-not (Test-Path -LiteralPath $generatedMsi -PathType Leaf)) {
+ throw "No MSI artifact was created for '$runtimeIdentifier'."
+ }
+
+ Copy-Item -Force -LiteralPath $generatedMsi -Destination (Join-Path $packageOutput ([IO.Path]::GetFileName($generatedMsi)))
+}
+
+Get-ChildItem -LiteralPath $packageOutput -File -Filter "*.msi" |
+ Sort-Object Name
diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1
index 7d3173c..0d8ca8a 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1
@@ -15,8 +15,6 @@ param(
[switch] $SkipNativeBuild,
- [switch] $SkipBundle,
-
[string] $CertificatePath,
[securestring] $CertificatePassword
@@ -118,7 +116,6 @@ if (-not $SkipNativeBuild) {
$plainTextPassword = Get-PlainText $CertificatePassword
try {
- $layouts = @()
foreach ($architecture in $Architectures) {
$runtimeIdentifier = "win-$architecture"
$layout = Join-Path $layoutRoot $runtimeIdentifier
@@ -201,26 +198,6 @@ try {
}
}
- Invoke-Checked -FilePath winapp -ArgumentList $arguments
- $layouts += $layout
- }
-
- if (-not $SkipBundle -and $layouts.Count -gt 1) {
- $architectureLabel = $Architectures -join "_"
- $bundlePath = Join-Path $packageOutput "Devolutions.Terminal_${Version}_${architectureLabel}.msixbundle"
- $arguments = @("package") + $layouts + @(
- "--manifest", $versionedManifest,
- "--output", $bundlePath,
- "--skip-pri",
- "--quiet"
- )
- if (-not [string]::IsNullOrWhiteSpace($CertificatePath)) {
- $arguments += @("--cert", [IO.Path]::GetFullPath($CertificatePath))
- if ($null -ne $plainTextPassword) {
- $arguments += @("--cert-password", $plainTextPassword)
- }
- }
-
Invoke-Checked -FilePath winapp -ArgumentList $arguments
}
}
@@ -229,5 +206,5 @@ finally {
}
Get-ChildItem -LiteralPath $packageOutput -File |
- Where-Object Extension -In ".msix", ".msixbundle" |
+ Where-Object Extension -eq ".msix" |
Sort-Object Name
diff --git a/src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1
index 425b822..db9b59e 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1
@@ -1,82 +1,214 @@
-[CmdletBinding()]
+[CmdletBinding(DefaultParameterSetName = "ArtifactSigning")]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
[string] $PackageDirectory,
[Parameter(Mandatory)]
+ [ValidatePattern("^\d{1,5}\.\d{1,5}\.\d{1,5}\.\d{1,5}$")]
+ [string] $Version,
+
+ [Parameter(ParameterSetName = "Certificate", Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string] $CertificatePath,
- [Parameter(Mandatory)]
- [ValidatePattern("^\d{1,5}\.\d{1,5}\.\d{1,5}\.\d{1,5}$")]
- [string] $Version,
+ [Parameter(ParameterSetName = "Certificate")]
+ [securestring] $Password,
+
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $ArtifactSigningEndpoint,
+
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $ArtifactSigningAccountName,
+
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $ArtifactSigningProfileName,
- [securestring] $Password
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $AzureTenantId,
+
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $ClientId,
+
+ [Parameter(ParameterSetName = "ArtifactSigning", Mandatory)]
+ [string] $ClientSecret,
+
+ [Parameter(ParameterSetName = "ArtifactSigning")]
+ [string] $TimestampServer = "http://timestamp.acs.microsoft.com/",
+
+ [string] $PsignTool
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
-if ($null -eq $Password) {
- $Password = Read-Host "Signing certificate password" -AsSecureString
+$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory)
+$useArtifactSigning = $PSCmdlet.ParameterSetName -eq "ArtifactSigning"
+
+function Get-ExpectedPackages {
+ param(
+ [string[]] $Names
+ )
+
+ $matches = @()
+ foreach ($name in $Names) {
+ $path = Join-Path $PackageDirectory $name
+ if (Test-Path -LiteralPath $path -PathType Leaf) {
+ $matches += Get-Item -LiteralPath $path
+ }
+ }
+
+ return $matches
}
-$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory)
-$CertificatePath = [IO.Path]::GetFullPath($CertificatePath)
-$expectedPackageNames = @(
- "Devolutions.Terminal_${Version}_x64.msix",
- "Devolutions.Terminal_${Version}_arm64.msix"
-)
-$packages = @(
- foreach ($packageName in $expectedPackageNames) {
- $packagePath = Join-Path $PackageDirectory $packageName
- if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) {
- throw "Expected MSIX package '$packagePath' was not found."
+function Resolve-PsignTool {
+ param(
+ [string] $PreferredPath
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($PreferredPath)) {
+ if (-not (Test-Path -LiteralPath $PreferredPath -PathType Leaf)) {
+ throw "psign-tool was not found at '$PreferredPath'."
}
- Get-Item -LiteralPath $packagePath
+ return (Get-Item -LiteralPath $PreferredPath).FullName
+ }
+
+ $command = Get-Command psign-tool -ErrorAction SilentlyContinue
+ if ($null -eq $command) {
+ throw "psign-tool is required for Azure Artifact Signing. Install Devolutions.Psign.Tool or download psign-tool from Devolutions/psign."
}
-)
-if ($packages.Count -ne $expectedPackageNames.Count) {
- throw "Signing requires the x64 and arm64 MSIX packages for version '$Version'."
+ return $command.Source
}
-$pointer = [Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Password)
-try {
- $plainTextPassword = [Runtime.InteropServices.Marshal]::PtrToStringUni($pointer)
- foreach ($package in $packages) {
- & winapp sign $package.FullName $CertificatePath --password $plainTextPassword
+function Invoke-PsignArtifactSign {
+ param(
+ [string] $ToolPath,
+ [string[]] $Files
+ )
+
+ $Files = @($Files | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) })
+ if ($Files.Count -eq 0) {
+ return
+ }
+
+ $metadataPath = Join-Path ([IO.Path]::GetTempPath()) ("artifact-signing-" + [guid]::NewGuid().ToString("N") + ".json")
+ $fileListPath = Join-Path ([IO.Path]::GetTempPath()) ("psign-files-" + [guid]::NewGuid().ToString("N") + ".txt")
+ try {
+ $metadata = [ordered]@{
+ Endpoint = $ArtifactSigningEndpoint
+ CodeSigningAccountName = $ArtifactSigningAccountName
+ CertificateProfileName = $ArtifactSigningProfileName
+ }
+ $metadata | ConvertTo-Json -Compress | Set-Content -LiteralPath $metadataPath -Encoding utf8
+ Set-Content -LiteralPath $fileListPath -Value $Files -Encoding utf8
+
+ & $ToolPath --mode portable --verbose sign `
+ --dmdf $metadataPath `
+ --artifact-signing-tenant-id $AzureTenantId `
+ --artifact-signing-client-id $ClientId `
+ --artifact-signing-client-secret $ClientSecret `
+ --timestamp-url $TimestampServer `
+ --timestamp-digest sha256 `
+ --digest sha256 `
+ --input-file-list $fileListPath
if ($LASTEXITCODE -ne 0) {
- throw "Signing '$($package.Name)' failed with exit code $LASTEXITCODE."
+ throw "psign-tool Artifact Signing failed with exit code $LASTEXITCODE."
}
}
+ finally {
+ Remove-Item -LiteralPath $metadataPath, $fileListPath -ErrorAction SilentlyContinue
+ }
+}
- if ($packages.Count -gt 1) {
- $bundleInput = Join-Path $PackageDirectory (".bundle-input-" + [guid]::NewGuid())
- New-Item -ItemType Directory -Path $bundleInput | Out-Null
- try {
- Copy-Item -LiteralPath $packages.FullName -Destination $bundleInput
- $bundlePath = Join-Path $PackageDirectory "Devolutions.Terminal_${Version}_x64_arm64.msixbundle"
- & winapp tool makeappx bundle /d $bundleInput /p $bundlePath /bv $Version /o
- if ($LASTEXITCODE -ne 0) {
- throw "Rebuilding the signed MSIX bundle failed with exit code $LASTEXITCODE."
- }
-
- & winapp sign $bundlePath $CertificatePath --password $plainTextPassword
- if ($LASTEXITCODE -ne 0) {
- throw "Signing the MSIX bundle failed with exit code $LASTEXITCODE."
- }
+function Invoke-LocalSign {
+ param(
+ [System.IO.FileInfo] $Package,
+ [string] $PlainTextPassword
+ )
+
+ $extension = $Package.Extension.ToLowerInvariant()
+ if ($extension -eq ".msi") {
+ if (-not (Get-Command signtool -ErrorAction SilentlyContinue)) {
+ throw "signtool.exe is required to sign MSI packages with a local certificate."
}
- finally {
- if (Test-Path -LiteralPath $bundleInput) {
- Remove-Item -Recurse -Force -LiteralPath $bundleInput
- }
+
+ & signtool sign /fd SHA256 /td SHA256 /v /tr http://timestamp.digicert.com /n "Devolutions Inc." /a /f $CertificatePath /p $PlainTextPassword $Package.FullName
+ if ($LASTEXITCODE -ne 0) {
+ throw "Signing '$($Package.Name)' failed with exit code $LASTEXITCODE."
}
+
+ return
+ }
+
+ & winapp sign $Package.FullName $CertificatePath --password $PlainTextPassword
+ if ($LASTEXITCODE -ne 0) {
+ throw "Signing '$($Package.Name)' failed with exit code $LASTEXITCODE."
+ }
+}
+
+if ($useArtifactSigning) {
+ if ([string]::IsNullOrWhiteSpace($TimestampServer)) {
+ $TimestampServer = "http://timestamp.acs.microsoft.com/"
+ }
+
+ $PsignTool = Resolve-PsignTool -PreferredPath $PsignTool
+}
+else {
+ if ($null -eq $Password) {
+ $Password = Read-Host "Signing certificate password" -AsSecureString
+ }
+
+ $CertificatePath = [IO.Path]::GetFullPath($CertificatePath)
+ if (-not (Get-Command winapp -ErrorAction SilentlyContinue) -and -not (Get-Command signtool -ErrorAction SilentlyContinue)) {
+ throw "Local certificate signing requires either WinApp CLI or signtool on PATH."
+ }
+}
+
+$msixPackages = Get-ExpectedPackages -Names @(
+ "Devolutions.Terminal_${Version}_x64.msix",
+ "Devolutions.Terminal_${Version}_arm64.msix"
+)
+$msiPackages = Get-ExpectedPackages -Names @(
+ "Devolutions.Terminal_${Version}_x64.msi",
+ "Devolutions.Terminal_${Version}_arm64.msi"
+)
+
+if ($msixPackages.Count -eq 0 -and $msiPackages.Count -eq 0) {
+ throw "No release packages for version '$Version' were found in '$PackageDirectory'."
+}
+
+$pointer = $null
+$plainTextPassword = $null
+try {
+ if ($useArtifactSigning) {
+ $filesToSign = @()
+ if ($msixPackages.Count -gt 0) {
+ $filesToSign += @($msixPackages.FullName)
+ }
+ if ($msiPackages.Count -gt 0) {
+ $filesToSign += @($msiPackages.FullName)
+ }
+
+ Invoke-PsignArtifactSign -ToolPath $PsignTool -Files $filesToSign
+ return
+ }
+
+ $pointer = [Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Password)
+ $plainTextPassword = [Runtime.InteropServices.Marshal]::PtrToStringUni($pointer)
+
+ foreach ($package in $msixPackages) {
+ Invoke-LocalSign -Package $package -PlainTextPassword $plainTextPassword
+ }
+
+ foreach ($package in $msiPackages) {
+ Invoke-LocalSign -Package $package -PlainTextPassword $plainTextPassword
}
}
finally {
$plainTextPassword = $null
- [Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($pointer)
+ if ($null -ne $pointer) {
+ [Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($pointer)
+ }
}
diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
index 1243129..e53bca5 100644
--- a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
+++ b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1
@@ -203,31 +203,7 @@ begin {
process {
foreach ($path in $PackagePath) {
$resolvedPath = [IO.Path]::GetFullPath($path)
- if ([IO.Path]::GetExtension($resolvedPath) -eq ".msixbundle") {
- if ($RequireSignature) {
- Test-Signature $resolvedPath
- }
-
- $bundlePath = Join-Path ([IO.Path]::GetTempPath()) ("wt-msixbundle-" + [guid]::NewGuid())
- New-Item -ItemType Directory -Path $bundlePath | Out-Null
- try {
- Invoke-MakeAppx -Arguments @("unbundle", "/p", $resolvedPath, "/d", $bundlePath, "/o")
- $bundledPackages = @(Get-ChildItem -LiteralPath $bundlePath -Filter "*.msix" -File)
- Assert-Condition ($bundledPackages.Count -eq 2) "The bundle must contain exactly x64 and arm64 packages."
- $results = @($bundledPackages | ForEach-Object { Test-Msix $_.FullName })
- $architectures = @($results.Architecture | Sort-Object -Unique)
- Assert-Condition (
- $architectures.Count -eq 2 -and
- $architectures -contains "x64" -and
- $architectures -contains "arm64"
- ) "The bundle does not contain both x64 and arm64 packages."
- $results
- }
- finally {
- Remove-Item -Recurse -Force -LiteralPath $bundlePath
- }
- }
- elseif ([IO.Path]::GetExtension($resolvedPath) -eq ".msix") {
+ if ([IO.Path]::GetExtension($resolvedPath) -eq ".msix") {
Test-Msix $resolvedPath
}
else {
diff --git a/src/Devolutions.Terminal.Package/Scripts/Write-MsiComponents.ps1 b/src/Devolutions.Terminal.Package/Scripts/Write-MsiComponents.ps1
new file mode 100644
index 0000000..894126f
--- /dev/null
+++ b/src/Devolutions.Terminal.Package/Scripts/Write-MsiComponents.ps1
@@ -0,0 +1,145 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $PublishDir,
+
+ [Parameter(Mandatory = $true)]
+ [string] $OutputFile
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$publishRoot = [IO.Path]::GetFullPath($PublishDir)
+if (-not (Test-Path -LiteralPath $publishRoot -PathType Container)) {
+ throw "Publish directory '$publishRoot' does not exist."
+}
+
+$directoryIds = @{}
+$files = Get-ChildItem -LiteralPath $publishRoot -File -Recurse |
+ Where-Object { $_.Extension -ne ".pdb" } |
+ Sort-Object FullName
+
+function Get-ParentPath {
+ param([string] $Path)
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or $Path -eq '.') {
+ return ''
+ }
+
+ $normalized = $Path.Replace('\\', '/')
+ $index = $normalized.LastIndexOf('/')
+ if ($index -lt 0) {
+ return ''
+ }
+
+ return $normalized.Substring(0, $index)
+}
+
+function Get-RelativePath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $BasePath,
+
+ [Parameter(Mandatory = $true)]
+ [string] $TargetPath
+ )
+
+ $baseFull = [IO.Path]::GetFullPath($BasePath).TrimEnd('\')
+ $targetFull = [IO.Path]::GetFullPath($TargetPath)
+
+ if ($targetFull.StartsWith($baseFull, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $relative = $targetFull.Substring($baseFull.Length).TrimStart('\', '/')
+ if ([string]::IsNullOrWhiteSpace($relative)) {
+ return '.'
+ }
+
+ return $relative.Replace('\\', '/')
+ }
+
+ $baseUri = [Uri]::new(($baseFull + [IO.Path]::DirectorySeparatorChar))
+ $targetUri = [Uri]::new($targetFull)
+ $relative = [Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString())
+ return $relative.Replace('\\', '/')
+}
+
+foreach ($file in $files) {
+ $relativeDirectory = Get-RelativePath -BasePath $publishRoot -TargetPath ([IO.Path]::GetDirectoryName($file.FullName))
+ if ([string]::IsNullOrWhiteSpace($relativeDirectory) -or $relativeDirectory -eq '.' -or $relativeDirectory -eq './') {
+ continue
+ }
+
+ $path = $relativeDirectory
+ while (-not [string]::IsNullOrWhiteSpace($path)) {
+ if (-not $directoryIds.ContainsKey($path)) {
+ $directoryIds[$path] = 'DIR_' + (($path -replace '[^A-Za-z0-9_]', '_').Trim('_'))
+ }
+
+ $parent = Get-ParentPath $path
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $path) {
+ break
+ }
+
+ $path = $parent
+ }
+}
+
+function EmitDirectories {
+ param(
+ [string] $ParentPath,
+ [System.Text.StringBuilder] $Builder
+ )
+
+ $children = @($directoryIds.Keys | Where-Object {
+ $parent = Get-ParentPath $_
+ $parent -eq $ParentPath
+ } | Sort-Object)
+
+ foreach ($child in $children) {
+ $directoryId = $directoryIds[$child]
+ $directoryName = [IO.Path]::GetFileName($child)
+ [void]$Builder.AppendLine(" ")
+ EmitDirectories -ParentPath $child -Builder $Builder
+ [void]$Builder.AppendLine(" ")
+ }
+}
+
+$builder = [System.Text.StringBuilder]::new()
+[void]$builder.AppendLine('')
+[void]$builder.AppendLine('')
+[void]$builder.AppendLine(' ')
+[void]$builder.AppendLine(' ')
+EmitDirectories -ParentPath '' -Builder $builder
+[void]$builder.AppendLine(' ')
+[void]$builder.AppendLine(' ')
+
+foreach ($file in $files) {
+ $relativePath = Get-RelativePath -BasePath $publishRoot -TargetPath $file.FullName
+ $relativeDirectory = [IO.Path]::GetDirectoryName($relativePath)
+ if ([string]::IsNullOrWhiteSpace($relativeDirectory) -or $relativeDirectory -eq '.' -or $relativeDirectory -eq './') {
+ $directoryId = 'INSTALLLOCATION'
+ }
+ else {
+ $directoryId = $directoryIds[$relativeDirectory.Replace('\\', '/')]
+ }
+
+ $componentId = 'cmp_' + (($relativePath -replace '[^A-Za-z0-9_]', '_').Trim('_'))
+ if ([string]::IsNullOrWhiteSpace($componentId)) {
+ $componentId = 'cmp_' + [guid]::NewGuid().ToString('N')
+ }
+
+ [void]$builder.AppendLine(" ")
+ [void]$builder.AppendLine(" ")
+ [void]$builder.AppendLine(' ')
+}
+
+[void]$builder.AppendLine(' ')
+[void]$builder.AppendLine(' ')
+[void]$builder.AppendLine('')
+
+$directory = Split-Path -Parent $OutputFile
+if (-not [string]::IsNullOrWhiteSpace($directory)) {
+ New-Item -ItemType Directory -Force -Path $directory | Out-Null
+}
+
+[IO.File]::WriteAllText($OutputFile, $builder.ToString(), [Text.UTF8Encoding]::new($false))