| 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 |