返回 DeepSeek-Reasonix
ci.yml
根目录 / .github / workflows / ci.yml
1 name: CI
2
3 on:
4 push:
5 branches: [main-v2]
6 pull_request:
7 branches: [main-v2]
8
9 permissions:
10 contents: read
11
12 concurrency:
13 # Push cancellation is handled by Supersede CI, which preserves the code
14 # ancestor still required by a release-notes-only candidate.
15 group: ci-${{ github.event_name == 'push' && github.sha || github.ref }}
16 cancel-in-progress: true
17
18 jobs:
19 # Cheap path gate for pull requests. PRs confined to docs/site/release-notes
20 # (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
21 # the desktop module skip the desktop jobs. Job-level `if` reports skipped,
22 # which satisfies the required status checks (lint, race, test) — a
23 # workflow-level paths-ignore would leave required checks pending and block
24 # merges. Gated jobs skip only on an explicit `false` output: wrapped in
25 # `always()`, a failed `changes` job (or a missing output) makes them run
26 # the full matrix instead of silently passing required checks as skipped.
27 # Pushes to main-v2 always run everything.
28 changes:
29 runs-on: ubuntu-latest
30 outputs:
31 code: ${{ steps.filter.outputs.code }}
32 desktop: ${{ steps.filter.outputs.desktop }}
33 desktop_go: ${{ steps.filter.outputs.desktop_go }}
34 frontend: ${{ steps.filter.outputs.frontend }}
35 browser: ${{ steps.filter.outputs.browser }}
36 memory: ${{ steps.filter.outputs.memory }}
37 electron: ${{ steps.filter.outputs.electron }}
38 native: ${{ steps.filter.outputs.native }}
39 packaging: ${{ steps.filter.outputs.packaging }}
40 site: ${{ steps.filter.outputs.site }}
41 sdk: ${{ steps.filter.outputs.sdk }}
42 windows_builtin: ${{ steps.filter.outputs.windows_builtin }}
43 release_control: ${{ steps.filter.outputs.release_control }}
44 notes_only: ${{ steps.filter.outputs.notes_only }}
45 steps:
46 - uses: actions/checkout@v7
47 with:
48 fetch-depth: 0
49 - uses: actions/setup-node@v7
50 with:
51 node-version: "24"
52 - id: filter
53 env:
54 EVENT_NAME: ${{ github.event_name }}
55 BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
56 HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
57 run: |
58 args=(--head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY")
59 if [ "$EVENT_NAME" = pull_request ]; then
60 args+=(--base "$BASE_SHA" --mode pull_request)
61 else
62 # Main pushes retain the complete qualification matrix after the
63 # existing release-notes-only exception is evaluated.
64 args+=(--base "$BASE_SHA" --mode push --full)
65 fi
66 node scripts/ci-paths.mjs "${args[@]}"
67
68 # The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
69 # A matrix job skipped at job level reports no per-leg checks at all, so
70 # those required checks would stay "Expected" and block merging. The job
71 # therefore always runs and the steps do the gating: when the changes
72 # detector reports the diff is unrelated, every step skips and each leg
73 # reports success in seconds. `always()` also keeps the legs alive when
74 # the changes job itself fails (fail-open: an empty output != 'false').
75 test:
76 needs: changes
77 if: ${{ !cancelled() }}
78 # Backstop against a wedged step holding the workflow's concurrency group:
79 # the Windows full-suite step has outlived its own timeout and kept the job
80 # alive. Normal full-suite wall time is 8-12 minutes per platform.
81 timeout-minutes: 45
82 strategy:
83 fail-fast: false
84 matrix:
85 os: [ubuntu-latest, macos-latest, windows-latest]
86 runs-on: ${{ matrix.os }}
87 env:
88 RUN_STEPS: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
89 steps:
90 - if: env.RUN_STEPS == 'true'
91 uses: actions/checkout@v7
92
93 - if: env.RUN_STEPS == 'true'
94 uses: actions/setup-go@v7
95 with:
96 go-version-file: go.mod
97 # Windows keeps setup-go's go.sum-keyed cache: restoring the per-run
98 # cache there measured slower (17m -> 21m, then a 25-minute step
99 # timeout), so the rolling cache serves only the Unix legs.
100 cache: ${{ runner.os == 'Windows' }}
101
102 - if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
103 uses: ./.github/actions/go-build-cache
104 id: gocache
105 with:
106 module: root
107
108 # Both non-Linux legs drive their package selection through a Node script.
109 - if: env.RUN_STEPS == 'true' && runner.os != 'Linux'
110 uses: actions/setup-node@v7
111 with:
112 node-version: "22"
113
114 # Defender real-time scanning on GitHub Windows runners intermittently
115 # crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
116 # syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
117 # written test files open, causing flaky sharing violations. Exclude the
118 # ephemeral build/test locations; fail this lane when the prerequisite
119 # cannot be established so a DEP crash is never treated as product evidence.
120 - name: Exclude build dirs from Defender scanning
121 if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
122 shell: pwsh
123 run: |
124 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
125 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
126 foreach ($p in $paths) {
127 try {
128 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
129 Write-Host "Defender exclusion added: $p"
130 } catch {
131 throw "Defender exclusion failed for ${p}: $($_.Exception.Message)"
132 }
133 }
134
135 - name: Install and verify Linux sandbox backend
136 if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
137 run: |
138 sudo apt-get update
139 sudo apt-get install -y bubblewrap
140 if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
141 sudo sysctl -w kernel.unprivileged_userns_clone=1
142 fi
143 if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
144 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
145 fi
146 bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
147 echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
148
149 # Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
150 # file. gofmt output is OS-independent, so the Unix legs already cover it.
151 - name: gofmt
152 if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
153 run: |
154 # Root module only — desktop/ is a separate module with its own tooling.
155 unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
156 if [ -n "$unformatted" ]; then
157 echo "These files are not gofmt-clean:"
158 echo "$unformatted"
159 exit 1
160 fi
161
162 - name: vet
163 if: env.RUN_STEPS == 'true'
164 run: go vet ./...
165
166 - name: build
167 if: env.RUN_STEPS == 'true'
168 run: go build ./...
169
170 - name: test
171 # Windows full coverage belongs to the disjoint selectors below and
172 # the isolated jobs; running ./... here would execute every test twice.
173 if: env.RUN_STEPS == 'true' && (runner.os == 'Linux' || (runner.os == 'macOS' && github.event_name != 'pull_request'))
174 env:
175 # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
176 # regression there silently tanks the cache hit rate the project is
177 # built around.
178 REASONIX_RELEASE_CACHE_GUARD: "1"
179 run: go test ./...
180
181 # Public repositories get five concurrent macOS runners and one pull
182 # request already claims three. Linux proves the portable packages on
183 # every pull request, so this leg sweeps only the darwin-specific ones
184 # and pushes keep the full ./... run above — the same tiering the Windows
185 # legs and the race job already use.
186 - name: test (macOS platform packages)
187 if: env.RUN_STEPS == 'true' && runner.os == 'macOS' && github.event_name == 'pull_request'
188 timeout-minutes: 15
189 env:
190 REASONIX_RELEASE_CACHE_GUARD: "1"
191 run: node scripts/macos-go-tests.mjs darwin
192
193 # ACP, Agent, Bot, boot and control own isolated Windows runners on PRs
194 # and pushes. ACP and Bot previously competed in the residual -p=4 lane:
195 # that hid ACP event ordering behind scheduler delay and made native Bot
196 # crash evidence inseparable from other package processes.
197 # The shared selector excludes them here, preserving platform smoke
198 # coverage without competing durable-session I/O or duplicate execution.
199 - name: test (Windows smoke)
200 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
201 timeout-minutes: 15
202 env:
203 REASONIX_RELEASE_CACHE_GUARD: "1"
204 run: node scripts/windows-go-tests.mjs smoke
205
206 - name: test (Windows credential ACL identity)
207 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
208 timeout-minutes: 3
209 run: go test -timeout=2m -run '^TestCredentialAccessRepairsLegacyCredentialDeny|^TestRepairLegacyCredentialDenyMatchesFileAcrossPathAliases$' ./internal/config ./internal/winaclresidue
210
211 # The PR smoke selector omits session; exercise native rename, writer-lock
212 # release, and interrupted purge recovery before accepting lifecycle fixes.
213 - name: test (Windows purge recovery)
214 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
215 timeout-minutes: 5
216 run: go test -timeout=3m -run '^TestPurge' ./internal/session
217
218 # Serve and taskmonitor are outside the general Windows PR smoke list.
219 # Cover runtime identities and snapshot publication before mainline push.
220 - name: test (Windows runtime status and task snapshots)
221 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
222 timeout-minutes: 5
223 run: go test -timeout=2m -run '^TestRuntimeStateHTTP|^TestFileStoreSaveTaskWaitsForTransientSnapshotReader$' ./internal/serve ./internal/taskmonitor
224
225 # Keep the platform contract in one selector with coverage assertions:
226 # primary shell registration, timeout/schema/session temp, unsupported OS
227 # sandbox behavior, ACP ordering, and Bot stop publication all gate PRs.
228 - name: test (Windows shell and lifecycle contract)
229 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
230 timeout-minutes: 10
231 env:
232 WINDOWS_BUILTIN_FULL: ${{ needs.changes.outputs.windows_builtin }}
233 run: node scripts/windows-pr-contract-tests.mjs
234
235 - name: test (Windows persistent PowerShell 7 and 5.1)
236 if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
237 timeout-minutes: 5
238 shell: pwsh
239 run: |
240 $env:REASONIX_TEST_PWSH = (Get-Command pwsh).Source
241 go test -timeout=2m -run '^TestPowerShell' ./internal/persistentshell
242 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
243 $env:REASONIX_TEST_PWSH = (Get-Command powershell.exe).Source
244 go test -timeout=2m -run '^TestPowerShell' ./internal/persistentshell
245 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
246
247 # Enumerate the entire module and run every remaining package. The union
248 # with the isolated jobs is exhaustive and disjoint, including new packages.
249 - name: test (full)
250 if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
251 timeout-minutes: 20
252 env:
253 # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
254 # regression there silently tanks the cache hit rate the project is
255 # built around.
256 REASONIX_RELEASE_CACHE_GUARD: "1"
257 run: node scripts/windows-go-tests.mjs full
258
259 - name: test (Scoop desktop launch)
260 if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
261 timeout-minutes: 10
262 shell: powershell
263 run: .\scripts\test-scoop-desktop-launch.ps1
264
265 - name: test (Windows installer acceptance harness)
266 if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
267 timeout-minutes: 2
268 shell: pwsh
269 run: |
270 ./scripts/test-windows-installer-startup.test.ps1
271 ./scripts/test-windows-upgrade-startup.test.ps1
272
273 # Only main-v2 pushes save, one job per OS and module, so the cache
274 # stays a few pushes deep instead of churning on every pull request.
275 - uses: actions/cache/save@v4
276 if: always() && env.RUN_STEPS == 'true' && runner.os != 'Windows' && github.event_name == 'push' && steps.gocache.outputs.key != ''
277 with:
278 path: ${{ steps.gocache.outputs.paths }}
279 key: ${{ steps.gocache.outputs.key }}
280
281 windows-control:
282 needs: changes
283 if: ${{ !cancelled() }}
284 runs-on: windows-latest
285 env:
286 RUN_STEPS: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
287 steps:
288 - if: env.RUN_STEPS == 'true'
289 uses: actions/checkout@v7
290
291 - if: env.RUN_STEPS == 'true'
292 uses: actions/setup-go@v7
293 with:
294 go-version-file: go.mod
295 cache: true
296
297 - if: env.RUN_STEPS == 'true'
298 uses: actions/setup-node@v7
299 with:
300 node-version: "22"
301
302 # Defender real-time scanning on GitHub Windows runners intermittently
303 # crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
304 # syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
305 # written test files open, causing flaky sharing violations. Exclude the
306 # ephemeral build/test locations; fail this lane when the prerequisite
307 # cannot be established so a DEP crash is never treated as product evidence.
308 - name: Exclude build dirs from Defender scanning
309 shell: pwsh
310 run: |
311 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
312 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
313 foreach ($p in $paths) {
314 try {
315 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
316 Write-Host "Defender exclusion added: $p"
317 } catch {
318 throw "Defender exclusion failed for ${p}: $($_.Exception.Message)"
319 }
320 }
321
322 - name: test
323 if: env.RUN_STEPS == 'true'
324 timeout-minutes: 10
325 env:
326 REASONIX_RELEASE_CACHE_GUARD: "1"
327 run: node scripts/windows-go-tests.mjs control
328
329 windows-isolated:
330 needs: changes
331 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false') }}
332 runs-on: windows-latest
333 strategy:
334 fail-fast: false
335 matrix:
336 group: [acp, agent, boot, bot, serve, session, worktree]
337 steps:
338 - uses: actions/checkout@v7
339 - uses: actions/setup-go@v7
340 with:
341 go-version-file: go.mod
342 cache: true
343 - uses: actions/setup-node@v7
344 with:
345 node-version: "22"
346
347 # A hardware exception on a host whose CPU saves a large XSTATE (Intel
348 # AMX) can corrupt the Go heap (golang/go#81238). Record the processor on
349 # the same isolated runner so any remaining native crash is attributable.
350 - name: runner CPU
351 shell: pwsh
352 run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
353
354 # Defender real-time scanning on GitHub Windows runners intermittently
355 # crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
356 # syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
357 # written test files open, causing flaky sharing violations. Exclude the
358 # ephemeral build/test locations; fail this lane when the prerequisite
359 # cannot be established so a DEP crash is never treated as product evidence.
360 - name: Exclude build dirs from Defender scanning
361 shell: pwsh
362 run: |
363 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
364 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
365 foreach ($p in $paths) {
366 try {
367 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
368 Write-Host "Defender exclusion added: $p"
369 } catch {
370 throw "Defender exclusion failed for ${p}: $($_.Exception.Message)"
371 }
372 }
373 - name: test
374 # The worktree group normally approaches ten minutes on hosted runners.
375 # Keep the script's 8m Go timeout authoritative and leave time to report it.
376 timeout-minutes: 15
377 env:
378 REASONIX_RELEASE_CACHE_GUARD: "1"
379 run: node scripts/windows-go-tests.mjs ${{ matrix.group }}
380
381 race:
382 needs: changes
383 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false') }}
384 runs-on: ubuntu-latest
385 steps:
386 - uses: actions/checkout@v7
387
388 - uses: actions/setup-go@v7
389 with:
390 go-version-file: go.mod
391 cache: false
392
393 - uses: ./.github/actions/go-build-cache
394 id: gocache
395 with:
396 module: root-race
397
398 - name: Install and verify Linux sandbox backend
399 run: |
400 sudo apt-get update
401 sudo apt-get install -y bubblewrap
402 if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
403 sudo sysctl -w kernel.unprivileged_userns_clone=1
404 fi
405 if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
406 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
407 fi
408 bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
409 echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
410
411 # The matrix never runs -race (it needs cgo); the project's concurrency
412 # (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
413 # ship without race coverage. Pull requests sweep only the
414 # concurrency-heavy packages so this required check stays fast; pushes
415 # to main-v2 keep the full ./... sweep as the safety net.
416 - name: test -race (concurrency packages)
417 if: github.event_name == 'pull_request'
418 env:
419 REASONIX_RELEASE_CACHE_GUARD: "1"
420 run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/... ./internal/bot/...
421
422 - name: test -race (full)
423 if: github.event_name != 'pull_request'
424 env:
425 REASONIX_RELEASE_CACHE_GUARD: "1"
426 # The session suite reached its final history tests after 580s; Go's
427 # default 10m package alarm killed a test that had run for only 20s.
428 run: go test -race -timeout=15m ./...
429
430 # Only main-v2 pushes save, one job per OS and module, so the cache
431 # stays a few pushes deep instead of churning on every pull request.
432 - uses: actions/cache/save@v4
433 if: always() && github.event_name == 'push' && steps.gocache.outputs.key != ''
434 with:
435 path: ${{ steps.gocache.outputs.paths }}
436 key: ${{ steps.gocache.outputs.key }}
437
438 # sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
439 # Its DTOs are generated from internal/extension/protocol, and the
440 # host-side conformance tests spawn the SDK example, so the job runs the
441 # same three-OS matrix as the root tests.
442 sdk:
443 needs: changes
444 if: ${{ !cancelled() }}
445 strategy:
446 fail-fast: false
447 matrix:
448 os: [ubuntu-latest, macos-latest, windows-latest]
449 runs-on: ${{ matrix.os }}
450 env:
451 RUN_STEPS: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.sdk != 'false' }}
452 defaults:
453 run:
454 working-directory: sdk/go
455 steps:
456 - if: env.RUN_STEPS == 'true'
457 uses: actions/checkout@v7
458
459 - if: env.RUN_STEPS == 'true'
460 uses: actions/setup-go@v7
461 with:
462 go-version-file: sdk/go/go.mod
463
464 # Defender real-time scanning on GitHub Windows runners intermittently
465 # crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
466 # syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
467 # written test files open, causing flaky sharing violations. Exclude the
468 # ephemeral build/test locations; failures stay non-fatal warnings.
469 - name: Exclude build dirs from Defender scanning
470 if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
471 shell: pwsh
472 run: |
473 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
474 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
475 foreach ($p in $paths) {
476 try {
477 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
478 Write-Host "Defender exclusion added: $p"
479 } catch {
480 Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
481 }
482 }
483
484 - name: gofmt
485 if: env.RUN_STEPS == 'true'
486 shell: bash
487 run: |
488 unformatted=$(gofmt -l .)
489 if [ -n "$unformatted" ]; then
490 echo "These files are not gofmt-clean:"
491 echo "$unformatted"
492 exit 1
493 fi
494
495 - name: vet
496 if: env.RUN_STEPS == 'true'
497 run: go vet ./...
498
499 - name: stdlib-only guard
500 if: env.RUN_STEPS == 'true'
501 shell: bash
502 run: |
503 # The SDK is a public module with a hard stdlib-only contract.
504 if go list -m all | grep -v '^github.com/esengine/DeepSeek-Reasonix/sdk/go$'; then
505 echo "sdk/go must not depend on anything outside the standard library"
506 exit 1
507 fi
508
509 - name: test
510 if: env.RUN_STEPS == 'true'
511 run: go test ./...
512
513 - name: test -race
514 if: github.event_name != 'pull_request' && env.RUN_STEPS == 'true'
515 run: go test -race ./...
516
517 # Required check. Every desktop job reaches the ruleset through this one name,
518 # so a job that is not listed here is not gated at all: that is how a failing
519 # desktop-windows-go merged. PACKAGE_REQUIRED mirrors desktop-windows-package's
520 # own `if:` verbatim rather than testing for `push`, so workflow_dispatch does
521 # not expect a skip from a job that runs.
522 desktop:
523 needs: [changes, desktop-prepare, desktop-go, desktop-go-race, desktop-frontend, desktop-browser,
524 desktop-macos, desktop-windows, desktop-windows-go, desktop-windows-package]
525 if: always()
526 runs-on: ubuntu-latest
527 steps:
528 - name: Verify desktop validation jobs
529 env:
530 CHANGES_RESULT: ${{ needs.changes.result }}
531 PREPARE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.desktop != 'false' }}
532 NATIVE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false' }}
533 FRONTEND_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.electron != 'false' }}
534 PACKAGE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.packaging != 'false' }}
535 PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
536 GO_RESULT: ${{ needs.desktop-go.result }}
537 GO_RACE_RESULT: ${{ needs.desktop-go-race.result }}
538 FRONTEND_RESULT: ${{ needs.desktop-frontend.result }}
539 BROWSER_RESULT: ${{ needs.desktop-browser.result }}
540 MACOS_RESULT: ${{ needs.desktop-macos.result }}
541 WINDOWS_RESULT: ${{ needs.desktop-windows.result }}
542 WINDOWS_GO_RESULT: ${{ needs.desktop-windows-go.result }}
543 PACKAGE_RESULT: ${{ needs.desktop-windows-package.result }}
544 run: |
545 test "$CHANGES_RESULT" = success
546 expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
547 test "$PREPARE_RESULT" = "$(expected "$PREPARE_REQUIRED")"
548 test "$GO_RESULT" = "$(expected "$NATIVE_REQUIRED")"
549 test "$GO_RACE_RESULT" = "$(expected "$NATIVE_REQUIRED")"
550 test "$FRONTEND_RESULT" = "$(expected "$FRONTEND_REQUIRED")"
551 # The browser aggregate always runs and validates skipped groups itself.
552 test "$BROWSER_RESULT" = success
553 # native ⇒ desktop by construction, so these can never contradict
554 # PREPARE_REQUIRED above.
555 test "$MACOS_RESULT" = "$(expected "$NATIVE_REQUIRED")"
556 test "$WINDOWS_RESULT" = "$(expected "$NATIVE_REQUIRED")"
557 test "$WINDOWS_GO_RESULT" = success
558 test "$PACKAGE_RESULT" = "$(expected "$PACKAGE_REQUIRED")"
559
560 desktop-frontend:
561 needs: [changes, desktop-prepare]
562 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.electron != 'false') }}
563 runs-on: ubuntu-22.04
564 defaults:
565 run:
566 working-directory: desktop
567 steps:
568 - uses: actions/checkout@v7
569 - uses: pnpm/action-setup@v6.1.0
570 with:
571 version: 10
572 run_install: false
573 - uses: actions/setup-node@v7
574 with:
575 node-version: "24"
576 cache: pnpm
577 cache-dependency-path: desktop/pnpm-lock.yaml
578 - name: Install frontend dependencies
579 run: pnpm --dir frontend install --frozen-lockfile
580 - uses: actions/download-artifact@v8
581 with:
582 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
583 path: desktop/frontend
584 - name: Verify stable frontend artifact
585 run: |
586 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
587 node frontend/scripts/artifact-identity.mjs verify \
588 --shell electron --channel stable \
589 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
590 --pnpm-version "$(pnpm --version)"
591 - name: Verify CI test coverage and runner
592 run: node --test frontend/scripts/ci-test-plan.test.mjs
593 - name: Check the Electron shell
594 run: |
595 pnpm --dir electron typecheck
596 pnpm --dir electron test
597 node --test packaging/*.test.mjs
598 pnpm --dir electron build
599 - name: Test desktop frontend once per suite
600 env:
601 REASONIX_TEST_CONCURRENCY: "2"
602 run: node frontend/scripts/run-ci-tests.mjs
603
604 desktop-browser-group:
605 needs: [changes, desktop-prepare]
606 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.browser != 'false') }}
607 runs-on: ubuntu-22.04
608 strategy:
609 fail-fast: false
610 max-parallel: 2
611 matrix:
612 group: [app-settings-motion, transcript]
613 defaults:
614 run:
615 working-directory: desktop
616 steps:
617 - uses: actions/checkout@v7
618 - uses: pnpm/action-setup@v6.1.0
619 with:
620 version: 10
621 run_install: false
622 - uses: actions/setup-node@v7
623 with:
624 node-version: "24"
625 cache: pnpm
626 cache-dependency-path: desktop/pnpm-lock.yaml
627 - name: Install frontend dependencies
628 run: pnpm --dir frontend install --frozen-lockfile
629 - uses: actions/download-artifact@v8
630 with:
631 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
632 path: desktop/frontend
633 - name: Verify stable frontend artifact
634 run: |
635 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
636 node frontend/scripts/artifact-identity.mjs verify \
637 --shell electron --channel stable \
638 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
639 --pnpm-version "$(pnpm --version)"
640 - name: Install browser runtimes
641 run: PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install --with-deps chromium
642 - name: Install isolated native input driver
643 if: matrix.group == 'transcript'
644 run: sudo apt-get update && sudo apt-get install -y xdotool
645 - name: Test submission handoff in Chromium
646 if: matrix.group == 'transcript'
647 timeout-minutes: 4
648 env:
649 REASONIX_HANDOFF_EVIDENCE: ${{ runner.temp }}/desktop-browser/transcript/submission-chromium
650 run: pnpm --dir frontend test:submission-browser
651 - name: Test submission handoff with native Electron input
652 if: matrix.group == 'transcript'
653 timeout-minutes: 4
654 env:
655 REASONIX_HANDOFF_EVIDENCE: ${{ runner.temp }}/desktop-browser/transcript/submission-electron
656 run: xvfb-run -a pnpm --dir frontend test:submission-electron --native-input
657 - name: Test desktop browser group
658 env:
659 BROWSER_GROUP: ${{ matrix.group }}
660 REASONIX_LAYOUT_ARTIFACTS: ${{ runner.temp }}/desktop-browser/${{ matrix.group }}/layout
661 run: |
662 set -o pipefail
663 evidence="$RUNNER_TEMP/desktop-browser/$BROWSER_GROUP"
664 mkdir -p "$evidence"
665 case "$BROWSER_GROUP" in
666 app-settings-motion)
667 PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend test:app-browser
668 PLAYWRIGHT_BROWSERS_PATH=.pw-browsers REASONIX_SETTINGS_BROWSERS=chromium pnpm --dir frontend test:settings-browser
669 pnpm --dir frontend test:motion-browser
670 ;;
671 transcript)
672 xvfb-run -a env PLAYWRIGHT_BROWSERS_PATH=.pw-browsers REASONIX_TRANSCRIPT_NATIVE_THUMB=1 \
673 REASONIX_TRANSCRIPT_MODE=native-scrollbar REASONIX_LAYOUT_ARTIFACTS="$evidence/native-scrollbar" \
674 pnpm --dir frontend test:transcript-browser
675 REASONIX_TRANSCRIPT_READER_BROWSERS=chromium PLAYWRIGHT_BROWSERS_PATH=.pw-browsers \
676 REASONIX_TRANSCRIPT_MODE=headless-reader REASONIX_LAYOUT_ARTIFACTS="$evidence/headless-reader" \
677 pnpm --dir frontend test:transcript-reader-browser
678 ;;
679 *) exit 2 ;;
680 esac 2>&1 | tee "$evidence/run.log"
681
682 - name: Upload browser group evidence
683 if: always()
684 uses: actions/upload-artifact@v7
685 with:
686 name: desktop-browser-${{ matrix.group }}-${{ github.run_id }}-${{ github.run_attempt }}
687 path: ${{ runner.temp }}/desktop-browser/${{ matrix.group }}
688 if-no-files-found: ignore
689 retention-days: 7
690
691 desktop-browser:
692 needs: [changes, desktop-prepare, desktop-browser-group]
693 if: always()
694 runs-on: ubuntu-latest
695 steps:
696 - name: Verify desktop browser groups
697 env:
698 CHANGES_RESULT: ${{ needs.changes.result }}
699 SHOULD_RUN: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.browser != 'false' }}
700 PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
701 GROUP_RESULT: ${{ needs.desktop-browser-group.result }}
702 run: |
703 test "$CHANGES_RESULT" = success
704 if [ "$SHOULD_RUN" = true ]; then
705 test "$PREPARE_RESULT" = success
706 test "$GROUP_RESULT" = success
707 else
708 test "$GROUP_RESULT" = skipped
709 fi
710
711 desktop-prepare:
712 needs: changes
713 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.desktop != 'false') }}
714 env:
715 REASONIX_COMMIT: ${{ github.sha }}
716 outputs:
717 producer_attempt: ${{ steps.artifact-identity.outputs.attempt }}
718 stable_artifact_name: desktop-frontend-stable-${{ github.run_id }}-${{ steps.artifact-identity.outputs.attempt }}
719 canary_artifact_name: desktop-frontend-canary-${{ github.run_id }}-${{ steps.artifact-identity.outputs.attempt }}
720 runs-on: ubuntu-22.04
721 defaults:
722 run:
723 working-directory: desktop
724 steps:
725 - uses: actions/checkout@v7
726 - name: Capture frontend artifact producer identity
727 id: artifact-identity
728 run: echo "attempt=$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT"
729
730 - uses: actions/setup-go@v7
731 with:
732 go-version-file: desktop/go.mod
733 cache: false
734
735 - uses: ./.github/actions/go-build-cache
736 id: gocache
737 with:
738 module: desktop
739
740 - uses: pnpm/action-setup@v6.1.0
741 with:
742 version: 10
743 run_install: false
744
745 - uses: actions/setup-node@v7
746 with:
747 node-version: "24"
748 cache: pnpm
749 cache-dependency-path: desktop/pnpm-lock.yaml
750
751 - name: gofmt
752 run: |
753 unformatted=$(gofmt -l .)
754 if [ -n "$unformatted" ]; then
755 echo "These files are not gofmt-clean:"
756 echo "$unformatted"
757 exit 1
758 fi
759
760 - name: go.mod tidy
761 run: |
762 go mod tidy
763 if ! git diff --quiet -- go.mod go.sum; then
764 echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit."
765 git diff -- go.mod go.sum
766 exit 1
767 fi
768
769 # The packaged Electron shell embeds desktopContract.json; a stale
770 # frontend/src/generated would ship a shell/service protocol mismatch.
771 - name: Check desktop host contract drift
772 run: |
773 go run . -emit-contract frontend/src/generated
774 if ! git diff --exit-code -- frontend/src/generated; then
775 echo "desktop contract is stale - run 'cd desktop && go run . -emit-contract frontend/src/generated' and commit."
776 exit 1
777 fi
778
779 - name: Install frontend dependencies
780 run: pnpm --dir frontend install --frozen-lockfile
781
782 - name: Build stable frontend
783 run: pnpm --dir frontend build:electron
784
785 - name: Record stable frontend artifact identity
786 run: |
787 node frontend/scripts/artifact-identity.mjs create \
788 --shell electron \
789 --channel stable \
790 --source-sha "$(git rev-parse HEAD)" \
791 --run-id "$GITHUB_RUN_ID" \
792 --attempt "$GITHUB_RUN_ATTEMPT" \
793 --pnpm-version "$(pnpm --version)"
794
795 - uses: actions/upload-artifact@v7
796 with:
797 name: desktop-frontend-stable-${{ github.run_id }}-${{ github.run_attempt }}
798 path: |
799 desktop/frontend/dist
800 desktop/frontend/sourcemaps/${{ github.sha }}
801 desktop/frontend/.reasonix-frontend-artifact.json
802 include-hidden-files: true
803 if-no-files-found: error
804 retention-days: 3
805
806 # Static checks ran in the stable build. The canary variant still gets a
807 # fresh Vite build and bundle-budget validation because its embedded
808 # channel is part of the shipped bytes.
809 - name: Build canary frontend
810 env:
811 REASONIX_CHANNEL: canary
812 run: node frontend/scripts/build-for-shell.mjs electron --bundle-only
813
814 - name: Record canary frontend artifact identity
815 run: |
816 node frontend/scripts/artifact-identity.mjs create \
817 --shell electron \
818 --channel canary \
819 --source-sha "$(git rev-parse HEAD)" \
820 --run-id "$GITHUB_RUN_ID" \
821 --attempt "$GITHUB_RUN_ATTEMPT" \
822 --pnpm-version "$(pnpm --version)"
823
824 - uses: actions/upload-artifact@v7
825 with:
826 name: desktop-frontend-canary-${{ github.run_id }}-${{ github.run_attempt }}
827 path: |
828 desktop/frontend/dist
829 desktop/frontend/sourcemaps/${{ github.sha }}
830 desktop/frontend/.reasonix-frontend-artifact.json
831 include-hidden-files: true
832 if-no-files-found: error
833 retention-days: 3
834
835 desktop-go:
836 needs: [changes, desktop-prepare]
837 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
838 runs-on: ubuntu-22.04
839 defaults:
840 run:
841 working-directory: desktop
842 steps:
843 - uses: actions/checkout@v7
844
845 - uses: actions/setup-go@v7
846 with:
847 go-version-file: desktop/go.mod
848 cache: false
849
850 - uses: ./.github/actions/go-build-cache
851 id: gocache
852 with:
853 module: desktop
854
855 - uses: pnpm/action-setup@v6.1.0
856 with:
857 version: 10
858 run_install: false
859
860 - uses: actions/setup-node@v7
861 with:
862 node-version: "24"
863 cache: pnpm
864 cache-dependency-path: desktop/pnpm-lock.yaml
865
866 - uses: actions/download-artifact@v8
867 with:
868 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
869 path: desktop/frontend
870 - name: Verify stable frontend artifact
871 run: |
872 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
873 node frontend/scripts/artifact-identity.mjs verify \
874 --shell electron --channel stable \
875 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
876 --pnpm-version "$(pnpm --version)"
877 - name: Install frontend dependencies
878 run: pnpm --dir frontend install --frozen-lockfile
879
880 - name: vet
881 run: go vet ./...
882
883 - name: golangci-lint
884 uses: golangci/golangci-lint-action@v9
885 with:
886 version: v2.12.2
887 working-directory: desktop
888 args: --timeout=5m
889
890 - name: build
891 run: go build ./...
892
893 - name: test
894 run: go test ./...
895
896 # Only main-v2 pushes save, one job per OS and module, so the cache
897 # stays a few pushes deep instead of churning on every pull request.
898 - uses: actions/cache/save@v4
899 if: always() && github.event_name == 'push' && steps.gocache.outputs.key != ''
900 with:
901 path: ${{ steps.gocache.outputs.paths }}
902 key: ${{ steps.gocache.outputs.key }}
903
904 # The full desktop race package exceeded Go's ten-minute package alarm.
905 # Reuse the verified test partition to keep complete coverage in parallel.
906 desktop-go-race:
907 needs: [changes, desktop-prepare]
908 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
909 runs-on: ubuntu-22.04
910 timeout-minutes: 30
911 strategy:
912 fail-fast: false
913 matrix:
914 group: [A-B, C, D, E-H, I-P, Q-S, T-Z]
915 defaults:
916 run:
917 working-directory: desktop
918 steps:
919 - uses: actions/checkout@v7
920
921 - uses: actions/setup-go@v7
922 with:
923 go-version-file: desktop/go.mod
924 cache: false
925
926 - uses: ./.github/actions/go-build-cache
927 id: gocache
928 with:
929 module: desktop-race
930
931 - uses: pnpm/action-setup@v6.1.0
932 with:
933 version: 10
934 run_install: false
935
936 - uses: actions/setup-node@v7
937 with:
938 node-version: "24"
939 cache: pnpm
940 cache-dependency-path: desktop/pnpm-lock.yaml
941
942 - uses: actions/download-artifact@v8
943 with:
944 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
945 path: desktop/frontend
946 - name: Verify stable frontend artifact
947 run: |
948 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
949 node frontend/scripts/artifact-identity.mjs verify \
950 --shell electron --channel stable \
951 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
952 --pnpm-version "$(pnpm --version)"
953 - name: Install frontend dependencies
954 run: pnpm --dir frontend install --frozen-lockfile
955
956 # The extension work added new shared-state paths to the desktop
957 # runtime (tab/rebuild fences, extension UI). Race-sweep the module so
958 # regressions are CI-blocked, not just author-verified locally.
959 - name: test -race
960 run: node ../scripts/desktop-windows-go-tests.mjs ${{ matrix.group }} --race
961
962 # One shard saves the shared compiler cache; all shards restore it.
963 - uses: actions/cache/save@v4
964 if: ${{ !cancelled() && github.event_name == 'push' && matrix.group == 'A-B' && steps.gocache.outputs.key != '' }}
965 with:
966 path: ${{ steps.gocache.outputs.paths }}
967 key: ${{ steps.gocache.outputs.key }}
968
969 # desktop/ is a separate module, so the root macOS matrix above does not
970 # compile or exercise the native PTY and FSEvents implementations.
971 desktop-macos:
972 needs: [changes, desktop-prepare]
973 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
974 runs-on: macos-latest
975 defaults:
976 run:
977 working-directory: desktop
978 steps:
979 - uses: actions/checkout@v7
980
981 - uses: actions/setup-go@v7
982 with:
983 go-version-file: desktop/go.mod
984 cache: false
985
986 - uses: ./.github/actions/go-build-cache
987 id: gocache
988 with:
989 module: desktop
990
991 - uses: pnpm/action-setup@v6.1.0
992 with:
993 version: 10
994 run_install: false
995
996 - uses: actions/setup-node@v7
997 with:
998 node-version: "24"
999 cache: pnpm
1000 cache-dependency-path: desktop/pnpm-lock.yaml
1001
1002 - name: Install frontend dependencies
1003 run: pnpm --dir frontend install --frozen-lockfile
1004 - uses: actions/download-artifact@v8
1005 with:
1006 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
1007 path: desktop/frontend
1008 - name: Verify stable frontend artifact
1009 run: |
1010 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
1011 node frontend/scripts/artifact-identity.mjs verify \
1012 --shell electron --channel stable \
1013 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
1014 --pnpm-version "$(pnpm --version)"
1015
1016 - name: Test integrated terminal, PTY, and FSEvents lifecycle
1017 run: go test -race -run 'Test(DarwinWorkspaceWatcher|WorkspaceChangeHub|ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
1018
1019 - name: Test native macOS signing coverage
1020 run: node --test packaging/sign-macos.test.mjs
1021
1022 - name: Test bounded Electron diagnostics
1023 timeout-minutes: 3
1024 run: node electron/scripts/performance-smoke.mjs
1025
1026 # The production desktop build uses CGO. Keep the explicit unavailable
1027 # backend buildable as a fail-closed portability contract instead of
1028 # silently falling back to kqueue's per-file descriptor usage.
1029 - name: Test macOS build without CGO
1030 env:
1031 CGO_ENABLED: "0"
1032 run: go test -run '^TestDarwinWorkspaceWatcherWithoutCGOIsUnavailable$' .
1033
1034 # desktop/*_darwin.go is the one build-tag set the lint job cannot reach:
1035 # the main-thread watchdog is cgo, so it only type-checks with a real
1036 # macOS toolchain.
1037 - name: golangci-lint
1038 uses: golangci/golangci-lint-action@v9
1039 with:
1040 version: v2.12.2
1041 working-directory: desktop
1042 args: --timeout=5m
1043
1044 # Packaging validation, not a code gate: run it on pushes to main-v2 (the
1045 # release pipeline packages again anyway), and let pull requests stop
1046 # after the lint step. Ad-hoc signs (no Apple secrets in CI) and skips
1047 # the DMG.
1048 - name: Remove stable frontend before canary packaging
1049 if: github.event_name != 'pull_request'
1050 run: rm -rf frontend/dist frontend/.reasonix-frontend-artifact.json
1051
1052 - uses: actions/download-artifact@v8
1053 if: github.event_name != 'pull_request'
1054 with:
1055 name: ${{ needs.desktop-prepare.outputs.canary_artifact_name }}
1056 path: desktop/frontend
1057
1058 - name: Verify canary frontend artifact
1059 if: github.event_name != 'pull_request'
1060 run: |
1061 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
1062 node frontend/scripts/artifact-identity.mjs verify \
1063 --shell electron --channel canary \
1064 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
1065 --pnpm-version "$(pnpm --version)"
1066
1067 - name: Package Electron app and verify bundle members
1068 if: github.event_name != 'pull_request'
1069 timeout-minutes: 25
1070 env:
1071 DESKTOP_BUILD_SKIP_DMG: "1"
1072 REASONIX_PACKAGE_REUSE_FRONTEND: "1"
1073 run: |
1074 REASONIX_FRONTEND_PNPM_VERSION="$(pnpm --version)"
1075 export REASONIX_FRONTEND_PNPM_VERSION
1076 ../scripts/desktop-build.sh darwin/arm64 v0.0.0-ci canary
1077 node packaging/verify.mjs ../dist/Reasonix-darwin-arm64.zip
1078
1079 # Signing and bundle members do not prove the app starts. Until now the
1080 # packaged macOS renderer path ran only in the release build, so a
1081 # regression there surfaced at publication time instead of on main-v2.
1082 - name: Smoke-test the packaged macOS startup
1083 if: github.event_name != 'pull_request'
1084 timeout-minutes: 5
1085 run: |
1086 ditto -xk ../dist/Reasonix-darwin-arm64.zip "$RUNNER_TEMP/desktop-startup"
1087 node packaging/smoke.mjs "$RUNNER_TEMP/desktop-startup/Reasonix.app"
1088
1089 # Only main-v2 pushes save, one job per OS and module, so the cache
1090 # stays a few pushes deep instead of churning on every pull request.
1091 - uses: actions/cache/save@v4
1092 if: always() && github.event_name == 'push' && steps.gocache.outputs.key != ''
1093 with:
1094 path: ${{ steps.gocache.outputs.paths }}
1095 key: ${{ steps.gocache.outputs.key }}
1096
1097 # Desktop project-root matching is case-insensitive only on Windows
1098 # (sameDesktopPath folds case when os.PathSeparator is '\'), so the
1099 # regression tests for that contract are named *OnWindows and can never
1100 # run on the ubuntu leg above.
1101 desktop-windows:
1102 needs: [changes, desktop-prepare]
1103 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
1104 runs-on: windows-latest
1105 # A hung native step must not hold the workflow's concurrency group for the
1106 # six-hour default. This bounds hangs, not duration: the leg's own spread on
1107 # 2026-09-11 was 34-47 minutes, so a cap near the mean cancels healthy runs.
1108 # Every step keeps its own tighter budget.
1109 timeout-minutes: 60
1110 defaults:
1111 run:
1112 shell: bash
1113 working-directory: desktop
1114 steps:
1115 - uses: actions/checkout@v7
1116
1117 - uses: actions/setup-go@v7
1118 with:
1119 go-version-file: desktop/go.mod
1120 cache: true
1121 cache-dependency-path: desktop/go.sum
1122
1123 - uses: pnpm/action-setup@v6.1.0
1124 with:
1125 version: 10
1126 run_install: false
1127
1128 - uses: actions/setup-node@v7
1129 with:
1130 node-version: "24"
1131 cache: pnpm
1132 cache-dependency-path: desktop/pnpm-lock.yaml
1133
1134 # Defender real-time scanning on GitHub Windows runners intermittently
1135 # crashes Go test binaries mid-syscall (DEP fault at PC=0 in the Windows
1136 # syscall trampoline, e.g. golang/go#67139) and briefly keeps freshly
1137 # written test files open, causing flaky sharing violations. Exclude the
1138 # ephemeral build/test locations; failures stay non-fatal warnings.
1139 - name: Exclude build dirs from Defender scanning
1140 shell: pwsh
1141 run: |
1142 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
1143 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
1144 foreach ($p in $paths) {
1145 try {
1146 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
1147 Write-Host "Defender exclusion added: $p"
1148 } catch {
1149 Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
1150 }
1151 }
1152
1153 - uses: actions/download-artifact@v8
1154 with:
1155 name: ${{ needs.desktop-prepare.outputs.canary_artifact_name }}
1156 path: desktop/frontend
1157 - name: Verify canary frontend artifact
1158 run: |
1159 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
1160 node frontend/scripts/artifact-identity.mjs verify \
1161 --shell electron --channel canary \
1162 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
1163 --pnpm-version "$(pnpm --version)"
1164
1165 # test:motion, test:transcript-browser and test:settings-browser run on
1166 # ubuntu (desktop-frontend and desktop-browser). They were repeated here
1167 # when each OS shipped its own engine; Electron ships one Chromium, so the
1168 # Windows-specific renderer evidence is the native Electron step below.
1169
1170 # Package the real Electron shell on every desktop PR and exercise the
1171 # production startup path (shell -> Go service handshake) in the packaged
1172 # layout. package.mjs builds the frontend itself (build:electron flavor).
1173 - name: Package Electron shell for native startup smoke
1174 timeout-minutes: 15
1175 env:
1176 REASONIX_PACKAGE_REUSE_FRONTEND: "1"
1177 run: |
1178 REASONIX_FRONTEND_PNPM_VERSION="$(pnpm --version)"
1179 export REASONIX_FRONTEND_PNPM_VERSION
1180 pnpm install --frozen-lockfile
1181 go build -trimpath -ldflags "-s -w -H windowsgui -X main.version=v0.0.0-ci -X main.channel=canary" -o build/bin/reasonix-desktop.exe .
1182 node ../scripts/verify-windows-gui-subsystem.mjs build/bin/reasonix-desktop.exe
1183 node packaging/package.mjs windows/amd64 v0.0.0-ci canary
1184
1185 - name: Smoke-test Electron native startup
1186 timeout-minutes: 3
1187 run: node packaging/smoke.mjs build/electron/windows-amd64/app --service build/bin/reasonix-desktop.exe
1188
1189 - name: Test native window geometry on Windows
1190 timeout-minutes: 2
1191 run: node electron/scripts/window-geometry-smoke.mjs
1192
1193 - name: Test transcript content bounds in native Electron
1194 timeout-minutes: 5
1195 env:
1196 REASONIX_LAYOUT_ARTIFACTS: ${{ runner.temp }}/transcript-layout-electron
1197 run: pnpm --dir frontend test:transcript-electron
1198
1199 - name: Upload native transcript layout evidence
1200 if: always()
1201 uses: actions/upload-artifact@v7
1202 with:
1203 name: transcript-layout-electron-windows
1204 if-no-files-found: ignore
1205 retention-days: 7
1206 path: ${{ runner.temp }}/transcript-layout-electron
1207
1208 - name: Test bounded Electron diagnostics
1209 timeout-minutes: 3
1210 run: node electron/scripts/performance-smoke.mjs
1211
1212 # The desktop Go suite shares nothing with the Electron and browser steps
1213 # beyond a built frontend for go:embed, and it is the leg's longest step by
1214 # far: the module that tests in 1.8 minutes on ubuntu takes ~17 on Windows,
1215 # ~11 of them compiling and linking the cgo-heavy root package before a
1216 # single test runs (GOCACHE restored, Defender excluded). Running it beside
1217 # the Electron steps makes the leg's wall time the longer half rather than
1218 # the sum, and a Go flake reruns only this job.
1219 desktop-windows-go-group:
1220 needs: [changes, desktop-prepare]
1221 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
1222 runs-on: windows-latest
1223 # Independent runners prevent valid groups from consuming each other's
1224 # step deadline. Go retains its default per-package ten-minute timeout.
1225 timeout-minutes: 45
1226 strategy:
1227 fail-fast: false
1228 matrix:
1229 group: [A-B, C, D, E-H, I-P, Q-S, T-Z]
1230 defaults:
1231 run:
1232 shell: bash
1233 working-directory: desktop
1234 steps:
1235 - uses: actions/checkout@v7
1236
1237 - uses: actions/setup-go@v7
1238 with:
1239 go-version-file: desktop/go.mod
1240 cache: true
1241 cache-dependency-path: desktop/go.sum
1242
1243 - uses: pnpm/action-setup@v6.1.0
1244 with:
1245 version: 10
1246 run_install: false
1247
1248 - uses: actions/setup-node@v7
1249 with:
1250 node-version: "24"
1251
1252 # Same Defender hazard as the Electron leg: exclusions keep Go test
1253 # binaries from crashing mid-syscall and freshly written files readable.
1254 - name: Exclude build dirs from Defender scanning
1255 shell: pwsh
1256 run: |
1257 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
1258 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
1259 foreach ($p in $paths) {
1260 try {
1261 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
1262 Write-Host "Defender exclusion added: $p"
1263 } catch {
1264 Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
1265 }
1266 }
1267
1268 - uses: actions/download-artifact@v8
1269 with:
1270 name: ${{ needs.desktop-prepare.outputs.stable_artifact_name }}
1271 path: desktop/frontend
1272 - name: Verify stable frontend artifact
1273 run: |
1274 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
1275 node frontend/scripts/artifact-identity.mjs verify \
1276 --shell electron --channel stable \
1277 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
1278 --pnpm-version "$(pnpm --version)"
1279
1280 - name: Validate Windows test partition
1281 run: node --test ../scripts/desktop-windows-go-tests.test.mjs
1282
1283 - name: test (Windows desktop and update helper)
1284 # Keep native Go output here. JSON mode forces verbose output into the
1285 # test cache and spent 8-10 minutes finalizing it on Windows runners.
1286 timeout-minutes: 25
1287 # The driver checks the current native inventory for unique ownership.
1288 run: node ../scripts/desktop-windows-go-tests.mjs ${{ matrix.group }}
1289
1290 # ConPTY startup and teardown depend on the hosted runner's interactive
1291 # Windows environment. Keep the end-to-end probe visible without making
1292 # a single host scheduling stall equivalent to a Go correctness failure.
1293 - name: probe (Windows ConPTY host integration)
1294 if: matrix.group == 'T-Z'
1295 id: conpty-smoke
1296 continue-on-error: true
1297 timeout-minutes: 2
1298 run: go test -run '^TestWindowsTerminalProcessConPTYSmoke$' .
1299
1300 - name: report ConPTY probe failure
1301 if: always() && matrix.group == 'T-Z' && steps.conpty-smoke.outcome == 'failure'
1302 shell: bash
1303 run: echo "::warning::The hosted Windows ConPTY integration probe failed; deterministic Windows Go tests still passed."
1304
1305 - name: test (vendored systray identity)
1306 if: matrix.group == 'T-Z'
1307 timeout-minutes: 2
1308 run: go test -timeout=60s fyne.io/systray
1309
1310 desktop-windows-go:
1311 needs: [changes, desktop-prepare, desktop-windows-go-group]
1312 if: always()
1313 runs-on: ubuntu-latest
1314 steps:
1315 - name: Verify Windows desktop Go groups
1316 env:
1317 CHANGES_RESULT: ${{ needs.changes.result }}
1318 SHOULD_RUN: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false' }}
1319 PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
1320 GROUP_RESULT: ${{ needs.desktop-windows-go-group.result }}
1321 run: |
1322 test "$CHANGES_RESULT" = success
1323 if [ "$SHOULD_RUN" = true ]; then
1324 test "$PREPARE_RESULT" = success
1325 test "$GROUP_RESULT" = success
1326 else
1327 test "$GROUP_RESULT" = skipped
1328 fi
1329
1330 # Installer packaging is packaging validation, not a code gate, and it shares
1331 # no state with the test steps above. It runs beside them instead of after
1332 # them so the Windows leg's wall time is the longer half rather than the sum,
1333 # and so a packaging failure costs a short rerun instead of the whole leg.
1334 # Packaging changes also qualify before merge, so installer/upgrade failures
1335 # cannot first appear on the release candidate's push.
1336 desktop-windows-package:
1337 needs: [changes, desktop-prepare]
1338 if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.packaging != 'false') }}
1339 runs-on: windows-latest
1340 # Bound packaging hangs; diagnostic measurement has its own workflow.
1341 timeout-minutes: 45
1342 defaults:
1343 run:
1344 shell: bash
1345 working-directory: desktop
1346 steps:
1347 - uses: actions/checkout@v7
1348
1349 - uses: actions/setup-go@v7
1350 with:
1351 go-version-file: desktop/go.mod
1352 cache: true
1353 cache-dependency-path: desktop/go.sum
1354
1355 - uses: pnpm/action-setup@v6.1.0
1356 with:
1357 version: 10
1358 run_install: false
1359
1360 - uses: actions/setup-node@v7
1361 with:
1362 node-version: "24"
1363 cache: pnpm
1364 cache-dependency-path: desktop/pnpm-lock.yaml
1365
1366 # Same Defender hazard as the test leg: exclusions keep packaging from
1367 # tripping over freshly written files that are still held open.
1368 - name: Exclude build dirs from Defender scanning
1369 shell: pwsh
1370 run: |
1371 $paths = @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP, (go env GOCACHE)) |
1372 Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
1373 foreach ($p in $paths) {
1374 try {
1375 Add-MpPreference -ExclusionPath $p -ErrorAction Stop
1376 Write-Host "Defender exclusion added: $p"
1377 } catch {
1378 Write-Host "::warning::Defender exclusion failed for ${p}: $($_.Exception.Message)"
1379 }
1380 }
1381
1382 - uses: actions/download-artifact@v8
1383 with:
1384 name: ${{ needs.desktop-prepare.outputs.canary_artifact_name }}
1385 path: desktop/frontend
1386 - name: Verify canary frontend artifact
1387 run: |
1388 test -n "${{ needs.desktop-prepare.outputs.producer_attempt }}"
1389 node frontend/scripts/artifact-identity.mjs verify \
1390 --shell electron --channel canary \
1391 --source-sha "$(git rev-parse HEAD)" --run-id "$GITHUB_RUN_ID" --attempt "${{ needs.desktop-prepare.outputs.producer_attempt }}" \
1392 --pnpm-version "$(pnpm --version)"
1393
1394 - name: Install NSIS
1395 run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
1396
1397 - name: Verify uninstaller-only compilation preserves the shared uninstaller
1398 run: node ../scripts/check-windows-uninstaller.mjs
1399
1400 - name: Build Windows installer and portable archive
1401 timeout-minutes: 20
1402 env:
1403 REASONIX_PACKAGE_REUSE_FRONTEND: "1"
1404 run: |
1405 REASONIX_FRONTEND_PNPM_VERSION="$(pnpm --version)"
1406 export REASONIX_FRONTEND_PNPM_VERSION
1407 ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
1408
1409 - name: Verify packaged Windows artifacts
1410 run: |
1411 node packaging/verify.mjs ../dist/Reasonix-windows-amd64.zip
1412 node packaging/signing-files.mjs build/windows/signing-payload --check
1413
1414 - name: Install and smoke-test Windows installer identity
1415 shell: pwsh
1416 run: |
1417 go build -o "$env:RUNNER_TEMP/windows-upgrade-fixture.exe" ./cmd/windows-upgrade-fixture
1418 ../scripts/test-windows-installer-startup.ps1 `
1419 -InstallerPath ../dist/Reasonix-windows-amd64-installer.exe `
1420 -ExpectedVersion v0.0.0-ci `
1421 -FixtureBuilderPath "$env:RUNNER_TEMP/windows-upgrade-fixture.exe" `
1422 -EvidenceDirectory "$env:RUNNER_TEMP/reasonix-installer-acceptance"
1423
1424 - name: Upload Windows installer acceptance evidence
1425 if: always()
1426 uses: actions/upload-artifact@v7
1427 with:
1428 name: windows-installer-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
1429 path: |
1430 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.json
1431 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.png
1432 ${{ runner.temp }}/reasonix-installer-acceptance/**/*.log
1433 !${{ runner.temp }}/reasonix-installer-acceptance/installed/**
1434 !${{ runner.temp }}/reasonix-installer-acceptance/**/cache/**
1435 if-no-files-found: ignore
1436 retention-days: 7
1437
1438 ci-metrics:
1439 needs: [desktop, desktop-macos, desktop-windows, desktop-windows-go, desktop-windows-package]
1440 if: always()
1441 runs-on: ubuntu-latest
1442 permissions:
1443 actions: read
1444 contents: read
1445 steps:
1446 - uses: actions/checkout@v7
1447 - name: Summarize queue, execution and stage timing
1448 env:
1449 GH_TOKEN: ${{ github.token }}
1450 run: |
1451 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > "$RUNNER_TEMP/ci-run.json"
1452 gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" > "$RUNNER_TEMP/ci-jobs.json"
1453 node scripts/ci-timings.mjs \
1454 --run "$RUNNER_TEMP/ci-run.json" \
1455 --jobs "$RUNNER_TEMP/ci-jobs.json" \
1456 --summary "$GITHUB_STEP_SUMMARY" \
1457 --title "Frontend CI timing"
1458
1459 # Every main-v2 push saves one Go cache per Unix OS and module (~2.6 GiB
1460 # measured) against a 10 GiB repository limit shared with pnpm, CodeQL and
1461 # setup-go caches; two pushes filled it. Keep only the newest cache per
1462 # prefix once the savers are done; restore-keys pick the newest anyway.
1463 prune-go-cache:
1464 needs: [test, race, desktop-go, desktop-go-race, desktop-macos]
1465 if: ${{ !cancelled() && github.event_name == 'push' }}
1466 runs-on: ubuntu-latest
1467 timeout-minutes: 5
1468 permissions:
1469 actions: write
1470 contents: read
1471 steps:
1472 - name: Keep the newest Go cache per OS and module
1473 env:
1474 GH_TOKEN: ${{ github.token }}
1475 run: |
1476 set -euo pipefail
1477 declare -A seen
1478 while read -r id key; do
1479 [ -n "$id" ] || continue
1480 prefix="${key%-*-*-*}"
1481 if [ -n "${seen[$prefix]:-}" ]; then
1482 echo "deleting older cache $key"
1483 gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null
1484 else
1485 seen[$prefix]=1
1486 echo "keeping newest cache $key"
1487 fi
1488 done < <(gh api "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100&sort=created_at&direction=desc" --paginate \
1489 --jq '.actions_caches[] | select(.key | startswith("go-")) | "\(.id) \(.key)"')
1490
1491 # repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
1492 # `code` filter would otherwise skip.
1493 lint-code:
1494 needs: changes
1495 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false') }}
1496 runs-on: ubuntu-latest
1497 steps:
1498 - uses: actions/checkout@v7
1499
1500 - uses: actions/setup-go@v7
1501 with:
1502 go-version-file: go.mod
1503 cache: false
1504
1505 - uses: ./.github/actions/go-build-cache
1506 id: gocache
1507 with:
1508 module: root
1509
1510 - name: repo standards
1511 run: go run ./tools/repolint
1512
1513 # `make lint-install` reads the same file, so a local run and this job
1514 # cannot drift onto different linter versions.
1515 - id: golangci
1516 run: echo "version=$(cat .golangci-version)" >> "$GITHUB_OUTPUT"
1517
1518 - name: golangci-lint
1519 uses: golangci/golangci-lint-action@v9
1520 with:
1521 version: ${{ steps.golangci.outputs.version }}
1522 args: --timeout=5m
1523
1524 # The step above only type-checks the linux/amd64 build, so every
1525 # //go:build windows and //go:build darwin file in the tree went unlinted.
1526 # Both modules cross-check without a toolchain; desktop under darwin does
1527 # not, because its bundle icon repair is cgo, so that leg lives in
1528 # desktop-macos.
1529 - name: golangci-lint (cross-platform build tags)
1530 run: |
1531 set -euo pipefail
1532 command -v golangci-lint
1533 for target in "darwin ." "windows ." "windows desktop"; do
1534 read -r target_os target_dir <<< "$target"
1535 echo "::group::golangci-lint GOOS=$target_os ($target_dir)"
1536 (cd "$target_dir" && GOOS="$target_os" golangci-lint run --timeout=5m ./...)
1537 echo "::endgroup::"
1538 done
1539
1540 release-control:
1541 needs: changes
1542 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.release_control != 'false') }}
1543 runs-on: ubuntu-latest
1544 timeout-minutes: 10
1545 steps:
1546 - uses: actions/checkout@v7
1547 - uses: actions/setup-go@v7
1548 with:
1549 go-version-file: go.mod
1550 cache: false
1551 - uses: actions/setup-node@v7
1552 with:
1553 node-version: "24"
1554 - name: Validate release workflows and executable contracts
1555 run: |
1556 go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 \
1557 -ignore 'label "windows-11-arm" is unknown' \
1558 -ignore 'label "macos-15-intel" is unknown' \
1559 .github/workflows/release-stable.yml \
1560 .github/workflows/prepare-release-notes.yml \
1561 .github/workflows/release-candidate.yml \
1562 .github/workflows/release-candidate-verify.yml \
1563 .github/workflows/release-promote.yml \
1564 .github/workflows/release-verify.yml \
1565 .github/workflows/release.yml \
1566 .github/workflows/release-npm.yml \
1567 .github/workflows/release-desktop.yml \
1568 .github/workflows/pages.yml \
1569 .github/workflows/apple-notary-log.yml \
1570 .github/workflows/macos-signing-check.yml \
1571 .github/workflows/release-verify-issues.yml \
1572 .github/workflows/docs-impact.yml \
1573 .github/workflows/ci.yml
1574 node --test scripts/release-candidate.test.mjs scripts/resolve-release-candidate.test.mjs \
1575 scripts/verify-release-artifact-archive.test.mjs \
1576 scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
1577 scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
1578 scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
1579 scripts/desktop-windows-go-tests.test.mjs \
1580 npm/publish.test.mjs
1581 bash scripts/validate-release-candidate-source.test.sh
1582 bash scripts/validate-release-control-plane.test.sh
1583 bash scripts/verify-release-push-ci.test.sh
1584 bash scripts/release-stable.test.sh
1585 bash scripts/release-candidate-tags.test.sh
1586 bash scripts/verify-release-authorization.test.sh
1587 bash scripts/release-workflows.test.sh
1588 node scripts/check-single-release-public-contract.mjs
1589
1590 # `lint` is a protected check name. Keep it as the fail-closed aggregate so
1591 # Required check for the root-module and site jobs the per-OS `test` legs do
1592 # not cover. windows-control and sdk gate internally through RUN_STEPS and so
1593 # always report a result: `success` here means "did not fail", not "validated
1594 # this diff" — the same trade-off the required `test` legs already make. Do
1595 # not add govulncheck: it sets continue-on-error, so needs.*.result is
1596 # `success` even when its steps fail and the assertion would be a tautology.
1597 root:
1598 needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
1599 if: always()
1600 runs-on: ubuntu-latest
1601 steps:
1602 - name: Verify root validation jobs
1603 env:
1604 CHANGES_RESULT: ${{ needs.changes.result }}
1605 CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
1606 SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
1607 COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
1608 CONTROL_RESULT: ${{ needs.windows-control.result }}
1609 ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
1610 SDK_RESULT: ${{ needs.sdk.result }}
1611 SITE_RESULT: ${{ needs.site.result }}
1612 COVERAGE_RESULT: ${{ needs.coverage.result }}
1613 run: |
1614 test "$CHANGES_RESULT" = success
1615 expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
1616 test "$CONTROL_RESULT" = success
1617 test "$SDK_RESULT" = success
1618 test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
1619 test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
1620 test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
1621
1622 # frontend unit coverage can run once in desktop-frontend without weakening
1623 # the branch gate.
1624 lint:
1625 needs: [changes, lint-code, release-control, desktop-prepare, desktop-frontend]
1626 if: always()
1627 runs-on: ubuntu-latest
1628 steps:
1629 - name: Verify lint and frontend validation jobs
1630 env:
1631 CHANGES_RESULT: ${{ needs.changes.result }}
1632 LINT_CODE_RESULT: ${{ needs.lint-code.result }}
1633 LINT_CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false' }}
1634 RELEASE_CONTROL_RESULT: ${{ needs.release-control.result }}
1635 RELEASE_CONTROL_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.release_control != 'false' }}
1636 FRONTEND_RESULT: ${{ needs.desktop-frontend.result }}
1637 PREPARE_RESULT: ${{ needs.desktop-prepare.result }}
1638 FRONTEND_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.frontend != 'false' || needs.changes.outputs.electron != 'false' }}
1639 run: |
1640 test "$CHANGES_RESULT" = success
1641 expected() { if [ "$1" = true ]; then echo success; else echo skipped; fi; }
1642 lint_expected=skipped
1643 if [ "$LINT_CODE_REQUIRED" = true ]; then lint_expected=success; fi
1644 test "$LINT_CODE_RESULT" = "$lint_expected"
1645 test "$RELEASE_CONTROL_RESULT" = "$(expected "$RELEASE_CONTROL_REQUIRED")"
1646 if [ "$FRONTEND_REQUIRED" = true ]; then
1647 test "$PREPARE_RESULT" = success
1648 test "$FRONTEND_RESULT" = success
1649 else
1650 test "$FRONTEND_RESULT" = skipped
1651 fi
1652
1653 # The site/ auth client has security-sensitive redirect-validation logic
1654 # (safeNext) covered by node:test unit tests. Those tests use only Node
1655 # builtins, so no `npm install` is needed — run them directly on every PR so
1656 # a regression in redirect validation fails the build instead of shipping.
1657 # The notes_only term matches every other gated job here. Without it a
1658 # release-notes-only push runs this job while the root aggregate expects the
1659 # house-style skip, which would leave main-v2 permanently red. A pure
1660 # release-notes/** diff cannot touch site/, so skipping it loses no coverage.
1661 site:
1662 needs: changes
1663 if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
1664 runs-on: ubuntu-latest
1665 defaults:
1666 run:
1667 working-directory: site
1668 steps:
1669 - uses: actions/checkout@v7
1670
1671 - uses: actions/setup-node@v7
1672 with:
1673 node-version: "24"
1674
1675 - name: test
1676 run: npm test
1677
1678 govulncheck:
1679 needs: changes
1680 if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
1681 runs-on: ubuntu-latest
1682 continue-on-error: true # informational — stdlib vulns need a Go patch release
1683 steps:
1684 - uses: actions/checkout@v7
1685
1686 - uses: actions/setup-go@v7
1687 with:
1688 go-version-file: go.mod
1689 cache: false
1690
1691 - uses: ./.github/actions/go-build-cache
1692 id: gocache
1693 with:
1694 module: root
1695
1696 - name: install govulncheck
1697 run: go install golang.org/x/vuln/cmd/govulncheck@latest
1698
1699 - name: govulncheck
1700 run: govulncheck ./...
1701
1702 # `always()` matches every other gated job: a failed changes job makes this
1703 # run the full sweep instead of silently reporting skipped to the aggregate.
1704 coverage:
1705 needs: changes
1706 if: ${{ !cancelled() && (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') }}
1707 runs-on: ubuntu-latest
1708 steps:
1709 - uses: actions/checkout@v7
1710
1711 - uses: actions/setup-go@v7
1712 with:
1713 go-version-file: go.mod
1714 cache: false
1715
1716 - uses: ./.github/actions/go-build-cache
1717 id: gocache
1718 with:
1719 module: root
1720
1721 - name: Install and verify Linux sandbox backend
1722 run: |
1723 sudo apt-get update
1724 sudo apt-get install -y bubblewrap
1725 if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
1726 sudo sysctl -w kernel.unprivileged_userns_clone=1
1727 fi
1728 if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
1729 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
1730 fi
1731 bwrap --ro-bind / / --dev /dev --proc /proc -- true
1732
1733 - name: test with coverage
1734 run: go test -coverprofile=coverage.out -covermode=atomic ./...
1735
1736 - name: upload coverage
1737 uses: actions/upload-artifact@v7
1738 with:
1739 name: coverage-report
1740 path: coverage.out
1741 retention-days: 7
1742
1742 lines YAML