ci: add bounded complete release gate

This commit is contained in:
Erik 2026-08-18 09:09:38 +02:00
parent 0a934cf578
commit 2ac054864d
11 changed files with 772 additions and 102 deletions

View file

@ -78,10 +78,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
global-json-file: global.json
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Bake, Plugin.Abstractions, Core,
@ -159,10 +159,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
global-json-file: global.json
- name: Build and test the portable launcher
shell: pwsh
@ -231,10 +231,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
global-json-file: global.json
- name: Build and publish Linux graphical client
shell: pwsh
@ -324,10 +324,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
global-json-file: global.json
- name: Install lavapipe, the Vulkan loader and Xvfb
shell: bash

38
.github/workflows/release-gate.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Complete Release gate
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
complete-release:
name: Complete Release suite (Windows)
runs-on: windows-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pinned .NET SDK
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: Run complete bounded Release gate
shell: pwsh
run: ./tools/run-release-gate.ps1
- name: Upload Release gate evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: release-gate-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
retention-days: 14
path: artifacts/release-gate/

View file

@ -419,9 +419,9 @@ checkpoint. Do not infer status from chat history.
| Slice | Status | Commit(s) | Evidence/gates | Exact next action |
|---|---|---|---|---|
| R0 | PLAN ACCEPTED; tracking/owner assignment pending | — | Audit complete at `15539a22`; user started R1 | Track plan+audit artifacts; assign R4 owner |
| R1 | IMPLEMENTED + REQUIRED GATES PASS; review/commit pending | — | 2026-08-18 checkpoint below; F-009/T-001 resolved in working tree | Review diff, then commit the coherent R1 checkpoint |
| R2 | NOT STARTED; technically unblocked after R1 review/commit | — | F-010/F-014/F-019 | Start only after R1 checkpoint is accepted |
| R3 | NOT STARTED | — | T-002T-016; F-015/F-021/F-022/F-030 | Wait for R2 |
| R1 | COMPLETE ON CAMPAIGN BRANCH; not yet merged | `0a934cf5` | 2026-08-18 checkpoint below; F-009/T-001 resolved and committed | Preserve while R2 runs; merge through the normal review path |
| R2 | COMPLETE GATE CHECKPOINT; broader reproducibility work remains; commit pending | — | Checkpoint below; F-014 resolved, F-010/F-019 partially resolved | Review and commit this checkpoint, then choose locked restore/warning work or R3 |
| R3 | NOT STARTED | — | T-002T-018; F-015/F-021/F-022/F-030 | Wait for remaining R2 sequencing decision |
| R4 | NOT STARTED; may run parallel | — | F-001/F-026/F-031/F-034 | Assign owner/legal provenance decision path |
| R5 | NOT STARTED | — | F-002/F-003/F-006/F-007/F-011/F-020/F-027/F-029/F-032 | Wait for R2R3 |
| R6 | NOT STARTED | — | F-004/F-005/F-012/F-016/F-023/F-028 | Wait for R3/R5 |
@ -433,8 +433,8 @@ checkpoint. Do not infer status from chat history.
### R1 implementation checkpoint — 2026-08-18
**Working-tree base:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e`
**Commit:** pending; do not describe this checkpoint as merged or durable until
the source, test, plan, and audit records are committed together.
**Commit:** `0a934cf5` on `codex/release-stabilization`; the checkpoint is
durable on that campaign branch but is not yet merged to `main`.
Implementation:
@ -495,9 +495,79 @@ Changed implementation/test files:
- `src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs`
- `tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs`
Rollback before commit is the exact reverse of those two file diffs. After a
future commit, record its hash here and use `git revert <R1-commit>` rather than
rewriting history.
Rollback is `git revert 0a934cf5`; do not rewrite branch history.
### R2 complete-gate checkpoint — 2026-08-18
**Working-tree base:** `0a934cf5781c003375c14af9a1f565254df0f9f9`
**Commit:** pending; the source and evidence records must be committed together
before this checkpoint is described as durable.
Implemented gate:
- `global.json` pins the accepted .NET 10 SDK feature band at `10.0.300` with
`latestPatch` roll-forward and prerelease SDKs disabled. Every existing
`actions/setup-dotnet` step now reads that file instead of floating on
`10.0.x`.
- `tools/run-release-gate.ps1` discovers every project under `tests/` that
declares `Microsoft.NET.Test.Sdk` or `IsTestProject`, verifies the project is
present in `AcDream.slnx`, restores/builds the solution, and runs each test
assembly exactly once in its own Release process. It does not retry.
- Restore, build, and each test process have 600/900/600-second outer bounds.
Tests additionally use VSTest's 180-second per-test blame-hang collector with
mini dumps. An outer timeout kills the complete process tree and reports exit
code 124; GitHub Actions adds a 45-minute job bound.
- Every run writes exact commands and output, one TRX per assembly, any blame
sequence/dumps, `dotnet --info`, configured NuGet sources, commit/branch/RID,
aggregate executed/passed/skipped/failed counts, and `SHA256SUMS.txt`.
- `.github/workflows/release-gate.yml` runs the gate on pull requests, pushes
to `main`, and manual dispatch on `windows-latest`, then uploads the evidence
even when the gate fails. The focused Windows/Linux portability and Vulkan
lanes remain separate and are no longer the only deterministic CI coverage.
- `docs/release-gate.md` is the repository-owned local/CI runbook.
Test-isolation corrections, with no product behavior change:
- Four `MainWindowViewTests` that call `Show()` now close their window and pump
dispatcher cleanup in `finally` on the owning Avalonia test session. The old
tests leaked shown, thread-affine compositor state to runner teardown; no
suite serialization or retry was added.
- The complete gate's first evidence run correctly failed
`RealChildStderrIsCapturedForTheProcessStartInfoPath`: its live polling helper
briefly denied write sharing, so the final async stderr callback observed an
`IOException` and the deliberately no-throw capture sink latched off. The
helper now reads with `FileShare.ReadWrite | FileShare.Delete`, matching the
production status tailer; its assertions and five-second bound are unchanged.
Verification:
- Focused `MainWindowViewTests`: 13 passed / 0 skipped / 0 failed.
- Three fresh default-parallel whole-solution runs completed inside independent
180-second process bounds with 12/12 TRX files and no Avalonia cleanup error:
56.719, 56.321, and 58.506 seconds. Each reported 14,748 passed / 77 skipped /
0 failed.
- The stderr ProcessStartInfo test passed 25/25 fresh-process repetitions after
the live-reader correction.
- The actual outer-watchdog function killed a controlled fixture process tree
at 2.107 seconds, returned 124, and left no child process.
- The repository command `pwsh ./tools/run-release-gate.ps1` completed in
107.337 seconds on SDK `10.0.300`, RID `win-x64`: 12 assemblies, 14,748
executed and passed / 77 skipped / 0 failed. The evidence manifest contains
28 verified SHA-256 entries.
Deliberately still open in the wider R2 slice:
- the 26 warnings observed by a clean recompilation, centralized compiler and
package settings, package lock files/locked restore, and broken or unsupported
tool-project decisions;
- the known duplicate Core theory row and classification of the 77 skips, which
remain R3 work; and
- stale public headline counts, which must be corrected with the documentation
authority work rather than hand-edited as part of this gate checkpoint.
Therefore this checkpoint closes F-014 on the campaign branch and the SDK part
of F-019, and gives F-010 a truthful machine-readable count. It does not claim
the broader R2 reproducibility slice or R3 test-quality cleanup is complete.
## 18. Session start protocol
@ -532,7 +602,7 @@ review, and cross-session ledger entry are all complete.
## 20. Immediate next action
Review the R1 diff and checkpoint evidence, then commit the source, regression
test, plan, and audit updates together if accepted. After that, begin R2's
pinned complete gate; assign the R4 decision owner in parallel. Do not begin
bulk comment, artifact, or giant-file cleanup first.
Review and commit the R2 complete-gate checkpoint, then decide whether the next
bounded goal is the remaining R2 locked-restore/warning/toolchain work or R3's
truthful test taxonomy. Assign the R4 decision owner in parallel. Do not begin
bulk comment, artifact, giant-file, or unrelated cleanup first.

41
docs/release-gate.md Normal file
View file

@ -0,0 +1,41 @@
# Complete Release gate
The default release gate is repository-owned and uses the SDK feature band in
`global.json`:
```powershell
pwsh ./tools/run-release-gate.ps1
```
The command restores and builds `AcDream.slnx`, discovers every project under
`tests/` that declares `Microsoft.NET.Test.Sdk` or `IsTestProject`, verifies
that the solution includes it, and runs each test assembly once in a fresh
Release process. It does not retry failures.
Each restore, build, and test process has an outer hard timeout. Every test
also runs with VSTest blame-hang enabled: after three minutes in one test, the
test host is terminated and a mini dump is collected; after ten minutes, the
outer watchdog kills the complete `dotnet test` process tree. CI additionally
has a 45-minute job bound.
Evidence is written to `artifacts/release-gate/`:
- `release-gate-summary.json` records the commit, branch, worktree state, SDK,
RID, bounds, process outcomes, assembly list, and
executed/passed/skipped/failed totals;
- `environment.txt` records `dotnet --info`, configured NuGet sources, and the
discovered test-project set;
- `test-results/` contains one TRX per assembly plus any VSTest hang sequence
and dump files;
- `logs/` contains the exact command and complete output for every child
process; and
- `SHA256SUMS.txt` hashes the evidence bundle.
The complete gate runs on Windows because it exercises the full product and
launcher surface. `.github/workflows/headless-portability.yml` remains the
focused Windows/Linux portability and Vulkan evidence workflow; those jobs are
not substitutes for the complete gate.
Environment-dependent tests retain their current skip behavior and are counted
explicitly in the JSON/TRX report. Classifying or replacing those tests belongs
to R3, not to this gate checkpoint.

View file

@ -178,14 +178,26 @@ external systems.
## Post-audit stabilization update — 2026-08-18
R1 has been implemented and fully gated in the current uncommitted working
tree. The launcher child is detached under the supervisor gate and stopped,
R1 has been implemented, fully gated, and preserved at `0a934cf5` on
`codex/release-stabilization`. The launcher child is detached under the supervisor gate and stopped,
unsubscribed, and disposed outside it; a deterministic captured-callback race
test fails against the old mechanism and passes against the fix. Launcher.Core
passes 339/339, and two fresh serialized complete Release runs each finish
inside a 900-second hard bound with 14,748 passes / 77 skips / 0 failures.
This resolves F-009 technically but does not change the baseline audit's
historical count until the coherent R1 checkpoint is reviewed and committed.
The public-release decision remains no-go because F-001, F-002, F-014, and the
remaining high-priority release-governance findings are untouched.
historical count; the checkpoint is committed but not yet merged to `main`.
The subsequent R2 gate checkpoint pins SDK feature band `10.0.300`, adds a
repository-owned bounded Release command and Windows CI workflow that discover
and run all 12 test assemblies, captures TRX/log/environment/hash evidence, and
collects mini dumps before an outer process-tree timeout. Its local evidence
run reports 14,748 executed and passed / 77 skipped / 0 failed in 107.337
seconds. Three additional default-parallel solution runs pass without the
previous Avalonia compositor cleanup failure after shown test windows were
closed on their owning session.
This resolves F-014 technically on the campaign branch and partially addresses
F-010/F-019. The public-release decision remains no-go because F-001, F-002,
the 26 clean-build warnings, unpinned package graph, and the remaining
high-priority release-governance/test-truth findings are untouched.

View file

@ -214,8 +214,8 @@ did not find an actionable exception beyond the documented classes.
test that proves both exit-callback and disposal orders converge. Audit the
ordinary `SystemChildProcess` wrapper for the same inversion.
**Post-baseline resolution checkpoint (2026-08-18, uncommitted):** the working
tree now performs the ownership transfer and all child operations outside
**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):** the campaign
branch now performs the ownership transfer and all child operations outside
`_gate`; `OnProcessExited` obtains its optional exit code without holding that
gate. A barrier-controlled regression captures the exit delegate before
unsubscription and makes child disposal wait for that callback. It times out in
@ -223,9 +223,8 @@ five seconds with the old lock shape, passes in milliseconds with the fix, and
passed 25/25 fresh-process repetitions. Launcher.Core passes 339/339. Two
fresh serialized complete-solution runs each finished under a 900-second hard
bound with 14,748 passes / 77 skips / 0 failures in 1:28.822 and 1:30.241.
F-009 remains a baseline finding until the coherent R1 checkpoint is reviewed
and committed; the current working-tree mechanism and required gates are
resolved.
F-009 remains in this historical baseline, but its mechanism and required gates
are resolved in a durable campaign-branch commit that is not yet merged.
## F-010 — Published build/test baseline materially overstates the portable gate
@ -250,6 +249,13 @@ resolved.
Fail on unexpected skips and duplicate discovery IDs; do not hand-edit test
totals into multiple living documents.
**Post-baseline gate checkpoint (2026-08-18, commit pending):** the new
repository gate emits per-assembly TRX plus a JSON summary that distinguishes
14,748 executed/passed cases from 77 reported skips and retains the known
duplicate-discovery warning in the process log. This makes the complete
portable count reproducible, but F-010 remains open until R3 classifies false
passes/skips/diagnostics and the stale public headline claims are replaced.
## F-011 — Mandatory WorldBuilder inventory is an obsolete OpenGL design guide
- **Severity:** P1 high
@ -341,6 +347,18 @@ resolved.
split environment-dependent gates explicitly. Give every job a hang timeout
and collect dumps; make the portable complete gate required before release.
**Post-baseline resolution checkpoint (2026-08-18, commit pending):**
`.github/workflows/release-gate.yml` now runs the repository-owned Release gate
on Windows for pull requests, `main` pushes, and manual dispatch. The script
auto-discovers and verifies all 12 solution test projects, runs each once in a
fresh bounded process, arms VSTest mini-dump collection before the outer
process-tree watchdog, and uploads commands, logs, TRX, environment metadata,
aggregate counts, and hashes even on failure. The exact local workflow command
passed 14,748 executed / 77 skipped / 0 failed in 107.337 seconds; an isolated
watchdog probe returned 124 at 2.107 seconds and left no child. F-014 is
technically resolved on the campaign branch, pending commit/review and actual
GitHub execution after publication.
## F-015 — Headline pass totals include hundreds of unexecuted test contracts
- **Severity:** P1 high
@ -471,6 +489,13 @@ resolved.
that proves a clean checkout. Keep tool-only exceptions explicit rather than
allowing per-project drift.
**Post-baseline partial checkpoint (2026-08-18, commit pending):** `global.json`
pins SDK feature band `10.0.300` with `latestPatch` roll-forward, and all five
CI setup sites now consume that file. `docs/release-gate.md` documents the one
complete command and its evidence. F-019 remains open: common compiler/package
settings are still duplicated, there are no package lock files or locked
restore, and the unsupported tool-project decisions have not been made.
## F-020 — The issue ledger is an unbounded mixed tracker, research log, and archive
- **Severity:** P1 high

View file

@ -120,7 +120,7 @@ callback. The test needs a deterministic race harness after the production
fix; the current timing-dependent version alternates between passing in 48 s
and hanging indefinitely.
**Post-baseline resolution checkpoint (2026-08-18, uncommitted):**
**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):**
`DisposeAllowsAnAlreadyCapturedExitCallbackToComplete` is now the deterministic
race harness. Explicit barriers make the fake child capture the exit delegate,
release it from inside disposal, and wait for the callback to return. The old
@ -401,3 +401,28 @@ regression contracts with T-011's output apparatus. When a test graduates into
a stable contract, organize it under the owning component/behavior and retain
the issue ID in a trait or comment; keep investigation programs in a separate,
non-default diagnostic project.
### T-017 — Shown Avalonia test windows escaped their owning UI session
Four `MainWindowViewTests` called `window.Show()` but never closed the window.
One default-parallel solution run then failed during Avalonia headless cleanup:
the runner/compositor touched an object from a thread other than the one that
owned it, even though the Launcher project passed alone. The tests now close
shown windows and pump dispatcher cleanup in `finally` on the owning Avalonia
session. No product code, suite serialization, or retry was added. The focused
class passes 13/13, and three fresh default-parallel complete-solution runs pass
all 12 assemblies in 56.719, 56.321, and 58.506 seconds.
### T-018 — A live stderr assertion could disable the producer it observed
`ReadFileEventuallyContainingAsync` used `File.ReadAllTextAsync` while the
ProcessStartInfo stderr callbacks were still draining. Its read handle did not
share writes. If the final callback overlapped that short read, the capture
sink saw a sharing violation, deliberately latched into its no-throw state, and
the test waited five seconds for a line it had itself caused to be dropped. The
first complete repository-gate run exposed exactly that two-of-three-lines
failure. The helper now opens a live reader with
`FileShare.ReadWrite | FileShare.Delete`, matching the production status
tailer; the expected lines and timeout are unchanged. The test subsequently
passed 25/25 fresh-process runs and Launcher.Core passed 339/339 in the final
gate.

7
global.json Normal file
View file

@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.300",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}

View file

@ -750,7 +750,18 @@ public sealed class LauncherProcessSupervisorTests
{
try
{
last = await File.ReadAllTextAsync(path);
// This is a live-file poll, not a post-close read. Match
// the production status tailer's sharing contract so the
// assertion cannot transiently deny the stderr callback's
// final append and make the no-throw capture sink latch
// itself off before the expected line arrives.
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
using var reader = new StreamReader(stream);
last = await reader.ReadToEndAsync();
if (last.Contains(expectedFragment, StringComparison.Ordinal))
{
return last;

View file

@ -128,32 +128,39 @@ public sealed class MainWindowViewTests
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
window.Show();
try
{
window.Show();
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
Assert.True(viewModel.EditorDialog.IsOpen);
Dispatcher.UIThread.RunJobs();
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
Assert.True(viewModel.EditorDialog.IsOpen);
Dispatcher.UIThread.RunJobs();
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
Assert.Same(expectedFocus, CurrentFocus(window));
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
Assert.Same(expectedFocus, CurrentFocus(window));
viewModel.EditorDialog.Close();
Assert.False(viewModel.EditorDialog.IsOpen);
viewModel.EditorDialog.Close();
Assert.False(viewModel.EditorDialog.IsOpen);
// Nothing held focus before the dialog opened, so
// OnViewModelPropertyChanged's close branch posts the fallback
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
// *runs* FocusActiveModal's caller and its ProfilesTree
// dereference — this is the #398 defect class: with
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
// ProfilesTree is null here and this throws
// NullReferenceException out of the dispatcher. TreeView's Fluent
// template sets Focusable="False" (focus lives on TreeViewItem
// rows, not the tree itself), so a successful, non-throwing
// ProfilesTree.Focus() call still leaves focus at null — that is
// expected, not a failure.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(expectedFocus, CurrentFocus(window));
// Nothing held focus before the dialog opened, so
// OnViewModelPropertyChanged's close branch posts the fallback
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
// *runs* FocusActiveModal's caller and its ProfilesTree
// dereference — this is the #398 defect class: with
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
// ProfilesTree is null here and this throws
// NullReferenceException out of the dispatcher. TreeView's Fluent
// template sets Focusable="False" (focus lives on TreeViewItem
// rows, not the tree itself), so a successful, non-throwing
// ProfilesTree.Focus() call still leaves focus at null — that is
// expected, not a failure.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(expectedFocus, CurrentFocus(window));
}
finally
{
CloseTestWindow(window);
}
}
[AvaloniaFact]
@ -161,22 +168,29 @@ public sealed class MainWindowViewTests
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
window.Show();
try
{
window.Show();
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Dispatcher.UIThread.RunJobs();
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Dispatcher.UIThread.RunJobs();
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
Assert.Same(datDirectoryBox, CurrentFocus(window));
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
Assert.Same(datDirectoryBox, CurrentFocus(window));
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
// See the comment in the editor-kind theory above: this pump is
// what actually executes the ProfilesTree.Focus() fallback.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
// See the comment in the editor-kind theory above: this pump is
// what actually executes the ProfilesTree.Focus() fallback.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
}
finally
{
CloseTestWindow(window);
}
}
[AvaloniaFact]
@ -184,22 +198,29 @@ public sealed class MainWindowViewTests
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
window.Show();
try
{
window.Show();
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
Assert.True(viewModel.UpdatePrompt.IsOpen);
Dispatcher.UIThread.RunJobs();
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
Assert.True(viewModel.UpdatePrompt.IsOpen);
Dispatcher.UIThread.RunJobs();
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
Assert.Same(closeButton, CurrentFocus(window));
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
Assert.Same(closeButton, CurrentFocus(window));
viewModel.UpdatePrompt.CloseCommand.Execute(null);
Assert.False(viewModel.UpdatePrompt.IsOpen);
viewModel.UpdatePrompt.CloseCommand.Execute(null);
Assert.False(viewModel.UpdatePrompt.IsOpen);
// See the comment in the editor-kind theory above: this pump is
// what actually executes the ProfilesTree.Focus() fallback.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(closeButton, CurrentFocus(window));
// See the comment in the editor-kind theory above: this pump is
// what actually executes the ProfilesTree.Focus() fallback.
Dispatcher.UIThread.RunJobs();
Assert.NotSame(closeButton, CurrentFocus(window));
}
finally
{
CloseTestWindow(window);
}
}
[AvaloniaFact]
@ -207,33 +228,54 @@ public sealed class MainWindowViewTests
{
using LauncherWindowViewModel viewModel = CreateViewModel();
var window = new MainWindow { DataContext = viewModel };
window.Show();
try
{
window.Show();
// ProfilesTree itself is not a Fluent focus target (its template
// sets Focusable="False"; individual TreeViewItem rows are the
// real tab stops), so use another genuinely focusable, always
// visible control from the same non-modal chrome as the
// "previously focused" anchor for the _focusBeforeModal != null
// branch of MainWindow.OnViewModelPropertyChanged.
Control addServerButton = window
.GetVisualDescendants()
.OfType<Button>()
.First(button => Equals(button.Content, "+ Server"));
addServerButton.Focus();
Assert.Same(addServerButton, CurrentFocus(window));
// ProfilesTree itself is not a Fluent focus target (its template
// sets Focusable="False"; individual TreeViewItem rows are the
// real tab stops), so use another genuinely focusable, always
// visible control from the same non-modal chrome as the
// "previously focused" anchor for the _focusBeforeModal != null
// branch of MainWindow.OnViewModelPropertyChanged.
Control addServerButton = window
.GetVisualDescendants()
.OfType<Button>()
.First(button => Equals(button.Content, "+ Server"));
addServerButton.Focus();
Assert.Same(addServerButton, CurrentFocus(window));
viewModel.EditorDialog.Open(ProfileEditorKind.AddServer, "Fixture title", _ => { });
Dispatcher.UIThread.RunJobs();
Control serverName = (Control)GetNamedField(window, "ServerNameTextBox")!;
Assert.Same(serverName, CurrentFocus(window));
viewModel.EditorDialog.Close();
Dispatcher.UIThread.RunJobs();
// addServerButton was focused before the dialog opened, so the
// restore branch (focusToRestore.Focus()) is what ran here, not
// the ProfilesTree.Focus() fallback exercised by the tests above.
Assert.Same(addServerButton, CurrentFocus(window));
}
finally
{
CloseTestWindow(window);
}
}
private static void CloseTestWindow(MainWindow window)
{
// Avalonia objects are thread-affine. A shown test window must be
// closed and its compositor cleanup pumped by the same Avalonia test
// session that created it; leaving it for runner/GC teardown made the
// full parallel solution intermittently clean it from another thread.
if (window.IsVisible)
{
window.Close();
}
viewModel.EditorDialog.Open(ProfileEditorKind.AddServer, "Fixture title", _ => { });
Dispatcher.UIThread.RunJobs();
Control serverName = (Control)GetNamedField(window, "ServerNameTextBox")!;
Assert.Same(serverName, CurrentFocus(window));
viewModel.EditorDialog.Close();
Dispatcher.UIThread.RunJobs();
// addServerButton was focused before the dialog opened, so the
// restore branch (focusToRestore.Focus()) is what ran here, not
// the ProfilesTree.Focus() fallback exercised by the tests above.
Assert.Same(addServerButton, CurrentFocus(window));
}
private static Control? CurrentFocus(MainWindow window) =>

399
tools/run-release-gate.ps1 Normal file
View file

@ -0,0 +1,399 @@
<#
.SYNOPSIS
Runs the complete portable Release gate with bounded child processes.
.DESCRIPTION
Discovers every test project under tests/ that declares itself through
Microsoft.NET.Test.Sdk or IsTestProject, verifies that AcDream.slnx owns it,
restores and builds the solution, and then runs each test assembly in a
fresh process. Every process has an outer hard timeout. Test processes also
use VSTest blame-hang collection, so a stuck test host is terminated with a
mini dump before the outer watchdog is allowed to kill the process tree.
The evidence directory contains process logs, one TRX per test assembly,
any blame sequence/dump files, an environment report, an aggregate JSON
summary, and SHA-256 hashes. The script never retries a failed test.
#>
[CmdletBinding()]
param(
[string]$ArtifactsDirectory = 'artifacts/release-gate',
[int]$RestoreTimeoutSeconds = 600,
[int]$BuildTimeoutSeconds = 900,
[int]$TestTimeoutSeconds = 600,
[int]$HangTimeoutSeconds = 180,
[switch]$SkipRestore,
[switch]$SkipBuild
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
$solutionPath = Join-Path $repoRoot 'AcDream.slnx'
$artifactRoot = if ([IO.Path]::IsPathRooted($ArtifactsDirectory)) {
[IO.Path]::GetFullPath($ArtifactsDirectory)
} else {
[IO.Path]::GetFullPath((Join-Path $repoRoot $ArtifactsDirectory))
}
$logsDirectory = Join-Path $artifactRoot 'logs'
$resultsDirectory = Join-Path $artifactRoot 'test-results'
if ($RestoreTimeoutSeconds -le 0 -or $BuildTimeoutSeconds -le 0 -or
$TestTimeoutSeconds -le 0 -or $HangTimeoutSeconds -le 0) {
throw 'Every timeout must be a positive number of seconds.'
}
if ($TestTimeoutSeconds -le $HangTimeoutSeconds) {
throw 'TestTimeoutSeconds must exceed HangTimeoutSeconds so blame-hang can collect before the outer watchdog fires.'
}
if (-not (Test-Path -LiteralPath $solutionPath -PathType Leaf)) {
throw "Solution not found at '$solutionPath'."
}
# Reusing the documented local output path must not mix a passing run with
# stale dumps or TRX from an earlier failure. These are exact children of the
# caller-selected evidence root, never inferred filesystem targets.
foreach ($directory in @($logsDirectory, $resultsDirectory)) {
if (Test-Path -LiteralPath $directory) {
Remove-Item -LiteralPath $directory -Recurse -Force
}
}
foreach ($fileName in @('environment.txt', 'release-gate-summary.json', 'SHA256SUMS.txt')) {
$filePath = Join-Path $artifactRoot $fileName
if (Test-Path -LiteralPath $filePath -PathType Leaf) {
Remove-Item -LiteralPath $filePath -Force
}
}
New-Item -ItemType Directory -Force -Path $logsDirectory, $resultsDirectory | Out-Null
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$env:DOTNET_NOLOGO = '1'
function ConvertTo-CommandText {
param([string[]]$Arguments)
$rendered = foreach ($argument in $Arguments) {
if ($argument -match '[\s\"]') {
'"' + $argument.Replace('"', '\"') + '"'
} else {
$argument
}
}
return 'dotnet ' + ($rendered -join ' ')
}
function Invoke-BoundedDotnet {
param(
[Parameter(Mandatory)] [string]$Label,
[Parameter(Mandatory)] [string[]]$Arguments,
[Parameter(Mandatory)] [int]$TimeoutSeconds
)
$command = ConvertTo-CommandText -Arguments $Arguments
$logPath = Join-Path $logsDirectory "$Label.log"
$startedUtc = [DateTimeOffset]::UtcNow
Write-Host "[$Label] $command"
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = 'dotnet'
$startInfo.WorkingDirectory = $repoRoot
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) {
$startInfo.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "[$Label] failed to start dotnet."
}
$standardOutputTask = $process.StandardOutput.ReadToEndAsync()
$standardErrorTask = $process.StandardError.ReadToEndAsync()
$completed = $process.WaitForExit($TimeoutSeconds * 1000)
$timedOut = -not $completed
if ($timedOut) {
Write-Error "[$Label] exceeded its hard ${TimeoutSeconds}s timeout; killing PID $($process.Id) and its descendants." -ErrorAction Continue
try {
$process.Kill($true)
} catch {
Write-Error "[$Label] process-tree kill failed: $($_.Exception.Message)" -ErrorAction Continue
}
}
if ($timedOut -and -not $process.WaitForExit(30000)) {
throw "[$Label] did not exit within 30 seconds after process-tree termination was requested."
}
if (-not $timedOut) {
$process.WaitForExit()
}
$standardOutput = $standardOutputTask.GetAwaiter().GetResult()
$standardError = $standardErrorTask.GetAwaiter().GetResult()
$endedUtc = [DateTimeOffset]::UtcNow
$exitCode = if ($timedOut) { 124 } else { $process.ExitCode }
$log = @(
"label: $Label"
"command: $command"
"startedUtc: $($startedUtc.ToString('O'))"
"endedUtc: $($endedUtc.ToString('O'))"
"durationSeconds: $([Math]::Round(($endedUtc - $startedUtc).TotalSeconds, 3))"
"hardTimeoutSeconds: $TimeoutSeconds"
"timedOut: $timedOut"
"exitCode: $exitCode"
''
'--- stdout ---'
$standardOutput
'--- stderr ---'
$standardError
)
Set-Content -LiteralPath $logPath -Encoding utf8 -Value $log
if ($standardOutput) {
Write-Host $standardOutput.TrimEnd()
}
if ($standardError) {
Write-Host $standardError.TrimEnd()
}
$result = [pscustomobject]@{
Label = $Label
Command = $command
StartedUtc = $startedUtc.ToString('O')
EndedUtc = $endedUtc.ToString('O')
DurationSeconds = [Math]::Round(($endedUtc - $startedUtc).TotalSeconds, 3)
HardTimeoutSeconds = $TimeoutSeconds
TimedOut = $timedOut
ExitCode = $exitCode
Log = [IO.Path]::GetRelativePath($artifactRoot, $logPath).Replace('\', '/')
}
$process.Dispose()
return $result
}
function Get-TestProjects {
$solutionText = Get-Content -LiteralPath $solutionPath -Raw
$projects = @(
Get-ChildItem -LiteralPath (Join-Path $repoRoot 'tests') -Recurse -Filter '*.csproj' -File |
Where-Object {
$projectText = Get-Content -LiteralPath $_.FullName -Raw
$projectText -match 'Microsoft\.NET\.Test\.Sdk' -or
$projectText -match '<IsTestProject>\s*true\s*</IsTestProject>'
} |
Sort-Object FullName
)
if ($projects.Count -eq 0) {
throw 'No test projects were discovered.'
}
foreach ($project in $projects) {
$relative = [IO.Path]::GetRelativePath($repoRoot, $project.FullName).Replace('\', '/')
if ($solutionText -notmatch [Regex]::Escape("Path=`"$relative`"")) {
throw "Discovered test project '$relative' is missing from AcDream.slnx."
}
}
return $projects
}
function Get-TrxCounters {
param([Parameter(Mandatory)] [string]$TrxPath)
[xml]$document = Get-Content -LiteralPath $TrxPath
$counters = $document.TestRun.ResultSummary.Counters
if ($null -eq $counters) {
throw "TRX '$TrxPath' has no ResultSummary/Counters element."
}
return [pscustomobject]@{
Total = [int]$counters.total
Executed = [int]$counters.executed
Passed = [int]$counters.passed
Failed = [int]$counters.failed
# xUnit v3 records skips in TRX as total minus executed while leaving
# the VSTest notExecuted attribute at zero. There is no filter in this
# gate, so that difference is the assembly's reported skip count.
Skipped = [int]$counters.total - [int]$counters.executed
Error = [int]$counters.error
Timeout = [int]$counters.timeout
Aborted = [int]$counters.aborted
NotRunnable = [int]$counters.notRunnable
}
}
function Write-ArtifactHashes {
$hashPath = Join-Path $artifactRoot 'SHA256SUMS.txt'
$lines = @(
Get-ChildItem -LiteralPath $artifactRoot -Recurse -File |
Where-Object FullName -ne $hashPath |
Sort-Object FullName |
ForEach-Object {
$relative = [IO.Path]::GetRelativePath($artifactRoot, $_.FullName).Replace('\', '/')
$hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $relative"
}
)
Set-Content -LiteralPath $hashPath -Encoding ascii -Value $lines
}
Push-Location $repoRoot
try {
$gateStartedUtc = [DateTimeOffset]::UtcNow
$testProjects = @(Get-TestProjects)
$processes = [Collections.Generic.List[object]]::new()
$testReports = [Collections.Generic.List[object]]::new()
$gateFailures = [Collections.Generic.List[string]]::new()
$worktreeStatus = @(git status --porcelain=v1 --untracked-files=all)
$worktreeDirty = $worktreeStatus.Count -ne 0
$environmentPath = Join-Path $artifactRoot 'environment.txt'
$environmentReport = @(
"commit: $(git rev-parse HEAD)"
"branch: $(git branch --show-current)"
"worktreeDirty: $worktreeDirty"
"runtimeIdentifier: $([Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier)"
"os: $([Runtime.InteropServices.RuntimeInformation]::OSDescription)"
"processArchitecture: $([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)"
"powershell: $($PSVersionTable.PSVersion)"
''
'--- dotnet --info ---'
(dotnet --info | Out-String)
'--- configured NuGet sources ---'
(dotnet nuget list source | Out-String)
'--- worktree status at gate start ---'
($worktreeStatus -join [Environment]::NewLine)
'--- discovered test projects ---'
($testProjects | ForEach-Object {
[IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/')
})
)
Set-Content -LiteralPath $environmentPath -Encoding utf8 -Value $environmentReport
if (-not $SkipRestore) {
$restore = Invoke-BoundedDotnet -Label 'restore' -TimeoutSeconds $RestoreTimeoutSeconds -Arguments @(
'restore', $solutionPath, '--nologo'
)
$processes.Add($restore)
if ($restore.ExitCode -ne 0) {
throw "Restore failed or timed out; see '$($restore.Log)'."
}
}
if (-not $SkipBuild) {
$buildArguments = @('build', $solutionPath, '-c', 'Release', '--nologo')
if (-not $SkipRestore) {
$buildArguments += '--no-restore'
}
$build = Invoke-BoundedDotnet -Label 'build' -TimeoutSeconds $BuildTimeoutSeconds -Arguments $buildArguments
$processes.Add($build)
if ($build.ExitCode -ne 0) {
throw "Build failed or timed out; see '$($build.Log)'."
}
}
foreach ($project in $testProjects) {
$projectName = [IO.Path]::GetFileNameWithoutExtension($project.Name)
$projectResultDirectory = Join-Path $resultsDirectory $projectName
New-Item -ItemType Directory -Force -Path $projectResultDirectory | Out-Null
$relativeProject = [IO.Path]::GetRelativePath($repoRoot, $project.FullName).Replace('\', '/')
$test = Invoke-BoundedDotnet -Label "test-$projectName" -TimeoutSeconds $TestTimeoutSeconds -Arguments @(
'test'
$project.FullName
'-c'
'Release'
'--no-restore'
'--no-build'
'--nologo'
'--results-directory'
$projectResultDirectory
'--logger'
"trx;LogFileName=$projectName.trx"
'--logger'
'console;verbosity=minimal'
'--blame-hang'
'--blame-hang-dump-type'
'mini'
'--blame-hang-timeout'
"${HangTimeoutSeconds}s"
)
$processes.Add($test)
$trxPath = Join-Path $projectResultDirectory "$projectName.trx"
$counters = if (Test-Path -LiteralPath $trxPath -PathType Leaf) {
Get-TrxCounters -TrxPath $trxPath
} else {
$null
}
$testReports.Add([pscustomobject]@{
Project = $relativeProject
Assembly = $projectName
Process = $test
Trx = if ($null -ne $counters) {
[IO.Path]::GetRelativePath($artifactRoot, $trxPath).Replace('\', '/')
} else {
$null
}
Counters = $counters
})
if ($test.ExitCode -ne 0) {
$gateFailures.Add("$projectName exited $($test.ExitCode) (timedOut=$($test.TimedOut)).")
}
if ($null -eq $counters) {
$gateFailures.Add("$projectName produced no TRX result.")
}
}
$totals = [pscustomobject]@{
Total = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Total } } | Measure-Object -Sum).Sum
Executed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Executed } } | Measure-Object -Sum).Sum
Passed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Passed } } | Measure-Object -Sum).Sum
Failed = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Failed } } | Measure-Object -Sum).Sum
Skipped = ($testReports | ForEach-Object { if ($null -ne $_.Counters) { $_.Counters.Skipped } } | Measure-Object -Sum).Sum
}
$gateEndedUtc = [DateTimeOffset]::UtcNow
$summary = [ordered]@{
SchemaVersion = 1
Verdict = if ($gateFailures.Count -eq 0) { 'passed' } else { 'failed' }
Commit = (git rev-parse HEAD)
Branch = (git branch --show-current)
WorktreeDirty = $worktreeDirty
SdkVersion = (dotnet --version)
RuntimeIdentifier = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
StartedUtc = $gateStartedUtc.ToString('O')
EndedUtc = $gateEndedUtc.ToString('O')
DurationSeconds = [Math]::Round(($gateEndedUtc - $gateStartedUtc).TotalSeconds, 3)
Solution = 'AcDream.slnx'
TestProjectCount = $testProjects.Count
Timeouts = [ordered]@{
RestoreSeconds = $RestoreTimeoutSeconds
BuildSeconds = $BuildTimeoutSeconds
TestProcessSeconds = $TestTimeoutSeconds
PerTestHangSeconds = $HangTimeoutSeconds
}
Totals = $totals
Failures = @($gateFailures)
Tests = @($testReports)
Processes = @($processes)
Evidence = [ordered]@{
Environment = 'environment.txt'
Hashes = 'SHA256SUMS.txt'
}
}
$summaryPath = Join-Path $artifactRoot 'release-gate-summary.json'
$summary | ConvertTo-Json -Depth 12 |
Set-Content -LiteralPath $summaryPath -Encoding utf8
Write-ArtifactHashes
Write-Host "Release gate $($summary.Verdict): $($totals.Passed) passed, $($totals.Skipped) skipped, $($totals.Failed) failed across $($testProjects.Count) test assemblies."
Write-Host "Evidence: $artifactRoot"
if ($gateFailures.Count -ne 0) {
foreach ($failure in $gateFailures) {
Write-Error $failure -ErrorAction Continue
}
exit 1
}
} finally {
Pop-Location
}