From 23ebef3b54058109b57e1199fbb0fbdf6e97d411 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 10 Apr 2026 22:58:08 -0400 Subject: [PATCH 1/9] Add macOS JIT entitlements and ad-hoc codesign step for aspire-managed macOS hardened runtime blocks CoreCLR JIT (W^X memory mapping) unless the binary carries com.apple.security.cs.allow-jit and related entitlements. MicroBuild's MacDeveloperHardenWithNotarization signing preserves entitlements from a prior ad-hoc signature, so we codesign with the entitlements plist before Arcade signing. This follows the same pattern used by dotnet/sdk for Roslyn managed binaries (roslyn-entitlements.plist). Fixes https://github.com/microsoft/aspire/issues/16043 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/aspire-managed-entitlements.plist | 18 ++++++++++++++++++ eng/pipelines/templates/build_sign_native.yml | 12 ++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 eng/aspire-managed-entitlements.plist diff --git a/eng/aspire-managed-entitlements.plist b/eng/aspire-managed-entitlements.plist new file mode 100644 index 00000000000..fde7a18e473 --- /dev/null +++ b/eng/aspire-managed-entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 39d92ec148a..3135ea1ca6f 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -81,6 +81,18 @@ jobs: /bl:$(Build.Arcade.LogsPath)PublishManaged.binlog displayName: 🟣Publish aspire-managed + # On macOS, ad-hoc codesign aspire-managed with JIT entitlements BEFORE Arcade signing. + # MicroBuild (MacDeveloperHardenWithNotarization) preserves entitlements from the prior + # ad-hoc signature when re-signing with the real certificate. Without this step, + # hardened runtime blocks CoreCLR JIT (W^X memory mapping) causing HRESULT: 0x80070008. + # This follows the same pattern used by dotnet/sdk for managed binaries (roslyn-entitlements.plist). + - ${{ if eq(parameters.agentOs, 'macos') }}: + - script: >- + codesign --sign - --force + --entitlements $(Build.SourcesDirectory)/eng/aspire-managed-entitlements.plist + $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Ad-hoc codesign aspire-managed with JIT entitlements + - ${{ if eq(parameters.codeSign, true) }}: - script: >- $(Build.SourcesDirectory)/$(scriptName) From 5e3bc77f4d052083f78b900d175686c63877a128 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 10 Apr 2026 22:58:24 -0400 Subject: [PATCH 2/9] Restore execute permissions on aspire-managed after MicroBuild signing MicroBuild rewrites the binary file during signing, which resets Unix file permissions to the default umask (typically 644). The execute bit must be restored before CreateLayout packs the binary into the CLI archive. Without this, macOS and Linux archives contain a non-executable aspire-managed binary. Fixes https://github.com/microsoft/aspire/issues/16043 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/pipelines/templates/build_sign_native.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 3135ea1ca6f..c5ff7b06e5a 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -104,6 +104,15 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + # On macOS/Linux, restore the execute bit after Arcade signing. + # MicroBuild rewrites the binary file during signing, which resets permissions + # to the default umask (typically 644). The execute bit must be restored before + # CreateLayout packs the binary into the bundle archive. + - ${{ if and(eq(parameters.codeSign, true), ne(parameters.agentOs, 'windows')) }}: + - script: >- + chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Restore execute permission on aspire-managed + - script: >- $(Build.SourcesDirectory)/$(dotnetScript) msbuild From a8562538e7f6fe00e20df1654ccaf4f46b976db1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 15 Apr 2026 20:27:56 +0000 Subject: [PATCH 3/9] Narrow non-interactive cert trust skip to macOS and Windows only Backport of fbdac8adb with conflict resolution: - Added EnsureHttpCertificateExists() to ICertificateToolRunner and NativeCertificateToolRunner - Added ICliHostEnvironment parameter to CertificateService constructor - Updated CliTestHelper default CertificateServiceFactory to pass ICliHostEnvironment - Added IsSuccessfulEnsureResult helper method - Added TestCliHostEnvironment and new non-interactive test cases Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- .../Certificates/CertificateService.cs | 29 ++- .../Certificates/ICertificateToolRunner.cs | 5 + .../NativeCertificateToolRunner.cs | 10 + .../Certificates/CertificateServiceTests.cs | 209 ++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- 5 files changed, 253 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index ca7d627d6b0..30f6b1d2486 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,7 +32,8 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry) : ICertificateService + AspireCliTelemetry telemetry, + ICliHostEnvironment hostEnvironment) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; @@ -74,6 +76,27 @@ private async Task HandleMachineReadableTrustAsync( // If not trusted at all, run the trust operation if (trustResult.IsNotTrusted) { + if (!hostEnvironment.SupportsInteractiveInput && !OperatingSystem.IsLinux()) + { + // In non-interactive mode (e.g. CI), skip the trust operation on platforms + // where it requires user interaction (macOS Keychain password prompt, Windows + // certificate trust dialog). Linux trust is non-interactive, so it can proceed. + 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 +122,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 7216f52bffa..03f2c3a7961 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -69,6 +69,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); + } + /// Win32 ERROR_CANCELLED (0x4C7) encoded as an HRESULT (0x800704C7). /// Thrown when the user dismisses the Windows certificate-store security dialog. private const int UserCancelledHResult = unchecked((int)0x800704C7); diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d6677f2a846..b2ade75db22 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; @@ -84,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)); + }; }); var sp = services.BuildServiceProvider(); @@ -175,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)); + }; }); var sp = services.BuildServiceProvider(); @@ -221,10 +238,186 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar Assert.NotNull(result); } + [Fact] + public async Task EnsureCertificatesTrustedAsync_NonInteractive_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()); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + // On Linux, trust proceeds even in non-interactive mode because it doesn't require UI. + // On macOS/Windows, trust is skipped in non-interactive mode. + Assert.Equal(OperatingSystem.IsLinux(), trustCalled); + Assert.False(ensureCalled); + } + + [Fact] + public async Task EnsureCertificatesTrustedAsync_NonInteractive_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()); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + if (OperatingSystem.IsLinux()) + { + // On Linux, trust proceeds in non-interactive mode (no UI needed), so + // TrustHttpCertificate is called which also creates the cert internally. + Assert.True(trustCalled); + Assert.False(ensureCalled); + } + else + { + // On macOS/Windows, trust is skipped but cert creation is attempted. + Assert.False(trustCalled); + Assert.True(ensureCalled); + } + } + + [Fact] + public async Task EnsureCertificatesTrustedAsync_NonInteractiveNonWindows_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(); + // Simulate macOS CI: non-interactive, non-Windows, non-Linux + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment()); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + // On Linux, trust proceeds in non-interactive mode (no UI needed). + // On macOS/Windows, trust is skipped to avoid blocking UI prompts. + Assert.Equal(OperatingSystem.IsLinux(), trustCalled); + Assert.False(ensureCalled); + } + private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -249,7 +442,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(bool supportsInteractiveInput = false, bool supportsInteractiveOutput = false, bool supportsAnsi = true) : ICliHostEnvironment + { + public bool SupportsInteractiveInput => supportsInteractiveInput; + + public bool SupportsInteractiveOutput => supportsInteractiveOutput; + + public bool SupportsAnsi => supportsAnsi; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 8b7b9c97af0..5d6dbdad1ea 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -403,7 +403,7 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, serviceProvider.GetRequiredService()); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => From 0c28cacedbd4fc2b1fd77683a03cdeaa825bfd6a Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 10 Apr 2026 22:59:29 -0400 Subject: [PATCH 4/9] Add CLI archive verification scripts Add verify-cli-archive.sh (Linux/macOS) and verify-cli-archive.ps1 (Windows) scripts that validate a signed CLI archive by: 1. Extracting the archive to a temp location 2. Running 'aspire --version' to verify the binary executes 3. Running 'aspire new aspire-starter' to test bundle self-extraction and project creation (exercises aspire-managed) 4. Cleaning up temp state (backs up and restores ~/.aspire) These scripts will be wired into the CI pipeline to catch signing and permissions regressions before they reach users. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/scripts/verify-cli-archive.ps1 | 171 ++++++++++++++++++++++++ eng/scripts/verify-cli-archive.sh | 204 +++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 eng/scripts/verify-cli-archive.ps1 create mode 100755 eng/scripts/verify-cli-archive.sh diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 new file mode 100644 index 00000000000..69699c45f10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Verify that a signed Aspire CLI archive produces a working binary. + +.DESCRIPTION + This script: + 1. Cleans ~/.aspire to ensure no stale state + 2. Extracts the CLI archive to a temp location + 3. Runs 'aspire --version' to validate the binary executes + 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation + 5. Cleans up temp directories + +.PARAMETER ArchivePath + Path to the CLI archive (.zip or .tar.gz) + +.EXAMPLE + .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip" +#> + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$ArchivePath +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan } +function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green } +function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red } + +$verifyTmpDir = $null +$aspireBackup = $null + +function Invoke-Cleanup { + if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { + Write-Step "Cleaning up temp directory: $verifyTmpDir" + Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue + } + # Restore ~/.aspire if we backed it up + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if ($aspireBackup -and (Test-Path $aspireBackup)) { + if (Test-Path $aspireDir) { + Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue + } + Move-Item $aspireBackup $aspireDir + Write-Step "Restored original ~/.aspire" + } +} + +try { + # Validate archive exists + if (-not (Test-Path $ArchivePath)) { + Write-Err "Archive not found: $ArchivePath" + exit 1 + } + + $ArchivePath = (Resolve-Path $ArchivePath).Path + + # Suppress interactive prompts and telemetry + $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + + Write-Host "" + Write-Host "==========================================" + Write-Host " Aspire CLI Archive Verification" + Write-Host "==========================================" + Write-Host " Archive: $ArchivePath" + Write-Host "==========================================" + Write-Host "" + + # Step 1: Back up and clean ~/.aspire + Write-Step "Cleaning ~/.aspire state..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if (Test-Path $aspireDir) { + $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())" + Move-Item $aspireDir $aspireBackup + Write-Step "Backed up existing ~/.aspire to $aspireBackup" + } + Write-Ok "Clean ~/.aspire state" + + # Step 2: Extract the archive + $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())" + $extractDir = Join-Path $verifyTmpDir "cli" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + Write-Step "Extracting archive to $extractDir..." + if ($ArchivePath.EndsWith(".zip")) { + Expand-Archive -Path $ArchivePath -DestinationPath $extractDir + } + elseif ($ArchivePath.EndsWith(".tar.gz")) { + tar -xzf $ArchivePath -C $extractDir + if ($LASTEXITCODE -ne 0) { + Write-Err "Failed to extract tar.gz archive" + exit 1 + } + } + else { + Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)" + exit 1 + } + + # Find the aspire binary + $aspireBin = Join-Path $extractDir "aspire.exe" + if (-not (Test-Path $aspireBin)) { + $aspireBin = Join-Path $extractDir "aspire" + if (-not (Test-Path $aspireBin)) { + Write-Err "Could not find 'aspire' binary in extracted archive." + Get-ChildItem $extractDir | Format-Table + exit 1 + } + } + Write-Ok "Extracted CLI binary: $aspireBin" + + # Install to ~/.aspire/bin so self-extraction works correctly + Write-Step "Installing CLI to ~/.aspire/bin..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + $aspireBinDir = Join-Path $aspireDir "bin" + New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null + Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)) + $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf) + $env:PATH = "$aspireBinDir;$env:PATH" + Write-Ok "CLI installed to ~/.aspire/bin" + + # Step 3: Verify aspire --version + Write-Step "Running 'aspire --version'..." + $versionOutput = & $aspireBin --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire --version' failed with exit code $LASTEXITCODE" + Write-Host "Output: $versionOutput" + exit 1 + } + Write-Host " Version: $versionOutput" + Write-Ok "'aspire --version' succeeded" + + # Step 4: Create a new project with aspire new + # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) + $projectDir = Join-Path $verifyTmpDir "VerifyApp" + New-Item -ItemType Directory -Path $projectDir -Force | Out-Null + + Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..." + & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new' failed with exit code $LASTEXITCODE" + exit 1 + } + + # Verify the project was created + $appHostDir = Join-Path $projectDir "VerifyApp.AppHost" + if (-not (Test-Path $appHostDir)) { + Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + Get-ChildItem $projectDir | Format-Table + exit 1 + } + Write-Ok "'aspire new' created project successfully" + + Write-Host "" + Write-Host "==========================================" + Write-Host " All verification checks passed!" -ForegroundColor Green + Write-Host "==========================================" + Write-Host "" +} +catch { + Write-Err "Verification failed: $_" + Write-Host $_.ScriptStackTrace + exit 1 +} +finally { + Invoke-Cleanup +} diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh new file mode 100755 index 00000000000..1e5cc3c2e20 --- /dev/null +++ b/eng/scripts/verify-cli-archive.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary. +# +# Usage: ./verify-cli-archive.sh +# +# This script: +# 1. Cleans ~/.aspire to ensure no stale state +# 2. Extracts the CLI archive to a temp location +# 3. Runs 'aspire --version' to validate the binary executes +# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation +# 5. Cleans up temp directories + +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly CYAN='\033[0;36m' +readonly RESET='\033[0m' + +ARCHIVE_PATH="" + +log_step() { echo -e "${CYAN}▶ $1${RESET}"; } +log_ok() { echo -e "${GREEN}✅ $1${RESET}"; } +log_err() { echo -e "${RED}❌ $1${RESET}"; } + +show_help() { + cat << 'EOF' +Aspire CLI Archive Verification Script + +USAGE: + verify-cli-archive.sh + +ARGUMENTS: + Path to the CLI archive (.tar.gz or .zip) + +OPTIONS: + -h, --help Show this help message + +DESCRIPTION: + Verifies that a signed Aspire CLI archive produces a working binary by: + 1. Extracting the archive + 2. Running 'aspire --version' + 3. Creating a new project with 'aspire new' +EOF + exit 0 +} + +cleanup() { + local exit_code=$? + if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then + log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}" + rm -rf "${VERIFY_TMPDIR}" + fi + # Restore ~/.aspire if we backed it up + if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then + if [[ -d "$HOME/.aspire" ]]; then + rm -rf "$HOME/.aspire" + fi + mv "${ASPIRE_BACKUP}" "$HOME/.aspire" + log_step "Restored original ~/.aspire" + fi + if [[ $exit_code -ne 0 ]]; then + log_err "Verification FAILED (exit code: $exit_code)" + fi + exit $exit_code +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -z "$ARCHIVE_PATH" ]]; then + ARCHIVE_PATH="$1" + else + echo "Unexpected argument: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$ARCHIVE_PATH" ]]; then + echo "Error: archive path is required." >&2 + echo "Usage: verify-cli-archive.sh " >&2 + exit 1 +fi + +if [[ ! -f "$ARCHIVE_PATH" ]]; then + log_err "Archive not found: $ARCHIVE_PATH" + exit 1 +fi + +echo "" +echo "==========================================" +echo " Aspire CLI Archive Verification" +echo "==========================================" +echo " Archive: $ARCHIVE_PATH" +echo "==========================================" +echo "" + +# Suppress interactive prompts and telemetry +export ASPIRE_CLI_TELEMETRY_OPTOUT=true +export DOTNET_CLI_TELEMETRY_OPTOUT=true +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false + +# Step 1: Back up and clean ~/.aspire +log_step "Cleaning ~/.aspire state..." +ASPIRE_BACKUP="" +if [[ -d "$HOME/.aspire" ]]; then + ASPIRE_BACKUP="$(mktemp -d)" + mv "$HOME/.aspire" "${ASPIRE_BACKUP}/.aspire" + ASPIRE_BACKUP="${ASPIRE_BACKUP}/.aspire" + log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" +fi +log_ok "Clean ~/.aspire state" + +# Step 2: Extract the archive +VERIFY_TMPDIR="$(mktemp -d)" +EXTRACT_DIR="${VERIFY_TMPDIR}/cli" +mkdir -p "$EXTRACT_DIR" + +log_step "Extracting archive to ${EXTRACT_DIR}..." +if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then + tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" +elif [[ "$ARCHIVE_PATH" == *.zip ]]; then + unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR" +else + log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)" + exit 1 +fi + +# Find the aspire binary +ASPIRE_BIN="" +if [[ -f "$EXTRACT_DIR/aspire" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire" +elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire.exe" +else + log_err "Could not find 'aspire' binary in extracted archive. Contents:" + ls -la "$EXTRACT_DIR" + exit 1 +fi + +chmod +x "$ASPIRE_BIN" +log_ok "Extracted CLI binary: $ASPIRE_BIN" + +# Install the CLI to ~/.aspire/bin so self-extraction works correctly +log_step "Installing CLI to ~/.aspire/bin..." +mkdir -p "$HOME/.aspire/bin" +cp "$ASPIRE_BIN" "$HOME/.aspire/bin/" +ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")" +chmod +x "$ASPIRE_BIN" +export PATH="$HOME/.aspire/bin:$PATH" +log_ok "CLI installed to ~/.aspire/bin" + +# Step 3: Verify aspire --version +log_step "Running 'aspire --version'..." +VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || { + log_err "'aspire --version' failed with exit code $?" + echo "Output: $VERSION_OUTPUT" + exit 1 +} +echo " Version: $VERSION_OUTPUT" +log_ok "'aspire --version' succeeded" + +# Step 4: Create a new project with aspire new +# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) +PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp" +mkdir -p "$PROJECT_DIR" + +log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..." +"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || { + log_err "'aspire new' failed" + echo "Contents of project directory:" + find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true + exit 1 +} + +# Verify the project was actually created +if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then + log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + echo "Contents of project directory:" + ls -la "$PROJECT_DIR" + exit 1 +fi +log_ok "'aspire new' created project successfully" + +echo "" +echo "==========================================" +echo -e " ${GREEN}All verification checks passed!${RESET}" +echo "==========================================" +echo "" From 1886d90110115e07245de481b5b1f3353c2aabda Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 10 Apr 2026 22:59:59 -0400 Subject: [PATCH 5/9] Wire CLI archive verification into CI pipelines Add post-signing verification steps to both build_sign_native.yml (macOS/Linux) and BuildAndTest.yml (Windows) that run the verification scripts after the CLI archives are built and signed. Verification runs for RIDs that can fully execute on the build agent: - macOS (Apple Silicon): osx-arm64 only - Linux (amd64): linux-x64 only - Windows: win-x64 This ensures that signing/permissions regressions (like the ones fixed in this PR) are caught during the official build before release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/pipelines/templates/BuildAndTest.yml | 21 +++++++++++++++++ eng/pipelines/templates/build_sign_native.yml | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index ff7be8d0c9c..caf49189d70 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -92,6 +92,27 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # Verify the signed win-x64 CLI archive works (version check + project creation + build) + - pwsh: | + $ErrorActionPreference = 'Stop' + $archiveDir = "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}" + $archive = Get-ChildItem -Path $archiveDir -Filter "aspire-cli-win-x64-*.zip" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $archive) { + Write-Host "##[warning]No win-x64 CLI archive found - skipping verification" + exit 0 + } + Write-Host "Found archive: $($archive.FullName)" + & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.ps1" -ArchivePath $archive.FullName + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]CLI archive verification failed" + exit 1 + } + displayName: 🟣Verify CLI archive (win-x64) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index c5ff7b06e5a..780259f97f6 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -140,6 +140,29 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Needed for the signing task + # Verify the signed CLI archive can execute and create a project. + # Run for RIDs that can fully execute on the build agent: + # macOS (Apple Silicon): osx-arm64 only (osx-x64 via Rosetta can run the CLI + # but aspire-managed template resolution fails) + # Linux (amd64): linux-x64 only (linux-arm64/linux-musl-x64 are cross-compiled) + - ${{ if or(and(eq(parameters.agentOs, 'macos'), eq(targetRid, 'osx-arm64')), and(eq(parameters.agentOs, 'linux'), eq(targetRid, 'linux-x64'))) }}: + - script: | + set -euo pipefail + echo "Finding CLI archive for ${{ targetRid }}..." + ARCHIVE=$(find "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)" -name "aspire-cli-${{ targetRid }}-*.tar.gz" -type f | head -1) + if [ -z "$ARCHIVE" ]; then + echo "##[error]No CLI archive found for ${{ targetRid }}" + exit 1 + fi + echo "Found archive: $ARCHIVE" + chmod +x "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.sh" + "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.sh" "$ARCHIVE" + displayName: 🟣Verify CLI archive (${{ targetRid }}) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish Artifacts condition: always() From a26fbdcbddbaf4a1775cd144287cf66df4cdf6c2 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 10 Apr 2026 23:09:32 -0400 Subject: [PATCH 6/9] Address PR feedback: restore testability and fix scripts - Restore mockable isNonInteractiveTrustSupported parameter in CertificateService for testability (was Func isWindows) - Fix cert tests to use explicit mocks instead of OS-dependent assertions - Move backup dir into VERIFY_TMPDIR to avoid orphaned temp dirs - Fix BuildAndTest.yml comment to match actual script behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- eng/pipelines/templates/BuildAndTest.yml | 2 +- eng/scripts/verify-cli-archive.sh | 9 +++-- .../Certificates/CertificateService.cs | 6 ++- .../Certificates/CertificateServiceTests.cs | 38 +++++++------------ 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index caf49189d70..bb54e4ddeb3 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -92,7 +92,7 @@ steps: /p:BuildExtension=true displayName: 🟣Build - # Verify the signed win-x64 CLI archive works (version check + project creation + build) + # Verify the signed win-x64 CLI archive works (version check + project creation) - pwsh: | $ErrorActionPreference = 'Stop' $archiveDir = "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}" diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh index 1e5cc3c2e20..40676d1ba10 100755 --- a/eng/scripts/verify-cli-archive.sh +++ b/eng/scripts/verify-cli-archive.sh @@ -115,19 +115,20 @@ export DOTNET_CLI_TELEMETRY_OPTOUT=true export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true export DOTNET_GENERATE_ASPNET_CERTIFICATE=false +VERIFY_TMPDIR="$(mktemp -d)" + # Step 1: Back up and clean ~/.aspire log_step "Cleaning ~/.aspire state..." ASPIRE_BACKUP="" if [[ -d "$HOME/.aspire" ]]; then - ASPIRE_BACKUP="$(mktemp -d)" - mv "$HOME/.aspire" "${ASPIRE_BACKUP}/.aspire" - ASPIRE_BACKUP="${ASPIRE_BACKUP}/.aspire" + ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire" + mkdir -p "${VERIFY_TMPDIR}/aspire-backup" + mv "$HOME/.aspire" "${ASPIRE_BACKUP}" log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" fi log_ok "Clean ~/.aspire state" # Step 2: Extract the archive -VERIFY_TMPDIR="$(mktemp -d)" EXTRACT_DIR="${VERIFY_TMPDIR}/cli" mkdir -p "$EXTRACT_DIR" diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index 30f6b1d2486..3e27766425c 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -33,9 +33,11 @@ internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, AspireCliTelemetry telemetry, - ICliHostEnvironment hostEnvironment) : ICertificateService + ICliHostEnvironment hostEnvironment, + Func? isNonInteractiveTrustSupported = null) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + private readonly Func _isNonInteractiveTrustSupported = isNonInteractiveTrustSupported ?? OperatingSystem.IsLinux; public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -76,7 +78,7 @@ private async Task HandleMachineReadableTrustAsync( // If not trusted at all, run the trust operation if (trustResult.IsNotTrusted) { - if (!hostEnvironment.SupportsInteractiveInput && !OperatingSystem.IsLinux()) + if (!hostEnvironment.SupportsInteractiveInput && !_isNonInteractiveTrustSupported()) { // In non-interactive mode (e.g. CI), skip the trust operation on platforms // where it requires user interaction (macOS Keychain password prompt, Windows diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index b2ade75db22..3759ae7aad2 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -277,7 +277,8 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNotTrustedCe var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment()); + // Simulate a platform where non-interactive trust is NOT supported (e.g. macOS/Windows) + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => false); }; }); @@ -287,9 +288,8 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNotTrustedCe var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); - // On Linux, trust proceeds even in non-interactive mode because it doesn't require UI. - // On macOS/Windows, trust is skipped in non-interactive mode. - Assert.Equal(OperatingSystem.IsLinux(), trustCalled); + // Non-interactive trust is not supported, so trust is skipped. + Assert.False(trustCalled); Assert.False(ensureCalled); } @@ -332,7 +332,8 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNoCertificat var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment()); + // Simulate a platform where non-interactive trust is NOT supported (e.g. macOS/Windows) + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => false); }; }); @@ -342,23 +343,13 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNoCertificat var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); - if (OperatingSystem.IsLinux()) - { - // On Linux, trust proceeds in non-interactive mode (no UI needed), so - // TrustHttpCertificate is called which also creates the cert internally. - Assert.True(trustCalled); - Assert.False(ensureCalled); - } - else - { - // On macOS/Windows, trust is skipped but cert creation is attempted. - Assert.False(trustCalled); - Assert.True(ensureCalled); - } + // Non-interactive trust is not supported, so trust is skipped but cert creation is attempted. + Assert.False(trustCalled); + Assert.True(ensureCalled); } [Fact] - public async Task EnsureCertificatesTrustedAsync_NonInteractiveNonWindows_WithNotTrustedCert_SkipsTrustOperation() + public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNonInteractiveTrustSupported_ProceedsWithTrust() { using var workspace = TemporaryWorkspace.Create(outputHelper); var trustCalled = false; @@ -396,8 +387,8 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractiveNonWindows_WithNo var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - // Simulate macOS CI: non-interactive, non-Windows, non-Linux - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment()); + // Simulate a platform where non-interactive trust IS supported (e.g. Linux) + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => true); }; }); @@ -407,9 +398,8 @@ public async Task EnsureCertificatesTrustedAsync_NonInteractiveNonWindows_WithNo var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); - // On Linux, trust proceeds in non-interactive mode (no UI needed). - // On macOS/Windows, trust is skipped to avoid blocking UI prompts. - Assert.Equal(OperatingSystem.IsLinux(), trustCalled); + // Non-interactive trust is supported (like Linux), so trust proceeds. + Assert.True(trustCalled); Assert.False(ensureCalled); } From 73d1ddc2987b17394a42b823d92767370e5e7a35 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 15 Apr 2026 20:29:53 +0000 Subject: [PATCH 7/9] Add EnsureHttpCertificateExists to shared TestCertificateToolRunner The TestCertificateToolRunner in TestServices/ also needs to implement the EnsureHttpCertificateExists method added to ICertificateToolRunner as part of the backport. Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- .../TestServices/TestCertificateToolRunner.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 14939e7f043..165358b05d6 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -14,6 +14,7 @@ internal sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() @@ -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 From 257ddff53125578fcba5b063cb4986c1b8c63781 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 15 Apr 2026 14:09:03 -0700 Subject: [PATCH 8/9] Remove non-entitlement changes from backport Keep only macOS entitlements plist and build_sign_native.yml signing changes. Remove CI verification scripts, cert trust changes, and related test updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/BuildAndTest.yml | 21 -- eng/scripts/verify-cli-archive.ps1 | 171 --------------- eng/scripts/verify-cli-archive.sh | 205 ------------------ .../Certificates/CertificateService.cs | 31 +-- .../Certificates/ICertificateToolRunner.cs | 5 - .../NativeCertificateToolRunner.cs | 10 - .../Certificates/CertificateServiceTests.cs | 199 ----------------- .../TestServices/TestCertificateToolRunner.cs | 8 - tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- 9 files changed, 2 insertions(+), 650 deletions(-) delete mode 100644 eng/scripts/verify-cli-archive.ps1 delete mode 100755 eng/scripts/verify-cli-archive.sh diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index bb54e4ddeb3..ff7be8d0c9c 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -92,27 +92,6 @@ steps: /p:BuildExtension=true displayName: 🟣Build - # Verify the signed win-x64 CLI archive works (version check + project creation) - - pwsh: | - $ErrorActionPreference = 'Stop' - $archiveDir = "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}" - $archive = Get-ChildItem -Path $archiveDir -Filter "aspire-cli-win-x64-*.zip" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $archive) { - Write-Host "##[warning]No win-x64 CLI archive found - skipping verification" - exit 0 - } - Write-Host "Found archive: $($archive.FullName)" - & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.ps1" -ArchivePath $archive.FullName - if ($LASTEXITCODE -ne 0) { - Write-Host "##[error]CLI archive verification failed" - exit 1 - } - displayName: 🟣Verify CLI archive (win-x64) - condition: succeeded() - env: - ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' - DOTNET_CLI_TELEMETRY_OPTOUT: 'true' - # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 deleted file mode 100644 index 69699c45f10..00000000000 --- a/eng/scripts/verify-cli-archive.ps1 +++ /dev/null @@ -1,171 +0,0 @@ -<# -.SYNOPSIS - Verify that a signed Aspire CLI archive produces a working binary. - -.DESCRIPTION - This script: - 1. Cleans ~/.aspire to ensure no stale state - 2. Extracts the CLI archive to a temp location - 3. Runs 'aspire --version' to validate the binary executes - 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation - 5. Cleans up temp directories - -.PARAMETER ArchivePath - Path to the CLI archive (.zip or .tar.gz) - -.EXAMPLE - .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip" -#> - -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$ArchivePath -) - -$ErrorActionPreference = 'Stop' - -function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan } -function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green } -function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red } - -$verifyTmpDir = $null -$aspireBackup = $null - -function Invoke-Cleanup { - if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { - Write-Step "Cleaning up temp directory: $verifyTmpDir" - Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue - } - # Restore ~/.aspire if we backed it up - $aspireDir = Join-Path $env:USERPROFILE ".aspire" - if ($aspireBackup -and (Test-Path $aspireBackup)) { - if (Test-Path $aspireDir) { - Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue - } - Move-Item $aspireBackup $aspireDir - Write-Step "Restored original ~/.aspire" - } -} - -try { - # Validate archive exists - if (-not (Test-Path $ArchivePath)) { - Write-Err "Archive not found: $ArchivePath" - exit 1 - } - - $ArchivePath = (Resolve-Path $ArchivePath).Path - - # Suppress interactive prompts and telemetry - $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true" - $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" - $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" - $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" - - Write-Host "" - Write-Host "==========================================" - Write-Host " Aspire CLI Archive Verification" - Write-Host "==========================================" - Write-Host " Archive: $ArchivePath" - Write-Host "==========================================" - Write-Host "" - - # Step 1: Back up and clean ~/.aspire - Write-Step "Cleaning ~/.aspire state..." - $aspireDir = Join-Path $env:USERPROFILE ".aspire" - if (Test-Path $aspireDir) { - $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())" - Move-Item $aspireDir $aspireBackup - Write-Step "Backed up existing ~/.aspire to $aspireBackup" - } - Write-Ok "Clean ~/.aspire state" - - # Step 2: Extract the archive - $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())" - $extractDir = Join-Path $verifyTmpDir "cli" - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - - Write-Step "Extracting archive to $extractDir..." - if ($ArchivePath.EndsWith(".zip")) { - Expand-Archive -Path $ArchivePath -DestinationPath $extractDir - } - elseif ($ArchivePath.EndsWith(".tar.gz")) { - tar -xzf $ArchivePath -C $extractDir - if ($LASTEXITCODE -ne 0) { - Write-Err "Failed to extract tar.gz archive" - exit 1 - } - } - else { - Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)" - exit 1 - } - - # Find the aspire binary - $aspireBin = Join-Path $extractDir "aspire.exe" - if (-not (Test-Path $aspireBin)) { - $aspireBin = Join-Path $extractDir "aspire" - if (-not (Test-Path $aspireBin)) { - Write-Err "Could not find 'aspire' binary in extracted archive." - Get-ChildItem $extractDir | Format-Table - exit 1 - } - } - Write-Ok "Extracted CLI binary: $aspireBin" - - # Install to ~/.aspire/bin so self-extraction works correctly - Write-Step "Installing CLI to ~/.aspire/bin..." - $aspireDir = Join-Path $env:USERPROFILE ".aspire" - $aspireBinDir = Join-Path $aspireDir "bin" - New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null - Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)) - $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf) - $env:PATH = "$aspireBinDir;$env:PATH" - Write-Ok "CLI installed to ~/.aspire/bin" - - # Step 3: Verify aspire --version - Write-Step "Running 'aspire --version'..." - $versionOutput = & $aspireBin --version 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Err "'aspire --version' failed with exit code $LASTEXITCODE" - Write-Host "Output: $versionOutput" - exit 1 - } - Write-Host " Version: $versionOutput" - Write-Ok "'aspire --version' succeeded" - - # Step 4: Create a new project with aspire new - # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) - $projectDir = Join-Path $verifyTmpDir "VerifyApp" - New-Item -ItemType Directory -Path $projectDir -Force | Out-Null - - Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..." - & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { - Write-Err "'aspire new' failed with exit code $LASTEXITCODE" - exit 1 - } - - # Verify the project was created - $appHostDir = Join-Path $projectDir "VerifyApp.AppHost" - if (-not (Test-Path $appHostDir)) { - Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" - Get-ChildItem $projectDir | Format-Table - exit 1 - } - Write-Ok "'aspire new' created project successfully" - - Write-Host "" - Write-Host "==========================================" - Write-Host " All verification checks passed!" -ForegroundColor Green - Write-Host "==========================================" - Write-Host "" -} -catch { - Write-Err "Verification failed: $_" - Write-Host $_.ScriptStackTrace - exit 1 -} -finally { - Invoke-Cleanup -} diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh deleted file mode 100755 index 40676d1ba10..00000000000 --- a/eng/scripts/verify-cli-archive.sh +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env bash - -# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary. -# -# Usage: ./verify-cli-archive.sh -# -# This script: -# 1. Cleans ~/.aspire to ensure no stale state -# 2. Extracts the CLI archive to a temp location -# 3. Runs 'aspire --version' to validate the binary executes -# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation -# 5. Cleans up temp directories - -set -euo pipefail - -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly CYAN='\033[0;36m' -readonly RESET='\033[0m' - -ARCHIVE_PATH="" - -log_step() { echo -e "${CYAN}▶ $1${RESET}"; } -log_ok() { echo -e "${GREEN}✅ $1${RESET}"; } -log_err() { echo -e "${RED}❌ $1${RESET}"; } - -show_help() { - cat << 'EOF' -Aspire CLI Archive Verification Script - -USAGE: - verify-cli-archive.sh - -ARGUMENTS: - Path to the CLI archive (.tar.gz or .zip) - -OPTIONS: - -h, --help Show this help message - -DESCRIPTION: - Verifies that a signed Aspire CLI archive produces a working binary by: - 1. Extracting the archive - 2. Running 'aspire --version' - 3. Creating a new project with 'aspire new' -EOF - exit 0 -} - -cleanup() { - local exit_code=$? - if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then - log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}" - rm -rf "${VERIFY_TMPDIR}" - fi - # Restore ~/.aspire if we backed it up - if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then - if [[ -d "$HOME/.aspire" ]]; then - rm -rf "$HOME/.aspire" - fi - mv "${ASPIRE_BACKUP}" "$HOME/.aspire" - log_step "Restored original ~/.aspire" - fi - if [[ $exit_code -ne 0 ]]; then - log_err "Verification FAILED (exit code: $exit_code)" - fi - exit $exit_code -} - -trap cleanup EXIT - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - show_help - ;; - -*) - echo "Unknown option: $1" >&2 - exit 1 - ;; - *) - if [[ -z "$ARCHIVE_PATH" ]]; then - ARCHIVE_PATH="$1" - else - echo "Unexpected argument: $1" >&2 - exit 1 - fi - shift - ;; - esac -done - -if [[ -z "$ARCHIVE_PATH" ]]; then - echo "Error: archive path is required." >&2 - echo "Usage: verify-cli-archive.sh " >&2 - exit 1 -fi - -if [[ ! -f "$ARCHIVE_PATH" ]]; then - log_err "Archive not found: $ARCHIVE_PATH" - exit 1 -fi - -echo "" -echo "==========================================" -echo " Aspire CLI Archive Verification" -echo "==========================================" -echo " Archive: $ARCHIVE_PATH" -echo "==========================================" -echo "" - -# Suppress interactive prompts and telemetry -export ASPIRE_CLI_TELEMETRY_OPTOUT=true -export DOTNET_CLI_TELEMETRY_OPTOUT=true -export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true -export DOTNET_GENERATE_ASPNET_CERTIFICATE=false - -VERIFY_TMPDIR="$(mktemp -d)" - -# Step 1: Back up and clean ~/.aspire -log_step "Cleaning ~/.aspire state..." -ASPIRE_BACKUP="" -if [[ -d "$HOME/.aspire" ]]; then - ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire" - mkdir -p "${VERIFY_TMPDIR}/aspire-backup" - mv "$HOME/.aspire" "${ASPIRE_BACKUP}" - log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" -fi -log_ok "Clean ~/.aspire state" - -# Step 2: Extract the archive -EXTRACT_DIR="${VERIFY_TMPDIR}/cli" -mkdir -p "$EXTRACT_DIR" - -log_step "Extracting archive to ${EXTRACT_DIR}..." -if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then - tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" -elif [[ "$ARCHIVE_PATH" == *.zip ]]; then - unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR" -else - log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)" - exit 1 -fi - -# Find the aspire binary -ASPIRE_BIN="" -if [[ -f "$EXTRACT_DIR/aspire" ]]; then - ASPIRE_BIN="$EXTRACT_DIR/aspire" -elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then - ASPIRE_BIN="$EXTRACT_DIR/aspire.exe" -else - log_err "Could not find 'aspire' binary in extracted archive. Contents:" - ls -la "$EXTRACT_DIR" - exit 1 -fi - -chmod +x "$ASPIRE_BIN" -log_ok "Extracted CLI binary: $ASPIRE_BIN" - -# Install the CLI to ~/.aspire/bin so self-extraction works correctly -log_step "Installing CLI to ~/.aspire/bin..." -mkdir -p "$HOME/.aspire/bin" -cp "$ASPIRE_BIN" "$HOME/.aspire/bin/" -ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")" -chmod +x "$ASPIRE_BIN" -export PATH="$HOME/.aspire/bin:$PATH" -log_ok "CLI installed to ~/.aspire/bin" - -# Step 3: Verify aspire --version -log_step "Running 'aspire --version'..." -VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || { - log_err "'aspire --version' failed with exit code $?" - echo "Output: $VERSION_OUTPUT" - exit 1 -} -echo " Version: $VERSION_OUTPUT" -log_ok "'aspire --version' succeeded" - -# Step 4: Create a new project with aspire new -# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) -PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp" -mkdir -p "$PROJECT_DIR" - -log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..." -"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || { - log_err "'aspire new' failed" - echo "Contents of project directory:" - find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true - exit 1 -} - -# Verify the project was actually created -if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then - log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" - echo "Contents of project directory:" - ls -la "$PROJECT_DIR" - exit 1 -fi -log_ok "'aspire new' created project successfully" - -echo "" -echo "==========================================" -echo -e " ${GREEN}All verification checks passed!${RESET}" -echo "==========================================" -echo "" diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index 3e27766425c..ca7d627d6b0 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -7,7 +7,6 @@ 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; @@ -32,12 +31,9 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry, - ICliHostEnvironment hostEnvironment, - Func? isNonInteractiveTrustSupported = null) : ICertificateService + AspireCliTelemetry telemetry) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; - private readonly Func _isNonInteractiveTrustSupported = isNonInteractiveTrustSupported ?? OperatingSystem.IsLinux; public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -78,27 +74,6 @@ private async Task HandleMachineReadableTrustAsync( // If not trusted at all, run the trust operation if (trustResult.IsNotTrusted) { - if (!hostEnvironment.SupportsInteractiveInput && !_isNonInteractiveTrustSupported()) - { - // In non-interactive mode (e.g. CI), skip the trust operation on platforms - // where it requires user interaction (macOS Keychain password prompt, Windows - // certificate trust dialog). Linux trust is non-interactive, so it can proceed. - 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()), @@ -124,10 +99,6 @@ 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 55474f9dab7..f800927769e 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -15,11 +15,6 @@ 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 03f2c3a7961..7216f52bffa 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -69,16 +69,6 @@ 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); - } - /// Win32 ERROR_CANCELLED (0x4C7) encoded as an HRESULT (0x800704C7). /// Thrown when the user dismisses the Windows certificate-store security dialog. private const int UserCancelledHResult = unchecked((int)0x800704C7); diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index 3759ae7aad2..d6677f2a846 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -3,10 +3,7 @@ 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; @@ -87,13 +84,6 @@ 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)); - }; }); var sp = services.BuildServiceProvider(); @@ -185,13 +175,6 @@ 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)); - }; }); var sp = services.BuildServiceProvider(); @@ -238,176 +221,10 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar Assert.NotNull(result); } - [Fact] - public async Task EnsureCertificatesTrustedAsync_NonInteractive_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(); - // Simulate a platform where non-interactive trust is NOT supported (e.g. macOS/Windows) - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => false); - }; - }); - - var sp = services.BuildServiceProvider(); - var cs = sp.GetRequiredService(); - - var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); - - Assert.NotNull(result); - // Non-interactive trust is not supported, so trust is skipped. - Assert.False(trustCalled); - Assert.False(ensureCalled); - } - - [Fact] - public async Task EnsureCertificatesTrustedAsync_NonInteractive_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(); - // Simulate a platform where non-interactive trust is NOT supported (e.g. macOS/Windows) - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => false); - }; - }); - - var sp = services.BuildServiceProvider(); - var cs = sp.GetRequiredService(); - - var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); - - Assert.NotNull(result); - // Non-interactive trust is not supported, so trust is skipped but cert creation is attempted. - Assert.False(trustCalled); - Assert.True(ensureCalled); - } - - [Fact] - public async Task EnsureCertificatesTrustedAsync_NonInteractive_WithNonInteractiveTrustSupported_ProceedsWithTrust() - { - 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(); - // Simulate a platform where non-interactive trust IS supported (e.g. Linux) - return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isNonInteractiveTrustSupported: () => true); - }; - }); - - var sp = services.BuildServiceProvider(); - var cs = sp.GetRequiredService(); - - var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); - - Assert.NotNull(result); - // Non-interactive trust is supported (like Linux), so trust proceeds. - Assert.True(trustCalled); - Assert.False(ensureCalled); - } - private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } - public Func? EnsureHttpCertificateExistsCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -432,23 +249,7 @@ 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(bool supportsInteractiveInput = false, bool supportsInteractiveOutput = false, bool supportsAnsi = true) : ICliHostEnvironment - { - public bool SupportsInteractiveInput => supportsInteractiveInput; - - public bool SupportsInteractiveOutput => supportsInteractiveOutput; - - public bool SupportsAnsi => supportsAnsi; - } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 165358b05d6..14939e7f043 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -14,7 +14,6 @@ internal sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } - public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() @@ -40,13 +39,6 @@ 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 5d6dbdad1ea..8b7b9c97af0 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -403,7 +403,7 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry, serviceProvider.GetRequiredService()); + return new CertificateService(certificateToolRunner, interactiveService, telemetry); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => From 9b9c51174fe1b55854c117eb33e9ec3ed07dab13 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 15 Apr 2026 14:47:45 -0700 Subject: [PATCH 9/9] Add CI verification scripts for signed CLI archives Restore BuildAndTest.yml wiring and verify-cli-archive scripts to validate signed archives work correctly after signing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/BuildAndTest.yml | 21 +++ eng/scripts/verify-cli-archive.ps1 | 171 +++++++++++++++++++ eng/scripts/verify-cli-archive.sh | 205 +++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 eng/scripts/verify-cli-archive.ps1 create mode 100755 eng/scripts/verify-cli-archive.sh diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index ff7be8d0c9c..bb54e4ddeb3 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -92,6 +92,27 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # Verify the signed win-x64 CLI archive works (version check + project creation) + - pwsh: | + $ErrorActionPreference = 'Stop' + $archiveDir = "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}" + $archive = Get-ChildItem -Path $archiveDir -Filter "aspire-cli-win-x64-*.zip" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $archive) { + Write-Host "##[warning]No win-x64 CLI archive found - skipping verification" + exit 0 + } + Write-Host "Found archive: $($archive.FullName)" + & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.ps1" -ArchivePath $archive.FullName + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]CLI archive verification failed" + exit 1 + } + displayName: 🟣Verify CLI archive (win-x64) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 new file mode 100644 index 00000000000..69699c45f10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Verify that a signed Aspire CLI archive produces a working binary. + +.DESCRIPTION + This script: + 1. Cleans ~/.aspire to ensure no stale state + 2. Extracts the CLI archive to a temp location + 3. Runs 'aspire --version' to validate the binary executes + 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation + 5. Cleans up temp directories + +.PARAMETER ArchivePath + Path to the CLI archive (.zip or .tar.gz) + +.EXAMPLE + .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip" +#> + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$ArchivePath +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan } +function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green } +function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red } + +$verifyTmpDir = $null +$aspireBackup = $null + +function Invoke-Cleanup { + if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { + Write-Step "Cleaning up temp directory: $verifyTmpDir" + Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue + } + # Restore ~/.aspire if we backed it up + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if ($aspireBackup -and (Test-Path $aspireBackup)) { + if (Test-Path $aspireDir) { + Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue + } + Move-Item $aspireBackup $aspireDir + Write-Step "Restored original ~/.aspire" + } +} + +try { + # Validate archive exists + if (-not (Test-Path $ArchivePath)) { + Write-Err "Archive not found: $ArchivePath" + exit 1 + } + + $ArchivePath = (Resolve-Path $ArchivePath).Path + + # Suppress interactive prompts and telemetry + $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + + Write-Host "" + Write-Host "==========================================" + Write-Host " Aspire CLI Archive Verification" + Write-Host "==========================================" + Write-Host " Archive: $ArchivePath" + Write-Host "==========================================" + Write-Host "" + + # Step 1: Back up and clean ~/.aspire + Write-Step "Cleaning ~/.aspire state..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if (Test-Path $aspireDir) { + $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())" + Move-Item $aspireDir $aspireBackup + Write-Step "Backed up existing ~/.aspire to $aspireBackup" + } + Write-Ok "Clean ~/.aspire state" + + # Step 2: Extract the archive + $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())" + $extractDir = Join-Path $verifyTmpDir "cli" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + Write-Step "Extracting archive to $extractDir..." + if ($ArchivePath.EndsWith(".zip")) { + Expand-Archive -Path $ArchivePath -DestinationPath $extractDir + } + elseif ($ArchivePath.EndsWith(".tar.gz")) { + tar -xzf $ArchivePath -C $extractDir + if ($LASTEXITCODE -ne 0) { + Write-Err "Failed to extract tar.gz archive" + exit 1 + } + } + else { + Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)" + exit 1 + } + + # Find the aspire binary + $aspireBin = Join-Path $extractDir "aspire.exe" + if (-not (Test-Path $aspireBin)) { + $aspireBin = Join-Path $extractDir "aspire" + if (-not (Test-Path $aspireBin)) { + Write-Err "Could not find 'aspire' binary in extracted archive." + Get-ChildItem $extractDir | Format-Table + exit 1 + } + } + Write-Ok "Extracted CLI binary: $aspireBin" + + # Install to ~/.aspire/bin so self-extraction works correctly + Write-Step "Installing CLI to ~/.aspire/bin..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + $aspireBinDir = Join-Path $aspireDir "bin" + New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null + Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)) + $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf) + $env:PATH = "$aspireBinDir;$env:PATH" + Write-Ok "CLI installed to ~/.aspire/bin" + + # Step 3: Verify aspire --version + Write-Step "Running 'aspire --version'..." + $versionOutput = & $aspireBin --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire --version' failed with exit code $LASTEXITCODE" + Write-Host "Output: $versionOutput" + exit 1 + } + Write-Host " Version: $versionOutput" + Write-Ok "'aspire --version' succeeded" + + # Step 4: Create a new project with aspire new + # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) + $projectDir = Join-Path $verifyTmpDir "VerifyApp" + New-Item -ItemType Directory -Path $projectDir -Force | Out-Null + + Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..." + & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new' failed with exit code $LASTEXITCODE" + exit 1 + } + + # Verify the project was created + $appHostDir = Join-Path $projectDir "VerifyApp.AppHost" + if (-not (Test-Path $appHostDir)) { + Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + Get-ChildItem $projectDir | Format-Table + exit 1 + } + Write-Ok "'aspire new' created project successfully" + + Write-Host "" + Write-Host "==========================================" + Write-Host " All verification checks passed!" -ForegroundColor Green + Write-Host "==========================================" + Write-Host "" +} +catch { + Write-Err "Verification failed: $_" + Write-Host $_.ScriptStackTrace + exit 1 +} +finally { + Invoke-Cleanup +} diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh new file mode 100755 index 00000000000..40676d1ba10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash + +# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary. +# +# Usage: ./verify-cli-archive.sh +# +# This script: +# 1. Cleans ~/.aspire to ensure no stale state +# 2. Extracts the CLI archive to a temp location +# 3. Runs 'aspire --version' to validate the binary executes +# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation +# 5. Cleans up temp directories + +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly CYAN='\033[0;36m' +readonly RESET='\033[0m' + +ARCHIVE_PATH="" + +log_step() { echo -e "${CYAN}▶ $1${RESET}"; } +log_ok() { echo -e "${GREEN}✅ $1${RESET}"; } +log_err() { echo -e "${RED}❌ $1${RESET}"; } + +show_help() { + cat << 'EOF' +Aspire CLI Archive Verification Script + +USAGE: + verify-cli-archive.sh + +ARGUMENTS: + Path to the CLI archive (.tar.gz or .zip) + +OPTIONS: + -h, --help Show this help message + +DESCRIPTION: + Verifies that a signed Aspire CLI archive produces a working binary by: + 1. Extracting the archive + 2. Running 'aspire --version' + 3. Creating a new project with 'aspire new' +EOF + exit 0 +} + +cleanup() { + local exit_code=$? + if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then + log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}" + rm -rf "${VERIFY_TMPDIR}" + fi + # Restore ~/.aspire if we backed it up + if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then + if [[ -d "$HOME/.aspire" ]]; then + rm -rf "$HOME/.aspire" + fi + mv "${ASPIRE_BACKUP}" "$HOME/.aspire" + log_step "Restored original ~/.aspire" + fi + if [[ $exit_code -ne 0 ]]; then + log_err "Verification FAILED (exit code: $exit_code)" + fi + exit $exit_code +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -z "$ARCHIVE_PATH" ]]; then + ARCHIVE_PATH="$1" + else + echo "Unexpected argument: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$ARCHIVE_PATH" ]]; then + echo "Error: archive path is required." >&2 + echo "Usage: verify-cli-archive.sh " >&2 + exit 1 +fi + +if [[ ! -f "$ARCHIVE_PATH" ]]; then + log_err "Archive not found: $ARCHIVE_PATH" + exit 1 +fi + +echo "" +echo "==========================================" +echo " Aspire CLI Archive Verification" +echo "==========================================" +echo " Archive: $ARCHIVE_PATH" +echo "==========================================" +echo "" + +# Suppress interactive prompts and telemetry +export ASPIRE_CLI_TELEMETRY_OPTOUT=true +export DOTNET_CLI_TELEMETRY_OPTOUT=true +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false + +VERIFY_TMPDIR="$(mktemp -d)" + +# Step 1: Back up and clean ~/.aspire +log_step "Cleaning ~/.aspire state..." +ASPIRE_BACKUP="" +if [[ -d "$HOME/.aspire" ]]; then + ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire" + mkdir -p "${VERIFY_TMPDIR}/aspire-backup" + mv "$HOME/.aspire" "${ASPIRE_BACKUP}" + log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" +fi +log_ok "Clean ~/.aspire state" + +# Step 2: Extract the archive +EXTRACT_DIR="${VERIFY_TMPDIR}/cli" +mkdir -p "$EXTRACT_DIR" + +log_step "Extracting archive to ${EXTRACT_DIR}..." +if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then + tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" +elif [[ "$ARCHIVE_PATH" == *.zip ]]; then + unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR" +else + log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)" + exit 1 +fi + +# Find the aspire binary +ASPIRE_BIN="" +if [[ -f "$EXTRACT_DIR/aspire" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire" +elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire.exe" +else + log_err "Could not find 'aspire' binary in extracted archive. Contents:" + ls -la "$EXTRACT_DIR" + exit 1 +fi + +chmod +x "$ASPIRE_BIN" +log_ok "Extracted CLI binary: $ASPIRE_BIN" + +# Install the CLI to ~/.aspire/bin so self-extraction works correctly +log_step "Installing CLI to ~/.aspire/bin..." +mkdir -p "$HOME/.aspire/bin" +cp "$ASPIRE_BIN" "$HOME/.aspire/bin/" +ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")" +chmod +x "$ASPIRE_BIN" +export PATH="$HOME/.aspire/bin:$PATH" +log_ok "CLI installed to ~/.aspire/bin" + +# Step 3: Verify aspire --version +log_step "Running 'aspire --version'..." +VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || { + log_err "'aspire --version' failed with exit code $?" + echo "Output: $VERSION_OUTPUT" + exit 1 +} +echo " Version: $VERSION_OUTPUT" +log_ok "'aspire --version' succeeded" + +# Step 4: Create a new project with aspire new +# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) +PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp" +mkdir -p "$PROJECT_DIR" + +log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..." +"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || { + log_err "'aspire new' failed" + echo "Contents of project directory:" + find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true + exit 1 +} + +# Verify the project was actually created +if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then + log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + echo "Contents of project directory:" + ls -la "$PROJECT_DIR" + exit 1 +fi +log_ok "'aspire new' created project successfully" + +echo "" +echo "==========================================" +echo -e " ${GREEN}All verification checks passed!${RESET}" +echo "==========================================" +echo ""