From a78576af2d73b42bae7f5ae9f2159b950b035681 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 16:06:43 -0700 Subject: [PATCH 01/10] Add Windows CLI starter validation\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 221 +++++++++++++++++++++++++++++++++++- 1 file changed, 218 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b2295116337..f3c161980a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -189,6 +189,219 @@ jobs: with: versionOverrideArg: ${{ inputs.versionOverrideArg }} + cli_starter_validation_win: + name: Aspire CLI Starter Validation (Windows) + runs-on: windows-latest + needs: [build_packages, build_cli_archive_windows] + if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} + env: + GH_TOKEN: ${{ github.token }} + MAX_STARTUP_SECONDS: '120' + RESOURCE_READY_TIMEOUT_SECONDS: '60' + steps: + - name: Checkout PR head + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install Aspire CLI from PR dogfood script + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + $scriptPath = Join-Path $env:GITHUB_WORKSPACE "eng/scripts/get-aspire-cli-pr.ps1" + & $scriptPath ${{ github.event.pull_request.number }} -WorkflowRunId ${{ github.run_id }} -SkipExtension + + aspire --version + + - name: Trust Aspire HTTPS development certificate + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + aspire certs trust --non-interactive --nologo + + - name: Create starter app and validate startup + timeout-minutes: 10 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation' + Remove-Item -Recurse -Force $validationRoot -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null + + $templates = @( + @{ + TemplateId = 'aspire-ts-starter' + ProjectName = 'AspireCliTsStarterSmoke' + ExpectedResources = @('server', 'webfrontend') + }, + @{ + TemplateId = 'aspire-starter' + ProjectName = 'AspireCliCsStarterSmoke' + ExpectedResources = @('apiservice', 'webfrontend') + } + ) + $failures = [System.Collections.Generic.List[string]]::new() + + foreach ($template in $templates) { + $templateId = $template.TemplateId + $projectName = $template.ProjectName + $expectedResources = @($template.ExpectedResources) + $templateRoot = Join-Path $validationRoot $templateId + $diagnosticsDir = Join-Path $templateRoot 'diagnostics' + $projectRoot = Join-Path $templateRoot $projectName + $startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log' + $startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log' + $startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log' + $stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log' + + New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null + New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null + + Push-Location $templateRoot + try { + try { + aspire new $templateId --name $projectName --output $projectRoot --channel pr-${{ github.event.pull_request.number }} --non-interactive --nologo + + $startAt = Get-Date + $process = Start-Process -FilePath 'aspire' ` + -ArgumentList @('start') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $startStdOutPath ` + -RedirectStandardError $startStdErrPath ` + -PassThru + + try { + $process | Wait-Process -Timeout ([int]$env:MAX_STARTUP_SECONDS) -ErrorAction Stop + } + catch { + if (-not $process.HasExited) { + $process | Stop-Process -Force -ErrorAction SilentlyContinue + } + + throw "${templateId}: aspire start did not exit within $($env:MAX_STARTUP_SECONDS) seconds." + } + + $elapsed = (Get-Date) - $startAt + $startOutput = @( + if (Test-Path $startStdOutPath) { Get-Content $startStdOutPath } + if (Test-Path $startStdErrPath) { Get-Content $startStdErrPath } + ) -join [Environment]::NewLine + + Set-Content -Path $startCombinedPath -Value $startOutput + + if ($process.ExitCode -ne 0) { + throw "${templateId}: aspire start failed with exit code $($process.ExitCode)." + } + + if ($elapsed.TotalSeconds -ge [int]$env:MAX_STARTUP_SECONDS) { + throw "${templateId}: aspire start took $([math]::Round($elapsed.TotalSeconds, 2)) seconds, which exceeds the $($env:MAX_STARTUP_SECONDS)-second limit." + } + + if ($startOutput -match 'Timeout waiting for apphost to start') { + throw "${templateId}: aspire start reported a startup timeout." + } + + if ($startOutput -notmatch 'Apphost started successfully\.') { + throw "${templateId}: aspire start did not report a successful startup." + } + + Set-Location $projectRoot + + foreach ($resourceName in $expectedResources) { + $sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_' + $waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log" + $waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log" + $waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log" + + $waitProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $env:RESOURCE_READY_TIMEOUT_SECONDS) ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $waitStdOutPath ` + -RedirectStandardError $waitStdErrPath ` + -Wait ` + -PassThru + + $waitOutput = @( + if (Test-Path $waitStdOutPath) { Get-Content $waitStdOutPath } + if (Test-Path $waitStdErrPath) { Get-Content $waitStdErrPath } + ) -join [Environment]::NewLine + + Set-Content -Path $waitCombinedPath -Value $waitOutput + + if ($waitProcess.ExitCode -ne 0) { + throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)." + } + } + + Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds." + } + catch { + $message = $_.Exception.Message + Write-Warning $message + $failures.Add($message) + } + } + finally { + if (Test-Path $projectRoot) { + Push-Location $projectRoot + try { + aspire stop *> $stopLogPath + } + catch { + Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)" + if (Test-Path $stopLogPath) { + Get-Content $stopLogPath + } + } + finally { + Pop-Location + } + } + + Pop-Location + } + } + + if ($failures.Count -gt 0) { + throw ("Starter validation failures:`n- " + ($failures -join "`n- ")) + } + + - name: Collect CLI logs + if: always() + shell: pwsh + run: | + $validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation' + $cliLogsDir = Join-Path $HOME '.aspire\logs' + $sharedLogsDir = Join-Path $validationRoot 'cli-logs' + + if (Test-Path $cliLogsDir) { + New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null + Copy-Item -Path $cliLogsDir -Destination $sharedLogsDir -Recurse -Force + } + + - name: Upload starter validation diagnostics + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: cli-starter-validation-windows + path: ${{ runner.temp }}/aspire-cli-starter-validation + if-no-files-found: ignore + extension_tests_win: name: Run VS Code extension tests (Windows) runs-on: windows-latest @@ -229,6 +442,7 @@ jobs: build_cli_archive_windows, build_cli_archive_macos, extension_tests_win, + cli_starter_validation_win, typescript_sdk_tests, tests_no_nugets, tests_no_nugets_overflow, @@ -294,9 +508,10 @@ jobs: contains(needs.*.result, 'cancelled') || (github.event_name == 'pull_request' && (needs.extension_tests_win.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || + needs.cli_starter_validation_win.result == 'skipped' || + needs.typescript_sdk_tests.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || needs.tests_requires_nugets_windows.result == 'skipped' || (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && needs.tests_requires_nugets_macos.result == 'skipped') || From f70be2804c2aabb994988311b4b447323d00f6f0 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 17:11:11 -0700 Subject: [PATCH 02/10] Cap CLI validation job runtime\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f3c161980a2..86fa68acb7d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -192,6 +192,7 @@ jobs: cli_starter_validation_win: name: Aspire CLI Starter Validation (Windows) runs-on: windows-latest + timeout-minutes: 10 needs: [build_packages, build_cli_archive_windows] if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} env: @@ -225,6 +226,7 @@ jobs: aspire --version - name: Trust Aspire HTTPS development certificate + timeout-minutes: 3 shell: pwsh run: | $ErrorActionPreference = 'Stop' From 78f87ac6ba297269be2f61d76ebeb02bd7ea1138 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 17:52:15 -0700 Subject: [PATCH 03/10] Skip Windows CI certificate trust\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 9 -- .../Certificates/CertificateService.cs | 28 +++- .../Certificates/ICertificateToolRunner.cs | 5 + .../NativeCertificateToolRunner.cs | 10 ++ .../Certificates/CertificateServiceTests.cs | 126 ++++++++++++++++++ .../TestServices/TestCertificateToolRunner.cs | 8 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 +- 7 files changed, 178 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86fa68acb7d..fcf576054e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -225,15 +225,6 @@ jobs: aspire --version - - name: Trust Aspire HTTPS development certificate - timeout-minutes: 3 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - - aspire certs trust --non-interactive --nologo - - name: Create starter app and validate startup timeout-minutes: 10 shell: pwsh diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index ca7d627d6b0..fc65a63b1e7 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.Certificates.Generation; namespace Aspire.Cli.Certificates; @@ -31,9 +32,12 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry) : ICertificateService + AspireCliTelemetry telemetry, + ICliHostEnvironment hostEnvironment, + Func? isWindows = null) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + private readonly Func _isWindows = isWindows ?? OperatingSystem.IsWindows; public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -74,6 +78,24 @@ private async Task HandleMachineReadableTrustAsync( // If not trusted at all, run the trust operation if (trustResult.IsNotTrusted) { + if (_isWindows() && !hostEnvironment.SupportsInteractiveInput) + { + if (!trustResult.HasCertificates) + { + var ensureResultCode = await interactionService.ShowStatusAsync( + InteractionServiceStrings.CheckingCertificates, + () => Task.FromResult(certificateToolRunner.EnsureHttpCertificateExists()), + emoji: KnownEmojis.LockedWithKey); + + if (!IsSuccessfulEnsureResult(ensureResultCode)) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, ensureResultCode)); + } + } + + return; + } + var trustResultCode = await interactionService.ShowStatusAsync( InteractionServiceStrings.TrustingCertificates, () => Task.FromResult(certificateToolRunner.TrustHttpCertificate()), @@ -99,6 +121,10 @@ private async Task HandleMachineReadableTrustAsync( } } + private static bool IsSuccessfulEnsureResult(EnsureCertificateResult result) => + result is EnsureCertificateResult.Succeeded + or EnsureCertificateResult.ValidCertificatePresent; + private static void ConfigureSslCertDir(Dictionary environmentVariables) { // Get the dev-certs trust path (respects DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override) diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs index f800927769e..55474f9dab7 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -15,6 +15,11 @@ internal interface ICertificateToolRunner /// CertificateTrustResult CheckHttpCertificate(); + /// + /// Ensures the HTTPS development certificate exists without trusting it. + /// + EnsureCertificateResult EnsureHttpCertificateExists(); + /// /// Trusts the HTTPS development certificate, creating one if necessary. /// diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index ca9726743c3..fe41f34c17b 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -86,6 +86,16 @@ public EnsureCertificateResult TrustHttpCertificate() trust: true); } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + var now = DateTimeOffset.Now; + return certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, + now.Add(TimeSpan.FromDays(365)), + trust: false, + isInteractive: false); + } + internal EnsureCertificateResult TrustHttpCertificateOnLinux(IEnumerable availableCertificates, DateTimeOffset now) { X509Certificate2? certificate = null; diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d6677f2a846..d3415955de2 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -3,7 +3,10 @@ using System.Runtime.InteropServices; using Aspire.Cli.Certificates; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -221,9 +224,116 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar Assert.NotNull(result); } + [Fact] + public async Task EnsureCertificatesTrustedAsync_OnWindowsCi_WithNotTrustedCert_SkipsTrustOperation() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var trustCalled = false; + var ensureCalled = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.None, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + TrustHttpCertificateCallback = () => + { + trustCalled = true; + return EnsureCertificateResult.ExistingHttpsCertificateTrusted; + }, + EnsureHttpCertificateExistsCallback = () => + { + ensureCalled = true; + return EnsureCertificateResult.ValidCertificatePresent; + } + }; + }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isWindows: () => true); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + Assert.False(trustCalled); + Assert.False(ensureCalled); + } + + [Fact] + public async Task EnsureCertificatesTrustedAsync_OnWindowsCi_WithNoCertificates_EnsuresCertificateExistsWithoutTrust() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var trustCalled = false; + var ensureCalled = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }; + }, + TrustHttpCertificateCallback = () => + { + trustCalled = true; + return EnsureCertificateResult.NewHttpsCertificateTrusted; + }, + EnsureHttpCertificateExistsCallback = () => + { + ensureCalled = true; + return EnsureCertificateResult.Succeeded; + } + }; + }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isWindows: () => true); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + Assert.False(trustCalled); + Assert.True(ensureCalled); + } + private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() @@ -249,7 +359,23 @@ public EnsureCertificateResult TrustHttpCertificate() : EnsureCertificateResult.ExistingHttpsCertificateTrusted; } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + return EnsureHttpCertificateExistsCallback is not null + ? EnsureHttpCertificateExistsCallback() + : EnsureCertificateResult.ValidCertificatePresent; + } + public CertificateCleanResult CleanHttpCertificate() => new CertificateCleanResult { Success = true }; } + + private sealed class TestCliHostEnvironment : ICliHostEnvironment + { + public bool SupportsInteractiveInput => false; + + public bool SupportsInteractiveOutput => false; + + public bool SupportsAnsi => true; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 14939e7f043..3e47f464a70 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -13,6 +13,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } @@ -39,6 +40,13 @@ public EnsureCertificateResult TrustHttpCertificate() : EnsureCertificateResult.ExistingHttpsCertificateTrusted; } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + return EnsureHttpCertificateExistsCallback is not null + ? EnsureHttpCertificateExistsCallback() + : EnsureCertificateResult.ValidCertificatePresent; + } + public CertificateCleanResult CleanHttpCertificate() { return CleanHttpCertificateCallback is not null diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 02606af4380..cca940b7a6c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -401,7 +401,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry); + var hostEnvironment = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, hostEnvironment); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => From d38faa6971ca315a77287f873dc2ad43759e55d2 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 18:27:19 -0700 Subject: [PATCH 04/10] Fix TS starter resource names in CI\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fcf576054e8..ee6ead3469a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -240,7 +240,7 @@ jobs: @{ TemplateId = 'aspire-ts-starter' ProjectName = 'AspireCliTsStarterSmoke' - ExpectedResources = @('server', 'webfrontend') + ExpectedResources = @('app', 'frontend') }, @{ TemplateId = 'aspire-starter' From 943e209256c1bd8afa9e9094f8fd9216374be471 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 19:20:24 -0700 Subject: [PATCH 05/10] Show resources before waiting in CI\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee6ead3469a..1a1a29a3ab8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -315,6 +315,30 @@ jobs: Set-Location $projectRoot + $resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log' + $resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log' + $resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log' + + $resourcesProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('resources') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $resourcesStdOutPath ` + -RedirectStandardError $resourcesStdErrPath ` + -Wait ` + -PassThru + + $resourcesOutput = @( + if (Test-Path $resourcesStdOutPath) { Get-Content $resourcesStdOutPath } + if (Test-Path $resourcesStdErrPath) { Get-Content $resourcesStdErrPath } + ) -join [Environment]::NewLine + + Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput + Write-Host $resourcesOutput + + if ($resourcesProcess.ExitCode -ne 0) { + throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)." + } + foreach ($resourceName in $expectedResources) { $sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_' $waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log" From ff5666f645b08f0d6bbd86a985eadb0776ee7ebf Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 20 Mar 2026 19:35:30 -0700 Subject: [PATCH 06/10] Tighten CLI starter validation cleanup\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a1a29a3ab8..bad1dbe8fc7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -198,7 +198,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} MAX_STARTUP_SECONDS: '120' - RESOURCE_READY_TIMEOUT_SECONDS: '60' + RESOURCE_READY_TIMEOUT_SECONDS: '120' steps: - name: Checkout PR head uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -245,7 +245,7 @@ jobs: @{ TemplateId = 'aspire-starter' ProjectName = 'AspireCliCsStarterSmoke' - ExpectedResources = @('apiservice', 'webfrontend') + ExpectedResources = @('apiservice') } ) $failures = [System.Collections.Generic.List[string]]::new() @@ -260,6 +260,7 @@ jobs: $startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log' $startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log' $startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log' + $preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log' $stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log' New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null @@ -270,6 +271,19 @@ jobs: try { aspire new $templateId --name $projectName --output $projectRoot --channel pr-${{ github.event.pull_request.number }} --non-interactive --nologo + try { + aspire stop *> $preStartStopLogPath + } + catch { + $preStartStopOutput = if (Test-Path $preStartStopLogPath) { Get-Content $preStartStopLogPath -Raw } else { '' } + if ($preStartStopOutput -notmatch 'No running apphost found\.') { + Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)" + if ($preStartStopOutput) { + Write-Host $preStartStopOutput + } + } + } + $startAt = Get-Date $process = Start-Process -FilePath 'aspire' ` -ArgumentList @('start') ` From f88d0bedae65ef8a44f4a0b06c4caeb86147bbdd Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 24 Mar 2026 07:28:39 -0700 Subject: [PATCH 07/10] Address PR review feedback Extract the Windows CLI starter validation harness into a reusable PowerShell script, preserve combined log formatting, and remove the flaky startup timing guard while keeping diagnostics intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 188 +---------------- eng/scripts/cli-starter-validation.ps1 | 267 +++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 182 deletions(-) create mode 100644 eng/scripts/cli-starter-validation.ps1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bad1dbe8fc7..10be83ce7ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -197,8 +197,6 @@ jobs: if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} env: GH_TOKEN: ${{ github.token }} - MAX_STARTUP_SECONDS: '120' - RESOURCE_READY_TIMEOUT_SECONDS: '120' steps: - name: Checkout PR head uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -231,186 +229,10 @@ jobs: run: | $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true - - $validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation' - Remove-Item -Recurse -Force $validationRoot -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null - - $templates = @( - @{ - TemplateId = 'aspire-ts-starter' - ProjectName = 'AspireCliTsStarterSmoke' - ExpectedResources = @('app', 'frontend') - }, - @{ - TemplateId = 'aspire-starter' - ProjectName = 'AspireCliCsStarterSmoke' - ExpectedResources = @('apiservice') - } - ) - $failures = [System.Collections.Generic.List[string]]::new() - - foreach ($template in $templates) { - $templateId = $template.TemplateId - $projectName = $template.ProjectName - $expectedResources = @($template.ExpectedResources) - $templateRoot = Join-Path $validationRoot $templateId - $diagnosticsDir = Join-Path $templateRoot 'diagnostics' - $projectRoot = Join-Path $templateRoot $projectName - $startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log' - $startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log' - $startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log' - $preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log' - $stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log' - - New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null - New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null - - Push-Location $templateRoot - try { - try { - aspire new $templateId --name $projectName --output $projectRoot --channel pr-${{ github.event.pull_request.number }} --non-interactive --nologo - - try { - aspire stop *> $preStartStopLogPath - } - catch { - $preStartStopOutput = if (Test-Path $preStartStopLogPath) { Get-Content $preStartStopLogPath -Raw } else { '' } - if ($preStartStopOutput -notmatch 'No running apphost found\.') { - Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)" - if ($preStartStopOutput) { - Write-Host $preStartStopOutput - } - } - } - - $startAt = Get-Date - $process = Start-Process -FilePath 'aspire' ` - -ArgumentList @('start') ` - -WorkingDirectory $projectRoot ` - -RedirectStandardOutput $startStdOutPath ` - -RedirectStandardError $startStdErrPath ` - -PassThru - - try { - $process | Wait-Process -Timeout ([int]$env:MAX_STARTUP_SECONDS) -ErrorAction Stop - } - catch { - if (-not $process.HasExited) { - $process | Stop-Process -Force -ErrorAction SilentlyContinue - } - - throw "${templateId}: aspire start did not exit within $($env:MAX_STARTUP_SECONDS) seconds." - } - - $elapsed = (Get-Date) - $startAt - $startOutput = @( - if (Test-Path $startStdOutPath) { Get-Content $startStdOutPath } - if (Test-Path $startStdErrPath) { Get-Content $startStdErrPath } - ) -join [Environment]::NewLine - - Set-Content -Path $startCombinedPath -Value $startOutput - - if ($process.ExitCode -ne 0) { - throw "${templateId}: aspire start failed with exit code $($process.ExitCode)." - } - - if ($elapsed.TotalSeconds -ge [int]$env:MAX_STARTUP_SECONDS) { - throw "${templateId}: aspire start took $([math]::Round($elapsed.TotalSeconds, 2)) seconds, which exceeds the $($env:MAX_STARTUP_SECONDS)-second limit." - } - - if ($startOutput -match 'Timeout waiting for apphost to start') { - throw "${templateId}: aspire start reported a startup timeout." - } - - if ($startOutput -notmatch 'Apphost started successfully\.') { - throw "${templateId}: aspire start did not report a successful startup." - } - - Set-Location $projectRoot - - $resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log' - $resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log' - $resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log' - - $resourcesProcess = Start-Process -FilePath 'aspire' ` - -ArgumentList @('resources') ` - -WorkingDirectory $projectRoot ` - -RedirectStandardOutput $resourcesStdOutPath ` - -RedirectStandardError $resourcesStdErrPath ` - -Wait ` - -PassThru - - $resourcesOutput = @( - if (Test-Path $resourcesStdOutPath) { Get-Content $resourcesStdOutPath } - if (Test-Path $resourcesStdErrPath) { Get-Content $resourcesStdErrPath } - ) -join [Environment]::NewLine - - Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput - Write-Host $resourcesOutput - - if ($resourcesProcess.ExitCode -ne 0) { - throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)." - } - - foreach ($resourceName in $expectedResources) { - $sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_' - $waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log" - $waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log" - $waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log" - - $waitProcess = Start-Process -FilePath 'aspire' ` - -ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $env:RESOURCE_READY_TIMEOUT_SECONDS) ` - -WorkingDirectory $projectRoot ` - -RedirectStandardOutput $waitStdOutPath ` - -RedirectStandardError $waitStdErrPath ` - -Wait ` - -PassThru - - $waitOutput = @( - if (Test-Path $waitStdOutPath) { Get-Content $waitStdOutPath } - if (Test-Path $waitStdErrPath) { Get-Content $waitStdErrPath } - ) -join [Environment]::NewLine - - Set-Content -Path $waitCombinedPath -Value $waitOutput - - if ($waitProcess.ExitCode -ne 0) { - throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)." - } - } - - Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds." - } - catch { - $message = $_.Exception.Message - Write-Warning $message - $failures.Add($message) - } - } - finally { - if (Test-Path $projectRoot) { - Push-Location $projectRoot - try { - aspire stop *> $stopLogPath - } - catch { - Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)" - if (Test-Path $stopLogPath) { - Get-Content $stopLogPath - } - } - finally { - Pop-Location - } - } - - Pop-Location - } - } - - if ($failures.Count -gt 0) { - throw ("Starter validation failures:`n- " + ($failures -join "`n- ")) - } + & "$env:GITHUB_WORKSPACE/eng/scripts/cli-starter-validation.ps1" ` + -PRNumber ${{ github.event.pull_request.number }} ` + -MaxStartupSeconds 120 ` + -ResourceReadyTimeoutSeconds 120 - name: Collect CLI logs if: always() @@ -532,6 +354,8 @@ jobs: # primary no-nugets matrix exceeds the overflow threshold. # - tests_requires_nugets_macos: some runs intentionally produce no macOS requires-nugets # tests, so an empty matrix and 'skipped' result are expected. + # - cli_starter_validation_win: this job only runs for pull requests and is expected to + # be skipped for other workflow events. # All other jobs in this gate are required, and a 'skipped' result is treated as a failure. if: >- ${{ always() && diff --git a/eng/scripts/cli-starter-validation.ps1 b/eng/scripts/cli-starter-validation.ps1 new file mode 100644 index 00000000000..8a8c9a3f447 --- /dev/null +++ b/eng/scripts/cli-starter-validation.ps1 @@ -0,0 +1,267 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Pull request number used to select the PR dogfood channel")] + [ValidateRange(1, [int]::MaxValue)] + [int]$PRNumber, + + [Parameter(HelpMessage = "Maximum number of seconds allowed for aspire start to complete")] + [ValidateRange(1, [int]::MaxValue)] + [int]$MaxStartupSeconds = 120, + + [Parameter(HelpMessage = "Maximum number of seconds to wait for each expected resource to reach the requested status")] + [ValidateRange(1, [int]::MaxValue)] + [int]$ResourceReadyTimeoutSeconds = 120, + + [Parameter(HelpMessage = "Directory used to store starter validation projects and diagnostics")] + [string]$ValidationRoot = "" +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true +Set-StrictMode -Version Latest + +function Get-ValidationRoot +{ + if (-not [string]::IsNullOrWhiteSpace($ValidationRoot)) + { + return $ValidationRoot + } + + if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) + { + return (Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation') + } + + return (Join-Path ([System.IO.Path]::GetTempPath()) 'aspire-cli-starter-validation') +} + +function Get-FileContentOrEmpty +{ + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path $Path) + { + return (Get-Content -Raw $Path) + } + + return '' +} + +function Get-CombinedProcessOutput +{ + param( + [Parameter(Mandatory = $true)] + [string]$StdOutPath, + + [Parameter(Mandatory = $true)] + [string]$StdErrPath + ) + + $stdout = Get-FileContentOrEmpty -Path $StdOutPath + $stderr = Get-FileContentOrEmpty -Path $StdErrPath + + if ($stdout -and $stderr) + { + return ($stdout, $stderr) -join [Environment]::NewLine + } + + return "$stdout$stderr" +} + +$validationRootPath = Get-ValidationRoot +Remove-Item -Recurse -Force $validationRootPath -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $validationRootPath -Force | Out-Null + +$templates = @( + @{ + TemplateId = 'aspire-ts-starter' + ProjectName = 'AspireCliTsStarterSmoke' + ExpectedResources = @('app', 'frontend') + }, + @{ + TemplateId = 'aspire-starter' + ProjectName = 'AspireCliCsStarterSmoke' + ExpectedResources = @('apiservice') + } +) + +$failures = [System.Collections.Generic.List[string]]::new() + +foreach ($template in $templates) +{ + $templateId = [string]$template.TemplateId + $projectName = [string]$template.ProjectName + $expectedResources = @($template.ExpectedResources) + $templateRoot = Join-Path $validationRootPath $templateId + $diagnosticsDir = Join-Path $templateRoot 'diagnostics' + $projectRoot = Join-Path $templateRoot $projectName + $startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log' + $startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log' + $startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log' + $preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log' + $stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log' + + New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null + New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null + + Push-Location $templateRoot + try + { + try + { + aspire new $templateId --name $projectName --output $projectRoot --channel "pr-$PRNumber" --non-interactive --nologo + + try + { + aspire stop *> $preStartStopLogPath + } + catch + { + $preStartStopOutput = Get-FileContentOrEmpty -Path $preStartStopLogPath + if ($preStartStopOutput -notmatch 'No running apphost found\.') + { + Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)" + if ($preStartStopOutput) + { + Write-Host $preStartStopOutput + } + } + } + + $startAt = Get-Date + $process = Start-Process -FilePath 'aspire' ` + -ArgumentList @('start') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $startStdOutPath ` + -RedirectStandardError $startStdErrPath ` + -PassThru + + try + { + $process | Wait-Process -Timeout $MaxStartupSeconds -ErrorAction Stop + } + catch + { + if (-not $process.HasExited) + { + $process | Stop-Process -Force -ErrorAction SilentlyContinue + } + + throw "${templateId}: aspire start did not exit within $MaxStartupSeconds seconds." + } + + $elapsed = (Get-Date) - $startAt + $startOutput = Get-CombinedProcessOutput -StdOutPath $startStdOutPath -StdErrPath $startStdErrPath + + Set-Content -Path $startCombinedPath -Value $startOutput + + if ($process.ExitCode -ne 0) + { + throw "${templateId}: aspire start failed with exit code $($process.ExitCode)." + } + + if ($startOutput -match 'Timeout waiting for apphost to start') + { + throw "${templateId}: aspire start reported a startup timeout." + } + + if ($startOutput -notmatch 'Apphost started successfully\.') + { + throw "${templateId}: aspire start did not report a successful startup." + } + + Set-Location $projectRoot + + $resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log' + $resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log' + $resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log' + + $resourcesProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('resources') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $resourcesStdOutPath ` + -RedirectStandardError $resourcesStdErrPath ` + -Wait ` + -PassThru + + $resourcesOutput = Get-CombinedProcessOutput -StdOutPath $resourcesStdOutPath -StdErrPath $resourcesStdErrPath + + Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput + Write-Host $resourcesOutput + + if ($resourcesProcess.ExitCode -ne 0) + { + throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)." + } + + foreach ($resourceName in $expectedResources) + { + $sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_' + $waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log" + $waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log" + $waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log" + + $waitProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $ResourceReadyTimeoutSeconds) ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $waitStdOutPath ` + -RedirectStandardError $waitStdErrPath ` + -Wait ` + -PassThru + + $waitOutput = Get-CombinedProcessOutput -StdOutPath $waitStdOutPath -StdErrPath $waitStdErrPath + + Set-Content -Path $waitCombinedPath -Value $waitOutput + + if ($waitProcess.ExitCode -ne 0) + { + throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)." + } + } + + Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds." + } + catch + { + $message = $_.Exception.Message + Write-Warning $message + $failures.Add($message) + } + } + finally + { + if (Test-Path $projectRoot) + { + Push-Location $projectRoot + try + { + aspire stop *> $stopLogPath + } + catch + { + Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)" + $stopOutput = Get-FileContentOrEmpty -Path $stopLogPath + if ($stopOutput) + { + Write-Host $stopOutput + } + } + finally + { + Pop-Location + } + } + + Pop-Location + } +} + +if ($failures.Count -gt 0) +{ + throw ("Starter validation failures:`n- " + ($failures -join "`n- ")) +} From 95bce3627f324a1ff5635286818852dfee3ef2a0 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 24 Mar 2026 08:26:18 -0700 Subject: [PATCH 08/10] Fix certificate tests for Windows CI Make the trust-operation certificate tests explicitly use an interactive host environment so they continue validating the trust path after the Windows non-interactive CI shortcut was introduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Certificates/CertificateServiceTests.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d3415955de2..682ad1fb825 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -87,6 +87,13 @@ public async Task EnsureCertificatesTrustedAsync_WithNotTrustedCert_RunsTrustOpe } }; }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(supportsInteractiveInput: true), isWindows: () => true); + }; }); var sp = services.BuildServiceProvider(); @@ -178,6 +185,13 @@ public async Task EnsureCertificatesTrustedAsync_WithNoCertificates_RunsTrustOpe } }; }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(supportsInteractiveInput: true), isWindows: () => true); + }; }); var sp = services.BuildServiceProvider(); @@ -370,12 +384,12 @@ public CertificateCleanResult CleanHttpCertificate() => new CertificateCleanResult { Success = true }; } - private sealed class TestCliHostEnvironment : ICliHostEnvironment + private sealed class TestCliHostEnvironment(bool supportsInteractiveInput = false, bool supportsInteractiveOutput = false, bool supportsAnsi = true) : ICliHostEnvironment { - public bool SupportsInteractiveInput => false; + public bool SupportsInteractiveInput => supportsInteractiveInput; - public bool SupportsInteractiveOutput => false; + public bool SupportsInteractiveOutput => supportsInteractiveOutput; - public bool SupportsAnsi => true; + public bool SupportsAnsi => supportsAnsi; } } From 63ff700dc97fe1a575322334def1257fd5600252 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 24 Mar 2026 11:08:13 -0700 Subject: [PATCH 09/10] Add Windows ARM starter validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 10be83ce7ba..529480d90c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,14 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "windows-latest", "runner": "windows-latest", "rids": "win-x64"}]' + build_cli_archive_windows_arm: + if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} + name: Build native CLI archive (Windows ARM64) + uses: ./.github/workflows/build-cli-native-archives.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} + targets: '[{"os": "windows-11-arm", "runner": "windows-11-arm", "rids": "win-arm64"}]' + build_cli_archive_macos: name: Build native CLI archive (macOS) uses: ./.github/workflows/build-cli-native-archives.yml @@ -189,12 +197,22 @@ jobs: with: versionOverrideArg: ${{ inputs.versionOverrideArg }} - cli_starter_validation_win: - name: Aspire CLI Starter Validation (Windows) - runs-on: windows-latest + cli_starter_validation_windows: + name: Aspire CLI Starter Validation (${{ matrix.os.name }}) + runs-on: ${{ matrix.os.runner }} timeout-minutes: 10 - needs: [build_packages, build_cli_archive_windows] + needs: [build_packages, build_cli_archive_windows, build_cli_archive_windows_arm] if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} + strategy: + fail-fast: false + matrix: + os: + - name: Windows + runner: windows-latest + artifact_suffix: windows-x64 + - name: Windows ARM64 + runner: windows-11-arm + artifact_suffix: windows-arm64 env: GH_TOKEN: ${{ github.token }} steps: @@ -251,7 +269,7 @@ jobs: if: always() uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: - name: cli-starter-validation-windows + name: cli-starter-validation-${{ matrix.os.artifact_suffix }} path: ${{ runner.temp }}/aspire-cli-starter-validation if-no-files-found: ignore @@ -295,7 +313,7 @@ jobs: build_cli_archive_windows, build_cli_archive_macos, extension_tests_win, - cli_starter_validation_win, + cli_starter_validation_windows, typescript_sdk_tests, tests_no_nugets, tests_no_nugets_overflow, @@ -354,7 +372,7 @@ jobs: # primary no-nugets matrix exceeds the overflow threshold. # - tests_requires_nugets_macos: some runs intentionally produce no macOS requires-nugets # tests, so an empty matrix and 'skipped' result are expected. - # - cli_starter_validation_win: this job only runs for pull requests and is expected to + # - cli_starter_validation_windows: this job only runs for pull requests and is expected to # be skipped for other workflow events. # All other jobs in this gate are required, and a 'skipped' result is treated as a failure. if: >- @@ -363,7 +381,7 @@ jobs: contains(needs.*.result, 'cancelled') || (github.event_name == 'pull_request' && (needs.extension_tests_win.result == 'skipped' || - needs.cli_starter_validation_win.result == 'skipped' || + needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || needs.tests_no_nugets.result == 'skipped' || needs.tests_requires_nugets_linux.result == 'skipped' || From 873d102f51384c502fb55659fe1e970018847cba Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 26 Mar 2026 21:44:59 -0700 Subject: [PATCH 10/10] Increase Windows ARM validation timeout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3bfa3ef1898..097baf256bb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -200,7 +200,7 @@ jobs: cli_starter_validation_windows: name: Aspire CLI Starter Validation (${{ matrix.os.name }}) runs-on: ${{ matrix.os.runner }} - timeout-minutes: 10 + timeout-minutes: 15 needs: [build_packages, build_cli_archive_windows, build_cli_archive_windows_arm] if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} strategy: @@ -210,9 +210,13 @@ jobs: - name: Windows runner: windows-latest artifact_suffix: windows-x64 + max_startup_seconds: 120 + resource_ready_timeout_seconds: 120 - name: Windows ARM64 runner: windows-11-arm artifact_suffix: windows-arm64 + max_startup_seconds: 120 + resource_ready_timeout_seconds: 300 env: GH_TOKEN: ${{ github.token }} steps: @@ -242,15 +246,15 @@ jobs: aspire --version - name: Create starter app and validate startup - timeout-minutes: 10 + timeout-minutes: 12 shell: pwsh run: | $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true & "$env:GITHUB_WORKSPACE/eng/scripts/cli-starter-validation.ps1" ` -PRNumber ${{ github.event.pull_request.number }} ` - -MaxStartupSeconds 120 ` - -ResourceReadyTimeoutSeconds 120 + -MaxStartupSeconds ${{ matrix.os.max_startup_seconds }} ` + -ResourceReadyTimeoutSeconds ${{ matrix.os.resource_ready_timeout_seconds }} - name: Collect CLI logs if: always()