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))