From 7daa556170d960cd51531ebfa2aa5bd83c2c7849 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 10:15:32 -0700 Subject: [PATCH 1/8] Prebuild CLI E2E Docker image Build the default CLI E2E Docker image once per workflow and load it in split test jobs through an explicit image override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 43 ++++++++++++++ .github/workflows/run-tests.yml | 18 ++++++ .github/workflows/specialized-test-runner.yml | 15 ++++- .github/workflows/tests-outerloop.yml | 5 +- .github/workflows/tests-quarantine.yml | 5 +- .github/workflows/tests.yml | 25 +++++--- .../Helpers/CliE2ETestHelpers.cs | 59 +++++++++++++++---- .../Helpers/CliInstallStrategyTests.cs | 57 ++++++++++++++++++ 8 files changed, 201 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/build-cli-e2e-image.yml diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml new file mode 100644 index 00000000000..90ef3975e21 --- /dev/null +++ b/.github/workflows/build-cli-e2e-image.yml @@ -0,0 +1,43 @@ +name: Build CLI E2E Docker Image + +on: + workflow_call: + +env: + CLI_E2E_DOTNET_IMAGE_ARTIFACT: cli-e2e-dotnet-image + CLI_E2E_DOTNET_IMAGE_TAG: aspire-cli-e2e-dotnet:${{ github.sha }} + +jobs: + build: + name: Build CLI E2E Docker image + runs-on: ${{ github.repository_owner == 'microsoft' && '8-core-ubuntu-latest' || 'ubuntu-latest' }} + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify Docker is running + run: docker info + + - name: Build CLI E2E Docker image + shell: bash + run: | + set -euo pipefail + + docker build \ + --build-arg SKIP_SOURCE_BUILD=true \ + --build-arg UBUNTU_APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu/ \ + -f tests/Shared/Docker/Dockerfile.e2e \ + -t "$CLI_E2E_DOTNET_IMAGE_TAG" \ + . + + mkdir -p artifacts/cli-e2e-image + docker save "$CLI_E2E_DOTNET_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz + + - name: Upload CLI E2E Docker image + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_DOTNET_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz + retention-days: 1 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 92e53231665..827871a702c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -118,6 +118,24 @@ jobs: if: runner.os == 'Linux' run: docker info + - name: Download prebuilt CLI E2E Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" + docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" + docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null + echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" + - name: Download built nugets if: ${{ fromJson(inputs.properties).requiresNugets == true }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/specialized-test-runner.yml b/.github/workflows/specialized-test-runner.yml index c6c1fa7b93c..a44145b212e 100644 --- a/.github/workflows/specialized-test-runner.yml +++ b/.github/workflows/specialized-test-runner.yml @@ -140,9 +140,15 @@ jobs: if: ${{ github.repository_owner == 'microsoft' && (needs.generate_tests_matrix.outputs.requiresNugets == 'true' || needs.generate_tests_matrix.outputs.requiresCliArchive == 'true') }} uses: ./.github/workflows/build-cli-native-archives.yml + build_cli_e2e_image: + name: Build CLI E2E Docker image + needs: [generate_tests_matrix] + if: ${{ github.repository_owner == 'microsoft' && needs.generate_tests_matrix.outputs.requiresCliArchive == 'true' }} + uses: ./.github/workflows/build-cli-e2e-image.yml + run_tests: name: ${{ matrix.tests.project }} - needs: [generate_tests_matrix, build_packages, build_cli_archives] + needs: [generate_tests_matrix, build_packages, build_cli_archives, build_cli_e2e_image] strategy: fail-fast: false matrix: @@ -156,7 +162,8 @@ jobs: github.repository_owner == 'microsoft' && !cancelled() && needs.generate_tests_matrix.result == 'success' && - (needs.build_packages.result == 'success' || needs.build_packages.result == 'skipped') + (needs.build_packages.result == 'success' || needs.build_packages.result == 'skipped') && + (needs.build_cli_e2e_image.result == 'success' || needs.build_cli_e2e_image.result == 'skipped') }} uses: ./.github/workflows/run-tests.yml with: @@ -172,7 +179,7 @@ jobs: if: ${{ always() && github.repository_owner == 'microsoft' }} runs-on: ubuntu-latest name: Final Results - needs: [generate_tests_matrix, build_packages, build_cli_archives, run_tests] + needs: [generate_tests_matrix, build_packages, build_cli_archives, build_cli_e2e_image, run_tests] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -244,6 +251,8 @@ jobs: needs.run_tests.result == 'failure' || needs.run_tests.result == 'cancelled' || needs.run_tests.result == 'skipped' || + (needs.build_cli_e2e_image.result == 'failure') || + (needs.build_cli_e2e_image.result == 'cancelled') || (needs.build_packages.result == 'failure') || (needs.build_packages.result == 'cancelled') ) diff --git a/.github/workflows/tests-outerloop.yml b/.github/workflows/tests-outerloop.yml index 48d7a8bf73d..0347cd2057a 100644 --- a/.github/workflows/tests-outerloop.yml +++ b/.github/workflows/tests-outerloop.yml @@ -2,8 +2,8 @@ # # COPILOT INSTRUCTIONS: # - Keep the shared 'paths:' entries (specialized-test-runner.yml, -# run-tests.yml) in sync across tests-outerloop.yml and -# tests-quarantine.yml. Each workflow also lists itself. +# run-tests.yml, build-cli-e2e-image.yml) in sync across +# tests-outerloop.yml and tests-quarantine.yml. Each workflow also lists itself. # - Validate that each path exists in the repository before adding or # updating the list # - No external YAML file is used—only the workflow YAMLs themselves @@ -28,6 +28,7 @@ on: - '.github/workflows/tests-outerloop.yml' - '.github/workflows/specialized-test-runner.yml' - '.github/workflows/run-tests.yml' + - '.github/workflows/build-cli-e2e-image.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/tests-quarantine.yml b/.github/workflows/tests-quarantine.yml index 73f57cbf20a..de8e198c7ce 100644 --- a/.github/workflows/tests-quarantine.yml +++ b/.github/workflows/tests-quarantine.yml @@ -2,8 +2,8 @@ # # COPILOT INSTRUCTIONS: # - Keep the shared 'paths:' entries (specialized-test-runner.yml, -# run-tests.yml) in sync across tests-outerloop.yml and -# tests-quarantine.yml. Each workflow also lists itself. +# run-tests.yml, build-cli-e2e-image.yml) in sync across +# tests-outerloop.yml and tests-quarantine.yml. Each workflow also lists itself. # - Validate that each path exists in the repository before adding or # updating the list # - No external YAML file is used—only the workflow YAMLs themselves @@ -29,6 +29,7 @@ on: - '.github/workflows/tests-quarantine.yml' - '.github/workflows/specialized-test-runner.yml' - '.github/workflows/run-tests.yml' + - '.github/workflows/build-cli-e2e-image.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4061445999a..9c53c4e14e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,12 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "macos-latest", "runner": "macos-latest", "rids": "osx-arm64"}]' + build_cli_e2e_image: + name: Build CLI E2E Docker image + uses: ./.github/workflows/build-cli-e2e-image.yml + needs: setup_for_tests + if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null }} + tests_no_nugets: name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml @@ -172,7 +178,7 @@ jobs: tests_requires_cli_archive: name: ${{ matrix.shortname }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests, build_packages, build_cli_archive_linux] + needs: [setup_for_tests, build_packages, build_cli_archive_linux, build_cli_e2e_image] if: ${{ fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null }} strategy: fail-fast: false @@ -319,6 +325,7 @@ jobs: build_cli_archive_windows, build_cli_archive_windows_arm64, build_cli_archive_macos, + build_cli_e2e_image, extension_tests_win, cli_starter_validation_windows, typescript_sdk_tests, @@ -403,13 +410,15 @@ jobs: (needs.extension_tests_win.result == 'skipped' || needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - needs.tests_requires_cli_archive.result == 'skipped' || - needs.polyglot_validation.result == 'skipped')) || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && + needs.build_cli_e2e_image.result == 'skipped') || + needs.tests_requires_cli_archive.result == 'skipped' || + needs.polyglot_validation.result == 'skipped')) || (github.event_name != 'pull_request' && (needs.extension_tests_win.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index b92a2b4d373..d4640a08ae4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -18,6 +18,7 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; internal static class CliE2ETestHelpers { internal const string CliArchiveDirEnvironmentVariableName = CliInstallStrategy.CliArchiveDirEnvironmentVariableName; + internal const string DotNetImageEnvironmentVariableName = "ASPIRE_E2E_DOTNET_IMAGE"; internal const string CliVersionOutputDirEnvironmentVariableName = "ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR"; internal const string ContainerCliVersionOutputDir = "/tmp/aspire-cli-versions"; private static readonly Regex s_commitShaPattern = new("^[0-9a-fA-F]{40}$", RegexOptions.Compiled); @@ -158,14 +159,8 @@ internal static Hex1bTerminal CreateDockerTestTerminal( { var recordingPath = GetTestResultsRecordingPath(testName); RegisterCaptureFile("recording.cast", recordingPath); - var dockerfileName = variant switch - { - DockerfileVariant.DotNet => "Dockerfile.e2e", - DockerfileVariant.Polyglot => "Dockerfile.e2e-polyglot-base", - DockerfileVariant.PolyglotJava => "Dockerfile.e2e-polyglot-java", - _ => throw new ArgumentOutOfRangeException(nameof(variant)), - }; - var dockerfilePath = Path.Combine(repoRoot, "tests", "Shared", "Docker", dockerfileName); + var dockerfilePath = GetDockerfilePath(repoRoot, variant); + var dotNetImageName = GetDotNetImageName(variant); if (variant is DockerfileVariant.PolyglotJava) { @@ -177,7 +172,8 @@ internal static Hex1bTerminal CreateDockerTestTerminal( output.WriteLine($" Strategy: {strategy}"); output.WriteLine($" Expected ver: {strategy.ExpectedVersion ?? "(not available)"}"); output.WriteLine($" Variant: {variant}"); - output.WriteLine($" Dockerfile: {dockerfilePath}"); + output.WriteLine($" Dockerfile: {(dotNetImageName is null ? dockerfilePath : "(prebuilt image)")}"); + output.WriteLine($" Image: {dotNetImageName ?? "(build from Dockerfile)"}"); output.WriteLine($" Workspace: {workspace?.WorkspaceRoot.FullName ?? "(none)"}"); output.WriteLine($" Docker socket: {mountDockerSocket}"); output.WriteLine($" Dimensions: {width}x{height}"); @@ -189,8 +185,7 @@ internal static Hex1bTerminal CreateDockerTestTerminal( .WithAsciinemaRecording(recordingPath) .WithDockerContainer(c => { - c.DockerfilePath = dockerfilePath; - c.BuildContext = repoRoot; + ConfigureDockerContainerSource(c, repoRoot, variant); if (mountDockerSocket) { @@ -225,6 +220,48 @@ internal static Hex1bTerminal CreateDockerTestTerminal( return builder.Build(); } + internal static void ConfigureDockerContainerSource(DockerContainerOptions options, string repoRoot, DockerfileVariant variant) + { + var dotNetImageName = GetDotNetImageName(variant); + if (dotNetImageName is not null) + { + options.Image = dotNetImageName; + return; + } + + if (variant is DockerfileVariant.DotNet && IsRunningInCI) + { + throw new InvalidOperationException($"{DotNetImageEnvironmentVariableName} must be set when running CLI E2E tests in CI."); + } + + options.DockerfilePath = GetDockerfilePath(repoRoot, variant); + options.BuildContext = repoRoot; + } + + private static string? GetDotNetImageName(DockerfileVariant variant) + { + if (variant is not DockerfileVariant.DotNet) + { + return null; + } + + var imageName = Environment.GetEnvironmentVariable(DotNetImageEnvironmentVariableName); + return string.IsNullOrWhiteSpace(imageName) ? null : imageName.Trim(); + } + + private static string GetDockerfilePath(string repoRoot, DockerfileVariant variant) + { + var dockerfileName = variant switch + { + DockerfileVariant.DotNet => "Dockerfile.e2e", + DockerfileVariant.Polyglot => "Dockerfile.e2e-polyglot-base", + DockerfileVariant.PolyglotJava => "Dockerfile.e2e-polyglot-java", + _ => throw new ArgumentOutOfRangeException(nameof(variant)), + }; + + return Path.Combine(repoRoot, "tests", "Shared", "Docker", dockerfileName); + } + /// /// Creates a Hex1b terminal backed by a privileged Docker container that runs Podman internally. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 6c79e77c563..eebb9a5a991 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -186,6 +186,63 @@ public void ConfigureContainer_DoesNotAddUbuntuAptMirrorBuildArgWhenEnvironmentV Assert.DoesNotContain(CliInstallStrategy.UbuntuAptMirrorBuildArgName, options.BuildArgs.Keys); } + [Fact] + public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariableIsSet() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal("aspire-cli-e2e-dotnet:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", null)); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresDotNetImageInCI() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet)); + + Assert.Contains(CliE2ETestHelpers.DotNetImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_IgnoresDotNetImageForPolyglotVariant() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + [Fact] public void Detect_DotnetTool_WhenEnvironmentVariableIsSet() { From 8cb7553f0382f9b27c44b19aaec6833f58f00265 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 10:19:59 -0700 Subject: [PATCH 2/8] Refine CLI E2E image prebuild coverage Wire daily smoke and flaky reproduction workflows into the prebuilt image path, and scope the helper fail-fast behavior to workflows that explicitly require the preloaded image. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 3 ++ .github/workflows/reproduce-flaky-tests.yml | 44 ++++++++++++++++++- .github/workflows/run-tests.yml | 1 + .github/workflows/tests-daily-smoke.yml | 25 +++++++++++ .../Helpers/CliE2ETestHelpers.cs | 14 +++++- .../Helpers/CliInstallStrategyTests.cs | 21 ++++++++- 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml index 90ef3975e21..a56a65a558e 100644 --- a/.github/workflows/build-cli-e2e-image.yml +++ b/.github/workflows/build-cli-e2e-image.yml @@ -1,3 +1,6 @@ +# Builds the default .NET CLI E2E image used by DockerfileVariant.DotNet. +# Polyglot and Podman variants use separate Dockerfiles with variant-specific +# base image behavior and are intentionally left on their existing per-job paths. name: Build CLI E2E Docker Image on: diff --git a/.github/workflows/reproduce-flaky-tests.yml b/.github/workflows/reproduce-flaky-tests.yml index 5be8d416106..fda3859d1f2 100644 --- a/.github/workflows/reproduce-flaky-tests.yml +++ b/.github/workflows/reproduce-flaky-tests.yml @@ -48,6 +48,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate.outputs.matrix }} + is_cli_e2e: ${{ steps.detect_cli_e2e.outputs.is_cli_e2e }} steps: - name: Validate configuration shell: bash @@ -89,6 +90,16 @@ jobs: if [[ $ERRORS -ne 0 ]]; then exit 1; fi + - name: Detect CLI E2E project + id: detect_cli_e2e + shell: bash + run: | + if [ "${{ env.TEST_PROJECT }}" = "Cli.EndToEnd" ]; then + echo "is_cli_e2e=true" >> "$GITHUB_OUTPUT" + else + echo "is_cli_e2e=false" >> "$GITHUB_OUTPUT" + fi + - name: Generate runner matrix id: generate shell: bash @@ -113,9 +124,21 @@ jobs: echo "Each runner will execute ${{ env.ITERATIONS_PER_RUNNER }} iterations" echo "Total test executions: $(( TOTAL * ${{ env.ITERATIONS_PER_RUNNER }} ))" + build_cli_e2e_image: + name: Build CLI E2E Docker image + needs: setup + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' }} + uses: ./.github/workflows/build-cli-e2e-image.yml + reproduce: name: "${{ matrix.os }} #${{ matrix.index }}" - needs: setup + needs: [setup, build_cli_e2e_image] + if: >- + ${{ + !cancelled() && + needs.setup.result == 'success' && + (needs.build_cli_e2e_image.result == 'success' || needs.build_cli_e2e_image.result == 'skipped') + }} runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: @@ -158,6 +181,25 @@ jobs: if: runner.os == 'Linux' run: docker info + - name: Download prebuilt CLI E2E Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" + docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" + docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null + echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" + - name: Unlock macOS keychain if: runner.os == 'macOS' uses: ./.github/actions/unlock-macos-keychain diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 827871a702c..4ac95485140 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -135,6 +135,7 @@ jobs: docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" - name: Download built nugets if: ${{ fromJson(inputs.properties).requiresNugets == true }} diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index 6768dcb5255..39bca08e451 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -26,8 +26,13 @@ permissions: contents: read jobs: + build_cli_e2e_image: + name: Build CLI E2E Docker image + uses: ./.github/workflows/build-cli-e2e-image.yml + smoke-test: name: CLI Smoke Test (${{ inputs.quality || 'dev' }}) + needs: build_cli_e2e_image runs-on: ubuntu-latest timeout-minutes: 30 @@ -45,6 +50,26 @@ jobs: - name: Restore run: ./restore.sh + - name: Verify Docker is running + run: docker info + + - name: Download prebuilt CLI E2E Docker image + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-dotnet-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker image + shell: bash + run: | + set -euo pipefail + + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" + docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" + docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null + echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" + - name: Build E2E test project run: dotnet build tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index d4640a08ae4..2f760518b80 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -19,6 +19,7 @@ internal static class CliE2ETestHelpers { internal const string CliArchiveDirEnvironmentVariableName = CliInstallStrategy.CliArchiveDirEnvironmentVariableName; internal const string DotNetImageEnvironmentVariableName = "ASPIRE_E2E_DOTNET_IMAGE"; + internal const string RequireDotNetImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE"; internal const string CliVersionOutputDirEnvironmentVariableName = "ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR"; internal const string ContainerCliVersionOutputDir = "/tmp/aspire-cli-versions"; private static readonly Regex s_commitShaPattern = new("^[0-9a-fA-F]{40}$", RegexOptions.Compiled); @@ -229,15 +230,17 @@ internal static void ConfigureDockerContainerSource(DockerContainerOptions optio return; } - if (variant is DockerfileVariant.DotNet && IsRunningInCI) + if (variant is DockerfileVariant.DotNet && IsDotNetImageRequired()) { - throw new InvalidOperationException($"{DotNetImageEnvironmentVariableName} must be set when running CLI E2E tests in CI."); + throw new InvalidOperationException($"{DotNetImageEnvironmentVariableName} must be set when the prebuilt CLI E2E .NET image is required."); } options.DockerfilePath = GetDockerfilePath(repoRoot, variant); options.BuildContext = repoRoot; } + // The prebuilt workflow artifact currently covers only the default .NET image, + // which is the image shared by most split CLI E2E jobs. private static string? GetDotNetImageName(DockerfileVariant variant) { if (variant is not DockerfileVariant.DotNet) @@ -262,6 +265,13 @@ private static string GetDockerfilePath(string repoRoot, DockerfileVariant varia return Path.Combine(repoRoot, "tests", "Shared", "Docker", dockerfileName); } + private static bool IsDotNetImageRequired() + { + var value = Environment.GetEnvironmentVariable(RequireDotNetImageEnvironmentVariableName); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "1", StringComparison.OrdinalIgnoreCase); + } + /// /// Creates a Hex1b terminal backed by a privileged Docker container that runs Podman internally. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index eebb9a5a991..30eb15a797f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -191,6 +191,7 @@ public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariabl { using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -206,6 +207,7 @@ public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() { using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", null)); var options = new DockerContainerOptions(); @@ -216,10 +218,11 @@ public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() } [Fact] - public void ConfigureDockerContainerSource_RequiresDotNetImageInCI() + public void ConfigureDockerContainerSource_RequiresDotNetImageWhenConfigured() { using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -229,11 +232,27 @@ public void ConfigureDockerContainerSource_RequiresDotNetImageInCI() Assert.Contains(CliE2ETestHelpers.DotNetImageEnvironmentVariableName, exception.Message); } + [Fact] + public void ConfigureDockerContainerSource_FallsBackToDockerfileInCIWhenDotNetImageIsNotRequired() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + [Fact] public void ConfigureDockerContainerSource_IgnoresDotNetImageForPolyglotVariant() { using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); From c093519d0c66864d783d0b4eb1dfc21045a0625b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 11:26:14 -0700 Subject: [PATCH 3/8] Optimize CLI E2E image prebuild Use a stable prebuilt image tag for CLI E2E artifact consumers and clear Docker build args when Hex1b runs from a prebuilt image. Add BuildKit remote cache with Ubuntu mirror fallback, remove Java from the default .NET image, and keep repository-dependent script copies after cache-stable image layers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 37 +++++++++++++++---- .github/workflows/reproduce-flaky-tests.yml | 1 - .github/workflows/run-tests.yml | 1 - .github/workflows/tests-daily-smoke.yml | 1 - .../Helpers/CliE2ETestHelpers.cs | 13 ++++++- .../Helpers/CliInstallStrategyTests.cs | 17 +++++++++ tests/Shared/Docker/Dockerfile.e2e | 24 +++++------- 7 files changed, 68 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml index a56a65a558e..f5981ca7fde 100644 --- a/.github/workflows/build-cli-e2e-image.yml +++ b/.github/workflows/build-cli-e2e-image.yml @@ -8,7 +8,8 @@ on: env: CLI_E2E_DOTNET_IMAGE_ARTIFACT: cli-e2e-dotnet-image - CLI_E2E_DOTNET_IMAGE_TAG: aspire-cli-e2e-dotnet:${{ github.sha }} + CLI_E2E_DOTNET_IMAGE_TAG: aspire-cli-e2e-dotnet:prebuilt + CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE: cli-e2e-dotnet jobs: build: @@ -28,12 +29,34 @@ jobs: run: | set -euo pipefail - docker build \ - --build-arg SKIP_SOURCE_BUILD=true \ - --build-arg UBUNTU_APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu/ \ - -f tests/Shared/Docker/Dockerfile.e2e \ - -t "$CLI_E2E_DOTNET_IMAGE_TAG" \ - . + docker buildx create --name cli-e2e-builder --use + docker buildx inspect --bootstrap + + build_image() { + local mirror="$1" + local -a build_args=(--build-arg SKIP_SOURCE_BUILD=true) + + if [[ -n "$mirror" ]]; then + echo "Building with Ubuntu apt mirror: $mirror" + build_args+=(--build-arg "UBUNTU_APT_MIRROR=$mirror") + else + echo "Building with the default Ubuntu apt sources" + fi + + docker buildx build \ + --load \ + --cache-from "type=gha,scope=$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE" \ + --cache-to "type=gha,scope=$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE,mode=max,ignore-error=true" \ + "${build_args[@]}" \ + -f tests/Shared/Docker/Dockerfile.e2e \ + -t "$CLI_E2E_DOTNET_IMAGE_TAG" \ + . + } + + if ! build_image "http://azure.archive.ubuntu.com/ubuntu/"; then + echo "Build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" + build_image "" + fi mkdir -p artifacts/cli-e2e-image docker save "$CLI_E2E_DOTNET_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz diff --git a/.github/workflows/reproduce-flaky-tests.yml b/.github/workflows/reproduce-flaky-tests.yml index fda3859d1f2..6cf76e6c694 100644 --- a/.github/workflows/reproduce-flaky-tests.yml +++ b/.github/workflows/reproduce-flaky-tests.yml @@ -195,7 +195,6 @@ jobs: set -euo pipefail docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4ac95485140..bd2b7211a77 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -132,7 +132,6 @@ jobs: set -euo pipefail docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index 39bca08e451..c7a51efa83c 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -65,7 +65,6 @@ jobs: set -euo pipefail docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker tag "aspire-cli-e2e-dotnet:${{ github.sha }}" "aspire-cli-e2e-dotnet:prebuilt" docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 2f760518b80..b38cdb0458f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -214,13 +214,22 @@ internal static Hex1bTerminal CreateDockerTestTerminal( } } - // Delegate all mode-specific Docker config to the strategy - strategy.ConfigureContainer(c); + ConfigureDockerContainerStrategy(c, strategy); }); return builder.Build(); } + internal static void ConfigureDockerContainerStrategy(DockerContainerOptions options, CliInstallStrategy strategy) + { + // Delegate all mode-specific Docker config to the strategy. + strategy.ConfigureContainer(options); + if (!string.IsNullOrEmpty(options.Image)) + { + options.BuildArgs.Clear(); + } + } + internal static void ConfigureDockerContainerSource(DockerContainerOptions options, string repoRoot, DockerfileVariant variant) { var dotNetImageName = GetDotNetImageName(variant); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 30eb15a797f..45bbcf10142 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -202,6 +202,23 @@ public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariabl Assert.True(string.IsNullOrEmpty(options.BuildContext)); } + [Fact] + public void ConfigureContainer_BuildArgsCanBeClearedForPrebuiltImage() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy); + + Assert.Equal("aspire-cli-e2e-dotnet:prebuilt", options.Image); + Assert.Empty(options.BuildArgs); + } + [Fact] public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() { diff --git a/tests/Shared/Docker/Dockerfile.e2e b/tests/Shared/Docker/Dockerfile.e2e index b05c5299010..dd6726b4944 100644 --- a/tests/Shared/Docker/Dockerfile.e2e +++ b/tests/Shared/Docker/Dockerfile.e2e @@ -1,7 +1,7 @@ # Multi-stage Dockerfile for Aspire E2E testing (.NET variant). # # Includes: .NET SDK 10.0, Docker CLI (via host socket mount), gh CLI, -# Node.js, Python, Java, Aspire install scripts. +# Node.js, Python, Aspire install scripts. # # Usage: # Local dev (build from source): @@ -86,19 +86,6 @@ RUN apt-get update -qq && \ RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:${PATH}" -# Install Java (needed for Java AppHost tests). -RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends openjdk-21-jdk && \ - rm -rf /var/lib/apt/lists/* - -# --- Aspire CLI setup --- - -# Copy the install scripts. -COPY eng/scripts/get-aspire-cli.sh /opt/aspire-scripts/ -COPY eng/scripts/get-aspire-cli-pr.sh /opt/aspire-scripts/ -COPY tests/Shared/Docker/NuGet.DotnetTool.config /opt/aspire-scripts/NuGet.config -RUN chmod +x /opt/aspire-scripts/*.sh - # Copy the bundle directory from the build stage. # When SKIP_SOURCE_BUILD=true the directory is empty; the conditional RUN below handles both cases. COPY --from=build /repo/artifacts/bundle /tmp/bundle @@ -110,6 +97,15 @@ RUN if ls /tmp/bundle/aspire-*-linux-x64.tar.gz 1>/dev/null 2>&1; then \ rm -f /tmp/bundle/aspire-*-linux-x64.tar.gz; \ fi +# --- Aspire CLI setup --- + +# Keep repository-dependent copies after the expensive toolchain and bundle layers so BuildKit +# cache hits are preserved when source files or scripts change independently. +COPY eng/scripts/get-aspire-cli.sh /opt/aspire-scripts/ +COPY eng/scripts/get-aspire-cli-pr.sh /opt/aspire-scripts/ +COPY tests/Shared/Docker/NuGet.DotnetTool.config /opt/aspire-scripts/NuGet.config +RUN chmod +x /opt/aspire-scripts/*.sh + # Create the workspace mount point. RUN mkdir -p /workspace WORKDIR /workspace From c138bb1569a3308c83a084d16502a097c9275750 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 13:02:52 -0700 Subject: [PATCH 4/8] Preserve CLI E2E Dockerfile build args Only clear Hex1b build args when the CLI E2E helper is using a prebuilt image without a Dockerfile path. Keep SKIP_SOURCE_BUILD and apt mirror build args for polyglot and other Dockerfile variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/CliE2ETestHelpers.cs | 2 +- .../Helpers/CliInstallStrategyTests.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index b38cdb0458f..9cf96695069 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -224,7 +224,7 @@ internal static void ConfigureDockerContainerStrategy(DockerContainerOptions opt { // Delegate all mode-specific Docker config to the strategy. strategy.ConfigureContainer(options); - if (!string.IsNullOrEmpty(options.Image)) + if (string.IsNullOrEmpty(options.DockerfilePath)) { options.BuildArgs.Clear(); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 45bbcf10142..58dc5e1ea3d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -219,6 +219,24 @@ public void ConfigureContainer_BuildArgsCanBeClearedForPrebuiltImage() Assert.Empty(options.BuildArgs); } + [Fact] + public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("true", options.BuildArgs["SKIP_SOURCE_BUILD"]); + Assert.Equal("http://azure.archive.ubuntu.com/ubuntu/", options.BuildArgs[CliInstallStrategy.UbuntuAptMirrorBuildArgName]); + } + [Fact] public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() { From a1456c408ec9862b4c377935062774e7de932489 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 13:55:01 -0700 Subject: [PATCH 5/8] Prebuild polyglot CLI E2E images Generalize the CLI E2E prebuilt image workflow so it produces the shared .NET/Python, polyglot, and Java polyglot images once per workflow run. CLI E2E jobs now load the matching artifacts and the helper can consume variant-specific image overrides with explicit requirements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 103 +++++++++++++++--- .github/workflows/reproduce-flaky-tests.yml | 22 ++++ .github/workflows/run-tests.yml | 28 ++++- .github/workflows/tests-daily-smoke.yml | 2 + .../Helpers/CliE2ETestHelpers.cs | 60 +++++++--- .../Helpers/CliInstallStrategyTests.cs | 87 +++++++++++++++ 6 files changed, 271 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml index f5981ca7fde..e170ab01056 100644 --- a/.github/workflows/build-cli-e2e-image.yml +++ b/.github/workflows/build-cli-e2e-image.yml @@ -1,21 +1,33 @@ -# Builds the default .NET CLI E2E image used by DockerfileVariant.DotNet. -# Polyglot and Podman variants use separate Dockerfiles with variant-specific -# base image behavior and are intentionally left on their existing per-job paths. +# Builds CLI E2E images shared across split test jobs. +# Podman uses a separate privileged runtime path and is intentionally left on its +# existing per-job Dockerfile path. name: Build CLI E2E Docker Image on: workflow_call: + inputs: + includePolyglotImages: + required: false + type: boolean + default: true env: CLI_E2E_DOTNET_IMAGE_ARTIFACT: cli-e2e-dotnet-image CLI_E2E_DOTNET_IMAGE_TAG: aspire-cli-e2e-dotnet:prebuilt CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE: cli-e2e-dotnet + CLI_E2E_POLYGLOT_IMAGE_ARTIFACT: cli-e2e-polyglot-image + CLI_E2E_POLYGLOT_IMAGE_TAG: aspire-cli-e2e-polyglot:prebuilt + CLI_E2E_POLYGLOT_JAVA_IMAGE_ARTIFACT: cli-e2e-polyglot-java-image + CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG: aspire-cli-e2e-polyglot-java:prebuilt + CLI_E2E_POLYGLOT_BASE_IMAGE_TAG: aspire-e2e-polyglot-base:latest + CLI_E2E_POLYGLOT_BASE_IMAGE_CACHE_SCOPE: cli-e2e-polyglot-base + CLI_E2E_INCLUDE_POLYGLOT_IMAGES: ${{ inputs.includePolyglotImages }} jobs: build: name: Build CLI E2E Docker image runs-on: ${{ github.repository_owner == 'microsoft' && '8-core-ubuntu-latest' || 'ubuntu-latest' }} - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout code @@ -33,37 +45,98 @@ jobs: docker buildx inspect --bootstrap build_image() { - local mirror="$1" + local display_name="$1" + local dockerfile="$2" + local tag="$3" + local cache_scope="$4" + local mirror="$5" local -a build_args=(--build-arg SKIP_SOURCE_BUILD=true) if [[ -n "$mirror" ]]; then - echo "Building with Ubuntu apt mirror: $mirror" + echo "Building $display_name with Ubuntu apt mirror: $mirror" build_args+=(--build-arg "UBUNTU_APT_MIRROR=$mirror") else - echo "Building with the default Ubuntu apt sources" + echo "Building $display_name with the default Ubuntu apt sources" fi docker buildx build \ --load \ - --cache-from "type=gha,scope=$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE" \ - --cache-to "type=gha,scope=$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE,mode=max,ignore-error=true" \ + --cache-from "type=gha,scope=$cache_scope" \ + --cache-to "type=gha,scope=$cache_scope,mode=max,ignore-error=true" \ + "${build_args[@]}" \ + -f "$dockerfile" \ + -t "$tag" \ + . + } + + build_java_image() { + local mirror="$1" + local -a build_args=() + + if [[ -n "$mirror" ]]; then + echo "Building Java polyglot image with Ubuntu apt mirror: $mirror" + build_args+=(--build-arg "UBUNTU_APT_MIRROR=$mirror") + else + echo "Building Java polyglot image with the default Ubuntu apt sources" + fi + + DOCKER_BUILDKIT=1 docker build \ "${build_args[@]}" \ - -f tests/Shared/Docker/Dockerfile.e2e \ - -t "$CLI_E2E_DOTNET_IMAGE_TAG" \ + -f tests/Shared/Docker/Dockerfile.e2e-polyglot-java \ + -t "$CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG" \ . } - if ! build_image "http://azure.archive.ubuntu.com/ubuntu/"; then - echo "Build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" - build_image "" + build_with_mirror_retry() { + local display_name="$1" + local dockerfile="$2" + local tag="$3" + local cache_scope="$4" + + if ! build_image "$display_name" "$dockerfile" "$tag" "$cache_scope" "http://azure.archive.ubuntu.com/ubuntu/"; then + echo "$display_name build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" + build_image "$display_name" "$dockerfile" "$tag" "$cache_scope" "" + fi + } + + build_with_mirror_retry ".NET image" "tests/Shared/Docker/Dockerfile.e2e" "$CLI_E2E_DOTNET_IMAGE_TAG" "$CLI_E2E_DOTNET_IMAGE_CACHE_SCOPE" + + if [[ "$CLI_E2E_INCLUDE_POLYGLOT_IMAGES" == "true" ]]; then + build_with_mirror_retry "polyglot base image" "tests/Shared/Docker/Dockerfile.e2e-polyglot-base" "$CLI_E2E_POLYGLOT_BASE_IMAGE_TAG" "$CLI_E2E_POLYGLOT_BASE_IMAGE_CACHE_SCOPE" + docker tag "$CLI_E2E_POLYGLOT_BASE_IMAGE_TAG" "$CLI_E2E_POLYGLOT_IMAGE_TAG" + + if ! build_java_image "http://azure.archive.ubuntu.com/ubuntu/"; then + echo "Java polyglot image build failed with the Azure Ubuntu apt mirror; retrying with default Ubuntu apt sources" + build_java_image "" + fi fi mkdir -p artifacts/cli-e2e-image docker save "$CLI_E2E_DOTNET_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz + if [[ "$CLI_E2E_INCLUDE_POLYGLOT_IMAGES" == "true" ]]; then + docker save "$CLI_E2E_POLYGLOT_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz + docker save "$CLI_E2E_POLYGLOT_JAVA_IMAGE_TAG" | gzip > artifacts/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz + fi - - name: Upload CLI E2E Docker image + - name: Upload CLI E2E .NET Docker image uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ env.CLI_E2E_DOTNET_IMAGE_ARTIFACT }} path: artifacts/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz retention-days: 1 + + - name: Upload CLI E2E polyglot Docker image + if: ${{ inputs.includePolyglotImages }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_POLYGLOT_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz + retention-days: 1 + + - name: Upload CLI E2E Java Docker image + if: ${{ inputs.includePolyglotImages }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.CLI_E2E_POLYGLOT_JAVA_IMAGE_ARTIFACT }} + path: artifacts/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz + retention-days: 1 diff --git a/.github/workflows/reproduce-flaky-tests.yml b/.github/workflows/reproduce-flaky-tests.yml index 6cf76e6c694..07583e058ba 100644 --- a/.github/workflows/reproduce-flaky-tests.yml +++ b/.github/workflows/reproduce-flaky-tests.yml @@ -188,6 +188,20 @@ jobs: name: cli-e2e-dotnet-image path: ${{ github.workspace }}/cli-e2e-image + - name: Download prebuilt CLI E2E Java Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-java-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E polyglot Docker image + if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-image + path: ${{ github.workspace }}/cli-e2e-image + - name: Load prebuilt CLI E2E Docker image if: ${{ needs.setup.outputs.is_cli_e2e == 'true' && runner.os == 'Linux' }} shell: bash @@ -198,6 +212,14 @@ jobs: docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz" + docker image inspect "aspire-cli-e2e-polyglot:prebuilt" > /dev/null + echo "ASPIRE_E2E_POLYGLOT_IMAGE=aspire-cli-e2e-polyglot:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE=true" >> "$GITHUB_ENV" + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" + docker image inspect "aspire-cli-e2e-polyglot-java:prebuilt" > /dev/null + echo "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE=aspire-cli-e2e-polyglot-java:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE=true" >> "$GITHUB_ENV" - name: Unlock macOS keychain if: runner.os == 'macOS' diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index bd2b7211a77..f435daf1f69 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -125,7 +125,21 @@ jobs: name: cli-e2e-dotnet-image path: ${{ github.workspace }}/cli-e2e-image - - name: Load prebuilt CLI E2E Docker image + - name: Download prebuilt CLI E2E polyglot Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Download prebuilt CLI E2E Java Docker image + if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' && contains(inputs.testShortName, 'Java') }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-e2e-polyglot-java-image + path: ${{ github.workspace }}/cli-e2e-image + + - name: Load prebuilt CLI E2E Docker images if: ${{ fromJson(inputs.properties).requiresCliArchive == true && runner.os == 'Linux' }} shell: bash run: | @@ -136,6 +150,18 @@ jobs: echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz" + docker image inspect "aspire-cli-e2e-polyglot:prebuilt" > /dev/null + echo "ASPIRE_E2E_POLYGLOT_IMAGE=aspire-cli-e2e-polyglot:prebuilt" >> "$GITHUB_ENV" + echo "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE=true" >> "$GITHUB_ENV" + + if [[ -f "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" ]]; then + docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" + docker image inspect "aspire-cli-e2e-polyglot-java:prebuilt" > /dev/null + echo "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE=aspire-cli-e2e-polyglot-java:prebuilt" >> "$GITHUB_ENV" + fi + echo "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE=${{ contains(inputs.testShortName, 'Java') }}" >> "$GITHUB_ENV" + - name: Download built nugets if: ${{ fromJson(inputs.properties).requiresNugets == true }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index c7a51efa83c..315ac3525a3 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -29,6 +29,8 @@ jobs: build_cli_e2e_image: name: Build CLI E2E Docker image uses: ./.github/workflows/build-cli-e2e-image.yml + with: + includePolyglotImages: false smoke-test: name: CLI Smoke Test (${{ inputs.quality || 'dev' }}) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 9cf96695069..95cd5739690 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -20,6 +20,10 @@ internal static class CliE2ETestHelpers internal const string CliArchiveDirEnvironmentVariableName = CliInstallStrategy.CliArchiveDirEnvironmentVariableName; internal const string DotNetImageEnvironmentVariableName = "ASPIRE_E2E_DOTNET_IMAGE"; internal const string RequireDotNetImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE"; + internal const string PolyglotImageEnvironmentVariableName = "ASPIRE_E2E_POLYGLOT_IMAGE"; + internal const string RequirePolyglotImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE"; + internal const string PolyglotJavaImageEnvironmentVariableName = "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE"; + internal const string RequirePolyglotJavaImageEnvironmentVariableName = "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE"; internal const string CliVersionOutputDirEnvironmentVariableName = "ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR"; internal const string ContainerCliVersionOutputDir = "/tmp/aspire-cli-versions"; private static readonly Regex s_commitShaPattern = new("^[0-9a-fA-F]{40}$", RegexOptions.Compiled); @@ -161,9 +165,9 @@ internal static Hex1bTerminal CreateDockerTestTerminal( var recordingPath = GetTestResultsRecordingPath(testName); RegisterCaptureFile("recording.cast", recordingPath); var dockerfilePath = GetDockerfilePath(repoRoot, variant); - var dotNetImageName = GetDotNetImageName(variant); + var prebuiltImageName = GetPrebuiltImageName(variant); - if (variant is DockerfileVariant.PolyglotJava) + if (variant is DockerfileVariant.PolyglotJava && prebuiltImageName is null) { EnsurePolyglotBaseImage(repoRoot, output); } @@ -173,8 +177,8 @@ internal static Hex1bTerminal CreateDockerTestTerminal( output.WriteLine($" Strategy: {strategy}"); output.WriteLine($" Expected ver: {strategy.ExpectedVersion ?? "(not available)"}"); output.WriteLine($" Variant: {variant}"); - output.WriteLine($" Dockerfile: {(dotNetImageName is null ? dockerfilePath : "(prebuilt image)")}"); - output.WriteLine($" Image: {dotNetImageName ?? "(build from Dockerfile)"}"); + output.WriteLine($" Dockerfile: {(prebuiltImageName is null ? dockerfilePath : "(prebuilt image)")}"); + output.WriteLine($" Image: {prebuiltImageName ?? "(build from Dockerfile)"}"); output.WriteLine($" Workspace: {workspace?.WorkspaceRoot.FullName ?? "(none)"}"); output.WriteLine($" Docker socket: {mountDockerSocket}"); output.WriteLine($" Dimensions: {width}x{height}"); @@ -232,10 +236,10 @@ internal static void ConfigureDockerContainerStrategy(DockerContainerOptions opt internal static void ConfigureDockerContainerSource(DockerContainerOptions options, string repoRoot, DockerfileVariant variant) { - var dotNetImageName = GetDotNetImageName(variant); - if (dotNetImageName is not null) + var prebuiltImageName = GetPrebuiltImageName(variant); + if (prebuiltImageName is not null) { - options.Image = dotNetImageName; + options.Image = prebuiltImageName; return; } @@ -244,20 +248,31 @@ internal static void ConfigureDockerContainerSource(DockerContainerOptions optio throw new InvalidOperationException($"{DotNetImageEnvironmentVariableName} must be set when the prebuilt CLI E2E .NET image is required."); } + if (variant is DockerfileVariant.Polyglot && IsPolyglotImageRequired()) + { + throw new InvalidOperationException($"{PolyglotImageEnvironmentVariableName} must be set when the prebuilt CLI E2E polyglot image is required."); + } + + if (variant is DockerfileVariant.PolyglotJava && IsPolyglotJavaImageRequired()) + { + throw new InvalidOperationException($"{PolyglotJavaImageEnvironmentVariableName} must be set when the prebuilt CLI E2E Java image is required."); + } + options.DockerfilePath = GetDockerfilePath(repoRoot, variant); options.BuildContext = repoRoot; } - // The prebuilt workflow artifact currently covers only the default .NET image, - // which is the image shared by most split CLI E2E jobs. - private static string? GetDotNetImageName(DockerfileVariant variant) + private static string? GetPrebuiltImageName(DockerfileVariant variant) { - if (variant is not DockerfileVariant.DotNet) + var environmentVariableName = variant switch { - return null; - } + DockerfileVariant.DotNet => DotNetImageEnvironmentVariableName, + DockerfileVariant.Polyglot => PolyglotImageEnvironmentVariableName, + DockerfileVariant.PolyglotJava => PolyglotJavaImageEnvironmentVariableName, + _ => throw new ArgumentOutOfRangeException(nameof(variant)), + }; - var imageName = Environment.GetEnvironmentVariable(DotNetImageEnvironmentVariableName); + var imageName = Environment.GetEnvironmentVariable(environmentVariableName); return string.IsNullOrWhiteSpace(imageName) ? null : imageName.Trim(); } @@ -276,7 +291,22 @@ private static string GetDockerfilePath(string repoRoot, DockerfileVariant varia private static bool IsDotNetImageRequired() { - var value = Environment.GetEnvironmentVariable(RequireDotNetImageEnvironmentVariableName); + return IsImageRequired(RequireDotNetImageEnvironmentVariableName); + } + + private static bool IsPolyglotImageRequired() + { + return IsImageRequired(RequirePolyglotImageEnvironmentVariableName); + } + + private static bool IsPolyglotJavaImageRequired() + { + return IsImageRequired(RequirePolyglotJavaImageEnvironmentVariableName); + } + + private static bool IsImageRequired(string environmentVariableName) + { + var value = Environment.GetEnvironmentVariable(environmentVariableName); return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "1", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 58dc5e1ea3d..18dd7a1b464 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -192,6 +192,8 @@ public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariabl using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -208,6 +210,10 @@ public void ConfigureContainer_BuildArgsCanBeClearedForPrebuiltImage() using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, null), (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); var strategy = CliInstallStrategy.LatestGa(); var options = new DockerContainerOptions(); @@ -225,6 +231,8 @@ public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); var strategy = CliInstallStrategy.LatestGa(); var options = new DockerContainerOptions(); @@ -237,6 +245,85 @@ public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() Assert.Equal("http://azure.archive.ubuntu.com/ubuntu/", options.BuildArgs[CliInstallStrategy.UbuntuAptMirrorBuildArgName]); } + [Fact] + public void ConfigureDockerContainerSource_UsesPolyglotImageWhenEnvironmentVariableIsSet() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, "aspire-cli-e2e-polyglot:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal("aspire-cli-e2e-polyglot:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresPolyglotImageWhenConfigured() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot)); + + Assert.Contains(CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_UsesPolyglotJavaImageWhenEnvironmentVariableIsSet() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.PolyglotJava); + + Assert.Equal("aspire-cli-e2e-polyglot-java:prebuilt", options.Image); + Assert.True(string.IsNullOrEmpty(options.DockerfilePath)); + Assert.True(string.IsNullOrEmpty(options.BuildContext)); + } + + [Fact] + public void ConfigureDockerContainerSource_RequiresPolyglotJavaImageWhenConfigured() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.PolyglotJava)); + + Assert.Contains(CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, exception.Message); + } + + [Fact] + public void ConfigureDockerContainerSource_IgnoresPolyglotJavaImageForPolyglotVariant() + { + using var environment = new EnvironmentVariableScope( + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + ("GITHUB_ACTIONS", "true")); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.Polyglot); + + Assert.Equal(Path.Combine("/repo", "tests", "Shared", "Docker", "Dockerfile.e2e-polyglot-base"), options.DockerfilePath); + Assert.Equal("/repo", options.BuildContext); + } + [Fact] public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() { From b7701bdaa1944e65f16ce128e6d8825a5a136ca7 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 5 May 2026 15:23:52 -0700 Subject: [PATCH 6/8] Isolate CLI E2E image helper tests Clear variant-specific prebuilt image environment variables in tests that intentionally verify fallback to Dockerfile builds. This matches the CI environment where the shared prebuilt image variables are set globally before running CliInstallStrategyTests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/CliInstallStrategyTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 18dd7a1b464..d9539fecacb 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -315,6 +315,8 @@ public void ConfigureDockerContainerSource_IgnoresPolyglotJavaImageForPolyglotVa using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -375,6 +377,8 @@ public void ConfigureDockerContainerSource_IgnoresDotNetImageForPolyglotVariant( using var environment = new EnvironmentVariableScope( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); From 269f60f6f86c1101f1c63154f28399a5b01a7018 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 6 May 2026 07:17:24 -0700 Subject: [PATCH 7/8] Address CLI E2E image review feedback Extract prebuilt CLI E2E image loading into a shared script, document the image contract, and make Java image requirements fail fast during artifact loading. Tighten helper tests and prebuilt-image strategy validation, align workflow indentation, and initialize Buildx through setup-buildx-action so the GHA cache backend can authenticate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 6 +- .github/workflows/reproduce-flaky-tests.yml | 18 +- .github/workflows/run-tests.yml | 22 +-- .github/workflows/tests-daily-smoke.yml | 8 +- .github/workflows/tests.yml | 46 ++--- docs/ci/cli-e2e-images.md | 50 +++++ eng/scripts/load-cli-e2e-images.sh | 186 ++++++++++++++++++ .../Helpers/CliE2ETestHelpers.cs | 12 +- .../Helpers/CliInstallStrategyTests.cs | 81 +++++--- 9 files changed, 338 insertions(+), 91 deletions(-) create mode 100644 docs/ci/cli-e2e-images.md create mode 100755 eng/scripts/load-cli-e2e-images.sh diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml index e170ab01056..503264e5d3a 100644 --- a/.github/workflows/build-cli-e2e-image.yml +++ b/.github/workflows/build-cli-e2e-image.yml @@ -36,14 +36,14 @@ jobs: - name: Verify Docker is running run: docker info + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Build CLI E2E Docker image shell: bash run: | set -euo pipefail - docker buildx create --name cli-e2e-builder --use - docker buildx inspect --bootstrap - build_image() { local display_name="$1" local dockerfile="$2" diff --git a/.github/workflows/reproduce-flaky-tests.yml b/.github/workflows/reproduce-flaky-tests.yml index 07583e058ba..fa46c6f8844 100644 --- a/.github/workflows/reproduce-flaky-tests.yml +++ b/.github/workflows/reproduce-flaky-tests.yml @@ -207,19 +207,11 @@ jobs: shell: bash run: | set -euo pipefail - - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null - echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz" - docker image inspect "aspire-cli-e2e-polyglot:prebuilt" > /dev/null - echo "ASPIRE_E2E_POLYGLOT_IMAGE=aspire-cli-e2e-polyglot:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE=true" >> "$GITHUB_ENV" - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" - docker image inspect "aspire-cli-e2e-polyglot-java:prebuilt" > /dev/null - echo "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE=aspire-cli-e2e-polyglot-java:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE=true" >> "$GITHUB_ENV" + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true \ + --require-polyglot true \ + --require-java true - name: Unlock macOS keychain if: runner.os == 'macOS' diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f435daf1f69..f295648f7aa 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -144,23 +144,11 @@ jobs: shell: bash run: | set -euo pipefail - - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null - echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" - - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot.tar.gz" - docker image inspect "aspire-cli-e2e-polyglot:prebuilt" > /dev/null - echo "ASPIRE_E2E_POLYGLOT_IMAGE=aspire-cli-e2e-polyglot:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE=true" >> "$GITHUB_ENV" - - if [[ -f "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" ]]; then - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-polyglot-java.tar.gz" - docker image inspect "aspire-cli-e2e-polyglot-java:prebuilt" > /dev/null - echo "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE=aspire-cli-e2e-polyglot-java:prebuilt" >> "$GITHUB_ENV" - fi - echo "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE=${{ contains(inputs.testShortName, 'Java') }}" >> "$GITHUB_ENV" + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true \ + --require-polyglot true \ + --require-java "${{ contains(inputs.testShortName, 'Java') }}" - name: Download built nugets if: ${{ fromJson(inputs.properties).requiresNugets == true }} diff --git a/.github/workflows/tests-daily-smoke.yml b/.github/workflows/tests-daily-smoke.yml index 315ac3525a3..62c86da7cf0 100644 --- a/.github/workflows/tests-daily-smoke.yml +++ b/.github/workflows/tests-daily-smoke.yml @@ -65,11 +65,9 @@ jobs: shell: bash run: | set -euo pipefail - - docker load -i "${{ github.workspace }}/cli-e2e-image/aspire-cli-e2e-dotnet.tar.gz" - docker image inspect "aspire-cli-e2e-dotnet:prebuilt" > /dev/null - echo "ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt" >> "$GITHUB_ENV" - echo "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE=true" >> "$GITHUB_ENV" + eng/scripts/load-cli-e2e-images.sh \ + --image-dir "${{ github.workspace }}/cli-e2e-image" \ + --require-dotnet true - name: Build E2E test project run: dotnet build tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c53c4e14e1..3a7a5b97e50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -405,29 +405,29 @@ jobs: if: >- ${{ always() && (contains(needs.*.result, 'failure') || - contains(needs.*.result, 'cancelled') || - (github.event_name == 'pull_request' && - (needs.extension_tests_win.result == 'skipped' || - needs.cli_starter_validation_windows.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && - needs.build_cli_e2e_image.result == 'skipped') || - needs.tests_requires_cli_archive.result == 'skipped' || - needs.polyglot_validation.result == 'skipped')) || - (github.event_name != 'pull_request' && - (needs.extension_tests_win.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped') || - needs.polyglot_validation.result == 'skipped'))) }} + contains(needs.*.result, 'cancelled') || + (github.event_name == 'pull_request' && + (needs.extension_tests_win.result == 'skipped' || + needs.cli_starter_validation_windows.result == 'skipped' || + needs.typescript_sdk_tests.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_cli_archive).include[0] != null && + needs.build_cli_e2e_image.result == 'skipped') || + needs.tests_requires_cli_archive.result == 'skipped' || + needs.polyglot_validation.result == 'skipped')) || + (github.event_name != 'pull_request' && + (needs.extension_tests_win.result == 'skipped' || + needs.typescript_sdk_tests.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped') || + needs.polyglot_validation.result == 'skipped'))) }} run: | echo "One or more dependent jobs failed." exit 1 diff --git a/docs/ci/cli-e2e-images.md b/docs/ci/cli-e2e-images.md new file mode 100644 index 00000000000..2c2cc8fae61 --- /dev/null +++ b/docs/ci/cli-e2e-images.md @@ -0,0 +1,50 @@ +# CLI E2E Docker images + +The CLI E2E tests run in Docker containers through Hex1b. GitHub Actions splits those tests across many isolated jobs, so the CI workflows prebuild shared images once per workflow run and pass them to test jobs as short-lived artifacts. + +## Image contract + +The reusable `.github/workflows/build-cli-e2e-image.yml` workflow produces these artifacts: + +| Variant | Dockerfile | Artifact | Image tag | Image env var | Require env var | +| --- | --- | --- | --- | --- | --- | +| DotNet | `tests/Shared/Docker/Dockerfile.e2e` | `cli-e2e-dotnet-image` | `aspire-cli-e2e-dotnet:prebuilt` | `ASPIRE_E2E_DOTNET_IMAGE` | `ASPIRE_E2E_REQUIRE_DOTNET_IMAGE` | +| Polyglot | `tests/Shared/Docker/Dockerfile.e2e-polyglot-base` | `cli-e2e-polyglot-image` | `aspire-cli-e2e-polyglot:prebuilt` | `ASPIRE_E2E_POLYGLOT_IMAGE` | `ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE` | +| Polyglot Java | `tests/Shared/Docker/Dockerfile.e2e-polyglot-java` | `cli-e2e-polyglot-java-image` | `aspire-cli-e2e-polyglot-java:prebuilt` | `ASPIRE_E2E_POLYGLOT_JAVA_IMAGE` | `ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE` | + +`Dockerfile.e2e-podman` is not part of this contract because it uses a separate privileged Podman-in-Docker runtime path. + +## Fail-fast semantics + +`CliE2ETestHelpers` uses the image environment variable for the selected Dockerfile variant when it is set. If the matching `ASPIRE_E2E_REQUIRE_*_IMAGE` variable is `true` or `1`, the helper throws when the image variable is missing. If the require variable is not set, the helper falls back to building the variant Dockerfile locally. + +This preserves the local development path while making opted-in CI jobs fail early when their prebuilt image artifact was not loaded correctly. + +## Workflow behavior + +The image build workflow has an `includePolyglotImages` input. It defaults to `true` and builds all three shared images. Daily CLI smoke tests set it to `false` because they only need the DotNet image. + +Consumer workflows download image artifacts into `${{ github.workspace }}/cli-e2e-image` and call `eng/scripts/load-cli-e2e-images.sh` to load the images and export the matching environment variables. Regular split CLI E2E jobs always require DotNet and Polyglot images. Java image download and loading is conditional on Java test jobs to avoid transferring the larger Java tarball to every split job. + +## Adding another variant + +When adding a new shared CLI E2E Dockerfile variant: + +1. Add a stable artifact name, image tag, image environment variable, and require environment variable. +2. Update `.github/workflows/build-cli-e2e-image.yml` to build, save, and upload the new image. +3. Update `eng/scripts/load-cli-e2e-images.sh` to load the tarball and export the environment variables. +4. Update workflow consumers to download the artifact where needed. +5. Update `CliE2ETestHelpers.GetDockerfilePath`, `CliE2ETestHelpers.GetPrebuiltImageName`, and the helper tests. + +## Local usage + +To run a CLI E2E test against a prebuilt image locally, load the tarball and set the matching image variable: + +```bash +docker load -i /path/to/aspire-cli-e2e-dotnet.tar.gz +ASPIRE_E2E_DOTNET_IMAGE=aspire-cli-e2e-dotnet:prebuilt \ + dotnet test tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj \ + -- --filter-class "*.SmokeTests" +``` + +Set the matching `ASPIRE_E2E_REQUIRE_*_IMAGE=true` variable when you want local execution to fail instead of falling back to a Dockerfile build. diff --git a/eng/scripts/load-cli-e2e-images.sh b/eng/scripts/load-cli-e2e-images.sh new file mode 100755 index 00000000000..49d3975c4f2 --- /dev/null +++ b/eng/scripts/load-cli-e2e-images.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -euo pipefail + +image_dir="" +github_env="${GITHUB_ENV:-}" +require_dotnet="false" +require_polyglot="false" +require_java="false" + +usage() { + cat <<'EOF' +Usage: load-cli-e2e-images.sh --image-dir [options] + +Loads prebuilt Aspire CLI E2E Docker image artifacts and exports the matching +ASPIRE_E2E_* image environment variables to GITHUB_ENV. + +Options: + --image-dir Directory containing the image tarballs. + --github-env Environment file to append to. Defaults to GITHUB_ENV. + --require-dotnet true, false, or auto. Defaults to false. + --require-polyglot true, false, or auto. Defaults to false. + --require-java true, false, or auto. Defaults to false. + +Mode behavior: + true The tarball must exist. Load it, export IMAGE and REQUIRE=true. + false Do not load the tarball. Export REQUIRE=false. + auto Load and require the image if the tarball exists; otherwise REQUIRE=false. +EOF +} + +read_value_arg() { + local option="$1" + local value="${2:-}" + if [[ -z "$value" ]]; then + echo "Missing value for $option" >&2 + usage >&2 + exit 2 + fi + + printf "%s" "$value" +} + +normalize_mode() { + local option="$1" + local value="$2" + local normalized_value + normalized_value="$(printf "%s" "$value" | tr '[:upper:]' '[:lower:]')" + case "$normalized_value" in + true|1) + printf "true" + ;; + false|0) + printf "false" + ;; + auto) + printf "auto" + ;; + *) + echo "Invalid value for $option: $value. Expected true, false, or auto." >&2 + exit 2 + ;; + esac +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image-dir) + image_dir="$(read_value_arg "$1" "${2:-}")" + shift 2 + ;; + --image-dir=*) + image_dir="${1#*=}" + shift + ;; + --github-env) + github_env="$(read_value_arg "$1" "${2:-}")" + shift 2 + ;; + --github-env=*) + github_env="${1#*=}" + shift + ;; + --require-dotnet) + require_dotnet="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-dotnet=*) + require_dotnet="$(normalize_mode "--require-dotnet" "${1#*=}")" + shift + ;; + --require-polyglot) + require_polyglot="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-polyglot=*) + require_polyglot="$(normalize_mode "--require-polyglot" "${1#*=}")" + shift + ;; + --require-java) + require_java="$(normalize_mode "$1" "$(read_value_arg "$1" "${2:-}")")" + shift 2 + ;; + --require-java=*) + require_java="$(normalize_mode "--require-java" "${1#*=}")" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$image_dir" ]]; then + echo "--image-dir is required." >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$github_env" ]]; then + echo "GITHUB_ENV is not set. Pass --github-env or run inside GitHub Actions." >&2 + exit 2 +fi + +write_env() { + local name="$1" + local value="$2" + printf "%s=%s\n" "$name" "$value" >> "$github_env" +} + +load_image() { + local display_name="$1" + local mode="$2" + local tarball_name="$3" + local image_tag="$4" + local image_env_name="$5" + local require_env_name="$6" + local tarball_path="${image_dir%/}/$tarball_name" + + if [[ "$mode" == "auto" ]]; then + if [[ -f "$tarball_path" ]]; then + mode="true" + else + mode="false" + fi + fi + + if [[ "$mode" == "false" ]]; then + write_env "$require_env_name" "false" + return + fi + + if [[ ! -f "$tarball_path" ]]; then + echo "::error::$display_name image is required but artifact was not found at $tarball_path" + exit 1 + fi + + docker load -i "$tarball_path" + docker image inspect "$image_tag" > /dev/null + write_env "$image_env_name" "$image_tag" + write_env "$require_env_name" "true" +} + +load_image ".NET" "$require_dotnet" \ + "aspire-cli-e2e-dotnet.tar.gz" \ + "aspire-cli-e2e-dotnet:prebuilt" \ + "ASPIRE_E2E_DOTNET_IMAGE" \ + "ASPIRE_E2E_REQUIRE_DOTNET_IMAGE" + +load_image "polyglot" "$require_polyglot" \ + "aspire-cli-e2e-polyglot.tar.gz" \ + "aspire-cli-e2e-polyglot:prebuilt" \ + "ASPIRE_E2E_POLYGLOT_IMAGE" \ + "ASPIRE_E2E_REQUIRE_POLYGLOT_IMAGE" + +load_image "Java polyglot" "$require_java" \ + "aspire-cli-e2e-polyglot-java.tar.gz" \ + "aspire-cli-e2e-polyglot-java:prebuilt" \ + "ASPIRE_E2E_POLYGLOT_JAVA_IMAGE" \ + "ASPIRE_E2E_REQUIRE_POLYGLOT_JAVA_IMAGE" diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 95cd5739690..efa72c80999 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -218,18 +218,24 @@ internal static Hex1bTerminal CreateDockerTestTerminal( } } - ConfigureDockerContainerStrategy(c, strategy); + ConfigureDockerContainerStrategy(c, strategy, prebuiltImageSelected: prebuiltImageName is not null); }); return builder.Build(); } - internal static void ConfigureDockerContainerStrategy(DockerContainerOptions options, CliInstallStrategy strategy) + internal static void ConfigureDockerContainerStrategy(DockerContainerOptions options, CliInstallStrategy strategy, bool prebuiltImageSelected = false) { // Delegate all mode-specific Docker config to the strategy. strategy.ConfigureContainer(options); - if (string.IsNullOrEmpty(options.DockerfilePath)) + + if (prebuiltImageSelected) { + if (!string.IsNullOrEmpty(options.DockerfilePath) || !string.IsNullOrEmpty(options.BuildContext)) + { + throw new InvalidOperationException("A prebuilt CLI E2E image was selected, but Dockerfile configuration was also set. Prebuilt-image runs must not fall back to Dockerfile builds."); + } + options.BuildArgs.Clear(); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index d9539fecacb..65060ccc578 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -189,11 +189,9 @@ public void ConfigureContainer_DoesNotAddUbuntuAptMirrorBuildArgWhenEnvironmentV [Fact] public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariableIsSet() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -207,32 +205,45 @@ public void ConfigureDockerContainerSource_UsesDotNetImageWhenEnvironmentVariabl [Fact] public void ConfigureContainer_BuildArgsCanBeClearedForPrebuiltImage() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, null), (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); var strategy = CliInstallStrategy.LatestGa(); var options = new DockerContainerOptions(); CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); - CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy); + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy, prebuiltImageSelected: true); Assert.Equal("aspire-cli-e2e-dotnet:prebuilt", options.Image); Assert.Empty(options.BuildArgs); } + [Fact] + public void ConfigureContainer_ThrowsWhenPrebuiltImageAlsoHasDockerfileConfiguration() + { + using var environment = WithCleanCliE2ETestEnvironment( + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true")); + var strategy = CliInstallStrategy.LatestGa(); + var options = new DockerContainerOptions(); + + CliE2ETestHelpers.ConfigureDockerContainerSource(options, "/repo", CliE2ETestHelpers.DockerfileVariant.DotNet); + options.DockerfilePath = "/unexpected/Dockerfile"; + options.BuildContext = "/unexpected"; + + var exception = Assert.Throws(() => + CliE2ETestHelpers.ConfigureDockerContainerStrategy(options, strategy, prebuiltImageSelected: true)); + + Assert.Contains("prebuilt CLI E2E image", exception.Message); + } + [Fact] public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), (CliInstallStrategy.UbuntuAptMirrorEnvironmentVariableName, "http://azure.archive.ubuntu.com/ubuntu/")); var strategy = CliInstallStrategy.LatestGa(); var options = new DockerContainerOptions(); @@ -248,7 +259,7 @@ public void ConfigureContainer_PreservesBuildArgsForDockerfileVariant() [Fact] public void ConfigureDockerContainerSource_UsesPolyglotImageWhenEnvironmentVariableIsSet() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, "aspire-cli-e2e-polyglot:prebuilt"), (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); @@ -264,7 +275,7 @@ public void ConfigureDockerContainerSource_UsesPolyglotImageWhenEnvironmentVaria [Fact] public void ConfigureDockerContainerSource_RequiresPolyglotImageWhenConfigured() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); @@ -279,11 +290,9 @@ public void ConfigureDockerContainerSource_RequiresPolyglotImageWhenConfigured() [Fact] public void ConfigureDockerContainerSource_UsesPolyglotJavaImageWhenEnvironmentVariableIsSet() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -297,7 +306,7 @@ public void ConfigureDockerContainerSource_UsesPolyglotJavaImageWhenEnvironmentV [Fact] public void ConfigureDockerContainerSource_RequiresPolyglotJavaImageWhenConfigured() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); @@ -312,11 +321,9 @@ public void ConfigureDockerContainerSource_RequiresPolyglotJavaImageWhenConfigur [Fact] public void ConfigureDockerContainerSource_IgnoresPolyglotJavaImageForPolyglotVariant() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, "aspire-cli-e2e-polyglot-java:prebuilt"), (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -329,7 +336,7 @@ public void ConfigureDockerContainerSource_IgnoresPolyglotJavaImageForPolyglotVa [Fact] public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", null)); @@ -344,7 +351,7 @@ public void ConfigureDockerContainerSource_FallsBackToDockerfileOutsideCI() [Fact] public void ConfigureDockerContainerSource_RequiresDotNetImageWhenConfigured() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), ("GITHUB_ACTIONS", "true")); @@ -359,7 +366,7 @@ public void ConfigureDockerContainerSource_RequiresDotNetImageWhenConfigured() [Fact] public void ConfigureDockerContainerSource_FallsBackToDockerfileInCIWhenDotNetImageIsNotRequired() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); @@ -374,11 +381,9 @@ public void ConfigureDockerContainerSource_FallsBackToDockerfileInCIWhenDotNetIm [Fact] public void ConfigureDockerContainerSource_IgnoresDotNetImageForPolyglotVariant() { - using var environment = new EnvironmentVariableScope( + using var environment = WithCleanCliE2ETestEnvironment( (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, "aspire-cli-e2e-dotnet:prebuilt"), (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, "true"), - (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), - (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), ("GITHUB_ACTIONS", "true")); var options = new DockerContainerOptions(); @@ -974,6 +979,28 @@ public void Detect_ReturnsPreinstalled_WhenPreinstalledIsSet() Assert.Equal(CliInstallMode.Preinstalled, strategy.Mode); } + private static EnvironmentVariableScope WithCleanCliE2ETestEnvironment(params (string Name, string? Value)[] variables) + { + (string Name, string? Value)[] defaults = + [ + (CliE2ETestHelpers.DotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequireDotNetImageEnvironmentVariableName, null), + (CliE2ETestHelpers.PolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotImageEnvironmentVariableName, null), + (CliE2ETestHelpers.PolyglotJavaImageEnvironmentVariableName, null), + (CliE2ETestHelpers.RequirePolyglotJavaImageEnvironmentVariableName, null), + ("GITHUB_ACTIONS", null), + ]; + + var cleanVariables = defaults.ToDictionary(variable => variable.Name, variable => variable.Value); + foreach (var (name, value) in variables) + { + cleanVariables[name] = value; + } + + return new EnvironmentVariableScope([.. cleanVariables.Select(variable => (variable.Key, variable.Value))]); + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly Dictionary _originalValues; From d5f29e8bc42a19377928ce10e26b974bd4236f74 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Wed, 6 May 2026 07:22:42 -0700 Subject: [PATCH 8/8] Avoid external Buildx setup action Export the GitHub Actions cache runtime with the repo-approved github-script action and create the Buildx builder in the shell step so CLI E2E image cache remains active without triggering workflow startup restrictions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-cli-e2e-image.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-cli-e2e-image.yml b/.github/workflows/build-cli-e2e-image.yml index 503264e5d3a..6d1ec2a5a6d 100644 --- a/.github/workflows/build-cli-e2e-image.yml +++ b/.github/workflows/build-cli-e2e-image.yml @@ -36,14 +36,26 @@ jobs: - name: Verify Docker is running run: docker info - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Export GitHub Actions cache runtime + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + for (const name of ['ACTIONS_RUNTIME_TOKEN', 'ACTIONS_RUNTIME_URL', 'ACTIONS_CACHE_URL', 'ACTIONS_RESULTS_URL']) { + const value = process.env[name]; + if (value) { + core.setSecret(value); + core.exportVariable(name, value); + } + } - name: Build CLI E2E Docker image shell: bash run: | set -euo pipefail + docker buildx create --name cli-e2e-builder --use + docker buildx inspect --bootstrap + build_image() { local display_name="$1" local dockerfile="$2"