ci: add bounded complete release gate
This commit is contained in:
parent
0a934cf578
commit
2ac054864d
11 changed files with 772 additions and 102 deletions
16
.github/workflows/headless-portability.yml
vendored
16
.github/workflows/headless-portability.yml
vendored
|
|
@ -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
38
.github/workflows/release-gate.yml
vendored
Normal 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/
|
||||
|
|
@ -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-002–T-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-002–T-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 R2–R3 |
|
||||
| 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
41
docs/release-gate.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
7
global.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"sdk": {
|
||||
"version": "10.0.300",
|
||||
"rollForward": "latestPatch",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
399
tools/run-release-gate.ps1
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue