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
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install .NET 10
|
- name: Install pinned .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
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
|
# 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,
|
# below is presentation-free: it builds Bake, Plugin.Abstractions, Core,
|
||||||
|
|
@ -159,10 +159,10 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install .NET 10
|
- name: Install pinned .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
global-json-file: global.json
|
||||||
|
|
||||||
- name: Build and test the portable launcher
|
- name: Build and test the portable launcher
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -231,10 +231,10 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install .NET 10
|
- name: Install pinned .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
global-json-file: global.json
|
||||||
|
|
||||||
- name: Build and publish Linux graphical client
|
- name: Build and publish Linux graphical client
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -324,10 +324,10 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install .NET 10
|
- name: Install pinned .NET SDK
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
global-json-file: global.json
|
||||||
|
|
||||||
- name: Install lavapipe, the Vulkan loader and Xvfb
|
- name: Install lavapipe, the Vulkan loader and Xvfb
|
||||||
shell: bash
|
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 |
|
| 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 |
|
| 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 |
|
| 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 | NOT STARTED; technically unblocked after R1 review/commit | — | F-010/F-014/F-019 | Start only after R1 checkpoint is accepted |
|
| 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-016; F-015/F-021/F-022/F-030 | Wait for R2 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### R1 implementation checkpoint — 2026-08-18
|
||||||
|
|
||||||
**Working-tree base:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e`
|
**Working-tree base:** `15539a22a67f8d915d88f8b1d8126cd55eedda6e`
|
||||||
**Commit:** pending; do not describe this checkpoint as merged or durable until
|
**Commit:** `0a934cf5` on `codex/release-stabilization`; the checkpoint is
|
||||||
the source, test, plan, and audit records are committed together.
|
durable on that campaign branch but is not yet merged to `main`.
|
||||||
|
|
||||||
Implementation:
|
Implementation:
|
||||||
|
|
||||||
|
|
@ -495,9 +495,79 @@ Changed implementation/test files:
|
||||||
- `src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs`
|
- `src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs`
|
||||||
- `tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs`
|
- `tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs`
|
||||||
|
|
||||||
Rollback before commit is the exact reverse of those two file diffs. After a
|
Rollback is `git revert 0a934cf5`; do not rewrite branch history.
|
||||||
future commit, record its hash here and use `git revert <R1-commit>` rather than
|
|
||||||
rewriting 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
|
## 18. Session start protocol
|
||||||
|
|
||||||
|
|
@ -532,7 +602,7 @@ review, and cross-session ledger entry are all complete.
|
||||||
|
|
||||||
## 20. Immediate next action
|
## 20. Immediate next action
|
||||||
|
|
||||||
Review the R1 diff and checkpoint evidence, then commit the source, regression
|
Review and commit the R2 complete-gate checkpoint, then decide whether the next
|
||||||
test, plan, and audit updates together if accepted. After that, begin R2's
|
bounded goal is the remaining R2 locked-restore/warning/toolchain work or R3's
|
||||||
pinned complete gate; assign the R4 decision owner in parallel. Do not begin
|
truthful test taxonomy. Assign the R4 decision owner in parallel. Do not begin
|
||||||
bulk comment, artifact, or giant-file cleanup first.
|
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
|
## Post-audit stabilization update — 2026-08-18
|
||||||
|
|
||||||
R1 has been implemented and fully gated in the current uncommitted working
|
R1 has been implemented, fully gated, and preserved at `0a934cf5` on
|
||||||
tree. The launcher child is detached under the supervisor gate and stopped,
|
`codex/release-stabilization`. The launcher child is detached under the supervisor gate and stopped,
|
||||||
unsubscribed, and disposed outside it; a deterministic captured-callback race
|
unsubscribed, and disposed outside it; a deterministic captured-callback race
|
||||||
test fails against the old mechanism and passes against the fix. Launcher.Core
|
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
|
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.
|
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
|
This resolves F-009 technically but does not change the baseline audit's
|
||||||
historical count until the coherent R1 checkpoint is reviewed and committed.
|
historical count; the checkpoint is committed but not yet merged to `main`.
|
||||||
The public-release decision remains no-go because F-001, F-002, F-014, and the
|
|
||||||
remaining high-priority release-governance findings are untouched.
|
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
|
test that proves both exit-callback and disposal orders converge. Audit the
|
||||||
ordinary `SystemChildProcess` wrapper for the same inversion.
|
ordinary `SystemChildProcess` wrapper for the same inversion.
|
||||||
|
|
||||||
**Post-baseline resolution checkpoint (2026-08-18, uncommitted):** the working
|
**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):** the campaign
|
||||||
tree now performs the ownership transfer and all child operations outside
|
branch now performs the ownership transfer and all child operations outside
|
||||||
`_gate`; `OnProcessExited` obtains its optional exit code without holding that
|
`_gate`; `OnProcessExited` obtains its optional exit code without holding that
|
||||||
gate. A barrier-controlled regression captures the exit delegate before
|
gate. A barrier-controlled regression captures the exit delegate before
|
||||||
unsubscription and makes child disposal wait for that callback. It times out in
|
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
|
passed 25/25 fresh-process repetitions. Launcher.Core passes 339/339. Two
|
||||||
fresh serialized complete-solution runs each finished under a 900-second hard
|
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.
|
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
|
F-009 remains in this historical baseline, but its mechanism and required gates
|
||||||
and committed; the current working-tree mechanism and required gates are
|
are resolved in a durable campaign-branch commit that is not yet merged.
|
||||||
resolved.
|
|
||||||
|
|
||||||
## F-010 — Published build/test baseline materially overstates the portable gate
|
## 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
|
Fail on unexpected skips and duplicate discovery IDs; do not hand-edit test
|
||||||
totals into multiple living documents.
|
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
|
## F-011 — Mandatory WorldBuilder inventory is an obsolete OpenGL design guide
|
||||||
|
|
||||||
- **Severity:** P1 high
|
- **Severity:** P1 high
|
||||||
|
|
@ -341,6 +347,18 @@ resolved.
|
||||||
split environment-dependent gates explicitly. Give every job a hang timeout
|
split environment-dependent gates explicitly. Give every job a hang timeout
|
||||||
and collect dumps; make the portable complete gate required before release.
|
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
|
## F-015 — Headline pass totals include hundreds of unexecuted test contracts
|
||||||
|
|
||||||
- **Severity:** P1 high
|
- **Severity:** P1 high
|
||||||
|
|
@ -471,6 +489,13 @@ resolved.
|
||||||
that proves a clean checkout. Keep tool-only exceptions explicit rather than
|
that proves a clean checkout. Keep tool-only exceptions explicit rather than
|
||||||
allowing per-project drift.
|
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
|
## F-020 — The issue ledger is an unbounded mixed tracker, research log, and archive
|
||||||
|
|
||||||
- **Severity:** P1 high
|
- **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
|
fix; the current timing-dependent version alternates between passing in 48 s
|
||||||
and hanging indefinitely.
|
and hanging indefinitely.
|
||||||
|
|
||||||
**Post-baseline resolution checkpoint (2026-08-18, uncommitted):**
|
**Post-baseline resolution checkpoint (2026-08-18, `0a934cf5`):**
|
||||||
`DisposeAllowsAnAlreadyCapturedExitCallbackToComplete` is now the deterministic
|
`DisposeAllowsAnAlreadyCapturedExitCallbackToComplete` is now the deterministic
|
||||||
race harness. Explicit barriers make the fake child capture the exit delegate,
|
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
|
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
|
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,
|
the issue ID in a trait or comment; keep investigation programs in a separate,
|
||||||
non-default diagnostic project.
|
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
|
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))
|
if (last.Contains(expectedFragment, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return last;
|
return last;
|
||||||
|
|
|
||||||
|
|
@ -128,32 +128,39 @@ public sealed class MainWindowViewTests
|
||||||
{
|
{
|
||||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||||
var window = new MainWindow { DataContext = viewModel };
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
window.Show();
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
|
||||||
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
|
viewModel.EditorDialog.Open(kind, "Fixture title", _ => { });
|
||||||
Assert.True(viewModel.EditorDialog.IsOpen);
|
Assert.True(viewModel.EditorDialog.IsOpen);
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
|
Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!;
|
||||||
Assert.Same(expectedFocus, CurrentFocus(window));
|
Assert.Same(expectedFocus, CurrentFocus(window));
|
||||||
|
|
||||||
viewModel.EditorDialog.Close();
|
viewModel.EditorDialog.Close();
|
||||||
Assert.False(viewModel.EditorDialog.IsOpen);
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||||
|
|
||||||
// Nothing held focus before the dialog opened, so
|
// Nothing held focus before the dialog opened, so
|
||||||
// OnViewModelPropertyChanged's close branch posts the fallback
|
// OnViewModelPropertyChanged's close branch posts the fallback
|
||||||
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
|
// (ProfilesTree.Focus()). Pumping the dispatcher is what actually
|
||||||
// *runs* FocusActiveModal's caller and its ProfilesTree
|
// *runs* FocusActiveModal's caller and its ProfilesTree
|
||||||
// dereference — this is the #398 defect class: with
|
// dereference — this is the #398 defect class: with
|
||||||
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
|
// AvaloniaXamlLoader.Load(this) instead of InitializeComponent(),
|
||||||
// ProfilesTree is null here and this throws
|
// ProfilesTree is null here and this throws
|
||||||
// NullReferenceException out of the dispatcher. TreeView's Fluent
|
// NullReferenceException out of the dispatcher. TreeView's Fluent
|
||||||
// template sets Focusable="False" (focus lives on TreeViewItem
|
// template sets Focusable="False" (focus lives on TreeViewItem
|
||||||
// rows, not the tree itself), so a successful, non-throwing
|
// rows, not the tree itself), so a successful, non-throwing
|
||||||
// ProfilesTree.Focus() call still leaves focus at null — that is
|
// ProfilesTree.Focus() call still leaves focus at null — that is
|
||||||
// expected, not a failure.
|
// expected, not a failure.
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
Assert.NotSame(expectedFocus, CurrentFocus(window));
|
Assert.NotSame(expectedFocus, CurrentFocus(window));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseTestWindow(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
|
|
@ -161,22 +168,29 @@ public sealed class MainWindowViewTests
|
||||||
{
|
{
|
||||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||||
var window = new MainWindow { DataContext = viewModel };
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
window.Show();
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
|
||||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
|
Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!;
|
||||||
Assert.Same(datDirectoryBox, CurrentFocus(window));
|
Assert.Same(datDirectoryBox, CurrentFocus(window));
|
||||||
|
|
||||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||||
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
|
||||||
|
|
||||||
// See the comment in the editor-kind theory above: this pump is
|
// See the comment in the editor-kind theory above: this pump is
|
||||||
// what actually executes the ProfilesTree.Focus() fallback.
|
// what actually executes the ProfilesTree.Focus() fallback.
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
|
Assert.NotSame(datDirectoryBox, CurrentFocus(window));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseTestWindow(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
|
|
@ -184,22 +198,29 @@ public sealed class MainWindowViewTests
|
||||||
{
|
{
|
||||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||||
var window = new MainWindow { DataContext = viewModel };
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
window.Show();
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
|
||||||
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
|
await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync();
|
||||||
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
Assert.True(viewModel.UpdatePrompt.IsOpen);
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
|
Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!;
|
||||||
Assert.Same(closeButton, CurrentFocus(window));
|
Assert.Same(closeButton, CurrentFocus(window));
|
||||||
|
|
||||||
viewModel.UpdatePrompt.CloseCommand.Execute(null);
|
viewModel.UpdatePrompt.CloseCommand.Execute(null);
|
||||||
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
Assert.False(viewModel.UpdatePrompt.IsOpen);
|
||||||
|
|
||||||
// See the comment in the editor-kind theory above: this pump is
|
// See the comment in the editor-kind theory above: this pump is
|
||||||
// what actually executes the ProfilesTree.Focus() fallback.
|
// what actually executes the ProfilesTree.Focus() fallback.
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
Assert.NotSame(closeButton, CurrentFocus(window));
|
Assert.NotSame(closeButton, CurrentFocus(window));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseTestWindow(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
|
|
@ -207,33 +228,54 @@ public sealed class MainWindowViewTests
|
||||||
{
|
{
|
||||||
using LauncherWindowViewModel viewModel = CreateViewModel();
|
using LauncherWindowViewModel viewModel = CreateViewModel();
|
||||||
var window = new MainWindow { DataContext = viewModel };
|
var window = new MainWindow { DataContext = viewModel };
|
||||||
window.Show();
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
|
||||||
// ProfilesTree itself is not a Fluent focus target (its template
|
// ProfilesTree itself is not a Fluent focus target (its template
|
||||||
// sets Focusable="False"; individual TreeViewItem rows are the
|
// sets Focusable="False"; individual TreeViewItem rows are the
|
||||||
// real tab stops), so use another genuinely focusable, always
|
// real tab stops), so use another genuinely focusable, always
|
||||||
// visible control from the same non-modal chrome as the
|
// visible control from the same non-modal chrome as the
|
||||||
// "previously focused" anchor for the _focusBeforeModal != null
|
// "previously focused" anchor for the _focusBeforeModal != null
|
||||||
// branch of MainWindow.OnViewModelPropertyChanged.
|
// branch of MainWindow.OnViewModelPropertyChanged.
|
||||||
Control addServerButton = window
|
Control addServerButton = window
|
||||||
.GetVisualDescendants()
|
.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.First(button => Equals(button.Content, "+ Server"));
|
.First(button => Equals(button.Content, "+ Server"));
|
||||||
addServerButton.Focus();
|
addServerButton.Focus();
|
||||||
Assert.Same(addServerButton, CurrentFocus(window));
|
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();
|
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) =>
|
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