From 3c15c10ce9b706bb1a2f7e363cabbfc1fcadf2ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:19:06 +0000 Subject: [PATCH 1/6] Treat MTP "zero tests ran" exit code as success in Deployment E2E workflow Co-authored-by: radical <1472+radical@users.noreply.github.com> --- .github/workflows/deployment-tests.yml | 22 +++- docs/ci/mtp-args-pipeline.md | 2 +- .../DeploymentTestsWorkflowTests.cs | 122 ++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index 0550b3b3510..18bf3323c0a 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -580,19 +580,27 @@ jobs: Azure__Location: westus3 GH_TOKEN: ${{ github.token }} run: | - # --ignore-exit-code 8: MTP returns exit code 8 ("zero tests ran") when the single - # matrix test is dynamically skipped (e.g. on a transient Azure regional-capacity error), - # which would otherwise be treated as a failure below. Treating 8 as success — the repo - # convention baked into MtpBaseArgs (eng/Testing.targets) — keeps a skipped deploy green. + # MTP returns exit code 8 ("zero tests ran") when the single matrix test is dynamically + # skipped — e.g. the opt-in Connector Namespace scenario + # (ASPIRE_DEPLOYMENT_TEST_ENABLE_CONNECTOR_NAMESPACE=false) or a transient Azure + # regional-capacity skip — so 8 must be treated as success to keep a skipped deploy green. + # The MTP `--ignore-exit-code 8` flag cannot do that here: it only remaps the exit code of + # the test module itself, while `dotnet test` computes its own exit code from the + # aggregated run and still reports 8. So the exit code is classified explicitly instead. + set +e ./dotnet.sh test --project tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj \ -c Release \ --results-directory ${{ github.workspace }}/testresults \ -- \ --report-trx --report-trx-filename "${{ matrix.shortname }}.trx" \ --filter-not-trait "quarantined=true" \ - --ignore-exit-code 8 \ - ${{ matrix.extraTestArgs }} \ - || echo "test_failed=true" >> $GITHUB_OUTPUT + ${{ matrix.extraTestArgs }} + test_exit_code=$? + set -e + echo "Test run exit code: $test_exit_code" + if [ "$test_exit_code" -ne 0 ] && [ "$test_exit_code" -ne 8 ]; then + echo "test_failed=true" >> $GITHUB_OUTPUT + fi - name: Collect Aspire CLI logs if: always() diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index 6a2dc33b144..39850b1e2a3 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -53,7 +53,7 @@ overrides declared in an individual test project are reflected in the final argu | Arg | Purpose | |-----|---------| -| `--ignore-exit-code 8` | Don't fail the test run when zero tests match filters ([MTP exit codes](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-exit-codes)) | +| `--ignore-exit-code 8` | Don't fail the test run when zero tests match filters ([MTP exit codes](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-exit-codes)). This only remaps the exit code of the test module itself: `dotnet test` computes its own exit code from the aggregated run and still reports 8, so a caller that must treat "zero tests ran" as success has to classify the exit code itself. | | `--crashdump` | Collect crash dumps on test host crash | | `--hangdump` | Enable hang detection and hang dump handling | | `--hangdump-type none` | Disable hang dump file creation; hang detection and timeout handling still occur, but no dump file is generated | diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs new file mode 100644 index 00000000000..5a2b49b3fc6 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.TestUtilities; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests; + +/// +/// Guards how the nightly Deployment E2E workflow classifies the exit code of the single +/// matrix test it runs, in particular the "zero tests ran" code produced by a dynamically +/// skipped scenario. +/// +public sealed class DeploymentTestsWorkflowTests(ITestOutputHelper output) : IDisposable +{ + private const string WorkflowRelativePath = ".github/workflows/deployment-tests.yml"; + private const string RunStepName = "Run deployment test (${{ matrix.shortname }})"; + + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); + + [Theory] + [InlineData(0, false)] // all tests passed + [InlineData(8, false)] // MTP "zero tests ran": the only test was dynamically skipped + [InlineData(2, true)] // a test failed + [InlineData(7, true)] // the test host crashed + [RequiresTools(["bash"])] + public async Task SkippedScenarioIsGreenWhileGenuineFailuresAreRed(int testExitCode, bool expectFailure) + { + var outputFile = Path.Combine(_workspace.Path, "github-output.txt"); + var result = await RunTestStepAsync(testExitCode, outputFile); + + Assert.Equal(0, result.ExitCode); + var stepOutputs = File.Exists(outputFile) ? await File.ReadAllTextAsync(outputFile) : string.Empty; + if (expectFailure) + { + Assert.Contains("test_failed=true", stepOutputs, StringComparison.Ordinal); + } + else + { + Assert.Equal(string.Empty, stepOutputs); + } + } + + private async Task RunTestStepAsync(int testExitCode, string outputFile) + { + var scriptPath = Path.Combine(_workspace.Path, "run-step.sh"); + await File.WriteAllTextAsync(scriptPath, BuildStepScript(testExitCode)); + + using var process = new Process(); + process.StartInfo.FileName = "bash"; + // GitHub runs `run:` steps with `bash -e {0}`, so the script must be exercised the same way. + process.StartInfo.ArgumentList.Add("-e"); + process.StartInfo.ArgumentList.Add(scriptPath); + process.StartInfo.WorkingDirectory = _workspace.Path; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.Environment["GITHUB_OUTPUT"] = outputFile; + + process.Start(); + + // Read both streams concurrently to avoid deadlock when a pipe buffer fills. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); + output.WriteLine(result.Output); + + return result; + } + + /// + /// Replaces the `./dotnet.sh test ...` invocation (a single backslash-continued command) with a + /// no-op that returns , so the surrounding classification logic + /// from the workflow runs verbatim without building or deploying anything. + /// + private static string BuildStepScript(int testExitCode) + { + var lines = GetRunStepScript().ReplaceLineEndings("\n").Split('\n'); + var start = Array.FindIndex(lines, line => line.Contains("./dotnet.sh test", StringComparison.Ordinal)); + Assert.True(start >= 0, $"Could not find the test invocation in the '{RunStepName}' step."); + + var end = start; + while (lines[end].TrimEnd().EndsWith('\\')) + { + end++; + } + + var rewritten = lines[..start] + .Append($"(exit {testExitCode})") + .Concat(lines[(end + 1)..]); + + return string.Join('\n', rewritten); + } + + private static string GetRunStepScript() + { + var yaml = new YamlStream(); + using (var reader = new StreamReader(Path.Combine(RepoRoot.Path, WorkflowRelativePath))) + { + yaml.Load(reader); + } + + var jobs = (YamlMappingNode)((YamlMappingNode)yaml.Documents[0].RootNode).Children[new YamlScalarNode("jobs")]; + var deployTest = (YamlMappingNode)jobs.Children[new YamlScalarNode("deploy-test")]; + var steps = ((YamlSequenceNode)deployTest.Children[new YamlScalarNode("steps")]).Cast(); + var runStep = Assert.Single(steps, step => Scalar(step, "name") == RunStepName); + + return Scalar(runStep, "run") ?? throw new InvalidOperationException($"The '{RunStepName}' step has no run script."); + } + + private static string? Scalar(YamlMappingNode node, string key) + => node.Children.TryGetValue(new YamlScalarNode(key), out var value) ? ((YamlScalarNode)value).Value : null; + + public void Dispose() => _workspace.Dispose(); + + private sealed record CommandResult(int ExitCode, string Output); +} From 2ae9cc26666584e2044eff8bfabb89095ca82e51 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 24 Sep 2026 19:13:18 -0400 Subject: [PATCH 2/6] Fix zero-test handling in shared test runner The Acquisition outerloop jobs fail on every platform when all selected tests are skipped. Microsoft Testing Platform returns exit code 8 for the test module, but dotnet test recomputes the aggregate result and still exits 8 despite --ignore-exit-code 8. Map only the aggregated zero-tests code to success in the shared Bash and PowerShell dotnet test paths. Preserve every other nonzero exit code and leave direct assembly execution unchanged, where the MTP option already works. Add focused tests that execute the classification logic extracted from the real workflow for success, zero tests, test failure, and host crash exit codes on both shell variants. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-tests.yml | 19 ++- docs/ci/mtp-args-pipeline.md | 4 + .../WorkflowScripts/RunTestsWorkflowTests.cs | 138 ++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index edb755d4dbf..7bd3fbe11a5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -549,6 +549,13 @@ jobs: --results-directory ${{ github.workspace }}/testresults \ ${{ inputs.extraTestArgs }} || TEST_EXIT_CODE=$? + # `--ignore-exit-code 8` only remaps the test module's exit code. `dotnet test` + # aggregates the run and still returns 8 when every selected test is skipped. + if [ "$TEST_EXIT_CODE" -eq 8 ]; then + echo "All selected tests were skipped; treating MTP exit code 8 as success." + TEST_EXIT_CODE=0 + fi + # Persist the MTP exit code so the "Verify test results exist" gate can classify the outcome # (test failure vs. infrastructure/crash) even though we exit 0 below in quarantine mode. echo "$TEST_EXIT_CODE" > "${{ github.workspace }}/test-exit-code.txt" @@ -612,6 +619,13 @@ jobs: --results-directory ${{ github.workspace }}/testresults ` ${{ inputs.extraTestArgs }} $testExitCode = $LASTEXITCODE + + # `--ignore-exit-code 8` only remaps the test module's exit code. `dotnet test` + # aggregates the run and still returns 8 when every selected test is skipped. + if ($testExitCode -eq 8) { + Write-Host "All selected tests were skipped; treating MTP exit code 8 as success." + $testExitCode = 0 + } } finally { # Stop heartbeat monitor @@ -654,8 +668,9 @@ jobs: # 10 = test adapter infrastructure failure (e.g. a fixture could not be created) # 11 = the test process exited because a dependent process exited # 12 = no supported protocol version - # (Exit code 8 "zero tests ran" is already mapped to 0 by --ignore-exit-code 8 in MtpBaseArgs, so a - # genuinely empty run is caught below by the .trx/artifact checks rather than by the exit code.) + # (Exit code 8 "zero tests ran" is mapped to 0 either by --ignore-exit-code 8 for direct assembly + # execution or explicitly by the dotnet test steps above, so a genuinely empty run is caught below + # by the .trx/artifact checks rather than by the exit code.) $exitCodeFile = "${{ github.workspace }}/test-exit-code.txt" if (Test-Path $exitCodeFile) { $mtpExitCode = [int]((Get-Content -Path $exitCodeFile -Raw).Trim()) diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index 39850b1e2a3..a92487de687 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -141,6 +141,10 @@ dotnet test --project --no-build -- \ MTP MSBuild integration injects `TestingPlatformCommandLineArguments` automatically (includes `--filter-not-trait "category=failing"` and TRX filename). These args are complementary to the explicit `-- `. +Because `dotnet test` still returns its aggregated exit code after the test module +applies `--ignore-exit-code 8`, the non-NuGet paths in `run-tests.yml` explicitly +map exit code 8 to success. Other nonzero exit codes remain failures. + ## Backward compatibility The `run-tests.yml` `mtpBaseArgs` input has a default value that includes diagnostic flags (crashdump, hangdump, exit-code handling) but does not include timeout arguments. Timeout values are baked into `mtpBaseArgs` at build time by MSBuild (via `eng/Testing.targets`) and flow through the test matrix metadata. Callers that bypass the matrix and don't pass `mtpBaseArgs` should include the timeout arguments explicitly if needed. diff --git a/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs new file mode 100644 index 00000000000..31a7dfeb2a7 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.TestUtilities; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests; + +public sealed class RunTestsWorkflowTests(ITestOutputHelper output) : IDisposable +{ + private const string WorkflowRelativePath = ".github/workflows/run-tests.yml"; + private const string UnixStepName = "Run tests (Linux/macOS)"; + private const string WindowsStepName = "Run tests (Windows)"; + + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); + + [Theory] + [InlineData(0, 0)] + [InlineData(8, 0)] + [InlineData(2, 2)] + [InlineData(7, 7)] + [RequiresTools(["bash"])] + public async Task UnixDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) + { + var scriptPath = Path.Combine(_workspace.Path, $"run-tests-{testExitCode}.sh"); + await File.WriteAllTextAsync(scriptPath, BuildUnixClassificationScript(testExitCode)); + + var result = await RunProcessAsync("bash", ["-e", scriptPath]); + + Assert.Equal(expectedExitCode, result.ExitCode); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(8, 0)] + [InlineData(2, 2)] + [InlineData(7, 7)] + [RequiresTools(["pwsh"])] + public async Task WindowsDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) + { + var scriptPath = Path.Combine(_workspace.Path, $"run-tests-{testExitCode}.ps1"); + await File.WriteAllTextAsync(scriptPath, BuildWindowsClassificationScript(testExitCode)); + + var result = await RunProcessAsync("pwsh", ["-NoProfile", "-File", scriptPath]); + + Assert.Equal(expectedExitCode, result.ExitCode); + } + + private string BuildUnixClassificationScript(int testExitCode) + { + var script = GetRunStepScript(UnixStepName); + var start = script.IndexOf("# Persist the MTP exit code", StringComparison.Ordinal); + var end = script.LastIndexOf("exit $TEST_EXIT_CODE", StringComparison.Ordinal); + Assert.True(start >= 0, $"Could not find the exit-code persistence block in '{UnixStepName}'."); + Assert.True(end >= start, $"Could not find the final exit in '{UnixStepName}'."); + + var classification = script[start..(end + "exit $TEST_EXIT_CODE".Length)] + .Replace("${{ github.workspace }}", _workspace.Path, StringComparison.Ordinal) + .Replace("${{ inputs.ignoreTestFailures }}", "false", StringComparison.Ordinal); + + var normalizationStart = script.IndexOf("# `--ignore-exit-code 8`", StringComparison.Ordinal); + Assert.True(normalizationStart >= 0, $"Could not find the exit-code normalization block in '{UnixStepName}'."); + classification = script[normalizationStart..start] + classification; + + return $"TEST_EXIT_CODE={testExitCode}\n{classification}\n"; + } + + private string BuildWindowsClassificationScript(int testExitCode) + { + var lines = GetRunStepScript(WindowsStepName).ReplaceLineEndings("\n").Split('\n'); + var normalizationStart = Array.FindIndex(lines, line => line.Contains("# `--ignore-exit-code 8`", StringComparison.Ordinal)); + var normalizationEnd = Array.FindIndex(lines, normalizationStart, line => line.Trim() == "}"); + var persistenceStart = Array.FindIndex(lines, line => line.Contains("# Persist the MTP exit code", StringComparison.Ordinal)); + var finalExit = Array.FindLastIndex(lines, line => line.Trim() == "exit $testExitCode"); + Assert.True(normalizationStart >= 0, $"Could not find the exit-code normalization block in '{WindowsStepName}'."); + Assert.True(normalizationEnd >= normalizationStart, $"Could not find the end of the exit-code normalization block in '{WindowsStepName}'."); + Assert.True(persistenceStart > normalizationEnd, $"Could not find the exit-code persistence block in '{WindowsStepName}'."); + Assert.True(finalExit >= persistenceStart, $"Could not find the final exit in '{WindowsStepName}'."); + + var classification = string.Join( + '\n', + lines[normalizationStart..(normalizationEnd + 1)] + .Concat(lines[persistenceStart..(finalExit + 1)])) + .Replace("${{ github.workspace }}", _workspace.Path, StringComparison.Ordinal) + .Replace("${{ inputs.ignoreTestFailures }}", "false", StringComparison.Ordinal); + + return $"$testExitCode = {testExitCode}\n{classification}\n"; + } + + private async Task RunProcessAsync(string fileName, IReadOnlyList arguments) + { + using var process = new Process(); + process.StartInfo.FileName = fileName; + process.StartInfo.WorkingDirectory = _workspace.Path; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); + output.WriteLine(result.Output); + + return result; + } + + private static string GetRunStepScript(string stepName) + { + var yaml = new YamlStream(); + using var reader = new StreamReader(Path.Combine(RepoRoot.Path, WorkflowRelativePath)); + yaml.Load(reader); + + var jobs = (YamlMappingNode)((YamlMappingNode)yaml.Documents[0].RootNode).Children[new YamlScalarNode("jobs")]; + var testJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("test")]; + var steps = ((YamlSequenceNode)testJob.Children[new YamlScalarNode("steps")]).Cast(); + var runStep = Assert.Single(steps, step => Scalar(step, "name") == stepName); + + return Scalar(runStep, "run") ?? throw new InvalidOperationException($"The '{stepName}' step has no run script."); + } + + private static string? Scalar(YamlMappingNode node, string key) + => node.Children.TryGetValue(new YamlScalarNode(key), out var value) ? ((YamlScalarNode)value).Value : null; + + public void Dispose() => _workspace.Dispose(); + + private sealed record CommandResult(int ExitCode, string Output); +} From 6d49f3b2bb20bc2b59147bdeae41dcff2bccbc82 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 25 Sep 2026 13:15:31 -0400 Subject: [PATCH 3/6] test(ci): run MTP exit classification scripts directly The workflow tests parsed YAML and rebuilt script fragments around marker comments. They could miss regressions in the actual workflow-to-script handoff and broke on harmless workflow formatting changes. Extract MTP exit-code normalization and Deployment E2E result classification into checked-in Bash and PowerShell scripts. The workflows now invoke those files directly, Infrastructure.Tests executes them for exit codes 0, 8, 2, and 7, and conditional test selection routes script changes to their workflow consumers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../classify-deployment-test-exit-code.sh | 16 ++ .github/workflows/deployment-tests.yml | 4 +- .github/workflows/normalize-mtp-exit-code.ps1 | 15 ++ .github/workflows/normalize-mtp-exit-code.sh | 18 +++ .github/workflows/run-tests.yml | 21 ++- docs/ci/mtp-args-pipeline.md | 7 +- eng/github-ci/test-trigger-map.yml | 3 + .../TestTriggerMap/TestTriggerMapTests.cs | 15 ++ .../DeploymentTestExitCodeScriptTests.cs | 77 ++++++++++ .../DeploymentTestsWorkflowTests.cs | 122 ---------------- .../WorkflowScripts/MtpExitCodeScriptTests.cs | 78 ++++++++++ .../WorkflowScripts/RunTestsWorkflowTests.cs | 138 ------------------ 12 files changed, 237 insertions(+), 277 deletions(-) create mode 100644 .github/workflows/classify-deployment-test-exit-code.sh create mode 100644 .github/workflows/normalize-mtp-exit-code.ps1 create mode 100644 .github/workflows/normalize-mtp-exit-code.sh create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs delete mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/MtpExitCodeScriptTests.cs delete mode 100644 tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs diff --git a/.github/workflows/classify-deployment-test-exit-code.sh b/.github/workflows/classify-deployment-test-exit-code.sh new file mode 100644 index 00000000000..3e97c908774 --- /dev/null +++ b/.github/workflows/classify-deployment-test-exit-code.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must identify the workflow step output file}" + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +normalized_exit_code=0 +bash "$SCRIPT_DIR/normalize-mtp-exit-code.sh" "$1" || normalized_exit_code=$? + +if [ "$normalized_exit_code" -ne 0 ]; then + echo "test_failed=true" >> "$GITHUB_OUTPUT" +fi diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index 18bf3323c0a..07c6089615a 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -598,9 +598,7 @@ jobs: test_exit_code=$? set -e echo "Test run exit code: $test_exit_code" - if [ "$test_exit_code" -ne 0 ] && [ "$test_exit_code" -ne 8 ]; then - echo "test_failed=true" >> $GITHUB_OUTPUT - fi + bash .github/workflows/classify-deployment-test-exit-code.sh "$test_exit_code" - name: Collect Aspire CLI logs if: always() diff --git a/.github/workflows/normalize-mtp-exit-code.ps1 b/.github/workflows/normalize-mtp-exit-code.ps1 new file mode 100644 index 00000000000..cfa44e853d9 --- /dev/null +++ b/.github/workflows/normalize-mtp-exit-code.ps1 @@ -0,0 +1,15 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +param( + [Parameter(Mandatory)] + [ValidateRange(0, 255)] + [int] $ExitCode +) + +if ($ExitCode -eq 8) { + Write-Host "All selected tests were skipped; treating MTP exit code 8 as success." + exit 0 +} + +exit $ExitCode diff --git a/.github/workflows/normalize-mtp-exit-code.sh b/.github/workflows/normalize-mtp-exit-code.sh new file mode 100644 index 00000000000..0a32e19f1d5 --- /dev/null +++ b/.github/workflows/normalize-mtp-exit-code.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +set -euo pipefail + +if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^[0-9]+$ ]]; then + echo "Usage: $0 " >&2 + exit 5 +fi + +if [ "$1" -eq 8 ]; then + echo "All selected tests were skipped; treating MTP exit code 8 as success." + exit 0 +fi + +exit "$1" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7bd3fbe11a5..ca877e95334 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -549,12 +549,11 @@ jobs: --results-directory ${{ github.workspace }}/testresults \ ${{ inputs.extraTestArgs }} || TEST_EXIT_CODE=$? - # `--ignore-exit-code 8` only remaps the test module's exit code. `dotnet test` - # aggregates the run and still returns 8 when every selected test is skipped. - if [ "$TEST_EXIT_CODE" -eq 8 ]; then - echo "All selected tests were skipped; treating MTP exit code 8 as success." - TEST_EXIT_CODE=0 - fi + # `--ignore-exit-code 8` only remaps the test module's exit code. Normalize the + # aggregated `dotnet test` result with the same helper exercised by Infrastructure.Tests. + NORMALIZED_TEST_EXIT_CODE=0 + bash .github/workflows/normalize-mtp-exit-code.sh "$TEST_EXIT_CODE" || NORMALIZED_TEST_EXIT_CODE=$? + TEST_EXIT_CODE=$NORMALIZED_TEST_EXIT_CODE # Persist the MTP exit code so the "Verify test results exist" gate can classify the outcome # (test failure vs. infrastructure/crash) even though we exit 0 below in quarantine mode. @@ -620,12 +619,10 @@ jobs: ${{ inputs.extraTestArgs }} $testExitCode = $LASTEXITCODE - # `--ignore-exit-code 8` only remaps the test module's exit code. `dotnet test` - # aggregates the run and still returns 8 when every selected test is skipped. - if ($testExitCode -eq 8) { - Write-Host "All selected tests were skipped; treating MTP exit code 8 as success." - $testExitCode = 0 - } + # `--ignore-exit-code 8` only remaps the test module's exit code. Normalize the + # aggregated `dotnet test` result with the same helper exercised by Infrastructure.Tests. + & .github/workflows/normalize-mtp-exit-code.ps1 -ExitCode $testExitCode + $testExitCode = $LASTEXITCODE } finally { # Stop heartbeat monitor diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index a92487de687..434a6f9485c 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -142,8 +142,11 @@ dotnet test --project --no-build -- \ MTP MSBuild integration injects `TestingPlatformCommandLineArguments` automatically (includes `--filter-not-trait "category=failing"` and TRX filename). These args are complementary to the explicit `-- `. Because `dotnet test` still returns its aggregated exit code after the test module -applies `--ignore-exit-code 8`, the non-NuGet paths in `run-tests.yml` explicitly -map exit code 8 to success. Other nonzero exit codes remain failures. +applies `--ignore-exit-code 8`, the non-NuGet paths in `run-tests.yml` normalize +the result through `normalize-mtp-exit-code.sh` or +`normalize-mtp-exit-code.ps1`. The Deployment E2E workflow uses the same Bash +normalizer before deciding whether to set its failure output. Other nonzero exit +codes remain failures. ## Backward compatibility diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index 96dba197214..f206c76ee58 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -238,6 +238,7 @@ path_rules: - eng/testing/CITestsProperties.props - .github/workflows/tests.yml - .github/workflows/run-tests.yml + - .github/workflows/normalize-mtp-exit-code.* targets: [ALL] reason: matrix, enumeration, and runner infrastructure can affect any regular PR test lane @@ -330,6 +331,8 @@ path_rules: - tests/Aspire.Deployment.EndToEnd.Tests/** - src/Aspire.ProjectTemplates/** - .github/workflows/deployment-tests.yml + - .github/workflows/normalize-mtp-exit-code.sh + - .github/workflows/classify-deployment-test-exit-code.sh targets: [job:deployment-e2e] reason: SCHEDULE/DISPATCH-ONLY today; ProjectTemplates is a Layer-1 blind spot; Azure/Cli/TypeSystem/Managed production projects are in affected_project_rules - paths: diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index d34a6dedba4..036ab5707d7 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -72,6 +72,21 @@ public void WarningPolicyChangesSelectAllTests(string path) Assert.Equal(["ALL"], targets); } + [Theory] + [InlineData(".github/workflows/normalize-mtp-exit-code.sh", "ALL")] + [InlineData(".github/workflows/normalize-mtp-exit-code.ps1", "ALL")] + [InlineData(".github/workflows/normalize-mtp-exit-code.sh", "job:deployment-e2e")] + [InlineData(".github/workflows/classify-deployment-test-exit-code.sh", "job:deployment-e2e")] + public void MtpExitCodeScriptsSelectTheirWorkflowConsumers(string path, string expectedTarget) + { + var targets = s_map.PathRules + .Where(rule => rule.Paths.Any(glob => TestTriggerMap.GlobMatches(glob, path))) + .SelectMany(rule => rule.Targets) + .ToHashSet(StringComparer.Ordinal); + + Assert.Contains(expectedTarget, targets); + } + [Fact] public void ExtensionUnitWorkflowChangesSelectUnitAndE2eJobs() { diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs new file mode 100644 index 00000000000..8adc7edcecc --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Guards how the nightly Deployment E2E workflow classifies the exit code of the single +/// matrix test it runs, in particular the "zero tests ran" code produced by a dynamically +/// skipped scenario. +/// +public sealed class DeploymentTestExitCodeScriptTests(ITestOutputHelper output) : IDisposable +{ + private static readonly string s_classifierPath = Path.Combine( + RepoRoot.Path, + ".github", + "workflows", + "classify-deployment-test-exit-code.sh"); + + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); + + [Theory] + [InlineData(0, false)] // all tests passed + [InlineData(8, false)] // MTP "zero tests ran": the only test was dynamically skipped + [InlineData(2, true)] // a test failed + [InlineData(7, true)] // the test host crashed + [RequiresTools(["bash"])] + public async Task ClassificationMarksOnlyGenuineFailures(int testExitCode, bool expectFailure) + { + var outputFile = Path.Combine(_workspace.Path, "github-output.txt"); + var result = await RunTestStepAsync(testExitCode, outputFile); + + Assert.Equal(0, result.ExitCode); + var stepOutputs = File.Exists(outputFile) ? await File.ReadAllTextAsync(outputFile) : string.Empty; + if (expectFailure) + { + Assert.Equal($"test_failed=true{Environment.NewLine}", stepOutputs); + } + else + { + Assert.Equal(string.Empty, stepOutputs); + } + } + + private async Task RunTestStepAsync(int testExitCode, string outputFile) + { + using var process = new Process(); + process.StartInfo.FileName = "bash"; + process.StartInfo.ArgumentList.Add(s_classifierPath); + process.StartInfo.ArgumentList.Add(testExitCode.ToString()); + process.StartInfo.WorkingDirectory = _workspace.Path; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.Environment["GITHUB_OUTPUT"] = outputFile; + + process.Start(); + + // Read both streams concurrently to avoid deadlock when a pipe buffer fills. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); + output.WriteLine(result.Output); + + return result; + } + + public void Dispose() => _workspace.Dispose(); + + private sealed record CommandResult(int ExitCode, string Output); +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs deleted file mode 100644 index 5a2b49b3fc6..00000000000 --- a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestsWorkflowTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using Aspire.TestUtilities; -using Xunit; -using YamlDotNet.RepresentationModel; - -namespace Infrastructure.Tests; - -/// -/// Guards how the nightly Deployment E2E workflow classifies the exit code of the single -/// matrix test it runs, in particular the "zero tests ran" code produced by a dynamically -/// skipped scenario. -/// -public sealed class DeploymentTestsWorkflowTests(ITestOutputHelper output) : IDisposable -{ - private const string WorkflowRelativePath = ".github/workflows/deployment-tests.yml"; - private const string RunStepName = "Run deployment test (${{ matrix.shortname }})"; - - private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); - - [Theory] - [InlineData(0, false)] // all tests passed - [InlineData(8, false)] // MTP "zero tests ran": the only test was dynamically skipped - [InlineData(2, true)] // a test failed - [InlineData(7, true)] // the test host crashed - [RequiresTools(["bash"])] - public async Task SkippedScenarioIsGreenWhileGenuineFailuresAreRed(int testExitCode, bool expectFailure) - { - var outputFile = Path.Combine(_workspace.Path, "github-output.txt"); - var result = await RunTestStepAsync(testExitCode, outputFile); - - Assert.Equal(0, result.ExitCode); - var stepOutputs = File.Exists(outputFile) ? await File.ReadAllTextAsync(outputFile) : string.Empty; - if (expectFailure) - { - Assert.Contains("test_failed=true", stepOutputs, StringComparison.Ordinal); - } - else - { - Assert.Equal(string.Empty, stepOutputs); - } - } - - private async Task RunTestStepAsync(int testExitCode, string outputFile) - { - var scriptPath = Path.Combine(_workspace.Path, "run-step.sh"); - await File.WriteAllTextAsync(scriptPath, BuildStepScript(testExitCode)); - - using var process = new Process(); - process.StartInfo.FileName = "bash"; - // GitHub runs `run:` steps with `bash -e {0}`, so the script must be exercised the same way. - process.StartInfo.ArgumentList.Add("-e"); - process.StartInfo.ArgumentList.Add(scriptPath); - process.StartInfo.WorkingDirectory = _workspace.Path; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.UseShellExecute = false; - process.StartInfo.Environment["GITHUB_OUTPUT"] = outputFile; - - process.Start(); - - // Read both streams concurrently to avoid deadlock when a pipe buffer fills. - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await process.WaitForExitAsync(timeout.Token); - - var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); - output.WriteLine(result.Output); - - return result; - } - - /// - /// Replaces the `./dotnet.sh test ...` invocation (a single backslash-continued command) with a - /// no-op that returns , so the surrounding classification logic - /// from the workflow runs verbatim without building or deploying anything. - /// - private static string BuildStepScript(int testExitCode) - { - var lines = GetRunStepScript().ReplaceLineEndings("\n").Split('\n'); - var start = Array.FindIndex(lines, line => line.Contains("./dotnet.sh test", StringComparison.Ordinal)); - Assert.True(start >= 0, $"Could not find the test invocation in the '{RunStepName}' step."); - - var end = start; - while (lines[end].TrimEnd().EndsWith('\\')) - { - end++; - } - - var rewritten = lines[..start] - .Append($"(exit {testExitCode})") - .Concat(lines[(end + 1)..]); - - return string.Join('\n', rewritten); - } - - private static string GetRunStepScript() - { - var yaml = new YamlStream(); - using (var reader = new StreamReader(Path.Combine(RepoRoot.Path, WorkflowRelativePath))) - { - yaml.Load(reader); - } - - var jobs = (YamlMappingNode)((YamlMappingNode)yaml.Documents[0].RootNode).Children[new YamlScalarNode("jobs")]; - var deployTest = (YamlMappingNode)jobs.Children[new YamlScalarNode("deploy-test")]; - var steps = ((YamlSequenceNode)deployTest.Children[new YamlScalarNode("steps")]).Cast(); - var runStep = Assert.Single(steps, step => Scalar(step, "name") == RunStepName); - - return Scalar(runStep, "run") ?? throw new InvalidOperationException($"The '{RunStepName}' step has no run script."); - } - - private static string? Scalar(YamlMappingNode node, string key) - => node.Children.TryGetValue(new YamlScalarNode(key), out var value) ? ((YamlScalarNode)value).Value : null; - - public void Dispose() => _workspace.Dispose(); - - private sealed record CommandResult(int ExitCode, string Output); -} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/MtpExitCodeScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/MtpExitCodeScriptTests.cs new file mode 100644 index 00000000000..226b3697066 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/MtpExitCodeScriptTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +public sealed class MtpExitCodeScriptTests(ITestOutputHelper output) +{ + private static readonly string s_unixNormalizerPath = Path.Combine( + RepoRoot.Path, + ".github", + "workflows", + "normalize-mtp-exit-code.sh"); + private static readonly string s_windowsNormalizerPath = Path.Combine( + RepoRoot.Path, + ".github", + "workflows", + "normalize-mtp-exit-code.ps1"); + + [Theory] + [InlineData(0, 0)] + [InlineData(8, 0)] + [InlineData(2, 2)] + [InlineData(7, 7)] + [RequiresTools(["bash"])] + public async Task UnixDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) + { + var result = await RunProcessAsync("bash", [s_unixNormalizerPath, testExitCode.ToString()]); + + Assert.Equal(expectedExitCode, result.ExitCode); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(8, 0)] + [InlineData(2, 2)] + [InlineData(7, 7)] + [RequiresTools(["pwsh"])] + public async Task WindowsDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) + { + var result = await RunProcessAsync( + "pwsh", + ["-NoProfile", "-File", s_windowsNormalizerPath, "-ExitCode", testExitCode.ToString()]); + + Assert.Equal(expectedExitCode, result.ExitCode); + } + + private async Task RunProcessAsync(string fileName, IReadOnlyList arguments) + { + using var process = new Process(); + process.StartInfo.FileName = fileName; + process.StartInfo.WorkingDirectory = RepoRoot.Path; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); + output.WriteLine(result.Output); + + return result; + } + + private sealed record CommandResult(int ExitCode, string Output); +} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs deleted file mode 100644 index 31a7dfeb2a7..00000000000 --- a/tests/Infrastructure.Tests/WorkflowScripts/RunTestsWorkflowTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using Aspire.TestUtilities; -using Xunit; -using YamlDotNet.RepresentationModel; - -namespace Infrastructure.Tests; - -public sealed class RunTestsWorkflowTests(ITestOutputHelper output) : IDisposable -{ - private const string WorkflowRelativePath = ".github/workflows/run-tests.yml"; - private const string UnixStepName = "Run tests (Linux/macOS)"; - private const string WindowsStepName = "Run tests (Windows)"; - - private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); - - [Theory] - [InlineData(0, 0)] - [InlineData(8, 0)] - [InlineData(2, 2)] - [InlineData(7, 7)] - [RequiresTools(["bash"])] - public async Task UnixDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) - { - var scriptPath = Path.Combine(_workspace.Path, $"run-tests-{testExitCode}.sh"); - await File.WriteAllTextAsync(scriptPath, BuildUnixClassificationScript(testExitCode)); - - var result = await RunProcessAsync("bash", ["-e", scriptPath]); - - Assert.Equal(expectedExitCode, result.ExitCode); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(8, 0)] - [InlineData(2, 2)] - [InlineData(7, 7)] - [RequiresTools(["pwsh"])] - public async Task WindowsDotnetTestExitCodeClassificationPreservesFailures(int testExitCode, int expectedExitCode) - { - var scriptPath = Path.Combine(_workspace.Path, $"run-tests-{testExitCode}.ps1"); - await File.WriteAllTextAsync(scriptPath, BuildWindowsClassificationScript(testExitCode)); - - var result = await RunProcessAsync("pwsh", ["-NoProfile", "-File", scriptPath]); - - Assert.Equal(expectedExitCode, result.ExitCode); - } - - private string BuildUnixClassificationScript(int testExitCode) - { - var script = GetRunStepScript(UnixStepName); - var start = script.IndexOf("# Persist the MTP exit code", StringComparison.Ordinal); - var end = script.LastIndexOf("exit $TEST_EXIT_CODE", StringComparison.Ordinal); - Assert.True(start >= 0, $"Could not find the exit-code persistence block in '{UnixStepName}'."); - Assert.True(end >= start, $"Could not find the final exit in '{UnixStepName}'."); - - var classification = script[start..(end + "exit $TEST_EXIT_CODE".Length)] - .Replace("${{ github.workspace }}", _workspace.Path, StringComparison.Ordinal) - .Replace("${{ inputs.ignoreTestFailures }}", "false", StringComparison.Ordinal); - - var normalizationStart = script.IndexOf("# `--ignore-exit-code 8`", StringComparison.Ordinal); - Assert.True(normalizationStart >= 0, $"Could not find the exit-code normalization block in '{UnixStepName}'."); - classification = script[normalizationStart..start] + classification; - - return $"TEST_EXIT_CODE={testExitCode}\n{classification}\n"; - } - - private string BuildWindowsClassificationScript(int testExitCode) - { - var lines = GetRunStepScript(WindowsStepName).ReplaceLineEndings("\n").Split('\n'); - var normalizationStart = Array.FindIndex(lines, line => line.Contains("# `--ignore-exit-code 8`", StringComparison.Ordinal)); - var normalizationEnd = Array.FindIndex(lines, normalizationStart, line => line.Trim() == "}"); - var persistenceStart = Array.FindIndex(lines, line => line.Contains("# Persist the MTP exit code", StringComparison.Ordinal)); - var finalExit = Array.FindLastIndex(lines, line => line.Trim() == "exit $testExitCode"); - Assert.True(normalizationStart >= 0, $"Could not find the exit-code normalization block in '{WindowsStepName}'."); - Assert.True(normalizationEnd >= normalizationStart, $"Could not find the end of the exit-code normalization block in '{WindowsStepName}'."); - Assert.True(persistenceStart > normalizationEnd, $"Could not find the exit-code persistence block in '{WindowsStepName}'."); - Assert.True(finalExit >= persistenceStart, $"Could not find the final exit in '{WindowsStepName}'."); - - var classification = string.Join( - '\n', - lines[normalizationStart..(normalizationEnd + 1)] - .Concat(lines[persistenceStart..(finalExit + 1)])) - .Replace("${{ github.workspace }}", _workspace.Path, StringComparison.Ordinal) - .Replace("${{ inputs.ignoreTestFailures }}", "false", StringComparison.Ordinal); - - return $"$testExitCode = {testExitCode}\n{classification}\n"; - } - - private async Task RunProcessAsync(string fileName, IReadOnlyList arguments) - { - using var process = new Process(); - process.StartInfo.FileName = fileName; - process.StartInfo.WorkingDirectory = _workspace.Path; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.UseShellExecute = false; - foreach (var argument in arguments) - { - process.StartInfo.ArgumentList.Add(argument); - } - - process.Start(); - - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await process.WaitForExitAsync(timeout.Token); - - var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); - output.WriteLine(result.Output); - - return result; - } - - private static string GetRunStepScript(string stepName) - { - var yaml = new YamlStream(); - using var reader = new StreamReader(Path.Combine(RepoRoot.Path, WorkflowRelativePath)); - yaml.Load(reader); - - var jobs = (YamlMappingNode)((YamlMappingNode)yaml.Documents[0].RootNode).Children[new YamlScalarNode("jobs")]; - var testJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("test")]; - var steps = ((YamlSequenceNode)testJob.Children[new YamlScalarNode("steps")]).Cast(); - var runStep = Assert.Single(steps, step => Scalar(step, "name") == stepName); - - return Scalar(runStep, "run") ?? throw new InvalidOperationException($"The '{stepName}' step has no run script."); - } - - private static string? Scalar(YamlMappingNode node, string key) - => node.Children.TryGetValue(new YamlScalarNode(key), out var value) ? ((YamlScalarNode)value).Value : null; - - public void Dispose() => _workspace.Dispose(); - - private sealed record CommandResult(int ExitCode, string Output); -} From 20c2bb38d0fb7fffec290085312ebf6eff9a394c Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 25 Sep 2026 14:20:21 -0400 Subject: [PATCH 4/6] test(ci): execute deployment workflow script directly The Deployment E2E test command was still assembled in workflow YAML, while regression coverage only exercised exit-code classification. This left argument forwarding and command construction outside direct tests. Move the complete command to run-deployment-test.sh and test it with a fake dotnet.sh across exit codes 0, 8, 2, and 7. Consolidate the normalizer trigger mapping into one rule that selects regular CI and the schedule-only Deployment E2E workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../classify-deployment-test-exit-code.sh | 16 --- .github/workflows/deployment-tests.yml | 14 +- .github/workflows/run-deployment-test.sh | 40 ++++++ docs/ci/mtp-args-pipeline.md | 7 +- eng/github-ci/test-trigger-map.yml | 9 +- .../TestTriggerMap/TestTriggerMapTests.cs | 24 ++-- .../DeploymentTestExitCodeScriptTests.cs | 77 ----------- .../DeploymentTestScriptTests.cs | 122 ++++++++++++++++++ 8 files changed, 190 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/classify-deployment-test-exit-code.sh create mode 100644 .github/workflows/run-deployment-test.sh delete mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestScriptTests.cs diff --git a/.github/workflows/classify-deployment-test-exit-code.sh b/.github/workflows/classify-deployment-test-exit-code.sh deleted file mode 100644 index 3e97c908774..00000000000 --- a/.github/workflows/classify-deployment-test-exit-code.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# Licensed to the .NET Foundation under one or more agreements. -# The .NET Foundation licenses this file to you under the MIT license. - -set -euo pipefail - -: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must identify the workflow step output file}" - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -normalized_exit_code=0 -bash "$SCRIPT_DIR/normalize-mtp-exit-code.sh" "$1" || normalized_exit_code=$? - -if [ "$normalized_exit_code" -ne 0 ]; then - echo "test_failed=true" >> "$GITHUB_OUTPUT" -fi diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index 07c6089615a..680486b7559 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -587,18 +587,10 @@ jobs: # The MTP `--ignore-exit-code 8` flag cannot do that here: it only remaps the exit code of # the test module itself, while `dotnet test` computes its own exit code from the # aggregated run and still reports 8. So the exit code is classified explicitly instead. - set +e - ./dotnet.sh test --project tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj \ - -c Release \ - --results-directory ${{ github.workspace }}/testresults \ - -- \ - --report-trx --report-trx-filename "${{ matrix.shortname }}.trx" \ - --filter-not-trait "quarantined=true" \ + bash .github/workflows/run-deployment-test.sh \ + "${{ github.workspace }}/testresults" \ + "${{ matrix.shortname }}" \ ${{ matrix.extraTestArgs }} - test_exit_code=$? - set -e - echo "Test run exit code: $test_exit_code" - bash .github/workflows/classify-deployment-test-exit-code.sh "$test_exit_code" - name: Collect Aspire CLI logs if: always() diff --git a/.github/workflows/run-deployment-test.sh b/.github/workflows/run-deployment-test.sh new file mode 100644 index 00000000000..f8639213ea4 --- /dev/null +++ b/.github/workflows/run-deployment-test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [extra test arguments...]" >&2 + exit 5 +fi + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must identify the workflow step output file}" + +results_directory=$1 +test_short_name=$2 +shift 2 + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +dotnet_script=${DOTNET_SCRIPT:-./dotnet.sh} +test_exit_code=0 + +bash "$dotnet_script" test \ + --project tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj \ + -c Release \ + --results-directory "$results_directory" \ + -- \ + --report-trx \ + --report-trx-filename "${test_short_name}.trx" \ + --filter-not-trait "quarantined=true" \ + "$@" || test_exit_code=$? + +echo "Test run exit code: $test_exit_code" + +normalized_exit_code=0 +bash "$SCRIPT_DIR/normalize-mtp-exit-code.sh" "$test_exit_code" || normalized_exit_code=$? + +if [ "$normalized_exit_code" -ne 0 ]; then + echo "test_failed=true" >> "$GITHUB_OUTPUT" +fi diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index 434a6f9485c..97570b2a06e 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -144,9 +144,10 @@ MTP MSBuild integration injects `TestingPlatformCommandLineArguments` automatica Because `dotnet test` still returns its aggregated exit code after the test module applies `--ignore-exit-code 8`, the non-NuGet paths in `run-tests.yml` normalize the result through `normalize-mtp-exit-code.sh` or -`normalize-mtp-exit-code.ps1`. The Deployment E2E workflow uses the same Bash -normalizer before deciding whether to set its failure output. Other nonzero exit -codes remain failures. +`normalize-mtp-exit-code.ps1`. The Deployment E2E workflow runs its complete +test command through `run-deployment-test.sh`, which uses the same Bash +normalizer before deciding whether to set its failure output. Other nonzero +exit codes remain failures. ## Backward compatibility diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index f206c76ee58..e83045c4b42 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -238,10 +238,14 @@ path_rules: - eng/testing/CITestsProperties.props - .github/workflows/tests.yml - .github/workflows/run-tests.yml - - .github/workflows/normalize-mtp-exit-code.* targets: [ALL] reason: matrix, enumeration, and runner infrastructure can affect any regular PR test lane + - paths: + - .github/workflows/normalize-mtp-exit-code.* + targets: [ALL, job:deployment-e2e] + reason: shared MTP exit normalization is consumed by regular test lanes and Deployment E2E + - paths: - .github/workflows/build-packages.yml targets: [BUILD_PACKAGE_CONSUMERS] @@ -331,8 +335,7 @@ path_rules: - tests/Aspire.Deployment.EndToEnd.Tests/** - src/Aspire.ProjectTemplates/** - .github/workflows/deployment-tests.yml - - .github/workflows/normalize-mtp-exit-code.sh - - .github/workflows/classify-deployment-test-exit-code.sh + - .github/workflows/run-deployment-test.sh targets: [job:deployment-e2e] reason: SCHEDULE/DISPATCH-ONLY today; ProjectTemplates is a Layer-1 blind spot; Azure/Cli/TypeSystem/Managed production projects are in affected_project_rules - paths: diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index 036ab5707d7..72d69d77653 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -72,19 +72,25 @@ public void WarningPolicyChangesSelectAllTests(string path) Assert.Equal(["ALL"], targets); } - [Theory] - [InlineData(".github/workflows/normalize-mtp-exit-code.sh", "ALL")] - [InlineData(".github/workflows/normalize-mtp-exit-code.ps1", "ALL")] - [InlineData(".github/workflows/normalize-mtp-exit-code.sh", "job:deployment-e2e")] - [InlineData(".github/workflows/classify-deployment-test-exit-code.sh", "job:deployment-e2e")] - public void MtpExitCodeScriptsSelectTheirWorkflowConsumers(string path, string expectedTarget) + [Fact] + public void MtpExitCodeNormalizerRuleSelectsAllConsumers() + { + var rule = Assert.Single( + s_map.PathRules, + rule => rule.Paths.Any(path => path.Contains("normalize-mtp-exit-code", StringComparison.Ordinal))); + + Assert.Equal(["ALL", "job:deployment-e2e"], rule.Targets); + } + + [Fact] + public void DeploymentTestRunnerSelectsDeploymentWorkflow() { + const string path = ".github/workflows/run-deployment-test.sh"; var targets = s_map.PathRules .Where(rule => rule.Paths.Any(glob => TestTriggerMap.GlobMatches(glob, path))) - .SelectMany(rule => rule.Targets) - .ToHashSet(StringComparer.Ordinal); + .SelectMany(rule => rule.Targets); - Assert.Contains(expectedTarget, targets); + Assert.Contains("job:deployment-e2e", targets); } [Fact] diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs deleted file mode 100644 index 8adc7edcecc..00000000000 --- a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestExitCodeScriptTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using Aspire.TestUtilities; -using Xunit; - -namespace Infrastructure.Tests; - -/// -/// Guards how the nightly Deployment E2E workflow classifies the exit code of the single -/// matrix test it runs, in particular the "zero tests ran" code produced by a dynamically -/// skipped scenario. -/// -public sealed class DeploymentTestExitCodeScriptTests(ITestOutputHelper output) : IDisposable -{ - private static readonly string s_classifierPath = Path.Combine( - RepoRoot.Path, - ".github", - "workflows", - "classify-deployment-test-exit-code.sh"); - - private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); - - [Theory] - [InlineData(0, false)] // all tests passed - [InlineData(8, false)] // MTP "zero tests ran": the only test was dynamically skipped - [InlineData(2, true)] // a test failed - [InlineData(7, true)] // the test host crashed - [RequiresTools(["bash"])] - public async Task ClassificationMarksOnlyGenuineFailures(int testExitCode, bool expectFailure) - { - var outputFile = Path.Combine(_workspace.Path, "github-output.txt"); - var result = await RunTestStepAsync(testExitCode, outputFile); - - Assert.Equal(0, result.ExitCode); - var stepOutputs = File.Exists(outputFile) ? await File.ReadAllTextAsync(outputFile) : string.Empty; - if (expectFailure) - { - Assert.Equal($"test_failed=true{Environment.NewLine}", stepOutputs); - } - else - { - Assert.Equal(string.Empty, stepOutputs); - } - } - - private async Task RunTestStepAsync(int testExitCode, string outputFile) - { - using var process = new Process(); - process.StartInfo.FileName = "bash"; - process.StartInfo.ArgumentList.Add(s_classifierPath); - process.StartInfo.ArgumentList.Add(testExitCode.ToString()); - process.StartInfo.WorkingDirectory = _workspace.Path; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.UseShellExecute = false; - process.StartInfo.Environment["GITHUB_OUTPUT"] = outputFile; - - process.Start(); - - // Read both streams concurrently to avoid deadlock when a pipe buffer fills. - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - await process.WaitForExitAsync(timeout.Token); - - var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); - output.WriteLine(result.Output); - - return result; - } - - public void Dispose() => _workspace.Dispose(); - - private sealed record CommandResult(int ExitCode, string Output); -} diff --git a/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestScriptTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestScriptTests.cs new file mode 100644 index 00000000000..cad1291ccb0 --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/DeploymentTestScriptTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Guards the command and exit-code behavior of the nightly Deployment E2E test step. +/// +public sealed class DeploymentTestScriptTests(ITestOutputHelper output) : IDisposable +{ + private static readonly string s_runnerPath = Path.Combine( + RepoRoot.Path, + ".github", + "workflows", + "run-deployment-test.sh"); + + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output); + + [Theory] + [InlineData(0, false)] // all tests passed + [InlineData(8, false)] // MTP "zero tests ran": the only test was dynamically skipped + [InlineData(2, true)] // a test failed + [InlineData(7, true)] // the test host crashed + [RequiresTools(["bash"])] + public async Task RunsDeploymentTestAndMarksOnlyGenuineFailures(int testExitCode, bool expectFailure) + { + var outputFile = Path.Combine(_workspace.Path, "github-output.txt"); + var argumentsFile = Path.Combine(_workspace.Path, "dotnet-arguments.txt"); + var fakeDotnetScript = Path.Combine(_workspace.Path, "dotnet.sh"); + await File.WriteAllTextAsync( + fakeDotnetScript, + """ + #!/usr/bin/env bash + printf '%s\n' "$@" > "$FAKE_DOTNET_ARGUMENTS" + exit "$FAKE_DOTNET_EXIT_CODE" + """); + + var resultsDirectory = Path.Combine(_workspace.Path, "test results"); + var result = await RunTestStepAsync( + testExitCode, + outputFile, + argumentsFile, + fakeDotnetScript, + resultsDirectory); + + Assert.Equal(0, result.ExitCode); + Assert.Equal( + [ + "test", + "--project", + "tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj", + "-c", + "Release", + "--results-directory", + resultsDirectory, + "--", + "--report-trx", + "--report-trx-filename", + "Deployment.EndToEnd-ExampleTests.trx", + "--filter-not-trait", + "quarantined=true", + "--filter-class", + "Aspire.ExampleTests", + ], + await File.ReadAllLinesAsync(argumentsFile)); + + var stepOutputs = File.Exists(outputFile) ? await File.ReadAllTextAsync(outputFile) : string.Empty; + if (expectFailure) + { + Assert.Equal($"test_failed=true{Environment.NewLine}", stepOutputs); + } + else + { + Assert.Equal(string.Empty, stepOutputs); + } + } + + private async Task RunTestStepAsync( + int testExitCode, + string outputFile, + string argumentsFile, + string fakeDotnetScript, + string resultsDirectory) + { + using var process = new Process(); + process.StartInfo.FileName = "bash"; + process.StartInfo.ArgumentList.Add(s_runnerPath); + process.StartInfo.ArgumentList.Add(resultsDirectory); + process.StartInfo.ArgumentList.Add("Deployment.EndToEnd-ExampleTests"); + process.StartInfo.ArgumentList.Add("--filter-class"); + process.StartInfo.ArgumentList.Add("Aspire.ExampleTests"); + process.StartInfo.WorkingDirectory = _workspace.Path; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.Environment["GITHUB_OUTPUT"] = outputFile; + process.StartInfo.Environment["DOTNET_SCRIPT"] = fakeDotnetScript; + process.StartInfo.Environment["FAKE_DOTNET_ARGUMENTS"] = argumentsFile; + process.StartInfo.Environment["FAKE_DOTNET_EXIT_CODE"] = testExitCode.ToString(); + + process.Start(); + + // Read both streams concurrently to avoid deadlock when a pipe buffer fills. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + var result = new CommandResult(process.ExitCode, await stdoutTask + await stderrTask); + output.WriteLine(result.Output); + + return result; + } + + public void Dispose() => _workspace.Dispose(); + + private sealed record CommandResult(int ExitCode, string Output); +} From 3c2b2e3c2325a70476698c99441e0fa33584af59 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 25 Sep 2026 14:59:10 -0400 Subject: [PATCH 5/6] docs(ci): explain .NET 11 zero-test aggregation Starting with .NET 11, dotnet test independently computes the whole-run zero-test result after individual MTP modules apply --ignore-exit-code 8. This causes an entirely skipped aggregate run to return exit code 8 even though direct module execution returns success. Document that distinction next to both normalizers and in the MTP CI guide so future SDK updates do not obscure why the workflows explicitly normalize only exit code 8. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/normalize-mtp-exit-code.ps1 | 4 ++++ .github/workflows/normalize-mtp-exit-code.sh | 4 ++++ docs/ci/mtp-args-pipeline.md | 17 ++++++++++------- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/normalize-mtp-exit-code.ps1 b/.github/workflows/normalize-mtp-exit-code.ps1 index cfa44e853d9..11463d5d576 100644 --- a/.github/workflows/normalize-mtp-exit-code.ps1 +++ b/.github/workflows/normalize-mtp-exit-code.ps1 @@ -7,6 +7,10 @@ param( [int] $ExitCode ) +# Starting with .NET 11, `dotnet test` decides the zero-tests result for the whole run from +# aggregated results. `--ignore-exit-code 8` still makes direct test-module execution return 0, +# but an all-empty or all-skipped `dotnet test` run returns 8 from the orchestrator. +# https://learn.microsoft.com/dotnet/core/tools/dotnet-test-mtp#whole-run-and-per-module-minimums if ($ExitCode -eq 8) { Write-Host "All selected tests were skipped; treating MTP exit code 8 as success." exit 0 diff --git a/.github/workflows/normalize-mtp-exit-code.sh b/.github/workflows/normalize-mtp-exit-code.sh index 0a32e19f1d5..4faeaa3c294 100644 --- a/.github/workflows/normalize-mtp-exit-code.sh +++ b/.github/workflows/normalize-mtp-exit-code.sh @@ -5,6 +5,10 @@ set -euo pipefail +# Starting with .NET 11, `dotnet test` decides the zero-tests result for the whole run from +# aggregated results. `--ignore-exit-code 8` still makes direct test-module execution return 0, +# but an all-empty or all-skipped `dotnet test` run returns 8 from the orchestrator. +# https://learn.microsoft.com/dotnet/core/tools/dotnet-test-mtp#whole-run-and-per-module-minimums if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^[0-9]+$ ]]; then echo "Usage: $0 " >&2 exit 5 diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index 97570b2a06e..630cceb0129 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -141,13 +141,16 @@ dotnet test --project --no-build -- \ MTP MSBuild integration injects `TestingPlatformCommandLineArguments` automatically (includes `--filter-not-trait "category=failing"` and TRX filename). These args are complementary to the explicit `-- `. -Because `dotnet test` still returns its aggregated exit code after the test module -applies `--ignore-exit-code 8`, the non-NuGet paths in `run-tests.yml` normalize -the result through `normalize-mtp-exit-code.sh` or -`normalize-mtp-exit-code.ps1`. The Deployment E2E workflow runs its complete -test command through `run-deployment-test.sh`, which uses the same Bash -normalizer before deciding whether to set its failure output. Other nonzero -exit codes remain failures. +Starting with the .NET 11 SDK, `dotnet test` decides the zero-tests result for +the whole run from its +[aggregated results](https://learn.microsoft.com/dotnet/core/tools/dotnet-test-mtp#whole-run-and-per-module-minimums). +`--ignore-exit-code 8` still makes direct test-module execution return 0, but an +all-empty or all-skipped `dotnet test` run returns 8 from the orchestrator. The +non-NuGet paths in `run-tests.yml` therefore normalize the result through +`normalize-mtp-exit-code.sh` or `normalize-mtp-exit-code.ps1`. The Deployment +E2E workflow runs its complete test command through `run-deployment-test.sh`, +which uses the same Bash normalizer before deciding whether to set its failure +output. Other nonzero exit codes remain failures. ## Backward compatibility From 1b164951f1619d6de53d5602f2b804a365707f9d Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Mon, 28 Sep 2026 17:42:58 -0400 Subject: [PATCH 6/6] docs(ci): track MTP workaround cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/normalize-mtp-exit-code.ps1 | 4 ++++ .github/workflows/normalize-mtp-exit-code.sh | 4 ++++ docs/ci/mtp-args-pipeline.md | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/normalize-mtp-exit-code.ps1 b/.github/workflows/normalize-mtp-exit-code.ps1 index 11463d5d576..14629945b12 100644 --- a/.github/workflows/normalize-mtp-exit-code.ps1 +++ b/.github/workflows/normalize-mtp-exit-code.ps1 @@ -10,6 +10,10 @@ param( # Starting with .NET 11, `dotnet test` decides the zero-tests result for the whole run from # aggregated results. `--ignore-exit-code 8` still makes direct test-module execution return 0, # but an all-empty or all-skipped `dotnet test` run returns 8 from the orchestrator. +# This temporarily works around https://github.com/dotnet/sdk/issues/56214. Remove it after Aspire +# upgrades to a .NET 11 SDK build containing https://github.com/dotnet/sdk/pull/56296 or the +# equivalent https://github.com/dotnet/sdk/pull/56219 fix. Cleanup is tracked by +# https://github.com/microsoft/aspire/issues/20565. # https://learn.microsoft.com/dotnet/core/tools/dotnet-test-mtp#whole-run-and-per-module-minimums if ($ExitCode -eq 8) { Write-Host "All selected tests were skipped; treating MTP exit code 8 as success." diff --git a/.github/workflows/normalize-mtp-exit-code.sh b/.github/workflows/normalize-mtp-exit-code.sh index 4faeaa3c294..f1159cf6cc6 100644 --- a/.github/workflows/normalize-mtp-exit-code.sh +++ b/.github/workflows/normalize-mtp-exit-code.sh @@ -8,6 +8,10 @@ set -euo pipefail # Starting with .NET 11, `dotnet test` decides the zero-tests result for the whole run from # aggregated results. `--ignore-exit-code 8` still makes direct test-module execution return 0, # but an all-empty or all-skipped `dotnet test` run returns 8 from the orchestrator. +# This temporarily works around https://github.com/dotnet/sdk/issues/56214. Remove it after Aspire +# upgrades to a .NET 11 SDK build containing https://github.com/dotnet/sdk/pull/56296 or the +# equivalent https://github.com/dotnet/sdk/pull/56219 fix. Cleanup is tracked by +# https://github.com/microsoft/aspire/issues/20565. # https://learn.microsoft.com/dotnet/core/tools/dotnet-test-mtp#whole-run-and-per-module-minimums if [ "$#" -ne 1 ] || [[ ! "$1" =~ ^[0-9]+$ ]]; then echo "Usage: $0 " >&2 diff --git a/docs/ci/mtp-args-pipeline.md b/docs/ci/mtp-args-pipeline.md index 630cceb0129..3cec6e5e292 100644 --- a/docs/ci/mtp-args-pipeline.md +++ b/docs/ci/mtp-args-pipeline.md @@ -150,7 +150,12 @@ non-NuGet paths in `run-tests.yml` therefore normalize the result through `normalize-mtp-exit-code.sh` or `normalize-mtp-exit-code.ps1`. The Deployment E2E workflow runs its complete test command through `run-deployment-test.sh`, which uses the same Bash normalizer before deciding whether to set its failure -output. Other nonzero exit codes remain failures. +output. Other nonzero exit codes remain failures. This is a temporary workaround +for [dotnet/sdk#56214](https://github.com/dotnet/sdk/issues/56214) and should be +removed after Aspire upgrades to a .NET 11 SDK build containing +[dotnet/sdk#56296](https://github.com/dotnet/sdk/pull/56296), or the equivalent +[dotnet/sdk#56219](https://github.com/dotnet/sdk/pull/56219) fix. Cleanup is +tracked by [microsoft/aspire#20565](https://github.com/microsoft/aspire/issues/20565). ## Backward compatibility