Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions .github/actions/windows-ci-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ inputs:
cache-key:
description: >-
rust-cache shared-key: the dependency graph this job builds. Exactly one
job saves each key, and only on main.
job saves each key, and only on pushes to main. rust-cache adds the
runner's OS and architecture to the key (`Windows_NT-x64`,
`Windows_NT-arm64`), so one name serves both architectures.
required: true
rust-cache:
description: >-
Restore the job's Rust cache. 'false' builds cold and touches no cache
entry (the nightly x64 tests run: no entry of its own, and it keeps no
stale one alive by restoring it).
default: 'true'
fast-linker:
description: >-
Link with rust-lld (ships with the toolchain) instead of link.exe.
Expand Down Expand Up @@ -79,10 +87,27 @@ runs:
- name: Rust toolchain
shell: pwsh
run: rustup show active-toolchain || rustup toolchain install
- name: Ensure clang for aarch64 ring builds
# As in release.yml: ring 0.17 has no MSVC assembly path for
# aarch64-pc-windows-msvc, so native ARM64 builds need LLVM's clang on
# PATH. The arm64 image ships LLVM; choco is the fallback.
if: runner.arch == 'ARM64'
shell: pwsh
run: |
if (-not (Get-Command clang -ErrorAction SilentlyContinue)) {
choco install llvm -y --no-progress
}
clang --version | Select-Object -First 1
- name: Locate Windows SDK shader compiler
shell: pwsh
# fxc is host-architecture neutral; the arm64 image may carry only one
# of the SDK's builds (the same lookup as release.yml).
run: |
$fxc = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe" |
$patterns = @(
"${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe",
"${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/arm64/fxc.exe"
)
$fxc = Get-ChildItem $patterns -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' }
"GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV
Expand Down Expand Up @@ -134,14 +159,17 @@ runs:
Get-PSDrive -PSProvider FileSystem | Format-Table Name, Used, Free, Root
exit 0
- uses: Swatinem/rust-cache@v2
if: inputs.rust-cache == 'true'
with:
shared-key: ${{ inputs.cache-key }}
add-job-id-key: 'false'
# The real directory, never the ./target junction: tar would store the
# junction itself.
workspaces: ${{ inputs.dev-drive == 'true' && '. -> ../roboco-devdrive/target' || '. -> target' }}
# Only main writes; PRs restore main's entry.
save-if: ${{ github.ref == 'refs/heads/main' }}
# Only pushes to main write; PRs restore main's entry. Dispatch and
# scheduled runs on main never save, so an A/B run on another image
# adds no entry.
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
cache-on-failure: 'true'
# Cargo rebuilds workspace crates after every checkout (mtime
# freshness), and they are opt-level 0 here, so caching them only
Expand Down
82 changes: 66 additions & 16 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,17 @@ on:
- '.github/workflows/windows.yml'
- '.github/actions/windows-ci-setup/**'
- '.config/nextest.toml'
# x64 coverage for `tests`, which PRs and pushes run on arm64: once a day
# on main, cold (no Rust cache), with no `app` job (pushes cover that).
schedule:
- cron: '11 4 * * *'
workflow_dispatch:
inputs:
native_gui:
description: 'Run native GUI/pixel probes (requires a usable desktop and Direct3D)'
type: boolean
default: false
# Fallback and A/B switches; pushes and PRs always run with both on.
# Fallback and A/B switches; pushes and PRs always run with all on.
fast_linker:
description: 'Link with rust-lld (off: link.exe)'
type: boolean
Expand All @@ -57,8 +61,17 @@ on:
description: 'Build on a Dev Drive, Cargo/rustup homes on D: (off: image defaults)'
type: boolean
default: true
runner:
description: 'Runner image'
rust_cache:
description: 'Restore the main-seeded Rust cache (off: cold, as the nightly x64 run)'
type: boolean
default: true
tests_runner:
description: 'Runner image for tests (the nightly uses windows-2022)'
type: choice
options: [windows-11-arm, windows-2022, windows-2025]
default: windows-11-arm
app_runner:
description: 'Runner image for app (x64: it packages the shipped build)'
type: choice
options: [windows-2022, windows-2025]
default: windows-2022
Expand Down Expand Up @@ -96,20 +109,59 @@ jobs:
# invocations this replaces each rebuilt the shared graph with a different
# package/feature set; the application build is the `app` job's, in
# parallel.
#
# PRs and pushes run it on windows-11-arm (free, and this repo's Windows
# builds are 20-25 % faster there), the nightly on windows-2022 for x64
# coverage. Back to x64 everywhere: change the last literal below.
tests:
runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.runner || 'windows-2022' }}
runs-on: ${{ github.event_name == 'schedule' && 'windows-2022' || github.event_name == 'workflow_dispatch' && inputs.tests_runner || 'windows-11-arm' }}
timeout-minutes: 45
steps:
# The ConPTY tests (roboco-engine terminals::windows_tests) drive Windows
# PowerShell 5.1 with 15 s waits. On windows-11-arm its module analysis
# cache starts cold: the first cmdlet outside the core (Set-Content,
# Split-Path, Start-Process) scans every module on PSModulePath, Az
# included, for about 22 s in every new powershell.exe, and nothing is
# kept until a process completes a full scan. windows-2022 arrives warm
# (0.2 s). One clean `Get-Command` writes the cache (85-117 s measured);
# it runs in the background from here and is joined before the tests.
- name: Warm Windows PowerShell's command cache (arm64, background)
if: runner.arch == 'ARM64'
run: |
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$warmup = Start-Process $ps -ArgumentList '-NoProfile', '-NonInteractive', '-Command', 'Get-Command | Out-Null' -WindowStyle Hidden -PassThru
"POWERSHELL_WARMUP_PID=$($warmup.Id)" >> $env:GITHUB_ENV
- uses: actions/checkout@v4
- uses: ./.github/actions/windows-ci-setup
with:
cache-key: windows-tests
fast-linker: ${{ github.event_name != 'workflow_dispatch' || inputs.fast_linker }}
dev-drive: ${{ github.event_name != 'workflow_dispatch' || inputs.dev_drive }}
rust-cache: ${{ github.event_name != 'schedule' && (github.event_name != 'workflow_dispatch' || inputs.rust_cache) }}
# The Cursor shim suite runs the shim under node: one pinned version on
# both architectures, whatever each image ships.
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Install nextest
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.144
- name: Join the PowerShell cache warmup (arm64)
# Fails here, by name, if the cache is still cold, instead of as three
# ConPTY timeouts.
if: runner.arch == 'ARM64'
run: |
$wait = [Diagnostics.Stopwatch]::StartNew()
$warmup = Get-Process -Id $env:POWERSHELL_WARMUP_PID -ErrorAction SilentlyContinue
if ($warmup -and -not $warmup.WaitForExit(300000)) { throw 'The PowerShell cache warmup did not finish in 5 min' }
"Joined the warmup after $([int]$wait.Elapsed.TotalSeconds) s"
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$probe = [Diagnostics.Stopwatch]::StartNew()
& $ps -NoProfile -NonInteractive -Command 'Split-Path -Leaf (Get-Location) | Out-Null'
$seconds = $probe.Elapsed.TotalSeconds
"Split-Path in a new powershell.exe: $([Math]::Round($seconds, 1)) s"
if ($seconds -gt 5) { throw 'Windows PowerShell command discovery is still cold' }
- name: Select the Windows test suites
# A suite runs on Windows when it has Windows-specific code paths or
# the desktop links it (Android-only crates run on Linux and in
Expand Down Expand Up @@ -182,6 +234,10 @@ jobs:
if ($case.SelectSingleNode('failure|error|skipped')) { throw "$($case.name) did not pass" }
"PASS: $($case.classname) $($case.name)"
}
- name: Architecture and linker of the test build
# The roboco binary the CLI tests ran: aarch64 on windows-11-arm, and
# linked by the same fast-linker switch as on x64.
run: ./scripts/test-windows-linker.ps1 -FastLinker '${{ github.event_name != 'workflow_dispatch' || inputs.fast_linker }}'
- name: Upload the test report
if: always()
uses: actions/upload-artifact@v4
Expand All @@ -193,9 +249,11 @@ jobs:

# The application as shipped (with the embedded Tailcat adapter), packaged
# and smoke-tested. Its own cache: `cargo build -p roboco` resolves
# different dependency features than the test build.
# different dependency features than the test build. Always x64 (the
# shipped x86_64 build), on every PR and push; not part of the nightly.
app:
runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.runner || 'windows-2022' }}
if: github.event_name != 'schedule'
runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.app_runner || 'windows-2022' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
Expand All @@ -204,6 +262,7 @@ jobs:
cache-key: windows-app
fast-linker: ${{ github.event_name != 'workflow_dispatch' || inputs.fast_linker }}
dev-drive: ${{ github.event_name != 'workflow_dispatch' || inputs.dev_drive }}
rust-cache: ${{ github.event_name != 'workflow_dispatch' || inputs.rust_cache }}
- name: Build the Tailcat adapter (embedded by every cargo build below)
# crates/engine/build.rs reruns when ROBOCO_TAILCAT_EMBED changes, so
# every cargo command in this job sees the same value, exported here
Expand All @@ -228,16 +287,7 @@ jobs:
# and the real web bundle is built by web-codegen/release workflows.
run: ./scripts/package-windows.ps1 -SkipWebBuild -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download"
- name: Linker of the packaged roboco.exe
# link.exe writes a "Rich" header (its build-tool record) into the DOS
# stub; rust-lld writes none.
env:
FAST_LINKER: ${{ github.event_name != 'workflow_dispatch' || inputs.fast_linker }}
run: |
$bytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath target/release/roboco.exe).Path)
$peOffset = [BitConverter]::ToInt32($bytes, 0x3c)
$rich = [Text.Encoding]::ASCII.GetString($bytes, 0, $peOffset).Contains('Rich')
"roboco.exe linked by $(if ($rich) { 'link.exe (Rich header)' } else { 'rust-lld (no Rich header)' })"
if (($env:FAST_LINKER -eq 'true') -eq $rich) { throw "fast-linker is $env:FAST_LINKER, but the Rich header says otherwise" }
run: ./scripts/test-windows-linker.ps1 -FastLinker '${{ github.event_name != 'workflow_dispatch' || inputs.fast_linker }}'
- name: Installer install/uninstall
run: ./scripts/test-windows-installer.ps1
- name: GUI subsystem and redirected CLI output
Expand Down
20 changes: 19 additions & 1 deletion .scratch/ci-speed/issues/15-windows-tests-arm64.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Evidence from `release.yml` (research-web §3.1). The Windows package step takes

**Blocked by:** 04 (the split), 05 (linker settings are shared), 13 (if option (a) needs the paid cache headroom)

**Status:** ready-for-agent (coverage option (b) chosen by the user 2026-10-29)
**Status:** implemented on branch `ci/windows-tests-arm64` (2026-10-29, coverage option (b)); the p50 items and the scheduled nightly runs are measured after merge

**Parent:** `.scratch/ci-speed/spec.md`

Expand All @@ -30,3 +30,21 @@ Baseline: ticket 04/05 after-numbers for `tests` (target ≤ 22 min warm).
- [ ] The chosen x64 coverage option runs green at least twice

## Comments

**2026-10-29 (implementation, branch `ci/windows-tests-arm64`):**

What changed:

- **`tests` runs on `windows-11-arm`** for PRs and pushes. Its `runs-on` is `schedule` → `windows-2022`, dispatch → the `tests_runner` input, else `windows-11-arm`. Reverting to x64 means changing that last literal.
- **Nightly x64 (option (b))**: `schedule: '11 4 * * *'` (UTC, apart from android's 02:47 and ui-tests' 03:23). On schedule, only `tests` runs, on `windows-2022`, with `rust-cache: false`. It neither saves nor restores a Rust cache, so it owns no entry and keeps no stale x64 entry alive. `app` gets `if: github.event_name != 'schedule'`, because every PR and push already runs it on x64, and `native-gui` is dispatch-only as before.
- **Dispatch inputs**: `runner` is split into `tests_runner` (`windows-11-arm` default, `windows-2022`, `windows-2025`) and `app_runner` (`windows-2022`, `windows-2025`; it stays x64 because it packages the shipped x86_64 build). New `rust_cache` (default on): off reproduces the nightly's cold build. A dispatch with `tests_runner=windows-2022, rust_cache=false` is the nightly's `tests` job, plus an `app` run.
- **`windows-ci-setup`**:
- The "Ensure clang for aarch64 ring builds" step comes from release.yml (`runner.arch == 'ARM64'`; choco fallback).
- The fxc lookup accepts the SDK's `x64` or `arm64` build, as release.yml does.
- New `rust-cache` input.
- **`save-if` is now `push` to main only** (was any run on `refs/heads/main`). Without this, the nightly or a dispatch A/B on main would write an entry.
- **Cache key per architecture**: rust-cache already puts `runner.os`-`runner.arch` in the key (`v0-rust-windows-tests-Windows_NT-x64-…` today), so `shared-key: windows-tests` gives arm64 its own `…-Windows_NT-arm64-…` entry. It replaces the x64 one, which stops being saved and ages out. It does not add a key.
- **LLD on aarch64**: no new setting. Ticket 05 writes `[target.<host triple>] linker = "rust-lld.exe"` into `$CARGO_HOME/config.toml`, so the arm64 job gets `[target.aarch64-pc-windows-msvc]`. That is per target, and it is the same switch in every job that shares a key (there is one job per key). The new "Architecture and linker of the test build" step checks the `roboco.exe` the CLI tests ran: PE machine (`aarch64`) and no Rich header. The `app` job's inline check moved into the same script, `scripts/test-windows-linker.ps1`, which is covered by the `scripts/test-windows-*.ps1` path filter. Checked locally under PowerShell 5.1 against `notepad.exe`: `-FastLinker false` passes ("x86_64, linked by link.exe"), and `-FastLinker true` throws.
- **node**: `actions/setup-node@v4` with node 24 in `tests` on both architectures (`cursor_shim` spawns `node` and fails without it). **Go**: not needed. Since ticket 04, `tests` builds no Tailcat adapter, and no selected suite runs `go`.
- Docs: the AGENTS.md CI line and `docs/reference/windows-development.md` "Verification" now describe the arm64 `tests` job and the nightly x64 run.
- Lint: `actionlint` clean on `windows.yml`, which also validates the composite action.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Still zeron-branded on purpose:

Android builds and tests run on these runners ([ADR 0009](docs/adr/0009-android-client.md)); there are no macOS jobs.

- `windows.yml` — Windows `tests` (one nextest build) and `app` (package, installer, startup) jobs (PR + push)
- `windows.yml` — Windows `tests` (one nextest build, on `windows-11-arm`) and `app` (x64: package, installer, startup) jobs (PR + push); nightly, `tests` again on x64 (`windows-2022`, cold, no cache save)
- `ui-tests.yml` — ubuntu, nextest on the dev profile, one build per graph: `core-tests` (`scripts/ci/test-core.sh`: harness, engine-local suites, sync, update, MCP, preview, doc, Android asset/xtask tooling), `ui-tests` (`roboco-ui` + the `roboco` CLI tests), `mobile-core`, `tailcat-adapter`; nightly `release-backstop` runs core + UI with `--release`. Not run for `apps/android/**`-only changes
- `web-codegen.yml` — ubuntu: `wiregen --check` wire-type freshness gate + `pnpm -r build` typecheck of `web/`
- `theme-artifact.yml` — web theme artifact freshness gate (`roboco-theme-export --check`) + `@roboco/theme` typecheck
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/windows-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ grids when the main window loses focus.

[Windows CI](../../.github/workflows/windows.yml) runs two parallel jobs. `tests`
builds every Windows suite once with nextest: agent discovery/protocols,
process cleanup, ConPTY, locking, UI behavior and shader layouts. `app` packages
the application and checks the installer, startup and the CLI. Both use a CI
release profile with workspace crates at opt-level 0 and no LTO
process cleanup, ConPTY, locking, UI behavior and shader layouts. It runs on
ARM64 (`windows-11-arm`) for PRs and pushes, and once a day on x64. `app`
packages the x64 application and checks the installer, startup and the CLI.
Both use a CI release profile with workspace crates at opt-level 0 and no LTO
(`.github/actions/windows-ci-setup`). Shared Rust regressions run on Linux. To
run the engine and harness checks locally:

Expand Down
21 changes: 21 additions & 0 deletions scripts/test-windows-linker.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Report which linker and architecture produced an executable, and fail when
# the linker disagrees with the CI fast-linker switch. link.exe writes a "Rich"
# header (its build-tool record) into the DOS stub; rust-lld writes none.
param(
[string]$Exe = (Join-Path $PSScriptRoot '../target/release/roboco.exe'),
[Parameter(Mandatory)][ValidateSet('true', 'false')][string]$FastLinker
)
$ErrorActionPreference = 'Stop'
$Exe = (Resolve-Path -LiteralPath $Exe).Path
$bytes = [IO.File]::ReadAllBytes($Exe)
$pe = [BitConverter]::ToInt32($bytes, 0x3c)
$machine = switch ([BitConverter]::ToUInt16($bytes, $pe + 4)) {
0x8664 { 'x86_64' }
0xAA64 { 'aarch64' }
default { '0x{0:X4}' -f $_ }
}
$rich = [Text.Encoding]::ASCII.GetString($bytes, 0, $pe).Contains('Rich')
Write-Output "$(Split-Path -Leaf $Exe): $machine, linked by $(if ($rich) { 'link.exe (Rich header)' } else { 'rust-lld (no Rich header)' })"
if (($FastLinker -eq 'true') -eq $rich) {
throw "fast-linker is $FastLinker, but the Rich header says otherwise"
}
Loading