ci(release): ship a native Windows ARM64 Studio

Build windows/arm64 on a native windows-11-arm runner and carry it through the
signing chain as a second leg of every Windows stage: one tree per job, legs
serial under the certum-signing group, a failed leg reruns alone. The package
check learns app-arm64.7z and refuses any PE image that is not the leg's
architecture, a new install smoke runs each installer on its own architecture
before publish, and publish counts, names and hash-checks both architectures.

The arm64 installer is packed with the 7-Zip BCJ filter: the default ARM64
filter yields an archive the NSIS extractor cannot decode, and the installer
then exits 0 with every executable missing. Signing contract tests pin the
matrix, the serial legs and the secret-free stages.
This commit is contained in:
yhh committed 2026-10-07 02:02:09 -07:00
1 parent bbdcef877e
commit 0ae7693526
10 files changed
+621 -126

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# Installs a Windows installer where it is meant to run, silently and per user,
# checks what landed, and uninstalls. The package check unpacks with the
# runner's modern 7-Zip, which reads archives the NSIS extractor cannot, so only
# running the installer shows that it installs every executable. Holds no
# credentials: it runs on pull requests against the unsigned installer and in
# the release against the signed one.
param(
[Parameter(Mandatory = $true)]
[string]$Installer,
[Parameter(Mandatory = $true)]
[ValidateSet("amd64", "arm64")]
[string]$Architecture
)
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "windows-signing-lib.ps1")
$temp = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() }
$target = Join-Path $temp ("rx-install-" + [guid]::NewGuid().ToString("N"))
$env:REASONIX_HOME = Join-Path $temp "reasonix-home"
$run = Start-Process -FilePath (Resolve-Path -LiteralPath $Installer).Path -ArgumentList "/S", "/D=$target" -Wait -PassThru
if ($run.ExitCode -ne 0) { throw "the installer exited $($run.ExitCode)" }
# The installer can exit before its files are on disk (real-time scanning holds
# them), so the tree is read once the application is there and the file count
# has stopped moving.
function Wait-InstallSettled {
$clock = [Diagnostics.Stopwatch]::StartNew()
$last = -1
$stable = 0
while ($clock.Elapsed.TotalSeconds -lt 120) {
$count = @(Get-ChildItem -LiteralPath $target -Recurse -File -Force -ErrorAction SilentlyContinue).Count
if ((Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe")) -and $count -eq $last) { $stable++ } else { $stable = 0 }
if ($stable -ge 3) { break }
$last = $count
Start-Sleep -Seconds 2
}
Write-Host "install settled $([int]$clock.Elapsed.TotalSeconds)s after the installer exited, $last files"
}
Wait-InstallSettled
if (-not (Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe"))) {
Write-Host "target holds: $((Get-ChildItem -Force $target -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
throw "the installer reported success and installed nothing"
}
# Every executable the release declares is on disk, and nothing else that is a
# PE image besides what the installer writes itself.
$found = @(Get-PeFiles -Root $target | Where-Object { $script:InstallerWrittenX86Pe -cnotcontains $_ })
$declared = @($script:ReleaseSignedPe + $script:MicrosoftSignedPe)
$diff = @(Compare-Object -CaseSensitive ($declared | Sort-Object -CaseSensitive) ($found | Sort-Object -CaseSensitive))
if ($diff.Count -gt 0) {
foreach ($d in $diff) { Write-Host "::error title=studio-install.pe-set::$($d.InputObject) $($d.SideIndicator)" }
throw "the installed PE set differs from the declared one"
}
foreach ($owned in $script:InstallerWrittenX86Pe) {
if (-not (Test-Path -LiteralPath (Join-Path $target $owned))) { throw "the installer did not write $owned" }
}
Assert-PeArchitecture -Root $target -Architecture $Architecture
$env:ELECTRON_RUN_AS_NODE = "1"
$out = Join-Path $temp "shell-arch.txt"
$node = Start-Process -FilePath (Join-Path $target "Reasonix Studio.exe") -ArgumentList "-p", "process.arch" -RedirectStandardOutput $out -Wait -PassThru
Remove-Item Env:ELECTRON_RUN_AS_NODE
$want = if ($Architecture -eq "amd64") { "x64" } else { $Architecture }
$got = (Get-Content -Raw $out).Trim()
if ($node.ExitCode -ne 0 -or $got -ne $want) { throw "the installed shell reports '$got' (exit $($node.ExitCode)), want $want" }
& (Join-Path $target "resources/bin/reasonix-studio-host.exe") -instance-id
if ($LASTEXITCODE -ne 0) { throw "the installed kernel exited $LASTEXITCODE" }
$run = Start-Process -FilePath (Join-Path $target "Uninstall Reasonix Studio.exe") -ArgumentList "/S", "_?=$target" -Wait -PassThru
if ($run.ExitCode -ne 0) { throw "the uninstaller exited $($run.ExitCode)" }
if (Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe")) { throw "the uninstaller left the application behind" }
Write-Host "Installed, ran and removed the $Architecture installer."
+5
View File
@@ -11,6 +11,10 @@ param(
[Parameter(ParameterSetName = "Payload")]
[string]$DigestPath,
[Parameter(Mandatory = $true, ParameterSetName = "Payload")]
[ValidateSet("amd64", "arm64")]
[string]$Architecture,
[Parameter(Mandatory = $true, ParameterSetName = "File")]
[string]$FilePath,
@@ -33,6 +37,7 @@ if ($PSCmdlet.ParameterSetName -eq "File") {
$root = (Resolve-Path -LiteralPath $PayloadDirectory).Path
Assert-DeclaredPeSet -Root $root
Assert-PeArchitecture -Root $root -Architecture $Architecture
foreach ($name in $script:ReleaseSignedPe) {
Assert-EmbeddedSignature -Path (Join-Path $root $name) -Thumbprint $ExpectedThumbprint -Subject $ExpectedSubject
}
+13 -5
View File
@@ -16,7 +16,11 @@ param(
[string]$PortableArchivePath,
[Parameter(Mandatory = $true)]
[string]$OutputPath
[string]$OutputPath,
[Parameter(Mandatory = $true)]
[ValidateSet("amd64", "arm64")]
[string]$Architecture
)
$ErrorActionPreference = "Stop"
@@ -71,6 +75,7 @@ if ($payloadDigest -ne $ExpectedPayloadDigest) {
throw "Signed payload digest $payloadDigest does not match the $ExpectedPayloadDigest the signing job recorded"
}
Assert-DeclaredPeSet -Root $payloadRoot
Assert-PeArchitecture -Root $payloadRoot -Architecture $Architecture
# Hashed before either is opened, so the hashes name the bytes that were checked.
$installerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $InstallerPath).Hash.ToLowerInvariant()
@@ -83,16 +88,19 @@ try {
Assert-TreeMatchesPayload -Label "Portable archive" -Root $portableRoot -PayloadManifest $payloadManifest
# electron-builder's NSIS installer carries each architecture's application
# as an app-<arch>.7z among its plugins; this release builds x64 alone.
# as an app-<arch>.7z among its plugins; electron-builder names the x64 one
# app-64.7z. A package for one architecture carries that architecture's alone.
$installerRoot = Join-Path $extractRoot "installer"
Invoke-SevenZip -Archive $InstallerPath -Destination $installerRoot
$appArchives = @(Get-ChildItem -LiteralPath (Join-Path $installerRoot '$PLUGINSDIR') -Filter 'app-*.7z' -File -ErrorAction SilentlyContinue |
ForEach-Object Name)
if ($appArchives.Count -ne 1 -or $appArchives[0] -cne 'app-64.7z') {
throw "Installer must carry exactly one application archive, app-64.7z; found: $($appArchives -join ', ')"
$appArchive = @{ amd64 = 'app-64.7z'; arm64 = 'app-arm64.7z' }[$Architecture]
if ($appArchives.Count -ne 1 -or $appArchives[0] -cne $appArchive) {
throw "Installer must carry exactly one application archive, $appArchive; found: $($appArchives -join ', ')"
}
$installedRoot = Join-Path $extractRoot "installed"
Invoke-SevenZip -Archive (Join-Path $installerRoot '$PLUGINSDIR\app-64.7z') -Destination $installedRoot
Invoke-SevenZip -Archive (Join-Path $installerRoot "`$PLUGINSDIR\$appArchive") -Destination $installedRoot
Assert-PeArchitecture -Root $installedRoot -Architecture $Architecture
Assert-TreeMatchesPayload -Label "Installer" -Root $installedRoot -PayloadManifest $payloadManifest -Extra $script:InstallerOwnedFiles
}
finally {
+51
View File
@@ -32,6 +32,14 @@ $script:InstallerOwnedFiles = @{
"resources/elevate.exe" = "9b1fbf0c11c520ae714af8aa9af12cfd48503eedecd7398d8992ee94d1b4dc37"
}
# 32-bit images the installer writes into an installed tree on every
# architecture: the elevation helper and the uninstaller. They are the only
# images exempt from the architecture check, by exact path.
$script:InstallerWrittenX86Pe = @(
"resources/elevate.exe",
"Uninstall Reasonix Studio.exe"
)
function Get-TreeFiles {
param([Parameter(Mandatory = $true)][string]$Root)
@@ -120,6 +128,49 @@ function Assert-DeclaredPeSet {
}
}
# The machine field of a PE image's COFF header, read from the bytes: an
# emulated x64 program runs on ARM64 Windows, so what a package claims to be
# built for has to come from the files themselves.
function Get-PeMachine {
param([Parameter(Mandatory = $true)][string]$Path)
$stream = [IO.File]::OpenRead($Path)
try {
$dos = New-Object byte[] 64
if ($stream.Read($dos, 0, 64) -ne 64 -or $dos[0] -ne 0x4D -or $dos[1] -ne 0x5A) { throw "Not a PE image: $Path" }
[void]$stream.Seek([BitConverter]::ToInt32($dos, 0x3C), [IO.SeekOrigin]::Begin)
$header = New-Object byte[] 6
if ($stream.Read($header, 0, 6) -ne 6 -or $header[0] -ne 0x50 -or $header[1] -ne 0x45 -or $header[2] -ne 0 -or $header[3] -ne 0) {
throw "No PE signature: $Path"
}
[BitConverter]::ToUInt16($header, 4)
}
finally {
$stream.Dispose()
}
}
# Every PE image under Root must be built for Architecture; the 32-bit images
# the installer writes (InstallerWrittenX86Pe) are the only exception, by path.
function Assert-PeArchitecture {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][ValidateSet("amd64", "arm64")][string]$Architecture
)
$want = @{ amd64 = 0x8664; arm64 = 0xAA64 }[$Architecture]
$wrong = @()
foreach ($name in Get-PeFiles -Root $Root) {
$expected = if ($script:InstallerWrittenX86Pe -ccontains $name) { 0x014C } else { $want }
$machine = Get-PeMachine -Path (Join-Path $Root $name)
if ($machine -ne $expected) {
Write-Host "::error title=studio-signing.wrong-architecture::$name is machine 0x$($machine.ToString('x')), want 0x$($expected.ToString('x')) for $Architecture"
$wrong += $name
}
}
if ($wrong.Count -gt 0) { throw "PE files under $Root are not all $Architecture`: $($wrong -join ', ')" }
}
function Get-SignTool {
$tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
Sort-Object FullName -Descending | Select-Object -First 1