From ceb6defcf3fa23e431d92475bccbbc0e3342eb37 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 09:38:29 -0400 Subject: [PATCH] Windows on ARM64: release builds, PE header check, install test on ARM64 hardware Release builds add an arm64 MSI and portable zip, cross-compiled on the x64 runner (signing and WiX as for x64). package.ps1 checks each binary's PE header before packaging: Machine must match -Arch, the app must be a GUI program and the CLI a console program. It skips the CLI smoke run when an ARM64 build can't run on the host. A new Windows ARM64 workflow packages the ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 + .github/workflows/windows-arm64.yml | 95 +++++++++++++++++++++++++++++ packaging/windows/package.ps1 | 31 +++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/windows-arm64.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70ebb9c6..c64571d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,9 @@ jobs: include: - { arch: x64, target: x86_64-pc-windows-msvc } - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } env: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 00000000..62a485fe --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install and run on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist + - name: Install the MSI, check it, run it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + $dir = Join-Path $env:ProgramFiles 'VectorCraft' + foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + } + # The installed CLI runs natively on ARM64. + $cli = Join-Path $dir 'vectorcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok vectorcraft-cli --version: $($out.Trim())" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 65d8ace9..e7141b08 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -17,9 +17,10 @@ .EXAMPLE pwsh packaging/windows/package.ps1 -Arch x64 pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild + pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools #> param( - [ValidateSet('x64', 'x86')] [string] $Arch = 'x64', + [ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64', [switch] $SkipBuild ) $ErrorActionPreference = 'Stop' @@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg # MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there. $MsiVersion = ($Version -split '-')[0] -$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' } +$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } } $Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' } New-Item -ItemType Directory -Force -Path $Dist | Out-Null @@ -65,6 +66,23 @@ if (-not $SkipBuild) { } $Bin = Join-Path $TargetDir "$Target\release" + +# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an +# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console. +# The app must be GUI (no console window opens with it); the CLI must stay console so its output +# reaches the terminal. +function Get-PeHeader([string] $Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + $pe = [BitConverter]::ToInt32($bytes, 0x3C) + return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) } +} +$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } } +foreach ($check in @(@('vectorcraft.exe', 2), @('vectorcraft-cli.exe', 3))) { + $h = Get-PeHeader (Join-Path $Bin $check[0]) + if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" } + if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" } + Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)" +} $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null @@ -96,5 +114,12 @@ $Zip = Join-Path $Dist "vectorcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip -Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version } +# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run +# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead. +$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { + Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version } +} else { + Write-Output "skipping vectorcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" +} Get-Item $Msi, $Zip | Format-Table Name, Length