1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# Run the gate on Windows, and ask the platform the one thing no test can.
#
# `linux.yml` is the older half of this pair and its header explains the shape.
# What is different here is worth stating rather than inferring, because a green
# tick invites more faith than it has earned.
#
# **The suite runs three times rather than once.** `CLAUDE.md` says why and a
# runner is the case it was written about: the tests watch real directories
# through `ReadDirectoryChangesW`, and event timing moves with load. Three
# rather than five only because the runner is slower than a desk.
#
# **A run that wedges is killed and retried, and this is not tolerance of a
# real failure.** `flow::tests::a_save_reaches_the_container_without_anybody_
# closing_the_session` has hung on this runner: the save's event never settles
# the watcher's wait and the test blocks past its own deadline, a rare timing
# race in the `notify` watcher rather than a loop in this crate. Measured
# 2026-09-07: two CI runs wedged, then fifty runs of the exact command did not,
# so instrumentation moves the timing and hides it — a Heisenbug.
# `docs/windows-save-test-hang.md` is the write-up and the way to chase it on a
# Windows machine. Until then the step needs three clean passes, kills a run
# that wedges past three minutes, and tolerates a bounded number of those
# before it calls the wedge real and fails. A run that *fails* is never
# retried; only a wedge is.
#
# **The verdict is taken from cargo and never read out of its output.** That
# distinction has cost this repository twice, and a loop is exactly where it
# would cost it a third time: the pass count comes from each process's exit
# code below, never from a line in its log.
#
# **Nothing here opens a document.** `platform::testing::Recording` is what the
# suite launches through, and on this platform there is nothing to launch
# through yet: `ShellExecuteEx` with `IAttachmentExecute` is still to be written,
# so concept 5 step 7 and the whole of Mark of the Web are unexercised here.
#
# **Nothing here shows a notification.** There is no toast yet, so the terminal
# channel is what runs, and concept 9's buttons are checked by hand.
#
# **Nothing here reads a policy file or a registry key.** `for_this_platform`
# names no source on Windows until the registry arrives, so what the suite
# resolves is the built-in set. The two integration tests that name Linux's
# files are `cfg(unix)` and do not run here, which is why this workflow's count
# is lower than the Linux one's rather than because something was skipped.
#
# **Nothing here packages anything.** MSIX and its signing are still to come.
#
# What only a machine can answer is the last step. The front door's name carries
# the SID of the account the process runs as, read from its own token, and the
# state directory has to be the one the environment names rather than the one
# this account happens to have. Both were written on one desk against one
# profile; a runner is a different account on a different machine, which is the
# entire point of asking here.
#
# Author: David M. Anderson
# Built with AI assistance (Claude, Anthropic)
name: Windows
on:
push:
pull_request:
# The release event is what attaches the package. A person's token creates
# the release, so the event fires.
release:
types:
workflow_dispatch:
inputs:
tag:
description: 'Tag to package for (e.g. v0.1.9)'
required: true
type: string
env:
CARGO_TERM_COLOR: always
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}
- name: What is installed
run: |
rustc -V
cargo -V
- name: Formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Build
run: cargo build --all-targets
# Not `check.sh`: that would run fmt and clippy again inside a step that
# says it is testing, which is the same reason `linux.yml` spells it out.
#
# Three clean passes, taken from each run's exit code. A run that wedges
# past the timeout is killed and does not count, up to a bounded number;
# a run that fails is fatal at once and never retried. The header says
# what the wedge is and where the write-up lives.
- name: Test, three times
run: |
$needed = 3 # clean passes required, the CLAUDE.md count
$maxWedge = 3 # wedges tolerated before a wedge is called real
$timeoutMs = 180000
$passes = 0
$wedges = 0
while ($passes -lt $needed) {
Write-Host "run (passes $passes/$needed, wedges $wedges/$maxWedge)"
$out = Join-Path $env:RUNNER_TEMP "test-$passes-$wedges.log"
$p = Start-Process -FilePath cargo -ArgumentList @('test','--quiet') `
-RedirectStandardOutput $out -RedirectStandardError "$out.err" `
-NoNewWindow -PassThru
if (-not $p.WaitForExit($timeoutMs)) {
try { $p.Kill($true) } catch { $p.Kill() }
$wedges++
$secs = [int]($timeoutMs / 1000)
Write-Host "::warning::a test run wedged past ${secs}s and was killed (wedge $wedges/$maxWedge). The known ReadDirectoryChangesW watcher hang; see docs/windows-save-test-hang.md."
Get-Content "$out.err" -Tail 20 -ErrorAction SilentlyContinue
if ($wedges -ge $maxWedge) {
throw "tests wedged $wedges times running to $needed passes; that is not a flake"
}
continue
}
Get-Content $out -ErrorAction SilentlyContinue
if ($p.ExitCode -ne 0) {
Get-Content "$out.err" -Tail 40 -ErrorAction SilentlyContinue
throw "a test run failed (exit $($p.ExitCode))"
}
$passes++
}
Write-Host "$needed clean passes (after $wedges wedge(s))"
# The two things that come out of the environment rather than the source,
# asked on a machine that is not the one they were written on.
#
# The door has to be this account's pipe. Its name is built from the SID
# in the process token, so a runner proves the lookup works against an
# account nobody here has seen; and the pipe namespace belongs to the
# machine, so the SID is what keeps two sessions off one door.
#
# The sessions have to be where `%LOCALAPPDATA%` says. That is not
# hypothetical: the integration suite redirected only `HOME` and the three
# `XDG_` variables, Windows reads none of them, and the tests were running
# against the real state directory until the pipe landed and it was found.
- name: The front door is this account's, and the state is where it is told
run: |
$world = Join-Path $env:RUNNER_TEMP 'world'
New-Item -ItemType Directory -Force -Path $world | Out-Null
$env:LOCALAPPDATA = $world
$said = (cargo run --quiet -- policy) -join "`n"
if ($LASTEXITCODE -ne 0) { throw 'the policy verb refused' }
Write-Host $said
# `Contains` rather than `-match`, because the prefix is backslashes
# and every one of them would have to be escaped for a regex.
if (-not $said.Contains('\\.\pipe\slipcase-open.S-1-')) {
throw "the front door is not this account's pipe"
}
if (-not $said.Contains($world)) {
throw 'the state directory was not the one the environment named'
}
# slipcase-desktop 0.1.1 was packaged, certified, submitted and failed
# Store policy 10.2.4.1 for importing VCRUNTIME140.dll from the Visual
# C++ Redistributable, which every build machine has and a tester's clean
# one does not. `.cargo/config.toml` is the fix and this is the guard on
# it, against the release binary because that is what ships. It runs
# before the package rather than inside it, so a refusal names the import
# rather than arriving as a failed packaging step.
- name: Every DLL the shipped binary imports comes with Windows
run: |
cargo build --release
powershell -ExecutionPolicy Bypass -File packaging/windows/check-imports.ps1
# The Store takes the package unsigned, and the runner assembles the one that
# is uploaded. The steps are excelano/.github's, because they were the same in
# every repository that has this target; the publisher is the organisation
# secret, being the one value in the identity that is an account's rather than
# this product's.
- name: The Store package
uses: excelano/.github/.github/actions/store-package@main
with:
publisher: ${{ secrets.STORE_PUBLISHER }}
tag: ${{ github.event.release.tag_name || inputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}