From 1790b940686e094827e9c116d56c3626e7c518bf Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 1 May 2026 16:04:54 -0400 Subject: [PATCH 1/5] fix(cli-e2e): install staging dotnet tool from resolved feed Resolve the staging CLI with the install script first, derive its SHA-specific feed, and install the dotnet tool from that stable feed without --prerelease. Also set the global CLI channel to staging so the dotnet tool path matches install-script behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/CliInstallStrategyTests.cs | 24 ++++++- tests/Shared/CliInstallStrategy.cs | 66 ++++++++++++++++++- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index f242b036c54..ce51c9b5e38 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -291,7 +291,7 @@ public void Detect_DotnetTool_TakesPriorityOverQuality() } [Fact] - public void Detect_DotnetTool_IncludesPrereleaseForStagingQuality() + public void Detect_DotnetTool_UsesStagingFeedForStagingQuality() { using var environment = new EnvironmentVariableScope( ("ASPIRE_E2E_ARCHIVE", null), @@ -308,7 +308,8 @@ public void Detect_DotnetTool_IncludesPrereleaseForStagingQuality() var strategy = CliInstallStrategy.Detect(); Assert.Equal(CliInstallMode.DotnetTool, strategy.Mode); - Assert.True(strategy.IncludePrerelease); + Assert.False(strategy.IncludePrerelease); + Assert.True(strategy.UsesStagingDotnetToolFeed); } [Fact] @@ -380,8 +381,9 @@ public void DotnetToolSmokeTests_UsesPublishedFeedWhenQualityIsSet() var strategy = DotnetToolSmokeTests.GetDotnetToolStrategy(); Assert.Equal(CliInstallMode.DotnetTool, strategy.Mode); - Assert.True(strategy.IncludePrerelease); + Assert.False(strategy.IncludePrerelease); Assert.Null(strategy.Version); + Assert.True(strategy.UsesStagingDotnetToolFeed); } [Fact] @@ -485,6 +487,22 @@ public void GetDotnetToolInstallCommandInDocker_WithPrerelease() Assert.Equal("dotnet tool install --global Aspire.Cli --prerelease --configfile '/opt/aspire-scripts/NuGet.config'", command); } + [Fact] + public void GetDotnetToolInstallCommandInDocker_WithStagingQuality() + { + var strategy = CliInstallStrategy.FromPublishedDotnetToolFeed(version: null, CliInstallQuality.Staging); + var command = AspireCliShellCommandHelpers.GetDotnetToolInstallCommandInDocker(strategy); + + Assert.Contains("/opt/aspire-scripts/get-aspire-cli.sh --quality staging --install-path \"$STAGING_INSTALL_DIR\" --skip-path", command); + Assert.Contains("darc-pub-microsoft-aspire-${STAGING_SHA8}/nuget/v3/index.json", command); + Assert.Contains("/opt/aspire-scripts/NuGet.config", command); + Assert.Contains("\"$STAGING_NUGET_CONFIG\"", command); + Assert.Contains("", command); + Assert.Contains("dotnet tool install --global Aspire.Cli --version \"$STAGING_BASE_VERSION\" --configfile \"$STAGING_NUGET_CONFIG\"", command); + Assert.Contains("aspire config set channel staging -g", command); + Assert.DoesNotContain("--prerelease", command); + } + [Fact] public void Detect_ReturnsLocalArchive_WhenArchiveDirIsSetInCIWithoutPrMetadata() { diff --git a/tests/Shared/CliInstallStrategy.cs b/tests/Shared/CliInstallStrategy.cs index 2803ab3530e..3fb49ac5e22 100644 --- a/tests/Shared/CliInstallStrategy.cs +++ b/tests/Shared/CliInstallStrategy.cs @@ -155,12 +155,52 @@ internal static string GetDotnetToolInstallCommand(CliInstallStrategy strategy) internal static string GetDotnetToolInstallCommandInDocker(CliInstallStrategy strategy) { + if (strategy.UsesStagingDotnetToolFeed) + { + return GetStagingDotnetToolInstallCommand( + AspireCliShellCommandHelpers.DockerInstallScriptCommandPrefix, + DockerNuGetConfigPath); + } + var nupkgSourcePath = strategy.NupkgSourcePath is not null ? "/tmp/aspire-nupkg-source" : null; var nuGetConfigPath = strategy.RequiresDotnetToolNuGetConfig ? DockerNuGetConfigPath : null; return $"dotnet tool install {GetDotnetToolInstallArgs(strategy, nupkgSourcePath, nuGetConfigPath)}"; } + internal static string GetStagingDotnetToolInstallCommand(string installScriptCommandPrefix, string baseNuGetConfigPath) + { + var script = $$""" +set -euo pipefail +TMP_DIR=$(mktemp -d) +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT +STAGING_INSTALL_DIR="$TMP_DIR/install-script" +STAGING_NUGET_CONFIG="$TMP_DIR/NuGet.config" +{{installScriptCommandPrefix}} --quality staging --install-path "$STAGING_INSTALL_DIR" --skip-path +STAGING_VERSION=$("$STAGING_INSTALL_DIR/aspire" --version) +STAGING_BASE_VERSION=${STAGING_VERSION%%+*} +STAGING_SHA=${STAGING_VERSION#*+} +if [ "$STAGING_SHA" = "$STAGING_VERSION" ] || [ -z "$STAGING_SHA" ]; then + echo "Unable to derive staging feed SHA from Aspire CLI version '$STAGING_VERSION'." >&2 + exit 1 +fi +STAGING_SHA8=${STAGING_SHA:0:8} +STAGING_FEED="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-${STAGING_SHA8}/nuget/v3/index.json" +cp {{QuoteBashArg(baseNuGetConfigPath)}} "$STAGING_NUGET_CONFIG" +sed -i \ + -e "s|||" \ + -e "s|||" \ + "$STAGING_NUGET_CONFIG" +echo "Resolved staging Aspire CLI version: $STAGING_VERSION" +echo "Resolved staging dotnet tool feed: $STAGING_FEED" +dotnet tool install --global Aspire.Cli --version "$STAGING_BASE_VERSION" --configfile "$STAGING_NUGET_CONFIG" +aspire config set channel staging -g +"""; + + return $"bash -c {QuoteBashArg(script)}"; + } + private static string GetDotnetToolInstallArgs(CliInstallStrategy strategy, string? nupkgSourcePath, string? nuGetConfigPath = null) { var args = "--global Aspire.Cli"; @@ -246,6 +286,15 @@ internal sealed class CliInstallStrategy /// public bool IncludePrerelease { get; } + /// + /// Gets whether published DotnetTool installation should first resolve the staging CLI build and use its SHA-specific package feed. + /// + public bool UsesStagingDotnetToolFeed => + Mode is CliInstallMode.DotnetTool && + NupkgSourcePath is null && + Version is null && + Quality is CliInstallQuality.Staging; + /// /// The expected CLI version after installation, when known. /// Set automatically for modes where the version is deterministic (LocalArchive, DotnetTool local source, explicit version). @@ -372,8 +421,13 @@ public static CliInstallStrategy FromDotnetTool(string? version = null, bool inc /// public static CliInstallStrategy FromPublishedDotnetToolFeed(string? version, CliInstallQuality? quality) { + if (version is not null && !CliPackageDiscovery.IsValidVersion(version)) + { + throw new ArgumentException($"Invalid version format: '{version}'. Must contain only alphanumeric characters, dots, and dashes.", nameof(version)); + } + var includePrerelease = ShouldIncludePrereleaseForPublishedDotnetTool(version, quality); - return FromDotnetTool(version, includePrerelease); + return new CliInstallStrategy(CliInstallMode.DotnetTool, quality: version is null ? quality : null, version: version, includePrerelease: version is null && includePrerelease, expectedVersion: version); } /// @@ -454,7 +508,12 @@ public static CliInstallStrategy Detect(Action? log = null) var toolVersion = Environment.GetEnvironmentVariable("ASPIRE_E2E_VERSION"); var quality = GetQualityFromEnvironment(); var strategy = FromPublishedDotnetToolFeed(toolVersion, quality); - var versionDescription = toolVersion ?? (strategy.IncludePrerelease ? "(latest prerelease)" : "(latest stable)"); + var versionDescription = toolVersion ?? strategy switch + { + { UsesStagingDotnetToolFeed: true } => "(staging)", + { IncludePrerelease: true } => "(latest prerelease)", + _ => "(latest stable)" + }; log?.Invoke($" → Selected: DotnetTool published feed (ASPIRE_E2E_DOTNET_TOOL={dotnetTool}, version={versionDescription})"); return strategy; } @@ -575,6 +634,7 @@ public override string ToString() CliInstallMode.InstallScript => "InstallScript (latest GA)", CliInstallMode.DotnetTool when NupkgSourcePath is not null => $"DotnetTool (local: {NupkgSourcePath}, --version {Version})", CliInstallMode.DotnetTool when Version is not null => $"DotnetTool (--version {Version})", + CliInstallMode.DotnetTool when UsesStagingDotnetToolFeed => "DotnetTool (staging)", CliInstallMode.DotnetTool when IncludePrerelease => "DotnetTool (latest prerelease)", CliInstallMode.DotnetTool => "DotnetTool (latest)", _ => Mode.ToString(), @@ -603,7 +663,7 @@ public override string ToString() private static bool ShouldIncludePrereleaseForPublishedDotnetTool(string? version, CliInstallQuality? quality) { - return string.IsNullOrEmpty(version) && quality is CliInstallQuality.Dev or CliInstallQuality.Staging; + return string.IsNullOrEmpty(version) && quality is CliInstallQuality.Dev; } private static bool IsPrereleaseVersion(string? version) From 370e591fae9bce232cff47b7ee109c99d3d13205 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 1 May 2026 16:04:54 -0400 Subject: [PATCH 2/5] fix(cli-e2e): expect staging channel for staging smoke tests Keep validating that the generated TypeScript AppHost uses a stable SDK version, but allow the config channel to be staging when the installed CLI strategy is staging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index 1a9a812d25b..72c924668f3 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -142,8 +142,9 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost() throw new FileNotFoundException($"Expected TypeScript AppHost file to exist: {appHostPath}", appHostPath); } - AssertStableTypeScriptAppHostConfig(Path.Combine(projectPath, "aspire.config.json")); - output.WriteLine("Stable TypeScript AppHost config verified."); + var expectedConfigChannel = strategy.Quality is CliInstallQuality.Staging ? "staging" : "stable"; + AssertStableTypeScriptAppHostConfig(Path.Combine(projectPath, "aspire.config.json"), expectedConfigChannel); + output.WriteLine($"Stable TypeScript AppHost config verified with channel '{expectedConfigChannel}'."); await auto.RunCommandFailFastAsync($"cd {projectName}", counter); await auto.AspireStartAsync(counter); @@ -169,17 +170,17 @@ private static string GetAppHostSdkVersion(string appHostPath) : throw new InvalidOperationException($"Could not find Aspire.AppHost.Sdk directive in {appHostPath}."); } - private static void AssertStableTypeScriptAppHostConfig(string configPath) + private static void AssertStableTypeScriptAppHostConfig(string configPath, string expectedChannel) { if (!File.Exists(configPath)) { throw new FileNotFoundException($"Expected Aspire config file to exist: {configPath}", configPath); } - // Expected shape: { "appHost": { "path": "apphost.ts", "language": "typescript/nodejs" }, "sdk": { "version": "13.2.0" }, "channel": "stable" } + // Expected shape: { "appHost": { "path": "apphost.ts", "language": "typescript/nodejs" }, "sdk": { "version": "13.2.0" }, "channel": "" } using var config = JsonDocument.Parse(File.ReadAllText(configPath)); var root = config.RootElement; - AssertJsonStringProperty(root, "channel", "stable", configPath); + AssertJsonStringProperty(root, "channel", expectedChannel, configPath); var sdk = GetRequiredJsonObjectProperty(root, "sdk", configPath); var sdkVersion = GetRequiredJsonStringProperty(sdk, "version", configPath); if (sdkVersion.Contains('-', StringComparison.Ordinal) || From ccd3f14ef6c7757d18e7c4de337fca58c6706dbb Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 1 May 2026 16:04:54 -0400 Subject: [PATCH 3/5] test(ci): verify daily smoke cli versions match Move the version consistency check into a reusable PowerShell script and run it after daily smoke tests so mismatched install routes fail the workflow before artifacts are uploaded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests-daily-smoke.yml | 4 + .../verify-daily-smoke-cli-versions.ps1 | 124 ++++++++++++++++ .../Infrastructure.Tests.csproj | 1 + .../VerifyDailySmokeCliVersionsTests.cs | 138 ++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 eng/scripts/verify-daily-smoke-cli-versions.ps1 create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/VerifyDailySmokeCliVersionsTests.cs diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index 6768dcb5255..ce86ae13726 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -64,6 +64,10 @@ jobs: --filter-not-trait "outerloop=true" \ --hangdump --hangdump-type none --hangdump-timeout 15m + - name: Verify Aspire CLI versions match + shell: pwsh + run: ./eng/scripts/verify-daily-smoke-cli-versions.ps1 -VersionsDir (Join-Path $env:GITHUB_WORKSPACE 'testresults/cli-versions') + - name: Upload logs and test results id: upload-logs if: always() diff --git a/eng/scripts/verify-daily-smoke-cli-versions.ps1 b/eng/scripts/verify-daily-smoke-cli-versions.ps1 new file mode 100644 index 00000000000..8e0fcfc8529 --- /dev/null +++ b/eng/scripts/verify-daily-smoke-cli-versions.ps1 @@ -0,0 +1,124 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$VersionsDir, + + [string]$StepSummaryPath = $env:GITHUB_STEP_SUMMARY +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $VersionsDir -PathType Container)) { + Write-Host "::error::Aspire CLI version records directory was not created: $VersionsDir" + exit 1 +} + +$versionFiles = @(Get-ChildItem -LiteralPath $VersionsDir -Filter '*.env' -File) + +if ($versionFiles.Count -eq 0) { + Write-Host '::error::No Aspire CLI version records were produced.' + exit 1 +} + +function Read-CliVersionRecord { + param([System.IO.FileInfo]$File) + + $values = @{} + foreach ($line in Get-Content -LiteralPath $File.FullName) { + $separatorIndex = $line.IndexOf('=') + if ($separatorIndex -lt 0) { + continue + } + + $key = $line.Substring(0, $separatorIndex) + if ([string]::IsNullOrWhiteSpace($key)) { + continue + } + + $values[$key] = $line.Substring($separatorIndex + 1) + } + + $testName = $values['test'] + $mode = $values['mode'] + $strategy = $values['strategy'] + $version = $values['version'] + + $route = if (-not [string]::IsNullOrWhiteSpace($strategy)) { + $strategy + } + elseif (-not [string]::IsNullOrWhiteSpace($mode)) { + $mode + } + else { + 'unknown install route' + } + + if ([string]::IsNullOrWhiteSpace($testName)) { + $testName = $File.Name + } + + $versionLabel = if ([string]::IsNullOrWhiteSpace($version)) { + '(missing version)' + } + else { + $version + } + + [pscustomobject]@{ + Test = $testName + Mode = $mode + Strategy = $strategy + Version = $version + Route = $route + Description = "$route | $testName | $versionLabel" + } +} + +function Write-FailureSummary { + param([object[]]$Records) + + if ([string]::IsNullOrWhiteSpace($StepSummaryPath)) { + return + } + + $summaryLines = @( + '## Aspire CLI version consistency check failed' + '' + 'All daily smoke install routes should test the same Aspire CLI version.' + '' + '### Version records' + '' + ) + + foreach ($record in $Records) { + $summaryLines += "- ``$($record.Description)``" + } + + Add-Content -LiteralPath $StepSummaryPath -Value ($summaryLines -join [Environment]::NewLine) +} + +$records = @($versionFiles | ForEach-Object { Read-CliVersionRecord $_ }) +$missingVersionRecords = @($records | Where-Object { [string]::IsNullOrWhiteSpace($_.Version) }) + +if ($missingVersionRecords.Count -gt 0) { + Write-Host '::error::Some Aspire CLI version records did not include a version.' + $missingVersionRecords | ForEach-Object { Write-Host "Missing version: $($_.Description)" } + Write-FailureSummary $records + exit 1 +} + +$uniqueVersions = @($records | ForEach-Object { $_.Version } | Sort-Object -Unique) +if ($uniqueVersions.Count -ne 1) { + Write-Host "::error::Daily smoke tests installed $($uniqueVersions.Count) different Aspire CLI versions." + Write-Host 'Installed versions:' + $uniqueVersions | ForEach-Object { Write-Host " $_" } + Write-Host 'Version records:' + $records | ForEach-Object { Write-Host " $($_.Description)" } + Write-FailureSummary $records + exit 1 +} + +Write-Host "All daily smoke tests installed Aspire CLI version: $($uniqueVersions[0])" diff --git a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj index 837fc0e615a..03cd6c26ac3 100644 --- a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj +++ b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/Infrastructure.Tests/PowerShellScripts/VerifyDailySmokeCliVersionsTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/VerifyDailySmokeCliVersionsTests.cs new file mode 100644 index 00000000000..755e675b81d --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/VerifyDailySmokeCliVersionsTests.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 Aspire.Hosting.Tests; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/verify-daily-smoke-cli-versions.ps1 +/// +public class VerifyDailySmokeCliVersionsTests : IDisposable +{ + private readonly TestTempDirectory _tempDir = new(); + private readonly string _scriptPath; + private readonly string _stepSummaryPath; + private readonly ITestOutputHelper _output; + + public VerifyDailySmokeCliVersionsTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(MSBuildUtils.GetRepoRoot(), "eng", "scripts", "verify-daily-smoke-cli-versions.ps1"); + _stepSummaryPath = Path.Combine(_tempDir.Path, "step-summary.md"); + } + + public void Dispose() => _tempDir.Dispose(); + + [Fact] + [RequiresTools(["pwsh"])] + public async Task SucceedsWhenAllVersionRecordsMatch() + { + var versionsDir = CreateVersionsDir(); + WriteVersionRecord(versionsDir, "install-script.env", "SmokeTests.CreateAndRun", "InstallScript", "InstallScript (--quality staging)", "13.3.0+abc123"); + WriteVersionRecord(versionsDir, "dotnet-tool.env", "DotnetToolSmokeTests.CreateAndRun", "DotnetTool", "DotnetTool (staging)", "13.3.0+abc123"); + + var result = await RunScript(versionsDir); + + result.EnsureSuccessful("verify-daily-smoke-cli-versions.ps1 failed"); + Assert.Contains("All daily smoke tests installed Aspire CLI version: 13.3.0+abc123", result.Output); + Assert.False(File.Exists(_stepSummaryPath)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenVersionRecordsDoNotMatch() + { + var versionsDir = CreateVersionsDir(); + WriteVersionRecord(versionsDir, "install-script.env", "SmokeTests.CreateAndRun", "InstallScript", "InstallScript (--quality staging)", "13.3.0+abc123"); + WriteVersionRecord(versionsDir, "dotnet-tool.env", "DotnetToolSmokeTests.CreateAndRun", "DotnetTool", "DotnetTool (staging)", "13.4.0-preview.1+def456"); + + var result = await RunScript(versionsDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("different Aspire CLI versions", result.Output); + Assert.Contains("InstallScript (--quality staging) | SmokeTests.CreateAndRun | 13.3.0+abc123", result.Output); + Assert.Contains("DotnetTool (staging) | DotnetToolSmokeTests.CreateAndRun | 13.4.0-preview.1+def456", result.Output); + var summary = File.ReadAllText(_stepSummaryPath); + Assert.Contains("Aspire CLI version consistency check failed", summary); + Assert.Contains("DotnetTool (staging) | DotnetToolSmokeTests.CreateAndRun | 13.4.0-preview.1+def456", summary); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenVersionRecordDoesNotIncludeVersion() + { + var versionsDir = CreateVersionsDir(); + WriteVersionRecord(versionsDir, "missing-version.env", "SmokeTests.CreateAndRun", "InstallScript", "InstallScript (--quality staging)", version: null); + + var result = await RunScript(versionsDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("did not include a version", result.Output); + Assert.Contains("InstallScript (--quality staging) | SmokeTests.CreateAndRun | (missing version)", result.Output); + Assert.Contains("Aspire CLI version consistency check failed", File.ReadAllText(_stepSummaryPath)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenVersionRecordsDirectoryDoesNotExist() + { + var versionsDir = Path.Combine(_tempDir.Path, "missing"); + + var result = await RunScript(versionsDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("version records directory was not created", result.Output); + Assert.False(File.Exists(_stepSummaryPath)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenVersionRecordsDirectoryIsEmpty() + { + var versionsDir = CreateVersionsDir(); + + var result = await RunScript(versionsDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("No Aspire CLI version records were produced", result.Output); + Assert.False(File.Exists(_stepSummaryPath)); + } + + private async Task RunScript(string versionsDir) + { + using var command = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(1)) + .WithEnvironmentVariable("GITHUB_STEP_SUMMARY", _stepSummaryPath); + + return await command.ExecuteAsync("-VersionsDir", $"\"{versionsDir}\""); + } + + private string CreateVersionsDir() + { + var versionsDir = Path.Combine(_tempDir.Path, Guid.NewGuid().ToString("N"), "testresults", "cli-versions"); + Directory.CreateDirectory(versionsDir); + return versionsDir; + } + + private static void WriteVersionRecord(string versionsDir, string fileName, string test, string mode, string strategy, string? version) + { + var lines = new List + { + $"test={test}", + $"mode={mode}", + $"strategy={strategy}" + }; + + if (version is not null) + { + lines.Add($"version={version}"); + lines.Add($"baseVersion={version.Split('+')[0]}"); + } + + File.WriteAllLines(Path.Combine(versionsDir, fileName), lines); + } + +} From 5c9604000961ef522356290a8a00ef00a97c9578 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 1 May 2026 18:14:10 -0400 Subject: [PATCH 4/5] Fix stable TypeScript smoke config channel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index 72c924668f3..d98c460ed76 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -142,9 +142,8 @@ public async Task LatestCliCanStartStableChannelTypeScriptAppHost() throw new FileNotFoundException($"Expected TypeScript AppHost file to exist: {appHostPath}", appHostPath); } - var expectedConfigChannel = strategy.Quality is CliInstallQuality.Staging ? "staging" : "stable"; - AssertStableTypeScriptAppHostConfig(Path.Combine(projectPath, "aspire.config.json"), expectedConfigChannel); - output.WriteLine($"Stable TypeScript AppHost config verified with channel '{expectedConfigChannel}'."); + AssertStableTypeScriptAppHostConfig(Path.Combine(projectPath, "aspire.config.json"), "stable"); + output.WriteLine("Stable TypeScript AppHost config verified with channel 'stable'."); await auto.RunCommandFailFastAsync($"cd {projectName}", counter); await auto.AspireStartAsync(counter); From 8955301cbaba5b6518002f0d73544817329ede04 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 1 May 2026 19:08:42 -0400 Subject: [PATCH 5/5] Fix infrastructure tool build races Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CreateFailingTestIssueToolTests.cs | 3 +++ .../DownloadFailingJobLogsToolTests.cs | 3 +++ .../GenerateTestSummaryToolTests.cs | 3 +++ .../Infrastructure.Tests/ToolBuildCollection.cs | 17 +++++++++++++++++ tools/Aspire.TestTools/Aspire.TestTools.csproj | 2 +- .../CreateFailingTestIssue.csproj | 2 +- .../FailingTestIssueCommand.cs | 2 +- .../GenerateTestSummary.csproj | 2 +- 8 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 tests/Infrastructure.Tests/ToolBuildCollection.cs diff --git a/tests/Infrastructure.Tests/CreateFailingTestIssue/CreateFailingTestIssueToolTests.cs b/tests/Infrastructure.Tests/CreateFailingTestIssue/CreateFailingTestIssueToolTests.cs index 7be53b1e9fa..e73da9969fb 100644 --- a/tests/Infrastructure.Tests/CreateFailingTestIssue/CreateFailingTestIssueToolTests.cs +++ b/tests/Infrastructure.Tests/CreateFailingTestIssue/CreateFailingTestIssueToolTests.cs @@ -11,6 +11,9 @@ namespace Infrastructure.Tests; /// /// End-to-end tests for the CreateFailingTestIssue tool. /// +// These tests build and run repository tools in child dotnet processes. Keep them in the same +// non-parallel collection as the other tool tests so concurrent child builds don't race in artifacts/obj. +[Collection(ToolBuildCollection.Name)] public sealed class CreateFailingTestIssueToolTests : IClassFixture, IDisposable { private readonly TestTempDirectory _tempDirectory = new(); diff --git a/tests/Infrastructure.Tests/DownloadFailingJobLogs/DownloadFailingJobLogsToolTests.cs b/tests/Infrastructure.Tests/DownloadFailingJobLogs/DownloadFailingJobLogsToolTests.cs index fa49f2b906e..4f31d7064f2 100644 --- a/tests/Infrastructure.Tests/DownloadFailingJobLogs/DownloadFailingJobLogsToolTests.cs +++ b/tests/Infrastructure.Tests/DownloadFailingJobLogs/DownloadFailingJobLogsToolTests.cs @@ -11,6 +11,9 @@ namespace Infrastructure.Tests; /// /// End-to-end tests for the DownloadFailingJobLogs script. /// +// These tests build and run repository tools in child dotnet processes. Keep them in the same +// non-parallel collection as the other tool tests so concurrent child builds don't race in artifacts/obj. +[Collection(ToolBuildCollection.Name)] public sealed class DownloadFailingJobLogsToolTests : IClassFixture, IDisposable { private const long RunId = 123; diff --git a/tests/Infrastructure.Tests/GenerateTestSummary/GenerateTestSummaryToolTests.cs b/tests/Infrastructure.Tests/GenerateTestSummary/GenerateTestSummaryToolTests.cs index f16fed21800..44cb1c475b8 100644 --- a/tests/Infrastructure.Tests/GenerateTestSummary/GenerateTestSummaryToolTests.cs +++ b/tests/Infrastructure.Tests/GenerateTestSummary/GenerateTestSummaryToolTests.cs @@ -9,6 +9,9 @@ namespace Infrastructure.Tests; /// /// End-to-end tests for the GenerateTestSummary tool. /// +// These tests build and run repository tools in child dotnet processes. Keep them in the same +// non-parallel collection as the other tool tests so concurrent child builds don't race in artifacts/obj. +[Collection(ToolBuildCollection.Name)] public sealed class GenerateTestSummaryToolTests : IClassFixture, IDisposable { private readonly TestTempDirectory _tempDirectory = new(); diff --git a/tests/Infrastructure.Tests/ToolBuildCollection.cs b/tests/Infrastructure.Tests/ToolBuildCollection.cs new file mode 100644 index 00000000000..9f938d65fcb --- /dev/null +++ b/tests/Infrastructure.Tests/ToolBuildCollection.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Serializes tests that launch nested dotnet build or dotnet run tool processes. +/// Those child processes share repository-level build output directories, so running them in +/// parallel can race while generating intermediate files. +/// +[CollectionDefinition("Tool build tests", DisableParallelization = true)] +public sealed class ToolBuildCollection +{ + public const string Name = "Tool build tests"; +} diff --git a/tools/Aspire.TestTools/Aspire.TestTools.csproj b/tools/Aspire.TestTools/Aspire.TestTools.csproj index 9ed914b5ba4..30402ac0e7a 100644 --- a/tools/Aspire.TestTools/Aspire.TestTools.csproj +++ b/tools/Aspire.TestTools/Aspire.TestTools.csproj @@ -1,7 +1,7 @@ - net10.0 + net8.0 enable enable diff --git a/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj b/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj index eb3af36f183..6c58cf9ab9f 100644 --- a/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj +++ b/tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net8.0 enable enable diff --git a/tools/CreateFailingTestIssue/FailingTestIssueCommand.cs b/tools/CreateFailingTestIssue/FailingTestIssueCommand.cs index 4303103504f..bd9943f3dd6 100644 --- a/tools/CreateFailingTestIssue/FailingTestIssueCommand.cs +++ b/tools/CreateFailingTestIssue/FailingTestIssueCommand.cs @@ -1247,7 +1247,7 @@ public static string ComputeStableSignature(string canonicalTestName, string wor var normalizedWorkflowFile = workflowFile.Trim().Replace('\\', '/').ToLowerInvariant(); var payload = $"{normalizedTestName}|{normalizedWorkflowFile}"; var hash = XxHash3.Hash(Encoding.UTF8.GetBytes(payload)); - return Convert.ToHexStringLower(hash); + return Convert.ToHexString(hash).ToLowerInvariant(); } public static TruncationResult TruncateContent(string? content, int maxChars, TruncationPreference preference) diff --git a/tools/GenerateTestSummary/GenerateTestSummary.csproj b/tools/GenerateTestSummary/GenerateTestSummary.csproj index 32a12e2d3af..d1f034d03c1 100644 --- a/tools/GenerateTestSummary/GenerateTestSummary.csproj +++ b/tools/GenerateTestSummary/GenerateTestSummary.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net8.0 enable enable