Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ jobs:
versionOverrideArg: ${{ inputs.versionOverrideArg }}
targets: '[{"os": "windows-latest", "runner": "windows-latest", "rids": "win-x64"}]'

build_cli_archive_windows_arm:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }}
name: Build native CLI archive (Windows ARM64)
uses: ./.github/workflows/build-cli-native-archives.yml
with:
versionOverrideArg: ${{ inputs.versionOverrideArg }}
targets: '[{"os": "windows-11-arm", "runner": "windows-11-arm", "rids": "win-arm64"}]'

build_cli_archive_macos:
name: Build native CLI archive (macOS)
uses: ./.github/workflows/build-cli-native-archives.yml
Expand Down Expand Up @@ -189,6 +197,86 @@ jobs:
with:
versionOverrideArg: ${{ inputs.versionOverrideArg }}

cli_starter_validation_windows:
name: Aspire CLI Starter Validation (${{ matrix.os.name }})
runs-on: ${{ matrix.os.runner }}
timeout-minutes: 15
needs: [build_packages, build_cli_archive_windows, build_cli_archive_windows_arm]
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }}
strategy:
fail-fast: false
matrix:
os:
- name: Windows
runner: windows-latest
artifact_suffix: windows-x64
max_startup_seconds: 120
resource_ready_timeout_seconds: 120
- name: Windows ARM64
runner: windows-11-arm
artifact_suffix: windows-arm64
max_startup_seconds: 120
resource_ready_timeout_seconds: 300
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
global-json-file: global.json

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'

- name: Install Aspire CLI from PR dogfood script
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$scriptPath = Join-Path $env:GITHUB_WORKSPACE "eng/scripts/get-aspire-cli-pr.ps1"
& $scriptPath ${{ github.event.pull_request.number }} -WorkflowRunId ${{ github.run_id }} -SkipExtension

aspire --version

- name: Create starter app and validate startup
timeout-minutes: 12
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
& "$env:GITHUB_WORKSPACE/eng/scripts/cli-starter-validation.ps1" `
-PRNumber ${{ github.event.pull_request.number }} `
-MaxStartupSeconds ${{ matrix.os.max_startup_seconds }} `
-ResourceReadyTimeoutSeconds ${{ matrix.os.resource_ready_timeout_seconds }}

- name: Collect CLI logs
if: always()
shell: pwsh
run: |
$validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation'
$cliLogsDir = Join-Path $HOME '.aspire\logs'
$sharedLogsDir = Join-Path $validationRoot 'cli-logs'

if (Test-Path $cliLogsDir) {
New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null
Copy-Item -Path $cliLogsDir -Destination $sharedLogsDir -Recurse -Force
}

- name: Upload starter validation diagnostics
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: cli-starter-validation-${{ matrix.os.artifact_suffix }}
path: ${{ runner.temp }}/aspire-cli-starter-validation
if-no-files-found: ignore

extension_tests_win:
name: Run VS Code extension tests (Windows)
runs-on: windows-latest
Expand Down Expand Up @@ -237,6 +325,7 @@ jobs:
build_cli_archive_windows,
build_cli_archive_macos,
extension_tests_win,
cli_starter_validation_windows,
typescript_sdk_tests,
java_sdk_tests,
tests_no_nugets,
Expand Down Expand Up @@ -296,17 +385,20 @@ jobs:
# primary no-nugets matrix exceeds the overflow threshold.
# - tests_requires_nugets_macos: some runs intentionally produce no macOS requires-nugets
# tests, so an empty matrix and 'skipped' result are expected.
# - cli_starter_validation_windows: this job only runs for pull requests and is expected to
# be skipped for other workflow events.
# All other jobs in this gate are required, and a 'skipped' result is treated as a failure.
if: >-
${{ 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.java_sdk_tests.result == 'skipped' ||
needs.tests_no_nugets.result == 'skipped' ||
needs.tests_requires_nugets_linux.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') ||
Expand Down
267 changes: 267 additions & 0 deletions eng/scripts/cli-starter-validation.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
#!/usr/bin/env pwsh

[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Pull request number used to select the PR dogfood channel")]
[ValidateRange(1, [int]::MaxValue)]
[int]$PRNumber,

[Parameter(HelpMessage = "Maximum number of seconds allowed for aspire start to complete")]
[ValidateRange(1, [int]::MaxValue)]
[int]$MaxStartupSeconds = 120,

[Parameter(HelpMessage = "Maximum number of seconds to wait for each expected resource to reach the requested status")]
[ValidateRange(1, [int]::MaxValue)]
[int]$ResourceReadyTimeoutSeconds = 120,

[Parameter(HelpMessage = "Directory used to store starter validation projects and diagnostics")]
[string]$ValidationRoot = ""
)

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
Set-StrictMode -Version Latest

function Get-ValidationRoot
{
if (-not [string]::IsNullOrWhiteSpace($ValidationRoot))
{
return $ValidationRoot
}

if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP))
{
return (Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation')
}

return (Join-Path ([System.IO.Path]::GetTempPath()) 'aspire-cli-starter-validation')
}

function Get-FileContentOrEmpty
{
param(
[Parameter(Mandatory = $true)]
[string]$Path
)

if (Test-Path $Path)
{
return (Get-Content -Raw $Path)
}

return ''
}

function Get-CombinedProcessOutput
{
param(
[Parameter(Mandatory = $true)]
[string]$StdOutPath,

[Parameter(Mandatory = $true)]
[string]$StdErrPath
)

$stdout = Get-FileContentOrEmpty -Path $StdOutPath
$stderr = Get-FileContentOrEmpty -Path $StdErrPath

if ($stdout -and $stderr)
{
return ($stdout, $stderr) -join [Environment]::NewLine
}

return "$stdout$stderr"
}

$validationRootPath = Get-ValidationRoot
Remove-Item -Recurse -Force $validationRootPath -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $validationRootPath -Force | Out-Null

$templates = @(
@{
TemplateId = 'aspire-ts-starter'
ProjectName = 'AspireCliTsStarterSmoke'
ExpectedResources = @('app', 'frontend')
},
@{
TemplateId = 'aspire-starter'
ProjectName = 'AspireCliCsStarterSmoke'
ExpectedResources = @('apiservice')
}
)

$failures = [System.Collections.Generic.List[string]]::new()

foreach ($template in $templates)
{
$templateId = [string]$template.TemplateId
$projectName = [string]$template.ProjectName
$expectedResources = @($template.ExpectedResources)
$templateRoot = Join-Path $validationRootPath $templateId
$diagnosticsDir = Join-Path $templateRoot 'diagnostics'
$projectRoot = Join-Path $templateRoot $projectName
$startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log'
$startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log'
$startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log'
$preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log'
$stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log'

New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null
New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null

Push-Location $templateRoot
try
{
try
{
aspire new $templateId --name $projectName --output $projectRoot --channel "pr-$PRNumber" --non-interactive --nologo

try
{
aspire stop *> $preStartStopLogPath
}
catch
{
$preStartStopOutput = Get-FileContentOrEmpty -Path $preStartStopLogPath
if ($preStartStopOutput -notmatch 'No running apphost found\.')
{
Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)"
if ($preStartStopOutput)
{
Write-Host $preStartStopOutput
}
}
}

$startAt = Get-Date
$process = Start-Process -FilePath 'aspire' `
-ArgumentList @('start') `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $startStdOutPath `
-RedirectStandardError $startStdErrPath `
-PassThru

try
{
$process | Wait-Process -Timeout $MaxStartupSeconds -ErrorAction Stop
}
catch
{
if (-not $process.HasExited)
{
$process | Stop-Process -Force -ErrorAction SilentlyContinue
}

throw "${templateId}: aspire start did not exit within $MaxStartupSeconds seconds."
}

$elapsed = (Get-Date) - $startAt
$startOutput = Get-CombinedProcessOutput -StdOutPath $startStdOutPath -StdErrPath $startStdErrPath

Set-Content -Path $startCombinedPath -Value $startOutput

if ($process.ExitCode -ne 0)
{
throw "${templateId}: aspire start failed with exit code $($process.ExitCode)."
}

if ($startOutput -match 'Timeout waiting for apphost to start')
{
throw "${templateId}: aspire start reported a startup timeout."
}

if ($startOutput -notmatch 'Apphost started successfully\.')
{
throw "${templateId}: aspire start did not report a successful startup."
}

Set-Location $projectRoot

$resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log'
$resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log'
$resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log'

$resourcesProcess = Start-Process -FilePath 'aspire' `
-ArgumentList @('resources') `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $resourcesStdOutPath `
-RedirectStandardError $resourcesStdErrPath `
-Wait `
-PassThru

$resourcesOutput = Get-CombinedProcessOutput -StdOutPath $resourcesStdOutPath -StdErrPath $resourcesStdErrPath

Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput
Write-Host $resourcesOutput

if ($resourcesProcess.ExitCode -ne 0)
{
throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)."
}

foreach ($resourceName in $expectedResources)
{
$sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_'
$waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log"
$waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log"
$waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log"

$waitProcess = Start-Process -FilePath 'aspire' `
-ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $ResourceReadyTimeoutSeconds) `
-WorkingDirectory $projectRoot `
-RedirectStandardOutput $waitStdOutPath `
-RedirectStandardError $waitStdErrPath `
-Wait `
-PassThru

$waitOutput = Get-CombinedProcessOutput -StdOutPath $waitStdOutPath -StdErrPath $waitStdErrPath

Set-Content -Path $waitCombinedPath -Value $waitOutput

if ($waitProcess.ExitCode -ne 0)
{
throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)."
}
}

Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds."
}
catch
{
$message = $_.Exception.Message
Write-Warning $message
$failures.Add($message)
}
}
finally
{
if (Test-Path $projectRoot)
{
Push-Location $projectRoot
try
{
aspire stop *> $stopLogPath
}
catch
{
Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)"
$stopOutput = Get-FileContentOrEmpty -Path $stopLogPath
if ($stopOutput)
{
Write-Host $stopOutput
}
}
finally
{
Pop-Location
}
}

Pop-Location
}
}

if ($failures.Count -gt 0)
{
throw ("Starter validation failures:`n- " + ($failures -join "`n- "))
}
Loading
Loading