ci(studio): build and smoke-test a native Windows ARM64 package

Add a windows-11-arm leg to the Studio package job: the kernel and the
computer-use helper build for the runner's architecture, every PE image in the
bundle must carry the package's machine type (the installer-written
elevate.exe and uninstaller are 32-bit by construction), and the package is
started, installed and uninstalled where it is meant to run. An arm64
installer now refuses a machine that is not ARM64 Windows instead of
installing nothing, and a second job checks that refusal on an x64 runner.

The arm64 installer is built with the 7-Zip BCJ filter: 7-Zip applies an
ARM64 filter to ARM64 executables by default, the NSIS extractor cannot decode
it, and the installer then exits 0 with every executable missing.

The ARM64 leg runs on pull requests only when files it verifies change.

Build and verification only: no secrets, nothing is uploaded to a release.
This commit is contained in:
yhh committed 2026-10-07 01:02:41 -07:00
1 parent c379851293
commit 46bd67824b
5 files changed
+302 -20

No files matched your search

+145 -12
View File
@@ -121,6 +121,45 @@ jobs:
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
done
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
# only when it touches what the leg verifies; a push to studio always does.
scope:
name: package matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
arm64: ${{ steps.pick.outputs.arm64 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
arm64=true
if [ "$EVENT" = pull_request ]; then
arm64=false
if git diff --name-only "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|\.github/workflows/studio\.yml$)'; then
arm64=true
fi
fi
legs='[
{"os":"macos-latest","arch":"arm64","platform":"darwin"},
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
]'
if [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
# The packaging contract, on real artifacts rather than on the config that
# describes them. Unsigned on purpose: signing and notarization are a
# distribution concern and belong to the release workflow, and treating their
@@ -128,20 +167,12 @@ jobs:
# cross-compile while only Windows had ever produced a package.
package:
name: package (${{ matrix.os }})
needs: scope
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
arch: arm64
platform: darwin
- os: ubuntu-22.04
arch: amd64
platform: linux
- os: windows-latest
arch: amd64
platform: windows
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
defaults:
run:
shell: bash
@@ -174,8 +205,13 @@ jobs:
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# The Windows legs name their architecture instead of taking the
# toolchain's, so a runner whose Go or Node is the other architecture
# fails here rather than shipping a mislabelled package.
- name: Build the kernel the shell speaks to
working-directory: desktop/electron
env:
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
run: pnpm install --frozen-lockfile && pnpm run build:host
# The .deb's absolute-path payload. A no-op off Linux, so it is not
@@ -191,7 +227,8 @@ jobs:
working-directory: desktop/electron
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: pnpm run build:dist
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
# The release vocabulary is GOARCH. electron-builder's own is not, and the
# rename that reconciles them runs here rather than in the manifest.
@@ -207,6 +244,78 @@ jobs:
exit 1
fi
# An emulated x64 program runs on ARM64 Windows, so a package that merely
# starts proves nothing about its architecture: every PE image in the
# bundle has to be built for the one the package is named for.
- name: Every PE image is built for the package's architecture
if: matrix.platform == 'windows'
run: |
dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)
test -n "$dir"
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
# Run on the machine the package is for: the shell's own binary as a
# node, the kernel's identity flag, then the installer end to end, per-user
# and silent, into a folder of its own.
- name: The package starts, installs and uninstalls
if: matrix.platform == 'windows'
shell: pwsh
env:
ARCH: ${{ matrix.arch }}
REASONIX_HOME: ${{ runner.temp }}/reasonix-home
run: |
$ErrorActionPreference = 'Stop'
$unpacked = Get-ChildItem dist -Directory -Filter 'win*-unpacked' | Select-Object -First 1
# A windowed executable returns to the shell at once with no stdout, so
# it is started and waited for with its output redirected to a file.
$env:ELECTRON_RUN_AS_NODE = '1'
$out = Join-Path $env:RUNNER_TEMP 'shell-arch.txt'
$node = Start-Process -FilePath "$($unpacked.FullName)/Reasonix Studio.exe" -ArgumentList '-p', 'process.arch' -RedirectStandardOutput $out -Wait -PassThru
Remove-Item Env:ELECTRON_RUN_AS_NODE
$arch = (Get-Content -Raw $out).Trim()
$want = if ($env:ARCH -eq 'amd64') { 'x64' } else { $env:ARCH }
if ($node.ExitCode -ne 0 -or $arch -ne $want) { throw "the shell reports arch '$arch' (exit $($node.ExitCode)), want $want" }
& "$($unpacked.FullName)/resources/bin/reasonix-studio-host.exe" -instance-id
if ($LASTEXITCODE -ne 0) { throw "the kernel exited $LASTEXITCODE" }
$installer = (Get-ChildItem dist -Filter "ReasonixStudio-windows-$env:ARCH-installer.exe").FullName
$target = Join-Path $env:RUNNER_TEMP 'rx-install'
$run = Start-Process -FilePath $installer -ArgumentList '/S', "/D=$target" -Wait -PassThru
if ($run.ExitCode -ne 0) { throw "the installer exited $($run.ExitCode)" }
$clock = [Diagnostics.Stopwatch]::StartNew()
$name = [IO.Path]::GetFileNameWithoutExtension($installer)
while (-not (Test-Path "$target/Reasonix Studio.exe") -and $clock.Elapsed.TotalSeconds -lt 120) { Start-Sleep -Seconds 2 }
Write-Host "application appeared after $([int]$clock.Elapsed.TotalSeconds)s past the installer's exit; installer processes still alive: $(@(Get-Process -Name $name -ErrorAction SilentlyContinue).Count)"
if (-not (Test-Path "$target/Reasonix Studio.exe")) {
Write-Host "target holds: $((Get-ChildItem -Force $target -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
Write-Host "files under the target: $((Get-ChildItem -Recurse -File $target -ErrorAction SilentlyContinue).Count); resources holds: $((Get-ChildItem -Force "$target/resources" -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
Write-Host "Defender detections: $((Get-MpThreatDetection -ErrorAction SilentlyContinue | ForEach-Object { $_.Resources }) -join '; ')"
Write-Host "real-time protection: $((Get-MpComputerStatus -ErrorAction SilentlyContinue).RealTimeProtectionEnabled)"
Write-Host "PE files present: $((Get-ChildItem -Recurse -File $target -Include *.exe,*.dll,*.node | ForEach-Object { $_.FullName.Substring($target.Length) }) -join ', ')"
Write-Host "Defender events: $((Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Windows Defender/Operational'; Id=1116,1117,1121,1122} -MaxEvents 5 -ErrorAction SilentlyContinue | ForEach-Object { $_.Message -replace '\s+',' ' }) -join ' || ')"
Set-MpPreference -DisableRealtimeMonitoring $true
$again = Join-Path $env:RUNNER_TEMP 'rx-install-nodefender'
Start-Process -FilePath $installer -ArgumentList '/S', "/D=$again" -Wait | Out-Null
Start-Sleep -Seconds 20
Write-Host "without real-time scanning the application is present: $(Test-Path "$again/Reasonix Studio.exe")"
throw 'the installer reported success and installed nothing'
}
node desktop/electron/packaging/pe.js $target $env:ARCH
if ($LASTEXITCODE -ne 0) { throw 'the installed tree is not the package architecture' }
$run = Start-Process -FilePath "$target/Uninstall Reasonix Studio.exe" -ArgumentList '/S', "_?=$target" -Wait -PassThru
if ($run.ExitCode -ne 0) { throw "the uninstaller exited $($run.ExitCode)" }
if (Test-Path "$target/Reasonix Studio.exe") { throw 'the uninstaller left the application behind' }
- name: Keep the arm64 installer for the x64 refusal check
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: studio-arm64-installer-ci
path: dist/ReasonixStudio-windows-arm64-installer.exe
if-no-files-found: error
retention-days: 1
# The update channel is the signed manifest. A second answer to which
# version is current is not a convenience. The .blockmap is the same
# channel's differential index and carries the release prefix, so it also
@@ -301,3 +410,27 @@ jobs:
echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}"
exit 1
fi
# The arm64 installer carries one application and no other. Run anywhere but
# ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing
# installed.
arm64-installer-refuses-x64:
name: arm64 installer refuses an x64 machine
needs: [scope, package]
if: needs.scope.outputs.arm64 == 'true'
runs-on: windows-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: studio-arm64-installer-ci
path: installer
- name: Run it silently
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$target = Join-Path $env:RUNNER_TEMP 'rx-refused'
$run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru
Write-Host "exit code $($run.ExitCode)"
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
+11
View File
@@ -99,6 +99,17 @@
# full of someone's work gets named in the first place. Refuse with an exit
# code, because that is all a script driving this can read.
!macro customInit
# The arm64 installer carries only an arm64 application: on any other
# machine electron-builder's extraction picks no archive and installs nothing
# while reporting success.
!ifdef APP_ARM64
${IfNot} ${IsNativeARM64}
DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine"
MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK
SetErrorLevel 3
Abort
${EndIf}
!endif
Push $R9
!insertmacro instDirTakesNothingElse $R9
${If} $R9 == 0
+24 -8
View File
@@ -45,22 +45,35 @@ function go(args, env) {
// statically so the helper needs no redistributable on the person's machine.
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
function msvcVars() {
// The helper is built for the architecture the kernel is, with that
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
// an ARM64 machine (vcvarsarm64 is the native environment there).
const WINDOWS_TOOLCHAIN = {
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
};
function msvcVars(toolchain) {
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
if (!fs.existsSync(vswhere)) return "";
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" });
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : "";
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
return vars && fs.existsSync(vars) ? vars : "";
}
function buildWindowsHelper() {
const vars = msvcVars();
function buildWindowsHelper(goarch) {
const toolchain = WINDOWS_TOOLCHAIN[goarch];
if (!toolchain) {
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
process.exit(1);
}
const vars = msvcVars(toolchain);
if (!vars) {
// A release without the helper would ship computer use switched off with
// nothing saying why, so CI refuses; a dev build only loses the feature.
if (process.env.CI) {
console.error("MSVC with the x64 C++ tools is required to build reasonix-computer-helper.exe");
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
process.exit(1);
}
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
@@ -91,9 +104,12 @@ function buildWindowsHelper() {
if (process.platform !== "darwin") {
// go build names the binary after the package and adds .exe where it belongs.
// Without cgo, because with it the Linux kernel inherits the runner's glibc.
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" });
// REASONIX_GOARCH names the architecture to build for; unset, Go's own is
// used, which is the runner's.
const goarch = (process.env.REASONIX_GOARCH || "").trim();
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
console.log("built reasonix-studio-host");
if (process.platform === "win32") buildWindowsHelper();
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
process.exit(0);
}
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const MACHINE = { amd64: 0x8664, arm64: 0xaa64 };
// Written by the NSIS installer rather than built by us, and 32-bit on every
// architecture; the release pins it by content (windows-signing-lib.ps1).
const INSTALLER_OWNED = { "resources/elevate.exe": 0x14c, "Uninstall Reasonix Studio.exe": 0x14c };
const EXTENSIONS = new Set([".exe", ".dll", ".node"]);
// The machine field of a PE image's COFF header. An emulated x64 binary and a
// native one are both valid programs on ARM64 Windows, so the architecture a
// package claims has to be read from the files themselves.
function peMachine(bytes) {
if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) throw new Error("not a PE image");
const header = bytes.readUInt32LE(0x3c);
if (header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x00004550) throw new Error("no PE signature");
return bytes.readUInt16LE(header + 4);
}
function isPeCandidate(file) {
if (EXTENSIONS.has(path.extname(file).toLowerCase())) return true;
const fd = fs.openSync(file, "r");
try {
const head = Buffer.alloc(2);
return fs.readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d;
} finally {
fs.closeSync(fd);
}
}
function walk(dir, into) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, into);
else if (entry.isFile() && isPeCandidate(full)) into.push(full);
}
return into;
}
// Every PE image under root with the machine it was built for.
function machines(root) {
return walk(root, []).sort().map((file) => {
const name = path.relative(root, file).split(path.sep).join("/");
try {
return { file: name, machine: peMachine(fs.readFileSync(file)) };
} catch (err) {
return { file: name, machine: null, error: err.message };
}
});
}
// The images under root that are not built for arch; none means the tree is
// uniformly that architecture. An empty tree is refused, not passed.
function foreign(root, arch) {
const want = MACHINE[arch];
if (want === undefined) throw new Error(`unknown architecture ${arch}`);
const found = machines(root);
if (found.length === 0) throw new Error(`no PE images under ${root}`);
return found.filter((f) => f.machine !== (INSTALLER_OWNED[f.file] ?? want));
}
module.exports = { peMachine, machines, foreign, MACHINE };
if (require.main === module) {
const [root, arch] = process.argv.slice(2);
if (!root || !arch) {
console.error("usage: node packaging/pe.js <dir> <amd64|arm64>");
process.exit(2);
}
for (const f of machines(root)) console.log(`${f.machine === null ? f.error : "0x" + f.machine.toString(16)} ${f.file}`);
const wrong = foreign(root, arch);
for (const f of wrong) {
console.error(f.error ? `::error::${f.file} cannot be read as a PE image: ${f.error}` : `::error::${f.file} is machine 0x${f.machine.toString(16)}, not ${arch}`);
}
process.exit(wrong.length === 0 ? 0 : 1);
}
+45
View File
@@ -1396,3 +1396,48 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
assert.equal(trayAsset(5).pixels, 48);
assert.equal(trayAsset(NaN).pixels, 16);
});
function pe(machine) {
const b = Buffer.alloc(0x100);
b.writeUInt16LE(0x5a4d, 0);
b.writeUInt32LE(0x80, 0x3c);
b.writeUInt32LE(0x00004550, 0x80);
b.writeUInt16LE(machine, 0x84);
return b;
}
test("the PE check reads each image's own machine and refuses a tree of another architecture", () => {
const { peMachine, machines, foreign } = require("../packaging/pe.js");
assert.equal(peMachine(pe(0xaa64)), 0xaa64);
assert.throws(() => peMachine(Buffer.from("#!/bin/sh\n".repeat(10))), /not a PE/);
assert.throws(() => peMachine(pe(0x8664).subarray(0, 0x70)), /no PE signature/);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-pe-"));
try {
assert.throws(() => foreign(dir, "arm64"), /no PE images/);
fs.mkdirSync(path.join(dir, "resources", "bin"), { recursive: true });
fs.writeFileSync(path.join(dir, "app.exe"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "resources", "bin", "host.exe"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "locales.pak"), "not an image");
fs.writeFileSync(path.join(dir, "noext"), pe(0xaa64));
assert.equal(machines(dir).length, 3);
assert.deepEqual(foreign(dir, "arm64"), []);
assert.deepEqual(foreign(dir, "amd64").map((f) => f.file), ["app.exe", "noext", "resources/bin/host.exe"]);
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0x8664));
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["ffmpeg.dll"]);
assert.throws(() => foreign(dir, "x64"), /unknown architecture/);
fs.writeFileSync(path.join(dir, "broken.dll"), "text, not an image");
const broken = foreign(dir, "arm64").find((f) => f.file === "broken.dll");
assert.match(broken.error, /not a PE/);
fs.rmSync(path.join(dir, "broken.dll"));
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0x14c));
assert.deepEqual(foreign(dir, "arm64"), []);
fs.writeFileSync(path.join(dir, "Uninstall Reasonix Studio.exe"), pe(0x14c));
assert.deepEqual(foreign(dir, "arm64"), []);
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0xaa64));
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["resources/elevate.exe"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});