diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index f95a4a48d75..fa5ac480a40 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -39,7 +39,8 @@ on:
required: false
type: boolean
default: false
- # Controls whether to set CLI E2E environment variables (GH_TOKEN, GITHUB_PR_NUMBER, GITHUB_PR_HEAD_SHA)
+ # Controls whether to build and install the CLI from a native archive and set
+ # CLI E2E environment variables (ASPIRE_CLI_PATH_DIR, ASPIRE_CLI_VERSION, BUILT_NUGETS_PATH)
requiresCliArchive:
required: false
type: boolean
@@ -202,6 +203,69 @@ jobs:
Write-Host "Merged $($nupkgs.Count) arch-specific nugets for RID=$env:RID into $dest"
}
+ - name: Download CLI archive (Linux)
+ if: ${{ inputs.requiresCliArchive && runner.os == 'Linux' }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: cli-native-archives-linux-x64
+ path: ${{ github.workspace }}/cli-archive
+
+ - name: Install CLI from archive (Linux)
+ if: ${{ inputs.requiresCliArchive && runner.os == 'Linux' }}
+ shell: bash
+ run: |
+ mkdir -p "$HOME/.aspire/bin"
+
+ # Find the tar.gz archive (e.g., aspire-cli-linux-x64-13.3.0-preview.1.25234.2.tar.gz)
+ archive=$(find "${{ github.workspace }}/cli-archive" -name "aspire-cli-*.tar.gz" 2>/dev/null | head -1)
+
+ if [ -z "$archive" ]; then
+ echo "ERROR: No aspire-cli-*.tar.gz found in ${{ github.workspace }}/cli-archive"
+ ls -la "${{ github.workspace }}/cli-archive/" || true
+ exit 1
+ fi
+
+ echo "Installing CLI from: $archive"
+ tar -xzf "$archive" -C "$HOME/.aspire/bin"
+ chmod +x "$HOME/.aspire/bin/aspire"
+
+ # Verify installation (first run triggers self-extraction of embedded bundle).
+ # Capture and print the version to confirm the correct build was installed.
+ installed_version=$("$HOME/.aspire/bin/aspire" --version 2>&1)
+ if [ $? -ne 0 ]; then
+ echo "WARNING: 'aspire --version' exited non-zero. Output: $installed_version"
+ else
+ echo "Installed CLI version: $installed_version"
+ fi
+
+ # Add to PATH for subsequent steps and tests
+ echo "$HOME/.aspire/bin" >> $GITHUB_PATH
+
+ # Expose the install dir and commit SHA so tests can detect the pre-installed CLI
+ # and verify they are running the correct build.
+ echo "ASPIRE_CLI_PATH_DIR=$HOME/.aspire/bin" >> $GITHUB_ENV
+ echo "ASPIRE_CLI_VERSION=$installed_version" >> $GITHUB_ENV
+
+ # Set up NuGet hive from built packages so 'aspire new' / 'aspire add' can resolve
+ # CI-built packages without going to nuget.org.
+ NUGETS_PATH="${{ github.workspace }}/artifacts/packages/Debug/Shipping"
+ if [ -d "$NUGETS_PATH" ]; then
+ HIVE_DIR="$HOME/.aspire/hives/ci/packages"
+ mkdir -p "$HIVE_DIR"
+ if cp "$NUGETS_PATH"/*.nupkg "$HIVE_DIR/" 2>/dev/null; then
+ pkg_count=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l)
+ echo "Copied $pkg_count packages to NuGet hive at $HIVE_DIR"
+ else
+ echo "WARNING: No .nupkg files found in $NUGETS_PATH — NuGet hive will be empty"
+ fi
+
+ # Configure the CLI channel to point to the local hive.
+ # Non-fatal: channel can be configured manually if this fails.
+ if ! "$HOME/.aspire/bin/aspire" config set channel ci --global --non-interactive 2>&1; then
+ echo "WARNING: Failed to set CLI channel to 'ci' — packages may be resolved from nuget.org"
+ fi
+ fi
+
- name: Install sdk for nuget based testing
if: ${{ inputs.requiresTestSdk }}
run: >
@@ -328,10 +392,6 @@ jobs:
TEST_LOG_PATH: ${{ github.workspace }}/artifacts/log/test-logs
TestsRunningOutsideOfRepo: true
TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io'
- # PR metadata and token for CLI E2E tests that download artifacts from a PR
- GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
- GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
- GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background
${{ github.workspace }}/${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs &
@@ -371,10 +431,6 @@ jobs:
TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io'
# Prevent VBCSCompiler from starting during tests. See #15832
UseSharedCompilation: false
- # PR metadata and token for CLI E2E tests that download artifacts from a PR
- GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
- GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
- GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (output goes to console directly)
# Use 60s interval on Windows to reduce overhead on constrained 2-core runners
@@ -420,10 +476,6 @@ jobs:
NUGET_PACKAGES: ${{ github.workspace }}/.packages
PLAYWRIGHT_INSTALLED: ${{ !inputs.enablePlaywrightInstall && 'false' || 'true' }}
TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io'
- # PR metadata and token for CLI E2E tests that download artifacts from a PR
- GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
- GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
- GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background
${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs &
@@ -470,10 +522,6 @@ jobs:
# so any rebuild triggered by DCP's "dotnet run" should be minimal.
# See https://github.com/microsoft/aspire/issues/15832
UseSharedCompilation: false
- # PR metadata and token for CLI E2E tests that download artifacts from a PR
- GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }}
- GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }}
- GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }}
run: |
# Start heartbeat monitor in background (output goes to console directly)
# Use 60s interval on Windows to reduce overhead on constrained 2-core runners
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
index 5679a174255..947b7e17fed 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs
@@ -19,15 +19,11 @@ public sealed class DockerDeploymentTests(ITestOutputHelper output)
private const string ProjectName = "AspireDockerDeployTest";
[Fact]
- [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")]
[QuarantinedTest("https://github.com/microsoft/aspire/issues/15882")]
public async Task CreateAndDeployToDockerCompose()
{
using var workspace = TemporaryWorkspace.Create(output);
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
using var terminal = CliE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
@@ -38,11 +34,11 @@ public async Task CreateAndDeployToDockerCompose()
// PrepareEnvironment
await auto.PrepareEnvironmentAsync(workspace, counter);
- if (isCI)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
- await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ // CI: CLI was pre-installed by the workflow — just configure env vars and verify.
await auto.SourceAspireCliEnvironmentAsync(counter);
- await auto.VerifyAspireCliVersionAsync(commitSha, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
}
// Step 1: Create a new Aspire Starter App (no Redis cache)
@@ -58,11 +54,12 @@ public async Task CreateAndDeployToDockerCompose()
await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
await auto.EnterAsync();
- // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set)
- if (isCI)
+ // In CI, aspire add shows a version selection prompt
+ // (unlike aspire new which auto-selects when channel is set)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
- await auto.EnterAsync(); // select first version (PR build)
+ await auto.EnterAsync(); // select first version
}
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
@@ -142,15 +139,11 @@ public async Task CreateAndDeployToDockerCompose()
}
[Fact]
- [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")]
[QuarantinedTest("https://github.com/microsoft/aspire/issues/15871")]
public async Task CreateAndDeployToDockerComposeInteractive()
{
using var workspace = TemporaryWorkspace.Create(output);
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
using var terminal = CliE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
@@ -161,11 +154,11 @@ public async Task CreateAndDeployToDockerComposeInteractive()
// PrepareEnvironment
await auto.PrepareEnvironmentAsync(workspace, counter);
- if (isCI)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
- await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ // CI: CLI was pre-installed by the workflow — just configure env vars and verify.
await auto.SourceAspireCliEnvironmentAsync(counter);
- await auto.VerifyAspireCliVersionAsync(commitSha, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
}
// Step 1: Create a new Aspire Starter App (no Redis cache)
@@ -181,11 +174,12 @@ public async Task CreateAndDeployToDockerComposeInteractive()
await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
await auto.EnterAsync();
- // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set)
- if (isCI)
+ // In CI, aspire add shows a version selection prompt
+ // (unlike aspire new which auto-selects when channel is set)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60));
- await auto.EnterAsync(); // select first version (PR build)
+ await auto.EnterAsync(); // select first version
}
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
index 57f388cf504..407640ec878 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
@@ -64,13 +64,22 @@ internal static async Task InstallAspireCliInDockerAsync(
{
switch (installMode)
{
- case CliE2ETestHelpers.DockerInstallMode.SourceBuild:
+ case CliE2ETestHelpers.DockerInstallMode.PreInstalled:
await auto.TypeAsync("mkdir -p ~/.aspire/bin && cp /opt/aspire-cli/aspire ~/.aspire/bin/aspire && chmod +x ~/.aspire/bin/aspire");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
+
+ // If the CI workflow mounted built NuGet packages, configure a local hive so that
+ // 'aspire new' and 'aspire add' can resolve CI packages without going to nuget.org.
+ await auto.TypeAsync("if [ -d /built-nugets ]; then mkdir -p ~/.aspire/hives/ci/packages && cp /built-nugets/*.nupkg ~/.aspire/hives/ci/packages/ 2>/dev/null; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
+ await auto.TypeAsync("if [ -d ~/.aspire/hives/ci/packages ] && ls ~/.aspire/hives/ci/packages/*.nupkg 1>/dev/null 2>&1; then aspire config set channel ci --global --non-interactive; fi");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
break;
case CliE2ETestHelpers.DockerInstallMode.GaRelease:
@@ -82,63 +91,11 @@ internal static async Task InstallAspireCliInDockerAsync(
await auto.WaitForSuccessPromptAsync(counter);
break;
- case CliE2ETestHelpers.DockerInstallMode.PullRequest:
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}");
- await auto.EnterAsync();
- await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
- await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH");
- await auto.EnterAsync();
- await auto.WaitForSuccessPromptAsync(counter);
- break;
-
default:
throw new ArgumentOutOfRangeException(nameof(installMode));
}
}
- ///
- /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation.
- /// Used by tests that run with (bare bash, no Docker).
- ///
- internal static async Task PrepareEnvironmentAsync(
- this Hex1bTerminalAutomator auto,
- TemporaryWorkspace workspace,
- SequenceCounter counter)
- {
- var waitingForInputPattern = new CellPatternSearcher()
- .Find("b").RightUntil("$").Right(' ').Right(' ');
-
- await auto.WaitUntilAsync(
- s => waitingForInputPattern.Search(s).Count > 0,
- timeout: TimeSpan.FromSeconds(10),
- description: "initial bash prompt");
- await auto.WaitAsync(500);
-
- const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'";
- await auto.TypeAsync(promptSetup);
- await auto.EnterAsync();
- await auto.WaitForSuccessPromptAsync(counter);
-
- await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}");
- await auto.EnterAsync();
- await auto.WaitForSuccessPromptAsync(counter);
- }
-
- ///
- /// Installs the Aspire CLI from PR build artifacts in a non-Docker environment.
- ///
- internal static async Task InstallAspireCliFromPullRequestAsync(
- this Hex1bTerminalAutomator auto,
- int prNumber,
- SequenceCounter counter)
- {
- var command = $"curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
- await auto.TypeAsync(command);
- await auto.EnterAsync();
- await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
- }
-
///
/// Configures the PATH and environment variables for the Aspire CLI in a non-Docker environment.
///
@@ -152,60 +109,51 @@ internal static async Task SourceAspireCliEnvironmentAsync(
}
///
- /// Verifies the installed Aspire CLI version matches the expected build.
- /// Always checks the dynamic version prefix from eng/Versions.props.
- /// For non-stabilized builds (all normal PR builds), also verifies the commit SHA suffix.
+ /// Verifies the installed Aspire CLI version matches the expected version from the
+ /// ASPIRE_CLI_VERSION environment variable. If the env var is not set (local dev),
+ /// this is a no-op.
///
internal static async Task VerifyAspireCliVersionAsync(
this Hex1bTerminalAutomator auto,
- string commitSha,
SequenceCounter counter)
{
- var versionPrefix = CliE2ETestHelpers.GetVersionPrefix();
- var isStabilized = CliE2ETestHelpers.IsStabilizedBuild();
-
- await auto.TypeAsync("aspire --version");
- await auto.EnterAsync();
-
- // Always verify the version prefix matches the branch's version (e.g., "13.3.0").
- await auto.WaitUntilTextAsync(versionPrefix, timeout: TimeSpan.FromSeconds(10));
-
- // For non-stabilized builds (all PR CI builds), also verify the commit SHA suffix
- // to uniquely identify the exact build. Stabilized builds (official releases only)
- // produce versions without SHA suffixes, so we skip this check.
- if (!isStabilized && commitSha.Length == 40)
+ var expectedVersion = CliE2ETestHelpers.ExpectedCliVersion;
+ if (expectedVersion is null)
{
- var shortCommitSha = commitSha[..8];
- var expectedVersionSuffix = $"g{shortCommitSha}";
- await auto.WaitUntilTextAsync(expectedVersionSuffix, timeout: TimeSpan.FromSeconds(10));
+ // Local dev — skip version check.
+ return;
}
+ await auto.TypeAsync("aspire --version");
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync(expectedVersion, timeout: TimeSpan.FromSeconds(10));
await auto.WaitForSuccessPromptAsync(counter);
}
///
- /// Installs the Aspire CLI and bundle from PR build artifacts, using the PR head SHA to fetch the install script.
+ /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation.
+ /// Used by tests that run with (bare bash, no Docker).
///
- internal static async Task InstallAspireBundleFromPullRequestAsync(
+ internal static async Task PrepareEnvironmentAsync(
this Hex1bTerminalAutomator auto,
- int prNumber,
+ TemporaryWorkspace workspace,
SequenceCounter counter)
{
- var command = $"ref=$(gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha') && curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}";
- await auto.TypeAsync(command);
+ var waitingForInputPattern = new CellPatternSearcher()
+ .Find("b").RightUntil("$").Right(' ').Right(' ');
+
+ await auto.WaitUntilAsync(
+ s => waitingForInputPattern.Search(s).Count > 0,
+ timeout: TimeSpan.FromSeconds(10),
+ description: "initial bash prompt");
+ await auto.WaitAsync(500);
+
+ const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'";
+ await auto.TypeAsync(promptSetup);
await auto.EnterAsync();
- await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300));
- }
+ await auto.WaitForSuccessPromptAsync(counter);
- ///
- /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment.
- /// Unlike , this includes ~/.aspire in PATH for bundle tools.
- ///
- internal static async Task SourceAspireBundleEnvironmentAsync(
- this Hex1bTerminalAutomator auto,
- SequenceCounter counter)
- {
- await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false");
+ await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
index 4411c907212..a4f6fe80517 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs
@@ -3,7 +3,6 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
-using System.Xml.Linq;
using Aspire.Cli.Tests.Utils;
using Hex1b;
using Xunit;
@@ -16,50 +15,21 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers;
internal static class CliE2ETestHelpers
{
///
- /// Gets whether the tests are running in CI (GitHub Actions) vs locally.
- /// When running locally, some commands are replaced with echo stubs.
+ /// Gets the expected version string from the ASPIRE_CLI_VERSION environment variable,
+ /// or when running locally (version check is skipped).
///
- internal static bool IsRunningInCI =>
- !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER")) &&
- !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA"));
+ internal static string? ExpectedCliVersion =>
+ Environment.GetEnvironmentVariable("ASPIRE_CLI_VERSION") is { Length: > 0 } v ? v : null;
///
- /// Gets the PR number from the GITHUB_PR_NUMBER environment variable.
- /// When running locally (not in CI), returns a dummy value (0) for testing.
+ /// Gets the directory that contains the pre-installed Aspire CLI binary, or
+ /// if the CLI was not pre-installed by the workflow.
+ /// Set by the "Install CLI from archive" step in run-tests.yml when
+ /// requiresCliArchive is and there is no pull-request context
+ /// (e.g., scheduled quarantine runs).
///
- /// The PR number, or 0 when running locally.
- internal static int GetRequiredPrNumber()
- {
- var prNumberStr = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER");
-
- if (string.IsNullOrEmpty(prNumberStr))
- {
- // Running locally - return dummy value
- return 0;
- }
-
- Assert.True(int.TryParse(prNumberStr, out var prNumber), $"GITHUB_PR_NUMBER must be a valid integer, got: {prNumberStr}");
- return prNumber;
- }
-
- ///
- /// Gets the commit SHA from the GITHUB_PR_HEAD_SHA environment variable.
- /// This is the actual PR head commit, not the merge commit (GITHUB_SHA).
- /// When running locally (not in CI), returns a dummy value for testing.
- ///
- /// The commit SHA, or a dummy value when running locally.
- internal static string GetRequiredCommitSha()
- {
- var commitSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA");
-
- if (string.IsNullOrEmpty(commitSha))
- {
- // Running locally - return dummy value
- return "local0000";
- }
-
- return commitSha;
- }
+ internal static string? PreInstalledCliDir =>
+ Environment.GetEnvironmentVariable("ASPIRE_CLI_PATH_DIR") is { Length: > 0 } v ? v : null;
///
/// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts.
@@ -92,19 +62,15 @@ internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 4
internal enum DockerInstallMode
{
///
- /// The CLI was built from source by the Dockerfile and is already on PATH.
+ /// The CLI binary is pre-installed on the host and mounted into the container.
+ /// Used for both local dev (./build.sh --bundle) and CI (PR, quarantine, outerloop).
///
- SourceBuild,
+ PreInstalled,
///
/// Install the latest GA release from aspire.dev.
///
GaRelease,
-
- ///
- /// Install from PR artifacts using the get-aspire-cli-pr.sh script.
- ///
- PullRequest,
}
///
@@ -139,28 +105,38 @@ internal enum DockerfileVariant
/// The detected .
internal static DockerInstallMode DetectDockerInstallMode(string repoRoot)
{
- if (IsRunningInCI)
- {
- return DockerInstallMode.PullRequest;
- }
-
- // Check if a locally-built native AOT CLI binary exists (developer has run ./build.sh --bundle).
+ // Check if a pre-installed or locally-built CLI binary exists.
var cliPublishDir = FindLocalCliBinary(repoRoot);
if (cliPublishDir is not null)
{
- return DockerInstallMode.SourceBuild;
+ return DockerInstallMode.PreInstalled;
}
return DockerInstallMode.GaRelease;
}
///
- /// Finds the locally-built native AOT CLI publish directory.
- /// Searches for the aspire binary under artifacts/bin/Aspire.Cli/*/net*/linux-x64/publish/.
+ /// Finds the CLI binary directory to use for Docker PreInstalled mode.
+ /// Checks, in order:
+ ///
+ /// - The ASPIRE_CLI_PATH_DIR env var set by the "Install CLI from archive" workflow step.
+ /// - The locally-built native AOT publish directory under artifacts/bin/Aspire.Cli/.
+ ///
+ /// Only Linux is supported (Docker-based E2E tests run on Linux only).
///
- /// The publish directory path, or null if not found.
+ /// The directory containing the aspire binary, or if not found.
internal static string? FindLocalCliBinary(string repoRoot)
{
+ // Docker E2E tests only run on Linux; the binary is named 'aspire' (no .exe).
+ const string binaryName = "aspire";
+
+ // Prefer the pre-installed CLI binary dir set by the CI workflow.
+ var preInstalled = PreInstalledCliDir;
+ if (preInstalled is not null && File.Exists(Path.Combine(preInstalled, binaryName)))
+ {
+ return preInstalled;
+ }
+
var cliBaseDir = Path.Combine(repoRoot, "artifacts", "bin", "Aspire.Cli");
if (!Directory.Exists(cliBaseDir))
{
@@ -168,7 +144,7 @@ internal static DockerInstallMode DetectDockerInstallMode(string repoRoot)
}
// Search for the native AOT binary under any config/TFM combination.
- var matches = Directory.GetFiles(cliBaseDir, "aspire", SearchOption.AllDirectories)
+ var matches = Directory.GetFiles(cliBaseDir, binaryName, SearchOption.AllDirectories)
.Where(f => f.Contains("linux-x64") && f.Contains("publish"))
.ToArray();
@@ -257,33 +233,26 @@ internal static Hex1bTerminal CreateDockerTestTerminal(
}
// Always skip the expensive source build inside Docker.
- // For SourceBuild mode, the CLI is installed from a mounted local bundle.
- // For PullRequest/GaRelease, it's installed via scripts after container start.
+ // For PreInstalled mode, the CLI is installed from a mounted local binary.
+ // For GaRelease, it's installed via scripts after container start.
c.BuildArgs["SKIP_SOURCE_BUILD"] = "true";
- if (installMode == DockerInstallMode.SourceBuild)
+ if (installMode == DockerInstallMode.PreInstalled)
{
- // Mount the locally-built native AOT CLI binary into the container.
+ // Mount the locally-built or CI-installed CLI binary into the container.
var cliPublishDir = FindLocalCliBinary(repoRoot)
- ?? throw new InvalidOperationException("SourceBuild mode detected but CLI binary not found");
+ ?? throw new InvalidOperationException("PreInstalled mode detected but CLI binary not found");
c.Volumes.Add($"{cliPublishDir}:/opt/aspire-cli:ro");
output.WriteLine($" CLI binary: {cliPublishDir}");
- }
- if (installMode == DockerInstallMode.PullRequest)
- {
- var ghToken = Environment.GetEnvironmentVariable("GH_TOKEN");
- if (!string.IsNullOrEmpty(ghToken))
+ // Also mount the built NuGet packages so 'aspire new' / 'aspire add' / restore
+ // can resolve CI-built packages without going to nuget.org.
+ var builtNugetsPath = Environment.GetEnvironmentVariable("BUILT_NUGETS_PATH");
+ if (!string.IsNullOrEmpty(builtNugetsPath) && Directory.Exists(builtNugetsPath))
{
- c.Environment["GH_TOKEN"] = ghToken;
+ c.Volumes.Add($"{builtNugetsPath}:/built-nugets:ro");
+ output.WriteLine($" Built NuGets: {builtNugetsPath}");
}
-
- var prNumber = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER") ?? "";
- var prSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA") ?? "";
- c.Environment["GITHUB_PR_NUMBER"] = prNumber;
- c.Environment["GITHUB_PR_HEAD_SHA"] = prSha;
- output.WriteLine($" PR number: {prNumber}");
- output.WriteLine($" PR head SHA: {prSha}");
}
});
@@ -378,53 +347,6 @@ internal static string ToContainerPath(string hostPath, TemporaryWorkspace works
return $"/workspace/{workspace.WorkspaceRoot.Name}/" + relativePath.Replace('\\', '/');
}
- ///
- /// Reads the VersionPrefix (e.g., "13.3.0") from eng/Versions.props by parsing
- /// the MajorVersion, MinorVersion, and PatchVersion MSBuild properties.
- ///
- internal static string GetVersionPrefix()
- {
- var repoRoot = GetRepoRoot();
- var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props");
-
- var doc = XDocument.Load(versionsPropsPath);
- var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
-
- string? GetProperty(string name) =>
- doc.Descendants(ns + name).FirstOrDefault()?.Value;
-
- var major = GetProperty("MajorVersion")
- ?? throw new InvalidOperationException("MajorVersion not found in eng/Versions.props");
- var minor = GetProperty("MinorVersion")
- ?? throw new InvalidOperationException("MinorVersion not found in eng/Versions.props");
- var patch = GetProperty("PatchVersion")
- ?? throw new InvalidOperationException("PatchVersion not found in eng/Versions.props");
-
- return $"{major}.{minor}.{patch}";
- }
-
- ///
- /// Checks whether the build is stabilized (StabilizePackageVersion=true in eng/Versions.props).
- /// Stabilized builds produce version strings without commit SHA suffixes (e.g., "13.2.0" instead
- /// of "13.2.0-preview.1.25175.1+g{sha}"). This is only true for official release builds,
- /// never for normal PR CI builds.
- ///
- internal static bool IsStabilizedBuild()
- {
- var repoRoot = GetRepoRoot();
- var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props");
-
- var doc = XDocument.Load(versionsPropsPath);
- var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
-
- // The default value in Versions.props uses a Condition to default to "false",
- // so we read the element's text directly.
- var stabilize = doc.Descendants(ns + "StabilizePackageVersion")
- .FirstOrDefault()?.Value;
-
- return string.Equals(stabilize, "true", StringComparison.OrdinalIgnoreCase);
- }
-
///
/// Copies a directory to testresults/workspaces/{testName}/{label} for CI artifact upload.
/// Renames dot-prefixed directories to underscore-prefixed (upload-artifact skips hidden files).
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
index 194e81090da..fa26923c240 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
@@ -25,9 +25,6 @@ public async Task AllPublishMethodsBuildDockerImages()
{
using var workspace = TemporaryWorkspace.Create(output);
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
using var terminal = CliE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
@@ -36,11 +33,10 @@ public async Task AllPublishMethodsBuildDockerImages()
await auto.PrepareEnvironmentAsync(workspace, counter);
- if (isCI)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
- await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
await auto.SourceAspireCliEnvironmentAsync(counter);
- await auto.VerifyAspireCliVersionAsync(commitSha, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
}
// Create TS AppHost and add packages
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
index 8aac2ef7af3..336034e1728 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs
@@ -27,15 +27,11 @@ private static string GenerateUniqueClusterName() =>
$"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars
[Fact]
- [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")]
[QuarantinedTest("https://github.com/microsoft/aspire/issues/15870")]
public async Task CreateAndPublishToKubernetes()
{
using var workspace = TemporaryWorkspace.Create(output);
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
var clusterName = GenerateUniqueClusterName();
output.WriteLine($"Using KinD version: {KindVersion}");
@@ -52,11 +48,11 @@ public async Task CreateAndPublishToKubernetes()
// Prepare environment
await auto.PrepareEnvironmentAsync(workspace, counter);
- if (isCI)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
- await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
+ // CI: CLI was pre-installed by the workflow — just configure env vars and verify.
await auto.SourceAspireCliEnvironmentAsync(counter);
- await auto.VerifyAspireCliVersionAsync(commitSha, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
}
try
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
index 32a52efa557..49387e7c575 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs
@@ -73,10 +73,12 @@ await auto.WaitUntilAsync(
await auto.TypeAsync(" "); // Toggle on Claude Code location
await auto.EnterAsync();
- // Third prompt: skills. Accept defaults (Aspire, Playwright CLI, dotnet-inspect).
+ // Third prompt: skills. Aspire is pre-selected; explicitly select Playwright CLI too.
await auto.WaitUntilAsync(
s => s.ContainsText("skills should be installed"),
timeout: TimeSpan.FromSeconds(30), description: "skill selection prompt");
+ await auto.DownAsync(); // Move to playwright-cli
+ await auto.TypeAsync(" "); // Toggle it on
await auto.EnterAsync();
// Wait for installation to complete (this downloads from npm, can take a while)
@@ -158,10 +160,12 @@ await auto.WaitUntilAsync(
await auto.TypeAsync(" "); // Toggle on Claude Code location
await auto.EnterAsync();
- // Accept default skills, which include Playwright CLI.
+ // Select skills: Aspire is pre-selected; explicitly select Playwright CLI too.
await auto.WaitUntilAsync(
s => s.ContainsText("skills should be installed"),
timeout: TimeSpan.FromSeconds(30), description: "skill selection prompt");
+ await auto.DownAsync(); // Move to playwright-cli
+ await auto.TypeAsync(" "); // Toggle it on
await auto.EnterAsync();
await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromMinutes(3));
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
index d5e6698291d..8f9900b19cf 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs
@@ -37,6 +37,7 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration()
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliInDockerAsync(installMode, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
// Step 1: Create a TypeScript AppHost (so we get the SDK version in aspire.config.json)
await auto.TypeAsync("aspire init --language typescript --non-interactive");
@@ -170,9 +171,11 @@ await auto.WaitUntilAsync(s =>
await auto.WaitForSuccessPromptAsync(counter);
// Step 5: Wait for the custom resource to be up
- await auto.TypeAsync("aspire wait my-svc --timeout 60");
+ // Use a longer timeout for Docker-in-Docker where container image pull and startup
+ // can take considerably more than a minute.
+ await auto.TypeAsync("aspire wait my-svc --timeout 120");
await auto.EnterAsync();
- await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(150));
// Step 6: Verify the resource appears in describe
await auto.TypeAsync("aspire describe my-svc --format json > /tmp/my-svc-describe.json");
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
index ef8ff4add5f..0e479bc47aa 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs
@@ -101,10 +101,6 @@ public async Task RunWithMissingAwaitShowsHelpfulError()
{
using var workspace = TemporaryWorkspace.Create(output);
- var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
- var commitSha = CliE2ETestHelpers.GetRequiredCommitSha();
- var isCI = CliE2ETestHelpers.IsRunningInCI;
-
using var terminal = CliE2ETestHelpers.CreateTestTerminal();
var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
@@ -114,11 +110,10 @@ public async Task RunWithMissingAwaitShowsHelpfulError()
// PrepareEnvironment
await auto.PrepareEnvironmentAsync(workspace, counter);
- if (isCI)
+ if (CliE2ETestHelpers.PreInstalledCliDir is not null)
{
- await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter);
- await auto.SourceAspireBundleEnvironmentAsync(counter);
- await auto.VerifyAspireCliVersionAsync(commitSha, counter);
+ await auto.SourceAspireCliEnvironmentAsync(counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
}
await auto.TypeAsync("aspire init --language typescript --non-interactive");
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
index 5b0ad9be9be..5ba4e0bb6c8 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs
@@ -37,6 +37,7 @@ public async Task CreateStartWaitAndStopAspireProject()
// Install the Aspire CLI
await auto.InstallAspireCliInDockerAsync(installMode, counter);
+ await auto.VerifyAspireCliVersionAsync(counter);
// Create a new project using aspire new
await auto.AspireNewAsync("AspireWaitApp", counter);
@@ -46,8 +47,15 @@ public async Task CreateStartWaitAndStopAspireProject()
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
- // Start the AppHost in the background using aspire start
- await auto.TypeAsync("aspire start");
+ // Pre-build the project so that 'aspire start --no-build' can establish the backchannel
+ // quickly. In Docker-in-Docker environments the NuGet restore + build can exceed the
+ // 120-second backchannel timeout inside 'aspire start', causing a spurious timeout failure.
+ await auto.TypeAsync("dotnet build");
+ await auto.EnterAsync();
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
+
+ // Start the AppHost in the background using aspire start (skip build since we just built)
+ await auto.TypeAsync("aspire start --no-build");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3));
await auto.WaitForSuccessPromptAsync(counter);
diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs
index aad2b2a8522..039c62b197d 100644
--- a/tests/Shared/Hex1bAutomatorTestHelpers.cs
+++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs
@@ -170,13 +170,39 @@ internal static async Task AspireNewAsync(
{
var templateTimeout = TimeSpan.FromSeconds(60);
- // Step 1: Type aspire new and wait for the template list
+ // Step 1: Type aspire new and wait for the template list or a version picker.
+ // When a non-default channel (e.g. "ci") is configured the CLI shows a version
+ // selection prompt before the template list.
await auto.TypeAsync("aspire new");
await auto.EnterAsync();
+
+ var waitingForTemplateList = new CellPatternSearcher().Find("> Starter App");
+ var waitingForVersionPicker = new CellPatternSearcher().Find("Select a version of");
+ var versionPickerShown = false;
+
await auto.WaitUntilAsync(
- s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0,
+ s =>
+ {
+ if (waitingForVersionPicker.Search(s).Count > 0)
+ {
+ versionPickerShown = true;
+ return true;
+ }
+ return waitingForTemplateList.Search(s).Count > 0;
+ },
timeout: templateTimeout,
- description: "template selection list (> Starter App)");
+ description: "template selection list or version picker");
+
+ // If we landed on a version picker, accept the first (latest) version and then
+ // wait for the template list to appear.
+ if (versionPickerShown)
+ {
+ await auto.EnterAsync(); // Accept the first (pre-selected) version
+ await auto.WaitUntilAsync(
+ s => waitingForTemplateList.Search(s).Count > 0,
+ timeout: templateTimeout,
+ description: "template selection list (> Starter App) after version selection");
+ }
// Step 2: Navigate to and select the desired template
switch (template)