From 42db8e908e9f4e316adb7a00582fee1bd91b24cb Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 00:45:12 -0400 Subject: [PATCH 01/21] fix(winget): harden local manifest dogfood installs Stage manifest YAML files before invoking winget, validate the pristine manifest, and serve local PR archives through a loopback HTTP listener so winget can install them with refreshed hashes.\n\nAlso make verification robust when winget reports success before the expected aspire.exe is on PATH, surface useful diagnostics on install failures, and warn when the installed winget version is older than the Mark-of-the-Web fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/winget/dogfood.ps1 | 675 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 623 insertions(+), 52 deletions(-) diff --git a/eng/winget/dogfood.ps1 b/eng/winget/dogfood.ps1 index 2d6e42862d8..f1b333655e7 100644 --- a/eng/winget/dogfood.ps1 +++ b/eng/winget/dogfood.ps1 @@ -10,9 +10,16 @@ Path to the directory containing the WinGet manifest YAML files. Defaults to auto-detecting the manifest directory relative to this script. +.PARAMETER ArchiveRoot + Root directory containing downloaded aspire-cli-win-* archive artifacts. When present, the + local manifest is rewritten to install from those archive files instead of ci.dot.net URLs. + .PARAMETER Uninstall Uninstall a previously dogfooded Aspire CLI. +.PARAMETER Force + Allow replacing an existing Microsoft.Aspire WinGet installation. + .EXAMPLE .\dogfood.ps1 # Auto-detects manifests in the script directory and installs @@ -21,6 +28,10 @@ .\dogfood.ps1 -ManifestPath .\manifests\m\Microsoft\Aspire\9.2.0 # Install from a specific manifest directory +.EXAMPLE + .\dogfood.ps1 -ArchiveRoot ..\native-archives + # Install using downloaded native archive artifacts + .EXAMPLE .\dogfood.ps1 -Uninstall # Uninstall the dogfooded Aspire CLI @@ -31,13 +42,442 @@ param( [Parameter(Position = 0)] [string]$ManifestPath, - [switch]$Uninstall + [string]$ArchiveRoot, + + [switch]$Uninstall, + + [switch]$Force ) $ErrorActionPreference = 'Stop' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +function Get-InstallerManifestPath { + param([string]$Path) + + $installerManifests = @(Get-ChildItem -Path $Path -File -Filter "*.installer.yaml") + if ($installerManifests.Count -ne 1) { + Write-Error "Expected exactly one *.installer.yaml manifest under $Path, but found $($installerManifests.Count)." + exit 1 + } + + return $installerManifests[0].FullName +} + +function Get-ManifestVersion { + param([string]$ManifestPath) + + foreach ($line in Get-Content -Path $ManifestPath) { + if ($line -match '^\s*PackageVersion:\s*"?([^"]+)"?\s*$') { + return $Matches[1] + } + } + + Write-Error "Could not read PackageVersion from $ManifestPath." + exit 1 +} + +function Find-ArchiveIfPresent { + param( + [string]$Root, + [string]$ArchiveName + ) + + if (-not (Test-Path $Root)) { + return $null + } + + return Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName +} + +function Find-Archive { + param( + [string]$Root, + [string]$ArchiveName + ) + + $matches = @(Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($matches.Count -eq 0) { + Write-Error "Could not find $ArchiveName under $Root." + exit 1 + } + + if ($matches.Count -gt 1) { + $matchList = $matches | ForEach-Object { " $($_.FullName)" } + Write-Error "Found multiple $ArchiveName archives under ${Root}:`n$($matchList -join "`n")" + exit 1 + } + + return $matches[0].FullName +} + +function Get-DefaultArchiveRoot { + param([string]$Version) + + foreach ($candidate in @($ScriptDir, (Split-Path -Parent $ScriptDir))) { + if ((Find-ArchiveIfPresent -Root $candidate -ArchiveName "aspire-cli-win-x64-$Version.zip") -and + (Find-ArchiveIfPresent -Root $candidate -ArchiveName "aspire-cli-win-arm64-$Version.zip")) { + return (Resolve-Path $candidate).Path + } + } + + return $null +} + +function Start-LocalArchiveServer { + param( + # Map of . Only these names are + # serveable; anything else returns 404. + [hashtable]$FileMap + ) + + # WinGet downloads InstallerUrl payloads via WinINet's InternetOpenUrl(), which + # only supports http/https/ftp — not file://. So we serve the local archives over + # a loopback HTTP listener instead of rewriting InstallerUrl to file:///, which + # used to fail at install time with: + # "InternetOpenUrl() failed. 0x8007007b : The filename, directory name, or + # volume label syntax is incorrect." + # + # HttpListener on a loopback prefix does NOT require admin / netsh urlacl + # registration for the current user — that restriction only applies to non-loopback + # bindings. See: + # https://learn.microsoft.com/dotnet/api/system.net.httplistener + # + # Get-FreeLoopbackPort picks a port by reserving and immediately releasing a + # TcpListener, so between that release and HttpListener.Start() below the + # kernel could hand the port to another process on a busy CI runner. Retry + # on HttpListenerException with a fresh port; only fail after several misses. + $listener = $null + $prefix = $null + for ($attempt = 1; $attempt -le 5; $attempt++) { + $port = (Get-FreeLoopbackPort) + $prefix = "http://127.0.0.1:$port/" + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add($prefix) + try { + $listener.Start() + break + } catch [System.Net.HttpListenerException] { + $listener.Close() + $listener = $null + if ($attempt -eq 5) { + throw + } + } + } + + # Use a runspace (not Start-Job/Start-ThreadJob) so we don't pull in the Jobs or + # ThreadJob modules, which aren't guaranteed to be available on every winget host. + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $ps = [powershell]::Create() + $ps.Runspace = $runspace + [void]$ps.AddScript({ + param($Listener, $FileMap) + while ($Listener.IsListening) { + try { + $context = $Listener.GetContext() + } catch { + break + } + + try { + $requestedName = [System.IO.Path]::GetFileName([System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath)) + if ($FileMap.ContainsKey($requestedName)) { + $filePath = $FileMap[$requestedName] + $bytes = [System.IO.File]::ReadAllBytes($filePath) + $context.Response.ContentType = 'application/octet-stream' + $context.Response.ContentLength64 = $bytes.LongLength + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } else { + $context.Response.StatusCode = 404 + } + } catch { + try { $context.Response.StatusCode = 500 } catch { } + } finally { + try { $context.Response.Close() } catch { } + } + } + }).AddArgument($listener).AddArgument($FileMap) + + $asyncResult = $ps.BeginInvoke() + + return [pscustomobject]@{ + Listener = $listener + BaseUri = $prefix.TrimEnd('/') + PowerShell = $ps + Runspace = $runspace + AsyncResult = $asyncResult + } +} + +function Stop-LocalArchiveServer { + param($Server) + + if (-not $Server) { return } + + try { + if ($Server.Listener -and $Server.Listener.IsListening) { + $Server.Listener.Stop() + } + if ($Server.Listener) { + $Server.Listener.Close() + } + } catch { } + + try { + if ($Server.PowerShell -and $Server.AsyncResult) { + [void]$Server.PowerShell.EndInvoke($Server.AsyncResult) + } + } catch { } + + try { if ($Server.PowerShell) { $Server.PowerShell.Dispose() } } catch { } + try { if ($Server.Runspace) { $Server.Runspace.Dispose() } } catch { } +} + +function Get-FreeLoopbackPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Show-LatestWinGetDiagLog { + param([string]$Reason = '') + + # Real winget writes per-invocation diag logs to + # %LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir + # When `winget install --manifest` exits non-zero, the diag log is almost always + # the only place that says *why* (the console output is often truncated or empty, + # and `winget` exit codes like 0x8A150001 are generic). Print the newest log to + # stderr so dogfood failures are diagnosable without separately hunting it down. + # + # Tests set ASPIRE_TEST_MODE=true and run a mock winget that doesn't produce diag + # logs, so skip this in test mode to avoid emitting unrelated stale logs. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + return + } + + $diagDir = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir' + if (-not (Test-Path -LiteralPath $diagDir)) { + Write-Host "(no winget diag dir at $diagDir)" + return + } + + $log = Get-ChildItem -LiteralPath $diagDir -Filter 'WinGet-*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $log) { + Write-Host "(no WinGet-*.log files in $diagDir)" + return + } + + Write-Host "" + Write-Host "=== winget diag log ($Reason) ===" + Write-Host " $($log.FullName)" + Write-Host "" + Get-Content -LiteralPath $log.FullName | ForEach-Object { Write-Host " $_" } + Write-Host "=== end winget diag log ===" + Write-Host "" +} + +function Find-AspireBinaryOnPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$ExpectedVersion + ) + + # Walks the freshly-refreshed PATH (machine+user from the registry, which + # picks up the dir winget added during portable install) for every aspire.exe + # and runs it. Returns the FIRST one whose --version output matches + # $ExpectedVersion. If none match, returns the first aspire.exe found so the + # caller can warn about PATH shadowing without losing the diagnostic. Returns + # $null if no aspire.exe is on PATH at all. + # + # This serves two callers: + # + # - Post-install fallback: some Windows environments (corp-managed machines + # hitting winget bug https://github.com/microsoft/winget-cli/issues/6230) + # cause `winget install --manifest` to return -1978335231 (0x8A150001) + # even when the install completed end-to-end. A successful version match + # here means the binary is really deployed despite winget's exit code. + # Don't fall back to `winget list` for this — winget's installed-package + # store keeps stale entries from prior partial installs even after a + # rollback, so it returns false positives. + # + # - Post-install verification: an older aspire.exe earlier on PATH (e.g. + # from a previous get-aspire-cli install) would shadow the winget-installed + # binary if we just used Get-Command. Walking PATH for a version match + # finds the freshly-installed binary even when it isn't first. + # + # Returns: $null OR [pscustomobject]@{ + # Path = full path to aspire.exe (or .cmd in test mode) + # Version = trimmed --version output + # ExitCode = aspire --version exit code + # ExpectedVersionMatched= $true iff Version contains $ExpectedVersion + # } + # + # In test mode the mock aspire.cmd's --version output ("mock aspire version") + # does not match any real PackageVersion. Fall back to Get-Command so the + # test-mode verification assertions still fire on the mock. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + $cmd = Get-Command aspire -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $output = & $cmd.Source --version 2>&1 + $exitCode = $LASTEXITCODE + $versionString = ($output | Out-String).Trim() + return [pscustomobject]@{ + Path = $cmd.Source + Version = $versionString + ExitCode = $exitCode + ExpectedVersionMatched = $false + } + } + + $newPath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path', 'User') + $firstFound = $null + foreach ($dir in ($newPath -split ';' | Where-Object { $_ })) { + $candidate = Join-Path $dir 'aspire.exe' + if (-not (Test-Path -LiteralPath $candidate)) { continue } + try { + $output = & $candidate --version 2>&1 + $exitCode = $LASTEXITCODE + } catch { + continue + } + $versionString = ($output | Out-String).Trim() + $info = [pscustomobject]@{ + Path = $candidate + Version = $versionString + ExitCode = $exitCode + ExpectedVersionMatched = ($versionString -match [regex]::Escape($ExpectedVersion)) + } + if ($info.ExpectedVersionMatched) { + return $info + } + if (-not $firstFound) { + $firstFound = $info + } + } + return $firstFound +} + +function Test-WinGetVersionForMotwFix { + [CmdletBinding()] + param() + + # winget-cli bug https://github.com/microsoft/winget-cli/issues/6230: portable + # installs exit -1978335231 (0x8A150001) at the post-install IAttachmentExecute + # Mark-of-the-Web step on some machines, even when the binary is deployed end-to-end. + # Fixed in v1.29.140-preview by https://github.com/microsoft/winget-cli/pull/6127; + # last broken stable release is v1.28.240 (2026-04-17). + # + # The failure is environmental (interaction with AV / IOfficeAntiVirus) and not every + # < 1.29.140 install actually trips it, so this is a soft warning, not a hard gate. + # Find-AspireBinaryOnPath in the post-install fallback already recovers from the + # case where winget exits non-zero but the binary IS deployed. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + return + } + + try { + $versionOutput = (winget --version 2>&1) | Out-String + } catch { + return + } + $trimmed = $versionOutput.Trim() + # winget --version output: "v1.29.170-preview" or "v1.28.240" + if ($trimmed -notmatch '^v(\d+\.\d+\.\d+)') { + return + } + $parsed = $null + if (-not [Version]::TryParse($Matches[1], [ref]$parsed)) { + return + } + if ($parsed -lt [Version]'1.29.140') { + Write-Warning @" +Local winget is $trimmed. Versions older than v1.29.140-preview can fail portable +installs with exit code -1978335231 (0x8A150001) at the post-install Mark-of-the-Web +step even when the binary is fully deployed. See: + https://github.com/microsoft/winget-cli/issues/6230 + +If the install below fails with that exit code, upgrade winget to v1.29.140-preview +or newer. Pre-release builds are available at: + https://github.com/microsoft/winget-cli/releases +"@ + } +} + +function Resolve-ArchiveFileMap { + param( + [string]$ResolvedArchiveRoot, + [string]$Version + ) + + # The PR-channel CI artifact extracts under /Debug/Shipping/, not + # the root of the artifact directory. Find-Archive scans recursively to locate + # the actual on-disk path. This returns: + # filename → absolute path (e.g. "aspire-cli-win-x64-13.4.0-pr.X.gSHA.zip" + # → "C:\...\Debug\Shipping\aspire-cli-win-x64-13.4.0-pr.X.gSHA.zip") + $archives = @{} + foreach ($arch in @("x64", "arm64")) { + $archiveName = "aspire-cli-win-$arch-$Version.zip" + $archives[$archiveName] = Find-Archive -Root $ResolvedArchiveRoot -ArchiveName $archiveName + } + return $archives +} + +function Set-LocalInstallerSources { + param( + [string]$InstallerManifestPath, + [hashtable]$ArchiveFileMap, + [string]$BaseUri + ) + + # winget install hashes the downloaded payload and compares it with the recorded + # InstallerSha256, so we have to refresh the hash whenever we redirect InstallerUrl + # at a local archive. PR-channel manifests in particular ship with the placeholder + # ("0" * 64) baked in by generate-manifests.ps1's -SkipUrlValidation path, and that + # hash never matches a real build. + $sha256ByArchitecture = @{} + $urlByArchitecture = @{} + foreach ($name in $ArchiveFileMap.Keys) { + $archivePath = $ArchiveFileMap[$name] + $arch = if ($name -match 'aspire-cli-win-(x64|arm64)-') { $Matches[1] } else { continue } + $sha256ByArchitecture[$arch] = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() + $urlByArchitecture[$arch] = "$BaseUri/$name" + } + + $currentArchitecture = $null + $updatedLines = foreach ($line in Get-Content -Path $InstallerManifestPath) { + if ($line -match '^\s*-\s*Architecture:\s*(\S+)\s*$') { + $currentArchitecture = $Matches[1] + $line + continue + } + + if ($line -match '^(\s*)InstallerUrl:\s*' -and $currentArchitecture -and $urlByArchitecture.ContainsKey($currentArchitecture)) { + "$($Matches[1])InstallerUrl: $($urlByArchitecture[$currentArchitecture])" + continue + } + + if ($line -match '^(\s*)InstallerSha256:\s*' -and $currentArchitecture -and $sha256ByArchitecture.ContainsKey($currentArchitecture)) { + "$($Matches[1])InstallerSha256: $($sha256ByArchitecture[$currentArchitecture])" + continue + } + + $line + } + + Set-Content -Path $InstallerManifestPath -Value $updatedLines +} + if ($Uninstall) { Write-Host "Uninstalling dogfooded Aspire CLI..." Write-Host "" @@ -65,19 +505,23 @@ if ($Uninstall) { # Auto-detect manifest path if not specified if (-not $ManifestPath) { - # Look for versioned manifest directories under the script directory. - # Convention: manifests/m/Microsoft/Aspire/{Version}/ - $candidates = Get-ChildItem -Path $ScriptDir -Directory -Recurse -Depth 6 | - Where-Object { - Test-Path (Join-Path $_.FullName "*.installer.yaml") - } | - Select-Object -First 1 - - if ($candidates) { - $ManifestPath = $candidates.FullName + if (Get-ChildItem -Path $ScriptDir -File -Filter "*.installer.yaml" | Select-Object -First 1) { + $ManifestPath = $ScriptDir } else { - Write-Error "No manifest directory found under $ScriptDir. Specify -ManifestPath explicitly." - exit 1 + # Look for versioned manifest directories under the script directory. + # Convention: manifests/m/Microsoft/Aspire/{Version}/ + $candidates = Get-ChildItem -Path $ScriptDir -Directory -Recurse -Depth 6 | + Where-Object { + Test-Path (Join-Path $_.FullName "*.installer.yaml") + } | + Select-Object -First 1 + + if ($candidates) { + $ManifestPath = $candidates.FullName + } else { + Write-Error "No manifest directory found under $ScriptDir. Specify -ManifestPath explicitly." + exit 1 + } } } @@ -102,56 +546,183 @@ foreach ($f in $manifestFiles) { } Write-Host "" -# Enable local manifest files -Write-Host "Enabling local manifest files in winget settings..." -winget settings --enable LocalManifestFiles -if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to enable local manifests. You may need to run this as Administrator." -} +Test-WinGetVersionForMotwFix -# Validate -Write-Host "" -Write-Host "Validating manifests..." -winget validate --manifest $ManifestPath -if ($LASTEXITCODE -ne 0) { - Write-Error "Manifest validation failed. Fix the manifests and try again." - exit $LASTEXITCODE +if (-not $Force) { + $existingInstall = winget list --id Microsoft.Aspire --accept-source-agreements 2>&1 + if ($LASTEXITCODE -eq 0 -and $existingInstall -match "Microsoft\.Aspire") { + Write-Error "Microsoft.Aspire is already installed. Uninstall it first, or rerun with -Force to replace it with the dogfood manifest." + exit 1 + } } -Write-Host "Validation passed." -# Install -Write-Host "" -Write-Host "Installing Aspire CLI from local manifest..." -winget install --manifest $ManifestPath --accept-package-agreements --accept-source-agreements -if ($LASTEXITCODE -ne 0) { - Write-Error "Installation failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE +# Stage the .yaml manifest files into a clean temp directory before calling winget. +# `winget validate --manifest ` and `winget install --manifest ` treat every +# file in the directory as a multi-file manifest. The CI artifact ships dogfood.ps1 +# alongside the yaml files, so pointing winget at the artifact directly fails with +# "The manifest does not contain a valid root. File: dogfood.ps1". Staging also keeps +# Set-LocalInstallerSources's rewrites out of the user's artifact so reruns are idempotent. +$stagedManifestDir = Join-Path ([System.IO.Path]::GetTempPath()) ("aspire-winget-manifest-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stagedManifestDir -Force | Out-Null + +$archiveServer = $null +try { + foreach ($f in $manifestFiles) { + Copy-Item -Path $f.FullName -Destination (Join-Path $stagedManifestDir $f.Name) -Force + } + + $installerManifestPath = Get-InstallerManifestPath -Path $stagedManifestDir + $version = Get-ManifestVersion -ManifestPath $installerManifestPath + + if (-not $ArchiveRoot) { + $ArchiveRoot = Get-DefaultArchiveRoot -Version $version + } + + if ($ArchiveRoot) { + $ArchiveRoot = (Resolve-Path $ArchiveRoot).Path + Write-Host "Using local native archive artifacts from: $ArchiveRoot" + } else { + Write-Host "No local native archive artifacts found; installing with URLs from the manifests." + } + Write-Host "" + + # Enable local manifest files + Write-Host "Enabling local manifest files in winget settings..." + winget settings --enable LocalManifestFiles + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to enable local manifests. You may need to run this as Administrator." + } + + # Validate the manifest BEFORE we rewrite InstallerUrl/InstallerSha256. winget's + # schema requires InstallerUrl to match ^https?:// (so any rewritten URL — even an + # http://127.0.0.1/... one — passes validation, but file:// does not). Validating + # the pristine yaml first catches manifest authoring problems before we mutate it. + Write-Host "" + Write-Host "Validating manifests..." + winget validate --manifest $stagedManifestDir + if ($LASTEXITCODE -ne 0) { + Write-Error "Manifest validation failed. Fix the manifests and try again." + exit $LASTEXITCODE + } + Write-Host "Validation passed." + + if ($ArchiveRoot) { + # winget's downloader is WinINet (InternetOpenUrl), which only supports + # http/https/ftp/gopher — not file://. Serve the local archives over a loopback + # HTTP listener and rewrite InstallerUrl to point at it. + # + # The PR-channel artifact is laid out as /Debug/Shipping/*.zip, + # so we resolve archive paths via Find-Archive (recursive) and serve the same + # map from the listener — otherwise the listener would 404 on the manifest's + # rewritten URL because the file isn't at the root of $ArchiveRoot. + $archiveFileMap = Resolve-ArchiveFileMap -ResolvedArchiveRoot $ArchiveRoot -Version $version + Write-Host "" + Write-Host "Starting local archive server..." + $archiveServer = Start-LocalArchiveServer -FileMap $archiveFileMap + Write-Host " Serving $($archiveFileMap.Count) archive(s) at $($archiveServer.BaseUri)" + foreach ($name in $archiveFileMap.Keys) { + Write-Host " $name -> $($archiveFileMap[$name])" + } + Set-LocalInstallerSources -InstallerManifestPath $installerManifestPath -ArchiveFileMap $archiveFileMap -BaseUri $archiveServer.BaseUri + } + + # Install + Write-Host "" + Write-Host "Installing Aspire CLI from local manifest..." + $installArgs = @("install", "--manifest", $stagedManifestDir, "--accept-package-agreements", "--accept-source-agreements") + if ($Force) { + $installArgs += "--force" + } + + winget @installArgs + $wingetExitCode = $LASTEXITCODE + + if ($wingetExitCode -eq 0) { + Write-Host "" + Write-Host "winget install succeeded." + } + else { + $installInfo = Find-AspireBinaryOnPath -ExpectedVersion $version + if ($installInfo -and $installInfo.ExpectedVersionMatched) { + # winget exited non-zero but an aspire.exe on PATH reports the expected + # version. See Find-AspireBinaryOnPath for context — this surfaces on + # machines hitting winget bug + # https://github.com/microsoft/winget-cli/issues/6230 (the post-install + # Mark-of-the-Web step in winget's DownloadFlow aborts even though the + # install completed). Fixed in v1.29.140-preview+. + Write-Host "" + Write-Warning @" +winget reported failure (exit code $wingetExitCode) but aspire $version is installed at: + $($installInfo.Path) +This is winget-cli bug https://github.com/microsoft/winget-cli/issues/6230 (post-install +Mark-of-the-Web step fails on some machines, fixed in winget v1.29.140-preview+). The CLI +itself is deployed. + +To avoid this on future installs, upgrade winget. Pre-release builds are at: + https://github.com/microsoft/winget-cli/releases + +Verify with: + aspire --version +(You may need to open a new shell to pick up the updated PATH.) +"@ + } + else { + Show-LatestWinGetDiagLog -Reason "winget exit code $wingetExitCode" + Write-Error "Installation failed with exit code $wingetExitCode" + exit $wingetExitCode + } + } +} +finally { + Stop-LocalArchiveServer -Server $archiveServer + Remove-Item -Path $stagedManifestDir -Recurse -Force -ErrorAction SilentlyContinue } -# Refresh PATH -$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +# Refresh PATH so the verification subprocess can see machine/user changes that +# winget made during install (the parent process's environment block is a snapshot +# from before install). Tests set ASPIRE_TEST_MODE=true so the mock winget bin +# already on PATH isn't replaced by the machine/user PATH from the registry. +if ($env:ASPIRE_TEST_MODE -ne 'true') { + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} -# Verify in a new process to pick up PATH changes +# Verify by walking PATH for the just-installed binary (matched by version), so +# an older aspire.exe earlier on PATH can't masquerade as the freshly-installed +# one. See Find-AspireBinaryOnPath for the matching strategy. Write-Host "" Write-Host "Verifying installation..." -$verifyResult = pwsh -NoProfile -Command ' - $cmd = Get-Command aspire -ErrorAction SilentlyContinue - if (-not $cmd) { Write-Error "aspire not found in PATH"; exit 1 } - Write-Host " Path: $($cmd.Source)" - $v = & aspire --version 2>&1 - if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } - Write-Host " Version: $v" -' 2>&1 - -if ($LASTEXITCODE -eq 0) { - Write-Host $verifyResult +$verifyInfo = Find-AspireBinaryOnPath -ExpectedVersion $version + +if (-not $verifyInfo) { Write-Host "" - Write-Host "Installed successfully!" -} else { - Write-Host $verifyResult + Write-Error "Failed to verify Aspire CLI installation: aspire not found in PATH." + exit 1 +} + +Write-Host " Path: $($verifyInfo.Path)" +Write-Host " Version: $($verifyInfo.Version)" + +if ($verifyInfo.ExitCode -ne 0) { Write-Host "" - Write-Warning "aspire command not found in PATH. You may need to restart your shell." + Write-Error "Failed to verify Aspire CLI installation: 'aspire --version' exited with code $($verifyInfo.ExitCode)." + exit $verifyInfo.ExitCode } +if ($env:ASPIRE_TEST_MODE -ne 'true' -and -not $verifyInfo.ExpectedVersionMatched) { + # Treat shadowing as a hard failure rather than a warning: the script's whole + # purpose is to validate that the freshly-built manifest is the one users will + # run. Reporting "Installed successfully!" while an older aspire.exe is + # masquerading on PATH would silently green-light broken dogfood runs in CI. + Write-Error @" +Reported version does not match the just-installed manifest version ($version). +An older aspire.exe at the path above is shadowing the winget-installed binary on PATH. +Either reorder PATH so the winget-installed location wins, or uninstall the older copy. +"@ + exit 1 +} + +Write-Host "" +Write-Host "Installed successfully!" + Write-Host "" Write-Host "To uninstall: .\dogfood.ps1 -Uninstall" From dac0025c9430b32c47b6d2b6a62dc11f59009dab Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 00:45:12 -0400 Subject: [PATCH 02/21] fix(homebrew): harden local cask dogfood installs Teach the local Homebrew dogfood helper to rewrite casks to local PR archive files, verify aspire --version after install, and remove quarantine from local unsigned dogfood binaries before verification.\n\nThis keeps dogfood installs self-contained and fails loudly when the installed CLI cannot be executed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/dogfood.sh | 179 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 165 insertions(+), 14 deletions(-) diff --git a/eng/homebrew/dogfood.sh b/eng/homebrew/dogfood.sh index fbe7ac44689..fc2e0a02fcb 100755 --- a/eng/homebrew/dogfood.sh +++ b/eng/homebrew/dogfood.sh @@ -5,9 +5,10 @@ set -euo pipefail # This script is intended for dogfooding builds before they are published to Homebrew/homebrew-cask. # # Usage: -# ./dogfood.sh # Auto-detects cask file in the same directory -# ./dogfood.sh aspire.rb # Explicit cask file path -# ./dogfood.sh --uninstall # Uninstall a previously dogfooded cask +# ./dogfood.sh # Auto-detects cask file and adjacent archives +# ./dogfood.sh --archive-root ../artifacts # Installs from downloaded native archive artifacts +# ./dogfood.sh aspire.rb # Explicit cask file path +# ./dogfood.sh --uninstall # Uninstall a previously dogfooded cask SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TAP_NAME="local/aspire-dogfood" @@ -22,13 +23,15 @@ Arguments: CASK_FILE Path to the .rb cask file (default: auto-detect in script directory) Options: + --archive-root PATH Root directory containing downloaded aspire-cli-osx-* archives --uninstall Uninstall a previously dogfooded cask and remove the local tap --help Show this help message Examples: - $(basename "$0") # Auto-detect and install - $(basename "$0") ./aspire.rb # Install from specific cask file - $(basename "$0") --uninstall # Clean up dogfood install + $(basename "$0") # Auto-detect cask and adjacent archives + $(basename "$0") --archive-root ../native-archives # Install from downloaded archive artifacts + $(basename "$0") ./aspire.rb # Install from specific cask file + $(basename "$0") --uninstall # Clean up dogfood install EOF exit 0 } @@ -59,11 +62,107 @@ uninstall() { exit 0 } +read_cask_version() { + local caskFile="$1" + awk -F'"' '/^[[:space:]]*version[[:space:]]+"/ { print $2; exit }' "$caskFile" +} + +find_archive_if_present() { + local archiveRoot="$1" + local archiveName="$2" + + find "$archiveRoot" -type f -name "$archiveName" -print -quit 2>/dev/null || true +} + +find_archive() { + local archiveRoot="$1" + local archiveName="$2" + local matches=() + local match + + while IFS= read -r match; do + matches+=("$match") + done < <(find "$archiveRoot" -type f -name "$archiveName" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: Could not find $archiveName under $archiveRoot" >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: Found multiple $archiveName archives under $archiveRoot:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + printf '%s' "${matches[0]}" +} + +detect_archive_root() { + local version="$1" + local candidate + + for candidate in "$SCRIPT_DIR" "$SCRIPT_DIR/.."; do + if [[ -f "$(find_archive_if_present "$candidate" "aspire-cli-osx-arm64-$version.tar.gz")" && + -f "$(find_archive_if_present "$candidate" "aspire-cli-osx-x64-$version.tar.gz")" ]]; then + printf '%s' "$(cd "$candidate" && pwd)" + return + fi + done +} + +rewrite_cask_for_local_archives() { + local caskFile="$1" + local archiveRoot="$2" + local localArchiveDir="$3" + local version="$4" + + local armArchive + local x64Archive + armArchive="$(find_archive "$archiveRoot" "aspire-cli-osx-arm64-$version.tar.gz")" + x64Archive="$(find_archive "$archiveRoot" "aspire-cli-osx-x64-$version.tar.gz")" + + mkdir -p "$localArchiveDir" + cp "$armArchive" "$localArchiveDir/aspire-cli-osx-arm64-$version.tar.gz" + cp "$x64Archive" "$localArchiveDir/aspire-cli-osx-x64-$version.tar.gz" + + local localUrlPrefix="file://$localArchiveDir" + + ruby - "$caskFile" "$localUrlPrefix" <<'RUBY' +cask_path = ARGV[0] +local_url_prefix = ARGV[1] +lines = File.readlines(cask_path, chomp: true) +rewritten = [] +skip_verified = false + +lines.each do |line| + stripped = line.strip + if stripped.start_with?('url "https://ci.dot.net/public/aspire/') + rewritten << " url \"#{local_url_prefix}/aspire-cli-osx-\#{arch}-\#{version}.tar.gz\"" + skip_verified = true + next + end + + if skip_verified && stripped.start_with?('verified: "ci.dot.net/public/aspire/"') + skip_verified = false + next + end + + skip_verified = false + rewritten << line +end + +File.write(cask_path, rewritten.join("\n") + "\n") +RUBY +} + CASK_FILE="" +ARCHIVE_ROOT="" UNINSTALL=false while [[ $# -gt 0 ]]; do case "$1" in + --archive-root) ARCHIVE_ROOT="$2"; shift 2 ;; --uninstall) UNINSTALL=true; shift ;; --help) usage ;; -*) echo "Unknown option: $1"; usage ;; @@ -149,6 +248,24 @@ tapCaskDir="$tapRoot/Casks" mkdir -p "$tapCaskDir" cp "$CASK_FILE" "$tapCaskDir/$CASK_FILENAME" +caskVersion="$(read_cask_version "$CASK_FILE")" +if [[ -z "$caskVersion" ]]; then + echo "Error: Could not read cask version from $CASK_FILE" + exit 1 +fi + +if [[ -z "$ARCHIVE_ROOT" ]]; then + ARCHIVE_ROOT="$(detect_archive_root "$caskVersion")" +fi + +if [[ -n "$ARCHIVE_ROOT" ]]; then + ARCHIVE_ROOT="$(cd "$ARCHIVE_ROOT" && pwd)" + echo "Using local native archive artifacts from: $ARCHIVE_ROOT" + rewrite_cask_for_local_archives "$tapCaskDir/$CASK_FILENAME" "$ARCHIVE_ROOT" "$tapRoot/LocalArtifacts" "$caskVersion" +else + echo "No local native archive artifacts found; installing with URLs from the cask." +fi + # Install echo "" echo "Installing $CASK_NAME from local tap..." @@ -156,17 +273,51 @@ echo "Installing $CASK_NAME from local tap..." # the cask file is picked up, causing a "cask unavailable" error. HOMEBREW_NO_AUTO_UPDATE=1 brew install --cask "$TAP_NAME/$CASK_NAME" +caskRoot="$(brew --prefix)/Caskroom/$CASK_NAME/$caskVersion" +if [[ -d "$caskRoot" ]] && xattr -p com.apple.quarantine "$caskRoot/aspire" &>/dev/null; then + # Local PR artifacts are unsigned ad-hoc signed binaries. Homebrew correctly + # quarantines cask downloads, but macOS kills these dogfood binaries before + # the CLI can print its version. Remove quarantine only from this local + # dogfood install after Homebrew has finished installing it. + xattr -dr com.apple.quarantine "$caskRoot" +fi + # Verify echo "" -if command -v aspire &>/dev/null; then - echo "Installed successfully!" - echo " Path: $(command -v aspire)" - aspireVersion="$(aspire --version 2>&1)" || true - echo " Version: $aspireVersion" -else - echo "Warning: aspire command not found in PATH after install." - echo "You may need to restart your shell or add the install location to your PATH." +if ! command -v aspire &>/dev/null; then + echo "Error: aspire command not found in PATH after install." >&2 + echo "You may need to restart your shell or add the install location to your PATH." >&2 + exit 1 +fi + +# Shadow check — Homebrew symlinks cask binaries into $(brew --prefix)/bin/, so +# the freshly-installed aspire must resolve under the brew prefix. An older +# aspire earlier on PATH (e.g. a dotnet-tool install, or a script install under +# ~/.aspire/bin) would otherwise silently shadow the cask and let this script +# report "Installed successfully!" against the wrong binary, defeating the +# whole point of the dogfood. The WinGet dogfood (eng/winget/dogfood.ps1) has +# the equivalent check via Find-AspireBinaryOnPath -ExpectedVersion. +# Skipped under ASPIRE_TEST_MODE because the test mock brew puts its fake +# aspire alongside the mock brew binary, not under $(brew --prefix)/bin/. +if [[ "${ASPIRE_TEST_MODE:-}" != "true" ]]; then + aspirePath="$(command -v aspire)" + brewPrefix="$(brew --prefix)" + if [[ "$aspirePath" != "$brewPrefix"/* ]]; then + echo "Error: 'aspire' resolved to '$aspirePath', not under the Homebrew prefix '$brewPrefix'." >&2 + echo "An older aspire earlier on PATH is shadowing the Homebrew cask install." >&2 + echo "Either reorder PATH so '$brewPrefix/bin' wins, or uninstall the older copy." >&2 + exit 1 + fi +fi + +echo "Installed successfully!" +echo " Path: $(command -v aspire)" +if ! aspireVersion="$(aspire --version 2>&1)"; then + echo "Error: aspire --version failed after install:" >&2 + echo "$aspireVersion" >&2 + exit 1 fi +echo " Version: $aspireVersion" echo "" echo "To uninstall: $(basename "$0") --uninstall" From 56d6f2193e7b8e12aa61ec3868ee136d25fd864c Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 00:45:12 -0400 Subject: [PATCH 03/21] ci(installer-artifacts): generate WinGet and Homebrew artifacts Add a reusable GitHub Actions workflow that prepares WinGet manifest and Homebrew cask artifacts from the native CLI archives, uploads those artifacts, and runs package-manager dogfood validation.\n\nMove the shared preparation logic into repo scripts so GitHub Actions and Azure Pipelines use the same generation and validation paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/prepare-installer-artifacts.yml | 71 ++++ .github/workflows/tests.yml | 56 ++- eng/homebrew/README.md | 12 +- eng/homebrew/prepare-cask-artifact.sh | 322 ++++++++++++++++++ .../templates/prepare-homebrew-cask.yml | 236 +------------ .../templates/prepare-winget-manifest.yml | 154 +-------- eng/winget/README.md | 28 +- eng/winget/prepare-manifest-artifact.ps1 | 227 ++++++++++++ 8 files changed, 723 insertions(+), 383 deletions(-) create mode 100644 .github/workflows/prepare-installer-artifacts.yml create mode 100755 eng/homebrew/prepare-cask-artifact.sh create mode 100644 eng/winget/prepare-manifest-artifact.ps1 diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml new file mode 100644 index 00000000000..a715057fde3 --- /dev/null +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -0,0 +1,71 @@ +name: Prepare installer artifacts + +on: + workflow_call: + inputs: + installer: + required: true + type: string + +jobs: + prepare_installer_artifacts: + name: Prepare ${{ inputs.installer == 'winget' && 'WinGet manifests' || 'Homebrew cask' }} + runs-on: ${{ inputs.installer == 'winget' && 'windows-latest' || 'macos-latest' }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ inputs.installer == 'winget' && 'cli-native-archives-win-*' || 'cli-native-archives-osx-*' }} + path: ${{ github.workspace }}/native-archives + merge-multiple: false + + - name: Prepare WinGet manifest artifact + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & "$env:GITHUB_WORKSPACE/eng/winget/prepare-manifest-artifact.ps1" ` + -Channel prerelease ` + -ArchiveRoot "$env:GITHUB_WORKSPACE/native-archives" ` + -OutputPath "$env:GITHUB_WORKSPACE/artifacts/installers/winget-manifests" ` + -ValidationMode Offline + + - name: Prepare Homebrew cask artifact + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: > + eng/homebrew/prepare-cask-artifact.sh + --channel prerelease + --archive-root "${GITHUB_WORKSPACE}/native-archives" + --output-dir "${GITHUB_WORKSPACE}/artifacts/installers/homebrew-cask" + --validation-mode Offline + + - name: Dogfood install (real winget install) + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & "$env:GITHUB_WORKSPACE/eng/winget/dogfood.ps1" ` + -ManifestPath "$env:GITHUB_WORKSPACE/artifacts/installers/winget-manifests" ` + -ArchiveRoot "$env:GITHUB_WORKSPACE/native-archives" ` + -Force + + - name: Dogfood install (real brew install) + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + eng/homebrew/dogfood.sh \ + --archive-root "${GITHUB_WORKSPACE}/native-archives" \ + "${GITHUB_WORKSPACE}/artifacts/installers/homebrew-cask/aspire.rb" + + - name: Upload installer artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ inputs.installer == 'winget' && 'winget-manifests-prerelease' || 'homebrew-cask-prerelease' }} + path: ${{ github.workspace }}/${{ inputs.installer == 'winget' && 'artifacts/installers/winget-manifests' || 'artifacts/installers/homebrew-cask' }} + if-no-files-found: error + retention-days: 15 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83bff77de61..b01b2e339e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,33 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "macos-latest", "runner": "macos-latest", "rids": "osx-arm64"}]' + build_cli_archive_macos_x64: + name: Build native CLI archive (macOS x64) + uses: ./.github/workflows/build-cli-native-archives.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} + targets: '[{"os": "macos-15-intel", "runner": "macos-15-intel", "rids": "osx-x64"}]' + + prepare_winget_installer_artifacts: + name: Prepare WinGet installer artifacts + uses: ./.github/workflows/prepare-installer-artifacts.yml + needs: [ + build_cli_archive_windows, + build_cli_archive_windows_arm64, + ] + with: + installer: winget + + prepare_homebrew_installer_artifacts: + name: Prepare Homebrew installer artifacts + uses: ./.github/workflows/prepare-installer-artifacts.yml + needs: [ + build_cli_archive_macos, + build_cli_archive_macos_x64, + ] + with: + installer: homebrew + build_cli_e2e_image: name: Build CLI E2E Docker image uses: ./.github/workflows/build-cli-e2e-image.yml @@ -332,6 +359,9 @@ jobs: build_cli_archive_windows, build_cli_archive_windows_arm64, build_cli_archive_macos, + build_cli_archive_macos_x64, + prepare_winget_installer_artifacts, + prepare_homebrew_installer_artifacts, build_cli_e2e_image, extension_tests_win, cli_starter_validation_windows, @@ -349,6 +379,10 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Create test results directory + shell: bash + run: mkdir -p "${{ github.workspace }}/testresults" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: logs-*-ubuntu-latest @@ -421,6 +455,9 @@ jobs: needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || needs.typescript_api_compat.result == 'skipped' || + needs.build_cli_archive_macos_x64.result == 'skipped' || + needs.prepare_winget_installer_artifacts.result == 'skipped' || + needs.prepare_homebrew_installer_artifacts.result == 'skipped' || needs.tests_no_nugets.result == 'skipped' || needs.tests_requires_nugets_linux.result == 'skipped' || needs.tests_requires_nugets_windows.result == 'skipped' || @@ -432,14 +469,17 @@ jobs: 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' || - (github.ref == 'refs/heads/main' && - needs.polyglot_validation.result == 'skipped') || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped')))) }} + needs.typescript_sdk_tests.result == 'skipped' || + needs.build_cli_archive_macos_x64.result == 'skipped' || + needs.prepare_winget_installer_artifacts.result == 'skipped' || + needs.prepare_homebrew_installer_artifacts.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (github.ref == 'refs/heads/main' && + needs.polyglot_validation.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped')))) }} run: | echo "One or more dependent jobs failed." exit 1 diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index 0f5bf88c142..cb8397c11ce 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -16,6 +16,8 @@ brew install --cask aspire # stable |---|---| | `aspire.rb.template` | Cask template for stable releases | | `generate-cask.sh` | Downloads tarballs, computes SHA256 hashes, generates cask from template | +| `prepare-cask-artifact.sh` | Prepares CI artifacts by generating, validating, and adding dogfood helpers | +| `dogfood.sh` | Installs a generated cask locally, optionally using downloaded native archive artifacts | ### Pipeline templates @@ -52,7 +54,8 @@ Where arch is `arm64` or `x64`. | Pipeline | Prepares | Publishes | |---|---|---| -| `azure-pipelines.yml` (prepare stage) | Stable casks (artifacts only) | — | +| `.github/workflows/tests.yml` | Prerelease casks (artifacts only) | — | +| `azure-pipelines.yml` (prepare stage) | Stable or prerelease casks (artifacts only) | — | | `release-publish-nuget.yml` (release) | — | Stable cask only | Publishing submits a PR to `Homebrew/homebrew-cask` using the GitHub REST API: @@ -71,6 +74,13 @@ Prepare validation currently runs: 3. `brew audit --cask --online`, or `brew audit --cask --new --online` when the cask does not yet exist upstream 4. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask ...` followed by uninstall validation +To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease` +artifact and the `cli-native-archives-osx-*` artifacts into the same parent directory, then run: + +```bash +./dogfood.sh --archive-root .. +``` + ## Open Items - [ ] Submit initial `aspire` cask PR to `Homebrew/homebrew-cask` for acceptance diff --git a/eng/homebrew/prepare-cask-artifact.sh b/eng/homebrew/prepare-cask-artifact.sh new file mode 100755 index 00000000000..7018f3f34b5 --- /dev/null +++ b/eng/homebrew/prepare-cask-artifact.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepares the Homebrew cask artifact for Aspire CLI builds. +# This script intentionally does not upload artifacts; each CI system owns that step. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <&2; usage ;; + esac +done + +case "$CHANNEL" in + stable|prerelease) ;; + "") echo "Error: --channel is required." >&2; usage ;; + *) echo "Error: --channel must be 'stable' or 'prerelease'." >&2; exit 1 ;; +esac + +case "$VALIDATION_MODE" in + Full|Offline|GenerateOnly) ;; + full) VALIDATION_MODE="Full" ;; + offline) VALIDATION_MODE="Offline" ;; + generateonly|generate-only) VALIDATION_MODE="GenerateOnly" ;; + *) echo "Error: --validation-mode must be Full, Offline, or GenerateOnly." >&2; exit 1 ;; +esac + +if [[ -z "$OUTPUT_DIR" ]]; then + echo "Error: --output-dir is required." >&2 + usage +fi + +infer_version_from_archive() { + local rid="$1" + local prefix="aspire-cli-$rid-" + local suffix=".tar.gz" + local matches=() + local archive_path + + if [[ -z "$ARCHIVE_ROOT" || ! -d "$ARCHIVE_ROOT" ]]; then + echo "Error: --version is required when --archive-root is not specified." >&2 + exit 1 + fi + + while IFS= read -r archive_path; do + matches+=("$archive_path") + done < <(find "$ARCHIVE_ROOT" -type f -name "$prefix*$suffix" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: Could not find archive '$prefix*$suffix' under '$ARCHIVE_ROOT' to infer the Aspire CLI version." >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: Found multiple archives matching '$prefix*$suffix' under '$ARCHIVE_ROOT':" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + local filename="${matches[0]##*/}" + filename="${filename#"$prefix"}" + printf '%s' "${filename%"$suffix"}" +} + +if [[ -z "$VERSION" ]]; then + VERSION="$(infer_version_from_archive "osx-arm64")" +fi + +if [[ -z "$ARTIFACT_VERSION" ]]; then + ARTIFACT_VERSION="$VERSION" +fi + +mkdir -p "$OUTPUT_DIR" +OUTPUT_FILE="$OUTPUT_DIR/aspire.rb" + +echo "Preparing Homebrew cask" +echo " Version: $VERSION" +echo " Channel: $CHANNEL" +echo " Artifact version: $ARTIFACT_VERSION" +echo " Output dir: $OUTPUT_DIR" +echo " Validation mode: $VALIDATION_MODE" + +args=( + --version "$VERSION" + --artifact-version "$ARTIFACT_VERSION" + --output "$OUTPUT_FILE" +) + +if [[ -n "$ARCHIVE_ROOT" ]]; then + args+=(--archive-root "$ARCHIVE_ROOT") +fi + +if [[ "$VALIDATION_MODE" != "Full" ]]; then + args+=(--skip-url-validation) +fi + +"$SCRIPT_DIR/generate-cask.sh" "${args[@]}" + +echo "" +echo "Generated cask:" +cat "$OUTPUT_FILE" + +if [[ "$VALIDATION_MODE" != "GenerateOnly" ]]; then + cask_file="aspire.rb" + cask_name="aspire" + first_letter="${cask_name:0:1}" + target_path="Casks/$first_letter/$cask_file" + tap_name="local/aspire" + + cleanup() { + brew untap "$tap_name" >/dev/null 2>&1 || true + } + + if ! command -v brew >/dev/null 2>&1; then + if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "Error: brew is required for Full validation mode, but it was not found in PATH." >&2 + exit 1 + fi + + echo "Warning: brew was not found in PATH; skipping offline cask validation." >&2 + else + tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire" + trap cleanup EXIT + + echo "Validating Ruby syntax..." + ruby -c "$OUTPUT_FILE" + echo "Ruby syntax OK" + + brew tap-new --no-git "$tap_name" + mkdir -p "$tap_root/Casks" + cp "$OUTPUT_FILE" "$tap_root/Casks/$cask_file" + tapped_cask_path="$tap_root/Casks/$cask_file" + + echo "" + echo "Applying Homebrew style fixes in local tap..." + brew style --fix "$tapped_cask_path" + cp "$tapped_cask_path" "$OUTPUT_FILE" + echo "Homebrew style OK" + + echo "" + echo "Auditing cask via local tap..." + + audit_args=(--cask) + is_new_cask=false + + if [[ "$VALIDATION_MODE" == "Full" ]]; then + upstream_status_code="$(curl -sS -o /dev/null -w "%{http_code}" "https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path")" + if [[ "$upstream_status_code" == "404" ]]; then + is_new_cask=true + echo "Detected new upstream cask; running new-cask audit." + audit_args+=(--new) + elif [[ "$upstream_status_code" == "200" ]]; then + echo "Detected existing upstream cask; running standard audit." + else + echo "Error: Could not determine whether $target_path exists upstream (HTTP $upstream_status_code)" >&2 + exit 1 + fi + echo "Including --online audit (URLs are expected to be valid)." + audit_args+=(--online) + else + echo "Skipping upstream cask check and online audit (URLs are not expected to be valid for this build)." + fi + + audit_args+=("$tap_name/$cask_name") + audit_command="brew audit ${audit_args[*]}" + + audit_code=0 + brew audit "${audit_args[@]}" || audit_code=$? + + if [[ "$audit_code" -ne 0 ]]; then + echo "Error: brew audit failed" >&2 + exit "$audit_code" + fi + echo "brew audit passed" + + if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "Testing cask install/uninstall: $OUTPUT_FILE" + + echo "Verifying aspire is not already installed..." + if command -v aspire &>/dev/null; then + echo "Error: aspire command is already available before install - test environment is not clean" >&2 + exit 1 + fi + echo " Confirmed: aspire is not in PATH" + + test_tap_name="local/aspire-test" + test_tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" + + cleanup_test_tap() { + brew untap "$test_tap_name" >/dev/null 2>&1 || true + } + + test_cask_ref="$test_tap_name/$cask_name" + test_cask_installed=false + + cleanup_test_install() { + if [[ "$test_cask_installed" == true ]]; then + brew uninstall --cask "$test_cask_ref" >/dev/null 2>&1 || true + fi + } + + trap 'cleanup_test_install; cleanup; cleanup_test_tap' EXIT + + echo "Setting up local tap..." + brew tap-new --no-git "$test_tap_name" + mkdir -p "$test_tap_root/Casks" + cp "$OUTPUT_FILE" "$test_tap_root/Casks/$cask_file" + + echo "Installing aspire from local tap..." + test_cask_installed=true + HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$test_cask_ref" + echo "Install succeeded" + + echo "Verifying aspire CLI is in PATH..." + if ! command -v aspire &>/dev/null; then + echo "Error: aspire command not found in PATH after install" >&2 + brew info --cask "$test_tap_name/$cask_name" || true + exit 1 + fi + + echo " Path: $(command -v aspire)" + aspire_version="$(aspire --version 2>&1)" + echo " Version: $aspire_version" + echo "aspire CLI verified" + + echo "Uninstalling aspire..." + brew uninstall --cask "$test_cask_ref" + test_cask_installed=false + echo "Uninstall succeeded" + + cleanup_test_tap + + if command -v aspire &>/dev/null; then + echo "Error: aspire command still found in PATH after uninstall" >&2 + exit 1 + else + echo " Confirmed: aspire is no longer in PATH" + fi + + validation_summary="$OUTPUT_DIR/validation-summary.json" + if [[ "$CHANNEL" == "stable" ]]; then + is_stable_release=true + else + is_stable_release=false + fi + cat > "$validation_summary" </dev/null 2>&1 || true - } - - trap cleanup EXIT - - echo "Validating Ruby syntax..." - ruby -c "$caskPath" - echo "Ruby syntax OK" - - brew tap-new --no-git "$tapName" - mkdir -p "$tapRoot/Casks" - cp "$caskPath" "$tapRoot/Casks/$caskFile" - tappedCaskPath="$tapRoot/Casks/$caskFile" - - echo "" - echo "Applying Homebrew style fixes in local tap..." - brew style --fix "$tappedCaskPath" - cp "$tappedCaskPath" "$caskPath" - echo "Homebrew style OK" - - echo "" - echo "Auditing cask via local tap..." - - skipUrlValidation="$(printf '%s' "${SKIP_URL_VALIDATION:-false}" | tr '[:upper:]' '[:lower:]')" - - auditArgs=(--cask) - isNewCask=false - - if [ "$skipUrlValidation" = "true" ]; then - echo "Skipping upstream cask check and online audit (URLs are not expected to be valid for this build)." - else - upstreamStatusCode="$(curl -sS -o /dev/null -w "%{http_code}" "https://api.github.com/repos/Homebrew/homebrew-cask/contents/$targetPath")" - if [ "$upstreamStatusCode" = "404" ]; then - isNewCask=true - echo "Detected new upstream cask; running new-cask audit." - auditArgs+=(--new) - elif [ "$upstreamStatusCode" = "200" ]; then - echo "Detected existing upstream cask; running standard audit." - else - echo "##[error]Could not determine whether $targetPath exists upstream (HTTP $upstreamStatusCode)" - exit 1 - fi - echo "Including --online audit (URLs are expected to be valid)." - auditArgs+=(--online) - fi - - auditArgs+=("$tapName/$caskName") - auditCommand="brew audit ${auditArgs[*]}" - - auditCode=0 - brew audit "${auditArgs[@]}" || auditCode=$? - - if [ $auditCode -ne 0 ]; then - echo "##[error]brew audit failed" - exit $auditCode - fi - echo "##vso[task.setvariable variable=HomebrewAuditCommand]$auditCommand" - echo "##vso[task.setvariable variable=HomebrewIsNewCask]$isNewCask" - echo "brew audit passed" - trap - EXIT - cleanup - displayName: 🟣Validate cask syntax and audit - env: - SKIP_URL_VALIDATION: '${{ parameters.skipUrlValidation }}' - - - bash: | - set -euo pipefail - caskFile="aspire.rb" - caskName="aspire" - caskPath="$(Build.StagingDirectory)/homebrew-cask/$caskFile" - tapName="local/aspire-test" - tapRoot="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" - - echo "Testing cask install/uninstall: $caskPath" - echo "" - - cleanup() { - brew untap "$tapName" >/dev/null 2>&1 || true - } - - trap cleanup EXIT - - # Verify aspire is NOT already installed - echo "Verifying aspire is not already installed..." - if command -v aspire &>/dev/null; then - echo "##[error]aspire command is already available before install — test environment is not clean" - exit 1 - fi - echo " Confirmed: aspire is not in PATH" - - # Set up a local tap so brew accepts the cask - echo "" - echo "Setting up local tap..." - brew tap-new --no-git "$tapName" - mkdir -p "$tapRoot/Casks" - cp "$caskPath" "$tapRoot/Casks/$caskFile" - - # Install from local tap - echo "" - echo "Installing aspire from local tap..." - HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$tapName/$caskName" - echo "✅ Install succeeded" - - # Verify aspire is now available and runs - echo "" - echo "Verifying aspire CLI is in PATH..." - if ! command -v aspire &>/dev/null; then - echo "##[error]aspire command not found in PATH after install" - # Show brew info for diagnostics - brew info --cask "$tapName/$caskName" || true - exit 1 - fi - - echo " Path: $(command -v aspire)" - aspireVersion="$(aspire --version 2>&1)" - echo " Version: $aspireVersion" - echo "✅ aspire CLI verified" - - # Uninstall - echo "" - echo "Uninstalling aspire..." - brew uninstall --cask "$tapName/$caskName" - echo "✅ Uninstall succeeded" - - # Clean up tap - trap - EXIT - cleanup - - # Verify aspire is removed - if command -v aspire &>/dev/null; then - echo "##[error]aspire command still found in PATH after uninstall" - exit 1 - else - echo " Confirmed: aspire is no longer in PATH" - fi - displayName: 🟣Test cask install/uninstall - condition: and(succeeded(), eq(${{ parameters.skipUrlValidation }}, false)) - - - bash: | - set -euo pipefail - outputPath="$(Build.StagingDirectory)/homebrew-cask/validation-summary.json" - if [ "$(HomebrewChannel)" = "stable" ]; then - isStableRelease=true - else - isStableRelease=false - fi - cat > "$outputPath" <&1 - if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } - Write-Host " Version: $v" - ' - if ($LASTEXITCODE -ne 0) { - throw "Child process exited with code $LASTEXITCODE" - } - Write-Host "✅ aspire CLI verified" - } catch { - Write-Host "##[error]Failed to verify aspire CLI: $_" - $failed = $true - } - - # Test uninstall (always attempt cleanup even if verification failed) - Write-Host "" - Write-Host "Uninstalling Aspire.Cli..." - winget uninstall --manifest $versionedManifestPath --accept-source-agreements - if ($LASTEXITCODE -ne 0) { - if ($failed) { - Write-Host "##[warning]winget uninstall also failed with exit code $LASTEXITCODE (ignoring since verification already failed)" - } else { - Write-Error "winget uninstall failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE - } - } else { - Write-Host "✅ Uninstall succeeded" - } - - if ($failed) { - exit 1 - } - displayName: 🟣Test WinGet manifest install/uninstall - condition: and(succeeded(), eq(${{ parameters.skipUrlValidation }}, false)) - - - pwsh: | - Copy-Item "$(Build.SourcesDirectory)/eng/winget/dogfood.ps1" "$(Build.StagingDirectory)/winget-manifests/dogfood.ps1" - displayName: 🟣Include dogfood script in artifact + & "$(Build.SourcesDirectory)/eng/winget/prepare-manifest-artifact.ps1" @args + displayName: 🟣Prepare WinGet manifests - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish WinGet manifests diff --git a/eng/winget/README.md b/eng/winget/README.md index 26395fdaddb..fbaa3534ab2 100644 --- a/eng/winget/README.md +++ b/eng/winget/README.md @@ -12,10 +12,12 @@ winget install Microsoft.Aspire # stable ## Contents -| Directory / File | Description | -|--------------------------|----------------------------------------------------------------------------------| -| `microsoft.aspire/` | Manifest templates for stable releases | -| `generate-manifests.ps1` | Downloads installers, computes SHA256 hashes, generates manifests from templates | +| Directory / File | Description | +|--------------------------------|----------------------------------------------------------------------------------| +| `microsoft.aspire/` | Manifest templates for stable releases | +| `generate-manifests.ps1` | Downloads installers, computes SHA256 hashes, generates manifests from templates | +| `prepare-manifest-artifact.ps1` | Prepares CI artifacts by generating, validating, and adding dogfood helpers | +| `dogfood.ps1` | Installs generated manifests locally, optionally using downloaded native archives | Each manifest set contains three YAML files following the [WinGet manifest schema v1.10](https://learn.microsoft.com/windows/package-manager/package/manifest): @@ -46,9 +48,19 @@ Where arch is `x64` or `arm64`. ## CI Pipeline -| Pipeline | Prepares | Publishes | -|---------------------------------------|------------------------------------------------|-----------------------| -| `azure-pipelines.yml` (prepare stage) | Stable manifests (artifacts only) | — | -| `release-publish-nuget.yml` (release) | — | Stable manifests only | +| Pipeline | Prepares | Publishes | +|---------------------------------------|-------------------------------------------------|-----------------------| +| `.github/workflows/tests.yml` | Prerelease manifests (artifacts only) | — | +| `azure-pipelines.yml` (prepare stage) | Stable or prerelease manifests (artifacts only) | — | +| `release-publish-nuget.yml` (release) | — | Stable manifests only | Publishing submits a PR to `microsoft/winget-pkgs` using `wingetcreate submit`. + +To dogfood a GitHub Actions artifact locally, download the `winget-manifests-prerelease` +artifact and the `cli-native-archives-win-*` artifacts into the same parent directory, then run: + +```powershell +.\dogfood.ps1 -ArchiveRoot .. +``` + +If `Microsoft.Aspire` is already installed through WinGet, uninstall it first or pass `-Force` to allow the local dogfood manifest to replace it. diff --git a/eng/winget/prepare-manifest-artifact.ps1 b/eng/winget/prepare-manifest-artifact.ps1 new file mode 100644 index 00000000000..ea34d7b6f78 --- /dev/null +++ b/eng/winget/prepare-manifest-artifact.ps1 @@ -0,0 +1,227 @@ +<# +.SYNOPSIS + Prepares the WinGet manifest artifact for Aspire CLI builds. + +.DESCRIPTION + Generates WinGet manifests, optionally validates/tests them, and adds the + dogfood helper script. This script intentionally does not publish artifacts; + each CI system owns its upload task. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$Version, + + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "prerelease")] + [string]$Channel, + + [Parameter(Mandatory = $false)] + [string]$ArtifactVersion, + + [Parameter(Mandatory = $false)] + [string]$ArchiveRoot, + + [Parameter(Mandatory = $false)] + [string]$TemplateDir, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $false)] + [ValidateSet("Full", "Offline", "GenerateOnly")] + [string]$ValidationMode = "Full" +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +if ([string]::IsNullOrWhiteSpace($TemplateDir)) { + $TemplateDir = Join-Path $ScriptDir "microsoft.aspire" +} + +function Get-ArchiveVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ArchiveRoot, + + [Parameter(Mandatory = $true)] + [string]$Rid + ) + + if (-not (Test-Path $ArchiveRoot)) { + Write-Error "Archive root directory not found: $ArchiveRoot" + exit 1 + } + + $prefix = "aspire-cli-$Rid-" + $suffix = ".zip" + $matches = @(Get-ChildItem -Path $ArchiveRoot -File -Recurse -Filter "$prefix*$suffix" | Sort-Object FullName) + + if ($matches.Count -eq 0) { + Write-Error "Could not find archive '$prefix*$suffix' under '$ArchiveRoot' to infer the Aspire CLI version." + exit 1 + } + + if ($matches.Count -gt 1) { + $matchList = $matches | ForEach-Object { " $($_.FullName)" } + Write-Error "Found multiple archives matching '$prefix*$suffix' under '$ArchiveRoot':`n$($matchList -join "`n")" + exit 1 + } + + $fileName = $matches[0].Name + return $fileName.Substring($prefix.Length, $fileName.Length - $prefix.Length - $suffix.Length) +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + if ([string]::IsNullOrWhiteSpace($ArchiveRoot)) { + Write-Error "Version is required when ArchiveRoot is not specified." + exit 1 + } + + $Version = Get-ArchiveVersion -ArchiveRoot $ArchiveRoot -Rid "win-x64" +} + +if ([string]::IsNullOrWhiteSpace($ArtifactVersion)) { + $ArtifactVersion = $Version +} + +$isPrereleaseInStablePackage = $Channel -ne "stable" + +Write-Host "Preparing WinGet manifests" +Write-Host " Version: $Version" +Write-Host " Channel: $Channel" +Write-Host " Artifact version: $ArtifactVersion" +Write-Host " Template dir: $TemplateDir" +Write-Host " Output path: $OutputPath" +Write-Host " Validation mode: $ValidationMode" + +$generateArgs = @{ + Version = $Version + ArtifactVersion = $ArtifactVersion + TemplateDir = $TemplateDir + OutputPath = $OutputPath +} + +if (-not [string]::IsNullOrWhiteSpace($ArchiveRoot)) { + $generateArgs.ArchiveRoot = $ArchiveRoot +} + +if ($ValidationMode -ne "Full") { + $generateArgs.SkipUrlValidation = $true +} + +if ($isPrereleaseInStablePackage) { + $generateArgs.IsPrereleaseInStablePackage = $true +} + +& (Join-Path $ScriptDir "generate-manifests.ps1") @generateArgs + +if ($LASTEXITCODE -ne 0) { + Write-Error "generate-manifests.ps1 failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "Manifest files generated:" +Get-ChildItem -Path $OutputPath -Recurse | Format-Table FullName + +$versionedManifestPath = Get-ChildItem -Path $OutputPath -Directory -Recurse | + Where-Object { $_.Name -eq $Version } | + Select-Object -First 1 -ExpandProperty FullName + +if (-not $versionedManifestPath) { + $versionedManifestPath = $OutputPath +} + +Write-Host "Versioned manifest path: $versionedManifestPath" + +if ($ValidationMode -ne "GenerateOnly") { + $winget = Get-Command winget -ErrorAction SilentlyContinue + + if ($winget) { + Write-Host "Enabling local manifest files in winget settings..." + winget settings --enable LocalManifestFiles + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to enable local manifests. This may require admin privileges." + } + + Write-Host "Running winget validate..." + winget validate --manifest $versionedManifestPath + if ($LASTEXITCODE -ne 0) { + Write-Error "winget validate failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + + Write-Host "winget validate passed" + } elseif ($ValidationMode -eq "Full") { + Write-Error "winget is required for Full validation mode, but it was not found in PATH." + exit 1 + } else { + Write-Warning "winget was not found in PATH; skipping offline manifest validation." + } +} + +if ($ValidationMode -eq "Full") { + Write-Host "Testing WinGet manifest install/uninstall at: $versionedManifestPath" + + Write-Host "Verifying aspire is not already installed..." + if (Get-Command aspire -ErrorAction SilentlyContinue) { + Write-Error "aspire command is already available before install - test environment is not clean" + exit 1 + } + Write-Host " Confirmed: aspire is not in PATH" + + Write-Host "Installing Aspire.Cli from local manifest..." + winget install --manifest $versionedManifestPath --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + Write-Error "winget install failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + Write-Host "Install succeeded" + + Write-Host "Refreshing PATH environment variable..." + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + $failed = $false + Write-Host "Verifying aspire CLI is in PATH (new process)..." + try { + $aspireInfo = pwsh -NoProfile -Command ' + $cmd = Get-Command aspire -ErrorAction SilentlyContinue + if (-not $cmd) { Write-Error "aspire not found in PATH"; exit 1 } + Write-Host " Path: $($cmd.Source)" + $v = & aspire --version 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } + Write-Host " Version: $v" + ' + if ($LASTEXITCODE -ne 0) { + throw "Child process exited with code $LASTEXITCODE" + } + Write-Host "aspire CLI verified" + } catch { + Write-Host "##[error]Failed to verify aspire CLI: $_" + $failed = $true + } + + Write-Host "Uninstalling Aspire.Cli..." + winget uninstall --manifest $versionedManifestPath --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + if ($failed) { + Write-Warning "winget uninstall also failed with exit code $LASTEXITCODE (ignoring since verification already failed)" + } else { + Write-Error "winget uninstall failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Write-Host "Uninstall succeeded" + } + + if ($failed) { + exit 1 + } +} + +Copy-Item (Join-Path $ScriptDir "dogfood.ps1") (Join-Path $OutputPath "dogfood.ps1") + +Write-Host "WinGet manifest artifact prepared at: $OutputPath" From c9c2e4ced2fa5fbc598f479bf575f7d334a16943 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 00:45:13 -0400 Subject: [PATCH 04/21] feat(acquisition): add PR installer dogfood modes Extend the PR acquisition scripts with WinGet and Homebrew install modes that download the generated installer artifacts plus their native archives, then invoke the bundled dogfood helpers.\n\nThe installer modes also populate the PR NuGet hive while avoiding persistent shell profile changes for temporary PR dogfood installs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dogfooding-pull-requests.md | 45 ++++- eng/scripts/README.md | 14 +- eng/scripts/get-aspire-cli-pr.ps1 | 225 ++++++++++++++++++++++-- eng/scripts/get-aspire-cli-pr.sh | 273 ++++++++++++++++++++++++++++-- 4 files changed, 521 insertions(+), 36 deletions(-) diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index 4e279bf6331..3a8ef6b2a90 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -6,12 +6,14 @@ Two cross-platform helper scripts are available: - Bash: `eng/scripts/get-aspire-cli-pr.sh` - PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` -They download the correct build artifacts for your OS/architecture and support two CLI install modes: +They download the correct build artifacts for your OS/architecture and support four CLI install modes: - **Archive mode** (default): installs the native CLI archive into an isolated PR-specific directory and populates a PR-scoped NuGet "hive" with the matching packages. - **Tool mode**: installs the `Aspire.Cli` .NET tool from the PR's RID-specific NuGet artifact and populates the same PR-scoped NuGet hive. Use this when you also want to dogfood the dotnet-tool packaging or acquisition route. +- **WinGet mode**: installs from the PR's generated `winget-manifests-prerelease` artifact and local native archive artifacts. +- **Homebrew mode**: installs from the PR's generated `homebrew-cask-prerelease` artifact and local native archive artifacts. -Both modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's NuGet packages via the hive at `~/.aspire/hives/pr-/packages`. The difference is only how the CLI binary itself is acquired. +All modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's NuGet packages via the hive at `~/.aspire/hives/pr-/packages`. The difference is only how the CLI binary itself is acquired. ## Prerequisites @@ -20,6 +22,8 @@ Both modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's - Authenticate: `gh auth login` - Network access to GitHub Actions - .NET SDK available on PATH when using tool mode (`--install-mode tool` or `-InstallMode Tool`) +- WinGet available on Windows when using WinGet mode (`--install-mode winget` or `-InstallMode WinGet`) +- Homebrew available on macOS when using Homebrew mode (`--install-mode homebrew` or `-InstallMode Homebrew`) - Archive tools: - On Unix/macOS: `tar` (and/or `unzip`, depending on the archive format) - On Windows (PowerShell script): built-in extraction is used; for Git Bash + `.sh` script, ensure `unzip` or `tar` is available @@ -65,21 +69,52 @@ To avoid changing the global .NET tool installation, pass a custom install path. ./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode Tool -InstallPath $HOME/.aspire-pr ``` +### WinGet mode + +WinGet mode downloads the PR's generated WinGet manifest artifact, downloads both Windows native archive artifacts, rewrites the manifest to use the local archive files, and installs via WinGet: + +```bash +./eng/scripts/get-aspire-cli-pr.sh 1234 --install-mode winget +``` + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode WinGet +``` + +WinGet mode is supported only on Windows. If `Microsoft.Aspire` is already installed through WinGet, uninstall it first or pass `--force`/`-Force` to allow the dogfood manifest to replace it. + +### Homebrew mode + +Homebrew mode downloads the PR's generated Homebrew cask artifact, downloads both macOS native archive artifacts, rewrites the cask to use the local archive files, and installs via Homebrew: + +```bash +./eng/scripts/get-aspire-cli-pr.sh 1234 --install-mode homebrew +``` + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode Homebrew +``` + +Homebrew mode is supported only on macOS. If `aspire` is already installed as a Homebrew cask, uninstall it first with `brew uninstall --cask aspire`. + ## What gets installed - Aspire CLI binary: - **Archive mode** default location: `~/.aspire/dogfood/pr-/bin/aspire` (or `aspire.exe` on Windows). - **Tool mode** default location: `dotnet tool install --global` puts the tool on the PATH per the .NET SDK's global-tools convention. When tool mode is combined with `--install-path`, the scripts pass that path to `dotnet tool --tool-path` using the PR-specific `bin` directory under the prefix. + - **WinGet/Homebrew mode** location: managed by the platform package manager. - Important: If you already have an Aspire CLI installed for the same PR under the same prefix, running this script will overwrite that PR installation. Other PR installs and the regular script install under `~/.aspire/bin` are isolated from this path. - PR-scoped NuGet packages "hive": - Default location: `~/.aspire/hives/pr-/packages` - - Populated in both archive and tool mode. This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. + - Populated in all install modes. This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. In archive mode, the scripts attempt to add the PR-specific `bin` directory to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. In default tool mode, PATH setup is left to `dotnet tool install --global`. When tool mode is used with a custom install path, the scripts treat the PR-specific `bin` directory as the tool path and can add it to PATH. +In WinGet and Homebrew mode, PATH setup is left to the platform package manager. + ## Channel names Every Aspire CLI binary is built for a specific channel. The channel name controls which package feed (or local hive) is used by commands like `aspire new` and `aspire add`, and is the value you pass to `aspire update --self --channel`. @@ -186,6 +221,10 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` - PowerShell: `-InstallMode Tool` - If an existing dotnet-tool install blocks the requested version, use Bash `--force` or PowerShell `-Force`. +- Install through a platform package manager: + - WinGet: Bash `--install-mode winget` or PowerShell `-InstallMode WinGet` + - Homebrew: Bash `--install-mode homebrew` or PowerShell `-InstallMode Homebrew` + - Verbose, keep archives, or dry run: - Bash: `-v/--verbose`, `-k/--keep-archive`, `--dry-run` - PowerShell: `-Verbose`, `-WhatIf` (PowerShell's dry-run), or provide equivalent parameters if present diff --git a/eng/scripts/README.md b/eng/scripts/README.md index bf115146b91..d3bb8849665 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -169,10 +169,12 @@ Additional scripts exist to fetch CLI and NuGet artifacts from a pull request bu - `get-aspire-cli-pr.sh` - `get-aspire-cli-pr.ps1` -The PR scripts support two install modes: +The PR scripts support four install modes: - **Archive mode** (default) installs the PR's native CLI archive under a PR-specific dogfood path and copies PR packages into `~/.aspire/hives/pr-/packages`. - **Tool mode** installs the PR's `Aspire.Cli` package as a .NET tool from the RID-specific NuGet artifact and also populates the same `~/.aspire/hives/pr-/packages` hive. Use this when you also want to dogfood the dotnet-tool packaging or acquisition route. +- **WinGet mode** installs from the PR's generated WinGet manifest artifact and local Windows native archive artifacts. +- **Homebrew mode** installs from the PR's generated Homebrew cask artifact and local macOS native archive artifacts. Quick archive-mode fetch (Bash): ```bash @@ -194,6 +196,16 @@ Quick tool-mode fetch (PowerShell): iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } -InstallMode Tool" ``` +Quick WinGet-mode fetch (Windows): +```powershell +iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } -InstallMode WinGet" +``` + +Quick Homebrew-mode fetch (macOS): +```bash +curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- --install-mode homebrew +``` + NuGet hive path pattern: `~/.aspire/hives/pr-/packages` ### Repository Override diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index db8cb63ae2e..1ae22dae739 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -39,7 +39,9 @@ .PARAMETER InstallMode How to install the CLI: 'Archive' (default) installs from the cli-native-archives- - artifact, 'Tool' installs the Aspire.Cli dotnet tool from the PR's RID-specific NuGet artifact. + artifact, 'Tool' installs the Aspire.Cli dotnet tool from the PR's RID-specific NuGet artifact, + 'WinGet' installs from the generated WinGet manifest artifact, and 'Homebrew' installs from + the generated Homebrew cask artifact. Alias: -m .PARAMETER Force @@ -106,6 +108,12 @@ .EXAMPLE .\get-aspire-cli-pr.ps1 1234 -InstallMode Tool -Force +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -InstallMode Homebrew + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -InstallMode WinGet + .EXAMPLE .\get-aspire-cli-pr.ps1 -LocalDir "C:\path\to\artifacts" -InstallMode Tool @@ -143,12 +151,12 @@ param( [Alias("i")] [string]$InstallPath = "", - [Parameter(HelpMessage = "How to install the CLI: 'Archive' (default) or 'Tool' (install Aspire.Cli dotnet tool from the PR package source)")] + [Parameter(HelpMessage = "How to install the CLI: 'Archive' (default), 'Tool', 'WinGet', or 'Homebrew'")] [Alias("m")] - [ValidateSet("Archive", "Tool")] + [ValidateSet("Archive", "Tool", "WinGet", "Homebrew")] [string]$InstallMode = "Archive", - [Parameter(HelpMessage = "Tool mode only: when Aspire.Cli is already installed globally, update it to the PR's exact package version (allows downgrades)")] + [Parameter(HelpMessage = "Tool mode updates an existing Aspire.Cli tool; WinGet mode allows replacing an existing Microsoft.Aspire install")] [switch]$Force, [Parameter(HelpMessage = "Override OS detection")] @@ -184,6 +192,8 @@ $Script:BuiltNugetsRidArtifactName = "built-nugets-for" $Script:CliArchiveArtifactNamePrefix = "cli-native-archives" $Script:AspireCliArtifactNamePrefix = "aspire-cli" $Script:ExtensionArtifactName = "aspire-extension" +$Script:WinGetManifestArtifactName = "winget-manifests-prerelease" +$Script:HomebrewCaskArtifactName = "homebrew-cask-prerelease" $Script:IsModernPowerShell = $PSVersionTable.PSVersion.Major -ge 6 -and $PSVersionTable.PSEdition -eq "Core" $Script:HostOS = "unset" $Script:Repository = if ($env:ASPIRE_REPO -and $env:ASPIRE_REPO.Trim()) { $env:ASPIRE_REPO.Trim() } else { 'microsoft/aspire' } @@ -1361,6 +1371,174 @@ function Get-AspireCliFromArtifact { return $downloadDir } +function Test-InstallerMode { + return $InstallMode -eq 'WinGet' -or $InstallMode -eq 'Homebrew' +} + +function Test-ScriptManagesCliPath { + return $InstallMode -eq 'Archive' -or ($InstallMode -eq 'Tool' -and $script:InstallPathExplicit) +} + +function Get-InstallerArtifactName { + switch ($InstallMode) { + 'WinGet' { return $Script:WinGetManifestArtifactName } + 'Homebrew' { return $Script:HomebrewCaskArtifactName } + default { throw "Install mode '$InstallMode' does not use an installer artifact." } + } +} + +function Get-InstallerArchiveRids { + switch ($InstallMode) { + 'WinGet' { return @('win-x64', 'win-arm64') } + 'Homebrew' { return @('osx-arm64', 'osx-x64') } + default { throw "Install mode '$InstallMode' does not use native installer archives." } + } +} + +function Get-InstallerArtifacts { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$TempDir + ) + + $installerArtifactName = Get-InstallerArtifactName + $installerArtifactDir = Join-Path $TempDir "installer-$($InstallMode.ToLowerInvariant())" + $archiveRoot = Join-Path $TempDir "installer-native-archives" + + Invoke-ArtifactDownload -RunId $RunId -ArtifactName $installerArtifactName -DownloadDirectory $installerArtifactDir + + foreach ($rid in Get-InstallerArchiveRids) { + Invoke-ArtifactDownload -RunId $RunId -ArtifactName "$($Script:CliArchiveArtifactNamePrefix)-$rid" -DownloadDirectory $archiveRoot + } + + return [pscustomobject]@{ + ArtifactDir = $installerArtifactDir + ArchiveRoot = $archiveRoot + } +} + +function Find-InstallerDogfoodScript { + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDir + ) + + $scriptName = switch ($InstallMode) { + 'WinGet' { 'dogfood.ps1' } + 'Homebrew' { 'dogfood.sh' } + default { throw "Install mode '$InstallMode' does not use a dogfood script." } + } + + $companionPattern = switch ($InstallMode) { + 'WinGet' { '*.installer.yaml' } + 'Homebrew' { 'aspire.rb' } + default { throw "Install mode '$InstallMode' does not use a dogfood script." } + } + + $matches = @( + Get-ChildItem -Path $ArtifactDir -File -Recurse -Filter $scriptName -ErrorAction SilentlyContinue | + Where-Object { + Get-ChildItem -Path $_.Directory.FullName -File -Filter $companionPattern -ErrorAction SilentlyContinue | + Select-Object -First 1 + } | + Sort-Object FullName + ) + + if ($matches.Count -eq 0) { + throw "Could not find $scriptName co-located with $companionPattern under: $ArtifactDir" + } + + if ($matches.Count -gt 1) { + $matchList = ($matches | ForEach-Object { " $($_.FullName)" }) -join [Environment]::NewLine + throw "Found multiple $scriptName files co-located with $companionPattern under ${ArtifactDir}:$([Environment]::NewLine)$matchList" + } + + return $matches[0].FullName +} + +function Install-AspireCliWithInstallerArtifact { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDir, + + [Parameter(Mandatory = $true)] + [string]$ArchiveRoot + ) + + switch ($InstallMode) { + 'WinGet' { + $dogfoodScript = if ($WhatIfPreference -and -not (Test-Path $ArtifactDir)) { + Join-Path $ArtifactDir 'dogfood.ps1' + } else { + Find-InstallerDogfoodScript -ArtifactDir $ArtifactDir + } + $command = @('pwsh', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $dogfoodScript, '-ArchiveRoot', $ArchiveRoot) + if ($Force) { + $command += '-Force' + } + + if ($PSCmdlet.ShouldProcess($dogfoodScript, "Install Aspire CLI with WinGet artifact: $($command -join ' ')")) { + & $command[0] $command[1..($command.Length - 1)] + if ($LASTEXITCODE -ne 0) { + throw "WinGet dogfood installer failed with exit code $LASTEXITCODE" + } + } + break + } + 'Homebrew' { + $dogfoodScript = if ($WhatIfPreference -and -not (Test-Path $ArtifactDir)) { + Join-Path $ArtifactDir 'dogfood.sh' + } else { + Find-InstallerDogfoodScript -ArtifactDir $ArtifactDir + } + $command = @('bash', $dogfoodScript, '--archive-root', $ArchiveRoot) + if ($PSCmdlet.ShouldProcess($dogfoodScript, "Install Aspire CLI with Homebrew artifact: $($command -join ' ')")) { + & $command[0] $command[1..($command.Length - 1)] + if ($LASTEXITCODE -ne 0) { + throw "Homebrew dogfood installer failed with exit code $LASTEXITCODE" + } + } + break + } + default { + throw "Unsupported installer mode: $InstallMode" + } + } +} + +function Test-InstallerModeEnvironment { + if ($WhatIfPreference) { + return + } + + switch ($InstallMode) { + 'WinGet' { + if ($Script:HostOS -ne 'win') { + throw "-InstallMode WinGet can only be executed on Windows. Use -WhatIf to preview downloads from another OS." + } + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + throw "-InstallMode WinGet requires PowerShell (pwsh) to run the WinGet dogfood installer." + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw "-InstallMode WinGet requires WinGet to install the generated manifest artifact." + } + } + 'Homebrew' { + if ($Script:HostOS -ne 'osx') { + throw "-InstallMode Homebrew can only be executed on macOS. Use -WhatIf to preview downloads from another OS." + } + if (-not (Get-Command brew -ErrorAction SilentlyContinue)) { + throw "-InstallMode Homebrew requires Homebrew (brew) to install the generated cask artifact." + } + } + } +} + # Writes the PR-route install-source sidecar (.aspire-install.json) next to # the installed binary. Under -WhatIf, prints the target path and skips the # write so a real user's sidecar is never overwritten by a describe pass. @@ -1560,6 +1738,8 @@ function Start-InstallFromLocalDir { Write-Message "Skipping CLI installation due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI archive lookup in local directory (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + Install-AspireCliWithInstallerArtifact -ArtifactDir $LocalDirPath -ArchiveRoot $LocalDirPath } else { $archiveMatch = Get-ChildItem -Path $LocalDirPath -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "^$Script:AspireCliArtifactNamePrefix-.*\.(tar\.gz|zip)$" } | @@ -1616,7 +1796,7 @@ function Start-InstallFromLocalDir { # Update PATH environment variables if (-not $HiveOnly) { - if ($InstallMode -ne 'Tool' -or $script:InstallPathExplicit) { + if (Test-ScriptManagesCliPath) { if ($SkipPath) { Write-Message "Skipping PATH configuration due to -SkipPath flag" -Level Info } else { @@ -1686,6 +1866,8 @@ function Start-DownloadAndInstall { Write-Message "Skipping CLI download due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI native archive download (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + $installerArtifacts = Get-InstallerArtifacts -RunId $runId -TempDir $TempDir } else { $cliDownloadDir = Get-AspireCliFromArtifact -RunId $runId -RID $rid -TempDir $TempDir } @@ -1718,6 +1900,8 @@ function Start-DownloadAndInstall { Write-Message "Skipping CLI installation due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI archive installation (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + Install-AspireCliWithInstallerArtifact -ArtifactDir $installerArtifacts.ArtifactDir -ArchiveRoot $installerArtifacts.ArchiveRoot } else { Install-AspireCliFromDownload -DownloadDir $cliDownloadDir -CliBinDir $cliBinDir } @@ -1748,7 +1932,7 @@ function Start-DownloadAndInstall { # Update PATH environment variables if (-not $HiveOnly) { - if ($InstallMode -ne 'Tool' -or $script:InstallPathExplicit) { + if (Test-ScriptManagesCliPath) { if ($SkipPath) { Write-Message "Skipping PATH configuration due to -SkipPath flag" -Level Info } else { @@ -1764,7 +1948,7 @@ function Start-DownloadAndInstall { # The new-PATH expression is emitted with double quotes so PowerShell expands `$env:PATH` # when the user pastes the line into their profile — single quotes would assign the literal # text "$env:PATH" and clobber the existing PATH. - if (-not $HiveOnly -and $PRNumber -gt 0 -and ($InstallMode -eq 'Archive' -or $script:InstallPathExplicit)) { + if (-not $HiveOnly -and $PRNumber -gt 0 -and (Test-ScriptManagesCliPath)) { $pathSep = [System.IO.Path]::PathSeparator Write-Host "Add to your shell profile: `$env:PATH = `"$cliBinDir$pathSep`$env:PATH`";" } @@ -1796,8 +1980,8 @@ OPTIONS: -LocalDir Use pre-downloaded artifacts from a local directory -HiveLabel Override the NuGet hive label -InstallPath Directory prefix to install - -InstallMode How to install the CLI: 'Archive' (default) or 'Tool' - -Force Tool mode only: run dotnet tool update for the PR's exact version + -InstallMode How to install the CLI: 'Archive' (default), 'Tool', 'WinGet', or 'Homebrew' + -Force Tool mode updates the existing tool; WinGet mode allows replacing an existing install -OS Override OS detection (win, linux, linux-musl, osx) -Architecture Override architecture detection (x64, arm64) -HiveOnly For installs from archives only: only install NuGet packages to the hive, skip CLI download @@ -1825,15 +2009,22 @@ OPTIONS: # Set host OS for PATH environment updates $script:HostOS = Get-OperatingSystem - if ($InstallMode -eq 'Tool' -and $HiveOnly) { - Write-Message "Error: -HiveOnly cannot be combined with -InstallMode Tool: -HiveOnly skips the CLI install, but -InstallMode Tool installs Aspire.Cli as a .NET tool." -Level Error - Write-Message "Drop one of the two flags. Both archive and tool modes populate the hive." -Level Info + $InstallMode = switch ($InstallMode.ToLowerInvariant()) { + 'archive' { 'Archive' } + 'tool' { 'Tool' } + 'winget' { 'WinGet' } + 'homebrew' { 'Homebrew' } + } + + if ($HiveOnly -and $InstallMode -ne 'Archive') { + Write-Message "Error: -HiveOnly cannot be combined with -InstallMode ${InstallMode}: -HiveOnly skips the CLI install, but this install mode installs Aspire CLI through a package or tool manager." -Level Error + Write-Message "Drop one of the two flags. All install modes populate the hive." -Level Info if ($InvokedFromFile) { exit 1 } else { return 1 } } - if ($InstallMode -ne 'Tool' -and $Force) { - Write-Message "Error: -Force can only be combined with -InstallMode Tool: archive mode installs from downloaded binaries and does not use dotnet tool update." -Level Error - Write-Message "Use -InstallMode Tool with -Force, or drop -Force." -Level Info + if ($InstallMode -ne 'Tool' -and $InstallMode -ne 'WinGet' -and $Force) { + Write-Message "Error: -Force can only be combined with -InstallMode Tool or -InstallMode WinGet." -Level Error + Write-Message "Use -InstallMode Tool/WinGet with -Force, or drop -Force." -Level Info if ($InvokedFromFile) { exit 1 } else { return 1 } } @@ -1843,6 +2034,10 @@ OPTIONS: Test-DotnetDependency } + if (Test-InstallerMode) { + Test-InstallerModeEnvironment + } + # Check gh dependency (not needed for -LocalDir mode) if (-not $LocalDir) { Test-GitHubCLIDependency diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 9d63ba8b795..d7350ca3558 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -14,6 +14,8 @@ readonly BUILT_NUGETS_RID_ARTIFACT_NAME="built-nugets-for" readonly CLI_ARCHIVE_ARTIFACT_NAME_PREFIX="cli-native-archives" readonly ASPIRE_CLI_ARTIFACT_NAME_PREFIX="aspire-cli" readonly EXTENSION_ARTIFACT_NAME="aspire-extension" +readonly WINGET_MANIFEST_ARTIFACT_NAME="winget-manifests-prerelease" +readonly HOMEBREW_CASK_ARTIFACT_NAME="homebrew-cask-prerelease" # Repository: Allow override via ASPIRE_REPO env var (owner/name). Default: microsoft/aspire readonly REPO="${ASPIRE_REPO:-microsoft/aspire}" @@ -85,9 +87,11 @@ USAGE: NuGet hive: /hives/pr-/packages (or run-) -m, --install-mode MODE How to install the CLI: 'archive' (default) installs from cli-native-archives- artifact, 'tool' installs the Aspire.Cli - dotnet tool from the PR's RID-specific NuGet artifact. - --force Tool mode only: run dotnet tool update instead of install to move an - existing Aspire.Cli tool to the exact PR package version (allows downgrades). + dotnet tool from the PR's RID-specific NuGet artifact, 'winget' + installs from the generated WinGet manifest artifact, and + 'homebrew' installs from the generated Homebrew cask artifact. + --force Tool mode: update an existing Aspire.Cli tool to the exact PR package + version. WinGet mode: allow replacing an existing Microsoft.Aspire install. --os OS Override OS detection (win, linux, linux-musl, osx) --arch ARCH Override architecture detection (x64, arm64) --hive-only For installs from archives only: only install NuGet packages to the hive, skip CLI download @@ -113,6 +117,8 @@ EXAMPLES: ./get-aspire-cli-pr.sh 1234 --use-insiders ./get-aspire-cli-pr.sh 1234 -m tool ./get-aspire-cli-pr.sh 1234 --install-mode tool --force + ./get-aspire-cli-pr.sh 1234 --install-mode homebrew + ./get-aspire-cli-pr.sh 1234 --install-mode winget ./get-aspire-cli-pr.sh --local-dir /path/to/artifacts --install-mode tool ./get-aspire-cli-pr.sh 1234 --skip-path ./get-aspire-cli-pr.sh 1234 --dry-run @@ -122,6 +128,8 @@ EXAMPLES: REQUIREMENTS: - GitHub CLI (gh) must be installed and authenticated (not needed with --local-dir) - In tool mode (--install-mode tool), the .NET SDK 'dotnet' command must be available in PATH + - In Homebrew mode (--install-mode homebrew), Homebrew must be available on macOS + - In WinGet mode (--install-mode winget), PowerShell and WinGet must be available on Windows - Permissions to download artifacts from the target repository - VS Code extension installation requires VS Code CLI (code) to be available in PATH @@ -221,11 +229,11 @@ parse_args() { exit 1 fi case "$2" in - archive|tool) + archive|tool|winget|homebrew) INSTALL_MODE="$2" ;; *) - say_error "Invalid value for --install-mode: '$2'. Allowed: archive, tool" + say_error "Invalid value for --install-mode: '$2'. Allowed: archive, tool, winget, homebrew" say_info "Use --help for usage information." exit 1 ;; @@ -1054,6 +1062,221 @@ download_aspire_cli() { return 0 } +is_installer_mode() { + [[ "$INSTALL_MODE" == "winget" || "$INSTALL_MODE" == "homebrew" ]] +} + +script_manages_cli_path() { + [[ "$INSTALL_MODE" == "archive" || ("$INSTALL_MODE" == "tool" && "$INSTALL_PREFIX_EXPLICIT" == true) ]] +} + +get_installer_artifact_name() { + case "$INSTALL_MODE" in + winget) + printf '%s' "$WINGET_MANIFEST_ARTIFACT_NAME" + ;; + homebrew) + printf '%s' "$HOMEBREW_CASK_ARTIFACT_NAME" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use an installer artifact." + return 1 + ;; + esac +} + +get_installer_archive_rids() { + case "$INSTALL_MODE" in + winget) + printf '%s\n' "win-x64" "win-arm64" + ;; + homebrew) + printf '%s\n' "osx-arm64" "osx-x64" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use native installer archives." + return 1 + ;; + esac +} + +download_artifact_by_name() { + local workflow_run_id="$1" + local artifact_name="$2" + local download_dir="$3" + local download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$artifact_name" -D "$download_dir") + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download $artifact_name with: ${download_command[*]}" + return 0 + fi + + say_info "Downloading artifact - $artifact_name ..." + say_verbose "Downloading with: ${download_command[*]}" + + if ! "${download_command[@]}"; then + say_verbose "gh run download command failed. Command: ${download_command[*]}" + say_error "Failed to download artifact '$artifact_name' from run: $workflow_run_id . If the workflow is still running then the artifact named '$artifact_name' may not be available yet. Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + return 1 + fi + + return 0 +} + +download_installer_artifacts() { + local workflow_run_id="$1" + local temp_dir="$2" + local installer_artifact_name + installer_artifact_name="$(get_installer_artifact_name)" + + INSTALLER_ARTIFACT_DIR="$temp_dir/installer-$INSTALL_MODE" + INSTALLER_ARCHIVE_ROOT="$temp_dir/installer-native-archives" + + if ! download_artifact_by_name "$workflow_run_id" "$installer_artifact_name" "$INSTALLER_ARTIFACT_DIR"; then + return 1 + fi + + local rid + while IFS= read -r rid; do + if [[ -z "$rid" ]]; then + continue + fi + + if ! download_artifact_by_name "$workflow_run_id" "$CLI_ARCHIVE_ARTIFACT_NAME_PREFIX-$rid" "$INSTALLER_ARCHIVE_ROOT"; then + return 1 + fi + done < <(get_installer_archive_rids) + + say_verbose "Downloaded installer artifact to: $INSTALLER_ARTIFACT_DIR" + say_verbose "Downloaded native installer archives to: $INSTALLER_ARCHIVE_ROOT" + return 0 +} + +find_installer_dogfood_script() { + local artifact_dir="$1" + local script_name + local companion_pattern + + case "$INSTALL_MODE" in + winget) + script_name="dogfood.ps1" + companion_pattern="*.installer.yaml" + ;; + homebrew) + script_name="dogfood.sh" + companion_pattern="aspire.rb" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use a dogfood script." + return 1 + ;; + esac + + local -a matches=() + local f + while IFS= read -r -d '' f; do + if find "$(dirname "$f")" -maxdepth 1 -type f -name "$companion_pattern" | grep -q .; then + matches+=("$f") + fi + done < <(find "$artifact_dir" -type f -name "$script_name" -print0 | sort -z) + + if [[ "${#matches[@]}" -eq 0 ]]; then + say_error "Could not find $script_name co-located with $companion_pattern under: $artifact_dir" + return 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + say_error "Found multiple $script_name files co-located with $companion_pattern under $artifact_dir:" + printf ' %s\n' "${matches[@]}" >&2 + return 1 + fi + + printf '%s' "${matches[0]}" + return 0 +} + +install_with_installer_artifact() { + local artifact_dir="$1" + local archive_root="$2" + local dogfood_script + + if [[ "$DRY_RUN" == true ]]; then + case "$INSTALL_MODE" in + winget) + dogfood_script="$artifact_dir/dogfood.ps1" + ;; + homebrew) + dogfood_script="$artifact_dir/dogfood.sh" + ;; + esac + else + if ! dogfood_script="$(find_installer_dogfood_script "$artifact_dir")"; then + return 1 + fi + fi + + case "$INSTALL_MODE" in + winget) + local winget_command=(pwsh -NoProfile -ExecutionPolicy Bypass -File "$dogfood_script" -ArchiveRoot "$archive_root") + if [[ "$FORCE" == true ]]; then + winget_command+=(-Force) + fi + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install Aspire CLI with WinGet artifact: ${winget_command[*]}" + return 0 + fi + + "${winget_command[@]}" + ;; + homebrew) + local homebrew_command=(bash "$dogfood_script" --archive-root "$archive_root") + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install Aspire CLI with Homebrew artifact: ${homebrew_command[*]}" + return 0 + fi + + "${homebrew_command[@]}" + ;; + *) + say_error "Unsupported installer mode: $INSTALL_MODE" + return 1 + ;; + esac +} + +validate_installer_mode_environment() { + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + + case "$INSTALL_MODE" in + winget) + if [[ "$HOST_OS" != "win" ]]; then + say_error "--install-mode winget can only be executed on Windows. Use --dry-run to preview downloads from another OS." + return 1 + fi + if ! command -v pwsh >/dev/null 2>&1; then + say_error "--install-mode winget requires PowerShell (pwsh) to run the WinGet dogfood installer." + return 1 + fi + if ! command -v winget >/dev/null 2>&1; then + say_error "--install-mode winget requires WinGet to install the generated manifest artifact." + return 1 + fi + ;; + homebrew) + if [[ "$HOST_OS" != "osx" ]]; then + say_error "--install-mode homebrew can only be executed on macOS. Use --dry-run to preview downloads from another OS." + return 1 + fi + if ! command -v brew >/dev/null 2>&1; then + say_error "--install-mode homebrew requires Homebrew (brew) to install the generated cask artifact." + return 1 + fi + ;; + esac +} + # Function to check if VS Code CLI is available check_vscode_cli_dependency() { local vscode_cmd="code" @@ -1321,11 +1544,15 @@ install_from_local_dir() { fi say_verbose "Computed RID: $rid" - # Find CLI archive in local directory, or auto-detect raw build output. + # Find CLI archive in local directory, use installer artifact, or auto-detect raw build output. if [[ "$HIVE_ONLY" == true ]]; then say_info "Skipping CLI installation due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI archive lookup in local directory (install mode: tool)" + elif is_installer_mode; then + if ! install_with_installer_artifact "$local_dir" "$local_dir"; then + return 1 + fi else local -a cli_files=() while IFS= read -r -d '' f; do @@ -1469,8 +1696,12 @@ download_and_install_from_pr() { say_info "Skipping CLI download due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI native archive download (install mode: tool)" + elif is_installer_mode; then + if ! download_installer_artifacts "$workflow_run_id" "$temp_dir"; then + return 1 + fi else - if ! cli_archive_path=$(download_aspire_cli "$workflow_run_id" "$rid" "$temp_dir"); then + if ! cli_archive_path=$(download_aspire_cli "$workflow_run_id" "$rid" "$temp_dir"); then return 1 fi fi @@ -1511,6 +1742,10 @@ download_and_install_from_pr() { say_info "Skipping CLI installation due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI archive installation (install mode: tool)" + elif is_installer_mode; then + if ! install_with_installer_artifact "$INSTALLER_ARTIFACT_DIR" "$INSTALLER_ARCHIVE_ROOT"; then + return 1 + fi else if ! install_aspire_cli "$cli_archive_path" "$cli_install_dir"; then return 1 @@ -1581,15 +1816,15 @@ main() { fi fi - if [[ "$INSTALL_MODE" == "tool" && "$HIVE_ONLY" == true ]]; then - say_error "--hive-only cannot be combined with --install-mode tool: --hive-only skips the CLI install, but --install-mode tool installs Aspire.Cli as a .NET tool." - say_info "Drop one of the two flags. Both archive and tool modes populate the hive." + if [[ "$HIVE_ONLY" == true && "$INSTALL_MODE" != "archive" ]]; then + say_error "--hive-only cannot be combined with --install-mode $INSTALL_MODE: --hive-only skips the CLI install, but this install mode installs Aspire CLI through a package or tool manager." + say_info "Drop one of the two flags. All install modes populate the hive." exit 1 fi - if [[ "$INSTALL_MODE" != "tool" && "$FORCE" == true ]]; then - say_error "--force can only be combined with --install-mode tool: archive mode installs from downloaded binaries and does not use dotnet tool update." - say_info "Use --install-mode tool with --force, or drop --force." + if [[ "$INSTALL_MODE" != "tool" && "$INSTALL_MODE" != "winget" && "$FORCE" == true ]]; then + say_error "--force can only be combined with --install-mode tool or --install-mode winget." + say_info "Use --install-mode tool/winget with --force, or drop --force." exit 1 fi @@ -1603,6 +1838,10 @@ main() { fi fi + if is_installer_mode && ! validate_installer_mode_environment; then + exit 1 + fi + # Check gh dependency (not needed for --local-dir mode) if [[ -z "$LOCAL_DIR" ]]; then check_gh_dependency @@ -1652,14 +1891,14 @@ main() { fi fi - # Add to shell profile for persistent PATH. Default tool installs use 'dotnet tool install -g', - # which owns any global-tools PATH guidance; explicit tool-path installs use cli_install_dir. + # Add to shell profile for persistent PATH. Package-manager modes and default tool installs own + # their own PATH guidance; explicit tool-path installs use cli_install_dir. # PR installs deliberately skip the persistent profile write: a PR build is a per-session # dogfood activation. Touching ~/.zshrc / ~/.bashrc would silently demote a developer's # daily/stable install on every new terminal until they hunt down the stale `export PATH=` # line. The activation hint printed below shows how to opt in manually. if [[ "$HIVE_ONLY" != true ]]; then - if [[ "$INSTALL_MODE" != "tool" || "$INSTALL_PREFIX_EXPLICIT" == true ]]; then + if script_manages_cli_path; then if [[ "$SKIP_PATH" == true ]]; then say_info "Skipping PATH configuration due to --skip-path flag" else @@ -1689,7 +1928,7 @@ main() { # Print PATH activation hint for PR installs. # Goes to stdout (not stderr) so it's visible in normal install output and tests can grep it. # Printed in success path (after install completes) and also under --dry-run. - if [[ "$HIVE_ONLY" != true && -n "$PR_NUMBER" && ("$INSTALL_MODE" == "archive" || "$INSTALL_PREFIX_EXPLICIT" == true) ]]; then + if [[ "$HIVE_ONLY" != true && -n "$PR_NUMBER" ]] && script_manages_cli_path; then local profile_path_unexpanded="$INSTALL_PATH_UNEXPANDED" echo "Add to your shell profile: export PATH=\"$profile_path_unexpanded:\$PATH\"" fi From 45052bae652b5eec00b41c24107c8ff9b90a7643 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 00:45:13 -0400 Subject: [PATCH 05/21] test(acquisition): cover installer artifact dogfood flows Add acquisition coverage for installer-mode dispatch, local artifact dogfood helpers, generator output, hash handling, cleanup behavior, and WinGet local archive rewrite semantics.\n\nThe tests catch broken cask or manifest generation before relying on the full installer CI jobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scripts/PRScriptInstallerModeTests.cs | 923 ++++++++++++++++++ 1 file changed, 923 insertions(+) create mode 100644 tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs diff --git a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs new file mode 100644 index 00000000000..4741ada81bf --- /dev/null +++ b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs @@ -0,0 +1,923 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using Aspire.TestUtilities; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace Aspire.Acquisition.Tests.Scripts; + +/// +/// Tests for package-manager installer modes on get-aspire-cli-pr.{sh,ps1}. +/// +public class PRScriptInstallerModeTests(ITestOutputHelper testOutput) +{ + private readonly ITestOutputHelper _testOutput = testOutput; + + private async Task CreateBashCommandWithMockGhAsync(TestEnvironment env) + { + var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput); + var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + return cmd; + } + + private async Task CreatePsCommandWithMockGhAsync(TestEnvironment env) + { + var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput); + var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + return cmd; + } + + private static async Task CreateHomebrewInstallerArtifactAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "aspire.rb"), "cask \"aspire\" do\n version \"13.3.0\"\nend\n"); + await File.WriteAllTextAsync(Path.Combine(root, "dogfood.sh"), "#!/usr/bin/env bash\nexit 0\n"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc"); + return root; + } + + private static async Task CreateWinGetInstallerArtifactAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "Microsoft.Aspire.installer.yaml"), "PackageIdentifier: Microsoft.Aspire\nPackageVersion: 13.3.0\nInstallers: []\n"); + await File.WriteAllTextAsync(Path.Combine(root, "dogfood.ps1"), "exit 0\n"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc"); + return root; + } + + // Builds the realistic PR-channel layout that prepare-manifest-artifact.ps1 produces: + // an installer.yaml with two Installers entries (https:// URLs and a placeholder + // SHA256 of all zeros) co-located with dogfood.ps1, plus fake aspire-cli-win-* + // archives. The archives are nested under /Debug/Shipping/ to mirror + // what `gh run download cli-native-archives-` produces — that artifact is + // uploaded with `path: artifacts/packages/**/aspire-cli*` in + // .github/workflows/build-cli-native-archives.yml, so the `**/` glob preserves the + // Debug/Shipping/ directory structure. dogfood.ps1's HTTP server is required to + // serve files regardless of how deep they are under -ArchiveRoot; a flat fixture + // hid that requirement and let a real-world 404 ship. + private static async Task<(string ManifestDir, string ArchiveRoot)> CreateWinGetPrChannelArtifactAsync(string root, string version = "13.3.0") + { + var manifestDir = Path.Combine(root, "installer-winget"); + var archiveRoot = Path.Combine(root, "installer-native-archives"); + // Archives live under Debug/Shipping/ when `gh run download` extracts the + // cli-native-archives- artifact — see comment above. + var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping"); + Directory.CreateDirectory(manifestDir); + Directory.CreateDirectory(archiveDir); + + var placeholder = new string('0', 64); + var installerYaml = $$""" + # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json + PackageIdentifier: Microsoft.Aspire + PackageVersion: "{{version}}" + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: aspire.exe + PortableCommandAlias: aspire + Installers: + - Architecture: x64 + InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-x64-{{version}}.zip + InstallerSha256: {{placeholder}} + - Architecture: arm64 + InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-arm64-{{version}}.zip + InstallerSha256: {{placeholder}} + ManifestType: installer + ManifestVersion: 1.10.0 + """; + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml"), installerYaml); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nManifestType: version\nManifestVersion: 1.10.0\n"); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.locale.en-US.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nPackageLocale: en-US\nManifestType: defaultLocale\nManifestVersion: 1.10.0\n"); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "dogfood.ps1"), "exit 0\n"); + + // Distinct fake bytes per RID so SHA256 differences flow into the mock install check. + // Real zips with a stub aspire.exe at the root, mirroring the contract real winget + // expects (NestedInstallerFiles[].RelativeFilePath must exist after extraction). + // The mock winget extracts these and checks the contract — see CreateMockWinGetBinAsync. + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-x64-{version}.zip"), $"stub-x64-{version}"); + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-arm64-{version}.zip"), $"stub-arm64-{version}"); + + return (manifestDir, archiveRoot); + } + + private static async Task CreateMockHomebrewBinAsync(TestEnvironment env, int aspireExitCode) + { + var mockBinDir = Path.Combine(env.TempDirectory, "mock-homebrew-bin"); + var brewRepository = Path.Combine(env.TempDirectory, "brew-repository"); + var brewPrefix = Path.Combine(env.TempDirectory, "brew-prefix"); + var brewLog = Path.Combine(env.TempDirectory, "brew.log"); + + Directory.CreateDirectory(mockBinDir); + Directory.CreateDirectory(brewRepository); + Directory.CreateDirectory(brewPrefix); + + var brewPath = Path.Combine(mockBinDir, "brew"); + await File.WriteAllTextAsync(brewPath, $$""" + #!/usr/bin/env bash + set -euo pipefail + echo "$*" >> "{{brewLog}}" + + create_aspire() { + cat > "{{mockBinDir}}/aspire" <<'ASPIRE' + #!/usr/bin/env bash + echo "mock aspire failure" + exit {{aspireExitCode}} + ASPIRE + chmod +x "{{mockBinDir}}/aspire" + } + + case "${1:-}" in + --repository) + echo "{{brewRepository}}" + exit 0 + ;; + --prefix) + echo "{{brewPrefix}}" + exit 0 + ;; + list) + exit 0 + ;; + tap-info) + exit 1 + ;; + tap-new) + tap="${@: -1}" + org="${tap%%/*}" + repo="${tap##*/}" + mkdir -p "{{brewRepository}}/Library/Taps/$org/homebrew-$repo" + exit 0 + ;; + style|audit|info) + exit 0 + ;; + install) + create_aspire + exit 0 + ;; + uninstall) + rm -f "{{mockBinDir}}/aspire" + exit 0 + ;; + untap) + exit 0 + ;; + esac + + echo "unexpected brew command: $*" >&2 + exit 1 + """); + FileHelper.MakeExecutable(brewPath); + + var curlPath = Path.Combine(mockBinDir, "curl"); + await File.WriteAllTextAsync(curlPath, """ + #!/usr/bin/env bash + printf '404' + exit 0 + """); + FileHelper.MakeExecutable(curlPath); + + return mockBinDir; + } + + // Mock winget for Windows. Mirrors enough of real winget's behaviour that + // dogfood.ps1 regressions surface here: + // * `validate --manifest ` and `install --manifest ` scan every file + // in and reject non-yaml entries. + // * `validate` enforces the schema rule that InstallerUrl matches ^https?://. + // * `install` actually downloads each InstallerUrl over the wire (via + // Invoke-WebRequest) and verifies the SHA256 of the bytes it received against + // InstallerSha256. This catches both schemes real winget can't fetch (real + // winget uses WinINet's InternetOpenUrl which doesn't support file://) and + // stale InstallerSha256 entries — both bugs we have already shipped in PRs. + // * `install` honors the manifest-vs-archive contract: for InstallerType: zip + // it extracts the downloaded zip and requires every NestedInstallerFiles[] + // .RelativeFilePath to exist after extraction. Real winget fails at this step + // with an opaque error (observed 0x8A150001 with no diag-log line beyond + // "Started applying motw"); catching it here makes the failure attributable. + private static async Task CreateMockWinGetBinAsync(TestEnvironment env, int aspireExitCode) + { + if (!OperatingSystem.IsWindows()) + { + throw new InvalidOperationException("Mock winget is Windows-only (winget itself is Windows-only)."); + } + + var mockBinDir = Path.Combine(env.TempDirectory, "mock-winget-bin"); + var wingetLog = Path.Combine(env.TempDirectory, "winget.log"); + + Directory.CreateDirectory(mockBinDir); + + // PowerShell implementation of the mock. Kept in a separate file so it can be + // edited as PowerShell rather than escaped through a .cmd shim. + var implPath = Path.Combine(mockBinDir, "winget-impl.ps1"); + await File.WriteAllTextAsync(implPath, $$""" + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Args) + $ErrorActionPreference = 'Stop' + $cmd = if ($Args.Count -ge 1) { $Args[0] } else { '' } + $rest = if ($Args.Count -ge 2) { $Args[1..($Args.Count - 1)] } else { @() } + Add-Content -LiteralPath '{{wingetLog.Replace("\\", "\\\\")}}' -Value ($Args -join ' ') + + switch ($cmd) { + 'list' { exit 1 } + 'settings' { exit 0 } + 'uninstall' { exit 0 } + { $_ -in 'validate','install' } { } + default { + Write-Error "Mock winget: unexpected command: $cmd" + exit 1 + } + } + + $manifestDir = $null + for ($i = 0; $i -lt $rest.Count - 1; $i++) { + if ($rest[$i] -eq '--manifest') { $manifestDir = $rest[$i + 1]; break } + } + if (-not $manifestDir -or -not (Test-Path -LiteralPath $manifestDir)) { + Write-Error "Mock winget: --manifest required" + exit 1 + } + + # Real winget treats every file in the manifest dir as a manifest and rejects + # non-yaml entries (e.g. "The manifest does not contain a valid root. + # File: dogfood.ps1"). Mirror that so manifest-staging regressions in + # dogfood.ps1 are caught here. + foreach ($f in Get-ChildItem -LiteralPath $manifestDir -File) { + if ($f.Extension -notin '.yaml', '.yml') { + Write-Error "Mock winget: non-yaml file in manifest dir: $($f.Name)" + exit 1 + } + } + + $installerYaml = Get-ChildItem -LiteralPath $manifestDir -File -Filter '*.installer.yaml' | Select-Object -First 1 + if (-not $installerYaml) { exit 0 } + + $entries = @() + $current = $null + $nestedFiles = @() + foreach ($line in Get-Content -LiteralPath $installerYaml.FullName) { + if ($line -match '^\s*InstallerUrl:\s*(\S+)\s*$') { + if ($current) { $entries += [pscustomobject]$current } + $current = @{ Url = $Matches[1]; Sha = $null } + } elseif ($line -match '^\s*InstallerSha256:\s*(\S+)\s*$' -and $current) { + $current.Sha = $Matches[1].ToUpperInvariant() + } elseif ($line -match '^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$') { + # NestedInstallerFiles is shared across all Installers in the manifest, + # not per-entry. Collect them once and apply to every downloaded zip. + $nestedFiles += $Matches[1] + } + } + if ($current) { $entries += [pscustomobject]$current } + + foreach ($e in $entries) { + # Schema rule: InstallerUrl must match ^https?://. Real winget enforces + # this in `validate`; in `install`, WinINet's InternetOpenUrl() rejects + # any other scheme (file://, ftp://, etc) with HRESULT 0x8007007b. Mock + # both layers so PR-script regressions can't sneak past tests. + if ($e.Url -notmatch '^(?i)https?://') { + Write-Error "Mock winget ${cmd}: InstallerUrl '$($e.Url)' does not match ^https?://" + exit 1 + } + } + + if ($cmd -eq 'install') { + foreach ($e in $entries) { + $tmp = New-TemporaryFile + $renamedZip = $null + $extractDir = $null + try { + try { + Invoke-WebRequest -Uri $e.Url -OutFile $tmp.FullName -UseBasicParsing -TimeoutSec 30 | Out-Null + } catch { + Write-Error "Mock winget install: failed to download $($e.Url): $_" + exit 1 + } + $actual = (Get-FileHash -LiteralPath $tmp.FullName -Algorithm SHA256).Hash.ToUpperInvariant() + if ($e.Sha -and $actual -ne $e.Sha) { + Write-Error "Mock winget install: InstallerSha256 mismatch for $($e.Url) (expected $($e.Sha), got $actual)" + exit 1 + } + + # Manifest-vs-archive contract: for InstallerType: zip with + # NestedInstallerType: portable, real winget extracts the zip + # and requires every NestedInstallerFiles[].RelativeFilePath to + # exist after extraction. If it doesn't, real winget fails at + # install time with an opaque error (we observed 0x8A150001 + # with no diag-log line beyond "Started applying motw"). Catch + # the contract violation here so the failure is attributable. + if ($nestedFiles.Count -gt 0) { + $renamedZip = "$($tmp.FullName).zip" + Move-Item -LiteralPath $tmp.FullName -Destination $renamedZip + $extractDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + try { + Expand-Archive -LiteralPath $renamedZip -DestinationPath $extractDir -Force + } catch { + Write-Error "Mock winget install: failed to extract $($e.Url): $_" + exit 1 + } + foreach ($rel in $nestedFiles) { + # RelativeFilePath uses '/' as separator in YAML even on Windows. + $relNative = $rel -replace '/', [System.IO.Path]::DirectorySeparatorChar + $expected = Join-Path $extractDir $relNative + if (-not (Test-Path -LiteralPath $expected)) { + Write-Error "Mock winget install: NestedInstallerFiles entry '$rel' is missing from extracted archive $($e.Url)" + exit 1 + } + } + } + } finally { + if ($extractDir -and (Test-Path -LiteralPath $extractDir)) { + Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue + } + if ($renamedZip -and (Test-Path -LiteralPath $renamedZip)) { + Remove-Item -LiteralPath $renamedZip -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $tmp.FullName) { + Remove-Item -LiteralPath $tmp.FullName -ErrorAction SilentlyContinue + } + } + } + } + + exit 0 + """); + + // .cmd shim so the mock is invokable as 'winget' from PATH (PowerShell honors + // PATHEXT and resolves 'winget' to winget.cmd before scanning system paths). + var wingetCmd = Path.Combine(mockBinDir, "winget.cmd"); + await File.WriteAllTextAsync(wingetCmd, """ + @echo off + pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0winget-impl.ps1" %* + """); + + var aspireCmd = Path.Combine(mockBinDir, "aspire.cmd"); + await File.WriteAllTextAsync(aspireCmd, $$""" + @echo off + echo mock aspire {{(aspireExitCode == 0 ? "version" : "failure")}} + exit /b {{aspireExitCode}} + """); + + return mockBinDir; + } + + private static async Task CreateFakeHomebrewArchivesAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-arm64-13.3.0.tar.gz"), "fake arm64 archive"); + await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-x64-13.3.0.tar.gz"), "fake x64 archive"); + } + + private static async Task CreateFakeWinGetArchivesAsync(string root) + { + var archiveDir = Path.Combine(root, "Debug", "Shipping"); + Directory.CreateDirectory(archiveDir); + // Real zips with a stub aspire.exe at the root so prepare-manifest-artifact.ps1 + // sees a valid archive layout and downstream contract tests (extraction + + // NestedInstallerFiles lookup) work against the same fixture. + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip"), "stub-x64-13.3.0"); + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip"), "stub-arm64-13.3.0"); + } + + private static async Task GetSha256HexAsync(string path) + { + var bytes = await File.ReadAllBytesAsync(path); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + + // Writes a real .zip containing a stub `aspire.exe` at the root, matching the + // top-level layout of the real aspire-cli-win-*.zip artifacts. The bytes are + // tiny stubs — winget and our tests only care about the extraction layout + // (NestedInstallerFiles[].RelativeFilePath = aspire.exe), not signed binary + // content. The `aspireExeContent` argument is hashed into the zip so callers + // can produce zips with distinct SHA256 hashes per RID. + private static async Task WriteRealAspireWinGetZipAsync(string path, string aspireExeContent) + { + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + if (File.Exists(path)) + { + File.Delete(path); + } + + await using var fs = File.Create(path); + using var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create); + var entry = archive.CreateEntry("aspire.exe", System.IO.Compression.CompressionLevel.NoCompression); + await using var s = entry.Open(); + await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes(aspireExeContent)); + } + + // Parses NestedInstallerFiles[].RelativeFilePath entries from an installer manifest + // YAML. Uses a regex instead of a YAML parser to avoid pulling in YamlDotNet just + // for the test project (we already do regex parsing of InstallerUrl/InstallerSha256 + // elsewhere in this file and in the mock winget script). + private static IReadOnlyList ParseRelativeFilePaths(string installerYaml) + { + var paths = new List(); + foreach (var rawLine in installerYaml.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + var m = System.Text.RegularExpressions.Regex.Match(line, @"^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$"); + if (m.Success) + { + paths.Add(m.Groups[1].Value); + } + } + return paths; + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_Help_DescribesInstallerModes() + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + + var result = await cmd.ExecuteAsync("--help"); + + result.EnsureSuccessful(); + Assert.Contains("winget", result.Output); + Assert.Contains("homebrew", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_WinGetMode_PrDryRun_DownloadsManifestAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "winget", + "--force", + "--dry-run", + "--skip-extension", + "--verbose"); + + result.EnsureSuccessful(); + Assert.Contains("winget-manifests-prerelease", result.Output); + Assert.Contains("cli-native-archives-win-x64", result.Output); + Assert.Contains("cli-native-archives-win-arm64", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("-Force", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewMode_PrDryRun_DownloadsCaskAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "homebrew", + "--dry-run", + "--skip-extension", + "--verbose"); + + result.EnsureSuccessful(); + Assert.Contains("homebrew-cask-prerelease", result.Output); + Assert.Contains("cli-native-archives-osx-arm64", result.Output); + Assert.Contains("cli-native-archives-osx-x64", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewMode_LocalDir_DryRun_UsesDogfoodArtifact() + { + using var env = new TestEnvironment(); + var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact")); + using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + + var result = await cmd.ExecuteAsync( + "--local-dir", localDir, + "--install-mode", "homebrew", + "--dry-run", + "--skip-path"); + + result.EnsureSuccessful(); + Assert.Contains("dogfood.sh", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("Would copy nugets", result.Output); + Assert.DoesNotContain("Would install CLI archive", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_InstallerMode_RejectsHiveOnly() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "homebrew", + "--hive-only", + "--dry-run", + "--skip-extension"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("--hive-only cannot be combined with --install-mode homebrew", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewDogfood_FailsWhenVersionCheckFails() + { + using var env = new TestEnvironment(); + var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact")); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/homebrew/dogfood.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync(Path.Combine(localDir, "aspire.rb")); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("aspire --version failed after install", result.Output); + } + + [Fact] + [RequiresTools(["ruby"])] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_PrepareHomebrewCask_FailedVerification_UninstallsCask() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeHomebrewArchivesAsync(archiveRoot); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync( + "--version", "13.3.0", + "--artifact-version", "13.3.0", + "--channel", "stable", + "--archive-root", archiveRoot, + "--output-dir", Path.Combine(env.TempDirectory, "homebrew-output")); + + Assert.NotEqual(0, result.ExitCode); + var brewLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "brew.log")); + Assert.Contains("uninstall --cask local/aspire-test/aspire", brewLog); + } + + [Fact] + [RequiresTools(["ruby"])] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_PrepareHomebrewCask_Offline_GeneratesCaskWithArchiveHashesAndDogfood() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeHomebrewArchivesAsync(archiveRoot); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 0); + var outputDir = Path.Combine(env.TempDirectory, "homebrew-output"); + using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync( + "--version", "13.3.0", + "--artifact-version", "13.3.0-pr.1234.abc", + "--channel", "prerelease", + "--archive-root", archiveRoot, + "--output-dir", outputDir, + "--validation-mode", "Offline"); + + result.EnsureSuccessful(); + + var cask = await File.ReadAllTextAsync(Path.Combine(outputDir, "aspire.rb")); + Assert.Contains("version \"13.3.0\"", cask); + Assert.Contains("https://ci.dot.net/public/aspire/13.3.0-pr.1234.abc/aspire-cli-osx-#{arch}-#{version}.tar.gz", cask); + Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-arm64-13.3.0.tar.gz"))).ToLowerInvariant(), cask); + Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-x64-13.3.0.tar.gz"))).ToLowerInvariant(), cask); + Assert.DoesNotContain("${", cask); + Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.sh"))); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_WinGetMode_WhatIf_DownloadsManifestAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "WinGet", + "-Force", + "-WhatIf", + "-SkipExtension", + "-Verbose"); + + result.EnsureSuccessful(); + Assert.Contains("winget-manifests-prerelease", result.Output); + Assert.Contains("cli-native-archives-win-x64", result.Output); + Assert.Contains("cli-native-archives-win-arm64", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("-Force", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("Route sidecar", result.Output); + Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_HomebrewMode_WhatIf_DownloadsCaskAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "Homebrew", + "-WhatIf", + "-SkipExtension", + "-Verbose"); + + result.EnsureSuccessful(); + Assert.Contains("homebrew-cask-prerelease", result.Output); + Assert.Contains("cli-native-archives-osx-arm64", result.Output); + Assert.Contains("cli-native-archives-osx-x64", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("Route sidecar", result.Output); + Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_WinGetMode_LocalDir_WhatIf_UsesDogfoodArtifact() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-LocalDir", localDir, + "-InstallMode", "WinGet", + "-WhatIf", + "-SkipPath"); + + result.EnsureSuccessful(); + Assert.Contains("dogfood.ps1", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("Copying built nugets", result.Output); + Assert.DoesNotContain("Installing Aspire CLI to", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_InstallerMode_RejectsHiveOnly() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "Homebrew", + "-HiveOnly", + "-WhatIf", + "-SkipExtension"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("-HiveOnly cannot be combined with -InstallMode Homebrew", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_PrepareWinGetManifest_GenerateOnly_GeneratesManifestsWithArchiveHashesAndDogfood() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeWinGetArchivesAsync(archiveRoot); + var outputDir = Path.Combine(env.TempDirectory, "winget-output"); + using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-Channel", "prerelease", + "-ArchiveRoot", archiveRoot, + "-OutputPath", outputDir, + "-ValidationMode", "GenerateOnly"); + + result.EnsureSuccessful(); + + var installerManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml")); + Assert.Contains("PackageVersion: \"13.3.0\"", installerManifest); + Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-x64-13.3.0.zip", installerManifest); + Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-arm64-13.3.0.zip", installerManifest); + Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip")), installerManifest); + Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-arm64-13.3.0.zip")), installerManifest); + Assert.DoesNotContain(new string('0', 64), installerManifest); + Assert.DoesNotContain("${", installerManifest); + + var localeManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.locale.en-US.yaml")); + Assert.Contains("For testing builds only. Prerelease package in stable manifest.", localeManifest); + Assert.True(File.Exists(Path.Combine(outputDir, "Microsoft.Aspire.yaml"))); + Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.ps1"))); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_Force_PassesForceToWingetInstall() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", localDir, "-Force"); + + result.EnsureSuccessful(); + var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log")); + Assert.Contains("install --manifest", wingetLog); + Assert.Contains("--force", wingetLog); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_FailsWhenVersionCheckFails() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", localDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Failed to verify Aspire CLI installation", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_ValidatesPristineAndInstallsRewrittenManifest() + { + // This mirrors get-aspire-cli-pr.ps1 -InstallMode WinGet's invocation of + // dogfood.ps1 -ArchiveRoot. End-to-end behaviours that have to hold: + // 1. ``winget validate`` runs against the pristine https:// URLs (the schema + // rejects anything that's not ^https?://). + // 2. ``Set-LocalInstallerSources`` rewrites InstallerUrl to point at the + // loopback HTTP listener (not file:// — winget's WinINet-based downloader + // rejects file:// with HRESULT 0x8007007b) and refreshes InstallerSha256 + // (PR-channel manifests ship with a placeholder of all zeros). + // 3. The mock then actually downloads the URL via Invoke-WebRequest and + // hashes the bytes, which exercises both points 1 and 2 end-to-end. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + result.EnsureSuccessful(); + + var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log")); + Assert.Contains("validate --manifest", wingetLog); + Assert.Contains("install --manifest", wingetLog); + + // The pristine manifest in the artifact directory must be untouched (re-runnable). + var originalInstaller = await File.ReadAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml")); + Assert.Contains("https://ci.dot.net/", originalInstaller); + Assert.Contains(new string('0', 64), originalInstaller); + Assert.DoesNotContain("file://", originalInstaller); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenArchiveBytesChange() + { + // Guard against silent regressions: if Set-LocalInstallerSources ever stops + // refreshing InstallerSha256, the mock winget install will download the + // archive over the loopback listener and detect the hash mismatch. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + + // Tamper the archive *after* the manifest's placeholder hash was written. The + // refreshed hash must reflect the new bytes for ``winget install`` to succeed. + // We replace with a valid zip (still extractable, still satisfies the + // NestedInstallerFiles contract) but with different stub contents — so only + // the hash refresh is being exercised, not the extraction layout check. + var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip"); + await WriteRealAspireWinGetZipAsync(x64Archive, "post-generate-mutated-x64-bytes"); + + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + result.EnsureSuccessful(); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents() + { + // Manifest-vs-archive contract: real winget extracts InstallerUrl's zip at + // install time and looks for every NestedInstallerFiles[].RelativeFilePath in + // the extracted contents. If the prepare-manifest-artifact.ps1 template ever + // drifts away from the actual archive layout — wrong RelativeFilePath, wrong + // CWD, missing file — real winget fails at install time with an opaque error + // (we observed 0x8A150001 with no diag-log line beyond "Started applying motw"). + // + // This test runs the real prepare script against real zips and checks the + // contract holds end-to-end without needing winget itself, so it can run on + // Linux CI as a fast deterministic gate. + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping"); + Directory.CreateDirectory(archiveDir); + var x64Zip = Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip"); + var arm64Zip = Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip"); + await WriteRealAspireWinGetZipAsync(x64Zip, "stub-x64-13.3.0"); + await WriteRealAspireWinGetZipAsync(arm64Zip, "stub-arm64-13.3.0"); + + var outputDir = Path.Combine(env.TempDirectory, "winget-output"); + using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-Channel", "prerelease", + "-ArchiveRoot", archiveRoot, + "-OutputPath", outputDir, + "-ValidationMode", "GenerateOnly"); + + result.EnsureSuccessful(); + + var installerYaml = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml")); + var relativeFilePaths = ParseRelativeFilePaths(installerYaml); + Assert.NotEmpty(relativeFilePaths); + + foreach (var archive in new[] { x64Zip, arm64Zip }) + { + var extractDir = Path.Combine(env.TempDirectory, $"extract-{Path.GetFileNameWithoutExtension(archive)}"); + System.IO.Compression.ZipFile.ExtractToDirectory(archive, extractDir); + foreach (var rel in relativeFilePaths) + { + var expected = Path.Combine(extractDir, rel.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(expected), + $"NestedInstallerFiles entry '{rel}' from {Path.GetFileName(archive)} not found at {expected}. Manifest disagrees with archive contents."); + } + } + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenNestedInstallerFileMissingFromArchive() + { + // Companion to PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents: + // exercises the same contract from the install side. The manifest declares + // NestedInstallerFiles: [RelativeFilePath: aspire.exe] but the served zip is + // rebuilt with only a sentinel file, no aspire.exe. Real winget would fail at + // install time with an opaque error (after the InstallerSha256 verify step); + // the mock catches the same contract violation deterministically with an + // attributable error message. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + + var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip"); + File.Delete(x64Archive); + await using (var fs = File.Create(x64Archive)) + using (var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("not-aspire.exe", System.IO.Compression.CompressionLevel.NoCompression); + await using var s = entry.Open(); + await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes("sentinel")); + } + + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("NestedInstallerFiles entry 'aspire.exe' is missing", result.Output); + } +} From 19123714204189ac5cd695f2b7c6ac94aa19c786 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 01:24:22 -0400 Subject: [PATCH 06/21] ci(installer-artifacts): upload CLI logs from smoke tests Run the installer artifact smoke-test commands with trace logging and always collect the Aspire CLI log directory into a workflow artifact. The console output usually shows the top-level failure, but the CLI writes the detailed exception and package/bundle diagnostics under .aspire/logs. Uploading those logs makes failures like bundle extraction issues debuggable without rerunning the job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/prepare-installer-artifacts.yml | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml index a715057fde3..ef4531c7890 100644 --- a/.github/workflows/prepare-installer-artifacts.yml +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -15,6 +15,11 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup .NET + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + - name: Download CLI archives uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -62,6 +67,81 @@ jobs: --archive-root "${GITHUB_WORKSPACE}/native-archives" \ "${GITHUB_WORKSPACE}/artifacts/installers/homebrew-cask/aspire.rb" + - name: Smoke test installed CLI (aspire new + restore) + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # winget added the shim under %LOCALAPPDATA%\Microsoft\WinGet\Links during the + # preceding step in this job. The runner shell that started before the install + # does not yet have that directory on PATH, so prepend it here. + $env:PATH = "$env:LOCALAPPDATA\Microsoft\WinGet\Links;" + $env:PATH + aspire --version + $work = Join-Path $env:RUNNER_TEMP "aspire-cli-smoke" + if (Test-Path $work) { Remove-Item -Recurse -Force $work } + New-Item -ItemType Directory -Path $work | Out-Null + Push-Location $work + try { + aspire --log-level trace new aspire-starter --name SmokeApp --output . --non-interactive --nologo --suppress-agent-init + if ($LASTEXITCODE -ne 0) { throw "aspire new failed: exit $LASTEXITCODE" } + aspire --log-level trace restore --non-interactive --nologo + if ($LASTEXITCODE -ne 0) { throw "aspire restore failed: exit $LASTEXITCODE" } + } + finally { + Pop-Location + } + + - name: Smoke test installed CLI (aspire new + restore) + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + # brew linked the binary into /opt/homebrew/bin which is already on PATH. + aspire --version + work="${RUNNER_TEMP}/aspire-cli-smoke" + rm -rf "$work" + mkdir -p "$work" + cd "$work" + aspire --log-level trace new aspire-starter --name SmokeApp --output . --non-interactive --nologo --suppress-agent-init + aspire --log-level trace restore --non-interactive --nologo + + - name: Collect Aspire CLI logs (Windows) + if: ${{ always() && inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $source = Join-Path $HOME ".aspire\logs" + $destination = Join-Path $env:RUNNER_TEMP "aspire-cli-logs" + if (Test-Path $source) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Copy-Item -Path $source -Destination $destination -Recurse -Force + } else { + Write-Host "No Aspire CLI logs found at $source" + } + + - name: Collect Aspire CLI logs (macOS) + if: ${{ always() && inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + source="${HOME}/.aspire/logs" + destination="${RUNNER_TEMP}/aspire-cli-logs" + if [[ -d "$source" ]]; then + mkdir -p "$destination" + cp -R "$source" "$destination/" + else + echo "No Aspire CLI logs found at $source" + fi + + - name: Upload Aspire CLI logs + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ inputs.installer == 'winget' && 'winget-aspire-cli-logs' || 'homebrew-aspire-cli-logs' }} + path: ${{ runner.temp }}/aspire-cli-logs + if-no-files-found: ignore + retention-days: 15 + - name: Upload installer artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: From c90bb0e702dc01dbb1df113a251e19573c340bce Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 02:22:46 -0400 Subject: [PATCH 07/21] fix(cli): resolve launcher links during layout discovery WinGet launches Aspire through a Links shim while the real package payload and bundle sidecar live under the Packages directory. Bundle extraction already resolves the launcher target before choosing the extraction root, but layout discovery validated relative to the raw shim path and rejected the freshly extracted bundle. Resolve process-path links during relative layout discovery, then fall back to the raw path if the resolved target does not contain a layout. This keeps custom symlink layouts working while allowing WinGet and Homebrew-style launchers to validate the package-owned bundle layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Layout/LayoutDiscovery.cs | 31 +++++++- .../LayoutDiscoveryReparsePointTests.cs | 74 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index e8dd5c1188b..30a76600576 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Utils; using Aspire.Shared; using Microsoft.Extensions.Logging; @@ -42,6 +43,12 @@ public LayoutDiscovery(ILogger logger) _logger = logger; } + /// + /// Overrides for relative-layout discovery. + /// Used in tests to simulate the CLI executable living at an arbitrary path. + /// + internal string? ProcessPathOverride { get; init; } + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) { // 1. Try environment variable for layout path @@ -138,18 +145,36 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryDiscoverRelativeLayout() { - // Get CLI executable location - var cliPath = Environment.ProcessPath; + var cliPath = ProcessPathOverride ?? Environment.ProcessPath; if (string.IsNullOrEmpty(cliPath)) { _logger.LogDebug("TryDiscoverRelativeLayout: ProcessPath is null or empty"); return null; } + var resolvedCliPath = CliPathHelper.ResolveSymlinkOrOriginalPath(cliPath, _logger); + if (!string.Equals(resolvedCliPath, cliPath, StringComparison.Ordinal)) + { + _logger.LogDebug("TryDiscoverRelativeLayout: Resolved CLI path {RawPath} -> {ResolvedPath}", cliPath, resolvedCliPath); + + var resolvedLayout = TryDiscoverRelativeLayout(resolvedCliPath); + if (resolvedLayout is not null) + { + return resolvedLayout; + } + + _logger.LogDebug("TryDiscoverRelativeLayout: No layout found relative to resolved CLI path; trying raw path {Path}.", cliPath); + } + + return TryDiscoverRelativeLayout(cliPath); + } + + private LayoutConfiguration? TryDiscoverRelativeLayout(string cliPath) + { var cliDir = Path.GetDirectoryName(cliPath); if (string.IsNullOrEmpty(cliDir)) { - _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from ProcessPath"); + _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from process path {Path}", cliPath); return null; } diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs index 849b616f2a5..ca29889cfd5 100644 --- a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs +++ b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs @@ -16,6 +16,68 @@ namespace Aspire.Cli.Tests.LayoutTests; /// public class LayoutDiscoveryReparsePointTests(ITestOutputHelper outputHelper) { + [Fact] + public void DiscoverLayout_ResolvesProcessPathSymlinkBeforeRelativeDiscovery() + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), + "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var realLayoutRoot = Path.Combine( + workspace.WorkspaceRoot.FullName, + "WinGet", + "Packages", + "Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe"); + CreateValidBundleLayout(realLayoutRoot); + + var realBinary = Path.Combine(realLayoutRoot, "aspire"); + File.WriteAllText(realBinary, "stub"); + + var linksDir = Path.Combine(workspace.WorkspaceRoot.FullName, "WinGet", "Links"); + Directory.CreateDirectory(linksDir); + var linkPath = Path.Combine(linksDir, "aspire"); + File.CreateSymbolicLink(linkPath, realBinary); + + var discovery = new LayoutDiscovery(NullLogger.Instance) + { + ProcessPathOverride = linkPath + }; + + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(realLayoutRoot, layout!.LayoutPath); + } + + [Fact] + public void DiscoverLayout_FallsBackToRawProcessPathWhenResolvedPathHasNoLayout() + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), + "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var rawLayoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "custom-layout"); + CreateValidBundleLayout(rawLayoutRoot); + + var realBinaryDir = Path.Combine(workspace.WorkspaceRoot.FullName, "real-binary"); + Directory.CreateDirectory(realBinaryDir); + var realBinary = Path.Combine(realBinaryDir, "aspire"); + File.WriteAllText(realBinary, "stub"); + + var linkPath = Path.Combine(rawLayoutRoot, "aspire"); + File.CreateSymbolicLink(linkPath, realBinary); + + var discovery = new LayoutDiscovery(NullLogger.Instance) + { + ProcessPathOverride = linkPath + }; + + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(rawLayoutRoot, layout!.LayoutPath); + } + [Fact] public void DiscoverLayout_ResolvesThroughReparsePoints() { @@ -54,4 +116,16 @@ public void DiscoverLayout_ResolvesThroughReparsePoints() ReparsePoint.RemoveIfExists(bundleLink); } } + + private static void CreateValidBundleLayout(string layoutRoot) + { + var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName); + var managedDir = Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName); + var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName); + Directory.CreateDirectory(managedDir); + Directory.CreateDirectory(dcpDir); + File.WriteAllText( + Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + "stub"); + } } From e49b2f0d50d2304c300479a0c7b21987d44d69dd Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 03:36:15 -0400 Subject: [PATCH 08/21] fix(installer): validate Homebrew casks in PR artifact prep Move Homebrew cask validation behind a shared script so GitHub Actions and Azure DevOps run the same syntax, style, and audit checks during artifact preparation. PR artifacts are not notarized, so Offline validation rewrites the cask URLs to loopback archive URLs and runs the Homebrew audit with signing disabled while still exercising download and extraction. Full release validation keeps the normal signing audit and writes the summary consumed by Homebrew submission. Also keep the cask itself aligned with Homebrew style by declaring the minimum supported macOS version and using an explicit %Q string for the install-route sidecar. Do not add a zap stanza because the cask should not remove shared ~/.aspire state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/README.md | 8 +- eng/homebrew/aspire.rb.template | 4 +- eng/homebrew/prepare-cask-artifact.sh | 191 +------- eng/homebrew/validate-cask-artifact.sh | 433 +++++++++++++++++++ eng/pipelines/templates/publish-homebrew.yml | 8 +- eng/pipelines/templates/publish-winget.yml | 119 +++++ 6 files changed, 578 insertions(+), 185 deletions(-) create mode 100755 eng/homebrew/validate-cask-artifact.sh diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index cb8397c11ce..f94333ec444 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -17,6 +17,7 @@ brew install --cask aspire # stable | `aspire.rb.template` | Cask template for stable releases | | `generate-cask.sh` | Downloads tarballs, computes SHA256 hashes, generates cask from template | | `prepare-cask-artifact.sh` | Prepares CI artifacts by generating, validating, and adding dogfood helpers | +| `validate-cask-artifact.sh` | Runs shared cask syntax, style, audit, and install validation used by GitHub Actions and Azure DevOps | | `dogfood.sh` | Installs a generated cask locally, optionally using downloaded native archive artifacts | ### Pipeline templates @@ -71,9 +72,14 @@ Prepare validation currently runs: 1. `ruby -c` for syntax validation 2. `brew style --fix` on the generated cask -3. `brew audit --cask --online`, or `brew audit --cask --new --online` when the cask does not yet exist upstream +3. `brew audit --cask --new --online local/aspire/aspire` 4. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask ...` followed by uninstall validation +PR artifact validation uses the same shared script and local tap, but rewrites +the cask URLs to loopback archive URLs and passes `--no-signing` to +`brew audit` because PR archives are not notarized release assets. Release +preparation keeps the full signing audit. + To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease` artifact and the `cli-native-archives-osx-*` artifacts into the same parent directory, then run: diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index 8df2cdc59db..7c9b9661573 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -15,10 +15,12 @@ cask "aspire" do skip "ci.dot.net artifact storage has no version index; Aspire release automation manages cask updates" end + depends_on macos: :monterey + binary "aspire" # Write the sidecar file postflight do - File.write("#{staged_path}/.aspire-install.json", %({"source":"brew"}\n)) + File.write("#{staged_path}/.aspire-install.json", %Q({"source":"brew"}\n)) end end diff --git a/eng/homebrew/prepare-cask-artifact.sh b/eng/homebrew/prepare-cask-artifact.sh index 7018f3f34b5..9e1e3bb1ca6 100755 --- a/eng/homebrew/prepare-cask-artifact.sh +++ b/eng/homebrew/prepare-cask-artifact.sh @@ -133,189 +133,22 @@ echo "" echo "Generated cask:" cat "$OUTPUT_FILE" -if [[ "$VALIDATION_MODE" != "GenerateOnly" ]]; then - cask_file="aspire.rb" - cask_name="aspire" - first_letter="${cask_name:0:1}" - target_path="Casks/$first_letter/$cask_file" - tap_name="local/aspire" - - cleanup() { - brew untap "$tap_name" >/dev/null 2>&1 || true - } - - if ! command -v brew >/dev/null 2>&1; then - if [[ "$VALIDATION_MODE" == "Full" ]]; then - echo "Error: brew is required for Full validation mode, but it was not found in PATH." >&2 - exit 1 - fi - - echo "Warning: brew was not found in PATH; skipping offline cask validation." >&2 - else - tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire" - trap cleanup EXIT - - echo "Validating Ruby syntax..." - ruby -c "$OUTPUT_FILE" - echo "Ruby syntax OK" - - brew tap-new --no-git "$tap_name" - mkdir -p "$tap_root/Casks" - cp "$OUTPUT_FILE" "$tap_root/Casks/$cask_file" - tapped_cask_path="$tap_root/Casks/$cask_file" - - echo "" - echo "Applying Homebrew style fixes in local tap..." - brew style --fix "$tapped_cask_path" - cp "$tapped_cask_path" "$OUTPUT_FILE" - echo "Homebrew style OK" - - echo "" - echo "Auditing cask via local tap..." - - audit_args=(--cask) - is_new_cask=false - - if [[ "$VALIDATION_MODE" == "Full" ]]; then - upstream_status_code="$(curl -sS -o /dev/null -w "%{http_code}" "https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path")" - if [[ "$upstream_status_code" == "404" ]]; then - is_new_cask=true - echo "Detected new upstream cask; running new-cask audit." - audit_args+=(--new) - elif [[ "$upstream_status_code" == "200" ]]; then - echo "Detected existing upstream cask; running standard audit." - else - echo "Error: Could not determine whether $target_path exists upstream (HTTP $upstream_status_code)" >&2 - exit 1 - fi - echo "Including --online audit (URLs are expected to be valid)." - audit_args+=(--online) - else - echo "Skipping upstream cask check and online audit (URLs are not expected to be valid for this build)." - fi - - audit_args+=("$tap_name/$cask_name") - audit_command="brew audit ${audit_args[*]}" - - audit_code=0 - brew audit "${audit_args[@]}" || audit_code=$? - - if [[ "$audit_code" -ne 0 ]]; then - echo "Error: brew audit failed" >&2 - exit "$audit_code" - fi - echo "brew audit passed" - - if [[ "$VALIDATION_MODE" == "Full" ]]; then - echo "Testing cask install/uninstall: $OUTPUT_FILE" - - echo "Verifying aspire is not already installed..." - if command -v aspire &>/dev/null; then - echo "Error: aspire command is already available before install - test environment is not clean" >&2 - exit 1 - fi - echo " Confirmed: aspire is not in PATH" - - test_tap_name="local/aspire-test" - test_tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" - - cleanup_test_tap() { - brew untap "$test_tap_name" >/dev/null 2>&1 || true - } - - test_cask_ref="$test_tap_name/$cask_name" - test_cask_installed=false - - cleanup_test_install() { - if [[ "$test_cask_installed" == true ]]; then - brew uninstall --cask "$test_cask_ref" >/dev/null 2>&1 || true - fi - } - - trap 'cleanup_test_install; cleanup; cleanup_test_tap' EXIT - - echo "Setting up local tap..." - brew tap-new --no-git "$test_tap_name" - mkdir -p "$test_tap_root/Casks" - cp "$OUTPUT_FILE" "$test_tap_root/Casks/$cask_file" - - echo "Installing aspire from local tap..." - test_cask_installed=true - HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$test_cask_ref" - echo "Install succeeded" - - echo "Verifying aspire CLI is in PATH..." - if ! command -v aspire &>/dev/null; then - echo "Error: aspire command not found in PATH after install" >&2 - brew info --cask "$test_tap_name/$cask_name" || true - exit 1 - fi - - echo " Path: $(command -v aspire)" - aspire_version="$(aspire --version 2>&1)" - echo " Version: $aspire_version" - echo "aspire CLI verified" - - echo "Uninstalling aspire..." - brew uninstall --cask "$test_cask_ref" - test_cask_installed=false - echo "Uninstall succeeded" - - cleanup_test_tap - - if command -v aspire &>/dev/null; then - echo "Error: aspire command still found in PATH after uninstall" >&2 - exit 1 - else - echo " Confirmed: aspire is no longer in PATH" - fi - - validation_summary="$OUTPUT_DIR/validation-summary.json" - if [[ "$CHANNEL" == "stable" ]]; then - is_stable_release=true - else - is_stable_release=false - fi - cat > "$validation_summary" <&2; usage ;; + esac +done + +case "$VALIDATION_MODE" in + Full|Offline|GenerateOnly) ;; + full) VALIDATION_MODE="Full" ;; + offline) VALIDATION_MODE="Offline" ;; + generateonly|generate-only) VALIDATION_MODE="GenerateOnly" ;; + *) echo "Error: --validation-mode must be Full, Offline, or GenerateOnly." >&2; exit 1 ;; +esac + +case "$CHANNEL" in + stable|prerelease) ;; + *) echo "Error: --channel must be 'stable' or 'prerelease'." >&2; exit 1 ;; +esac + +if [[ -z "$CASK_FILE" ]]; then + echo "Error: --cask-file is required." >&2 + usage +fi + +if [[ ! -f "$CASK_FILE" ]]; then + echo "Error: cask file not found: $CASK_FILE" >&2 + exit 1 +fi + +CASK_FILE="$(cd "$(dirname "$CASK_FILE")" && pwd)/$(basename "$CASK_FILE")" +CASK_NAME="$(basename "$CASK_FILE" .rb)" + +if [[ "$CASK_NAME" != "aspire" ]]; then + echo "Error: only the aspire cask is supported; got '$CASK_NAME'." >&2 + exit 1 +fi + +if [[ "$VALIDATION_MODE" == "GenerateOnly" ]]; then + echo "Skipping Homebrew cask validation because validation mode is GenerateOnly." + exit 0 +fi + +read_cask_version() { + local cask_file="$1" + awk -F'"' '/^[[:space:]]*version[[:space:]]+"/ { print $2; exit }' "$cask_file" +} + +# Detects whether the Aspire cask already exists in the upstream +# Homebrew/homebrew-cask repository. Echoes 'true' if the cask is new (404 from +# the contents API), 'false' if it already exists (200), and exits non-zero on +# any other response so we don't silently mis-classify the audit mode. +# +# brew audit treats new submissions and updates differently: `--new` enables +# extra checks (e.g. canonical naming) that fail on existing casks. The PR body +# template downstream also keys "validated as a new upstream cask" off the same +# signal, so getting this wrong produces misleading review text in the bot PR. +detect_upstream_cask_is_new() { + local cask_name="$1" + local first_letter="${cask_name:0:1}" + local target_path="Casks/$first_letter/$cask_name.rb" + local api_url="https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path" + local status_code + + status_code="$(curl -sS -o /dev/null -w "%{http_code}" "$api_url")" + case "$status_code" in + 200) echo "false" ;; + 404) echo "true" ;; + *) echo "Error: could not determine whether $target_path exists upstream (HTTP $status_code)" >&2; exit 1 ;; + esac +} + +find_archive() { + local archive_root="$1" + local archive_name="$2" + local matches=() + local match + + while IFS= read -r match; do + matches+=("$match") + done < <(find "$archive_root" -type f -name "$archive_name" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: could not find $archive_name under $archive_root" >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: found multiple $archive_name archives under $archive_root:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + printf '%s' "${matches[0]}" +} + +start_archive_server() { + local archive_root="$1" + local version="$2" + local server_root="$3" + local port_file="$4" + + local arm_archive + local x64_archive + arm_archive="$(find_archive "$archive_root" "aspire-cli-osx-arm64-$version.tar.gz")" + x64_archive="$(find_archive "$archive_root" "aspire-cli-osx-x64-$version.tar.gz")" + + mkdir -p "$server_root" + cp "$arm_archive" "$server_root/aspire-cli-osx-arm64-$version.tar.gz" + cp "$x64_archive" "$server_root/aspire-cli-osx-x64-$version.tar.gz" + + python3 - "$server_root" "$port_file" <<'PY' & +import http.server +import socketserver +import sys + +root = sys.argv[1] +port_file = sys.argv[2] + +class ArchiveHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root, **kwargs) + + def log_message(self, format, *args): + pass + + def copyfile(self, source, outputfile): + try: + super().copyfile(source, outputfile) + except BrokenPipeError: + pass + +class ArchiveServer(socketserver.TCPServer): + allow_reuse_address = True + +with ArchiveServer(("127.0.0.1", 0), ArchiveHandler) as httpd: + with open(port_file, "w", encoding="utf-8") as f: + f.write(str(httpd.server_address[1])) + httpd.serve_forever() +PY + + ARCHIVE_SERVER_PID=$! + + for _ in {1..100}; do + if [[ -s "$port_file" ]]; then + return + fi + sleep 0.1 + done + + echo "Error: timed out waiting for local archive server to start." >&2 + exit 1 +} + +rewrite_cask_for_local_archives() { + local cask_file="$1" + local local_url_prefix="$2" + local verified_host="$3" + + ruby - "$cask_file" "$local_url_prefix" "$verified_host" <<'RUBY' +cask_path = ARGV[0] +local_url_prefix = ARGV[1] +verified_host = ARGV[2] +lines = File.readlines(cask_path, chomp: true) +rewritten = [] +skip_verified = false + +lines.each do |line| + stripped = line.strip + if stripped.start_with?('url "https://ci.dot.net/public/aspire/') + rewritten << " url \"#{local_url_prefix}/aspire-cli-osx-\#{arch}-\#{version}.tar.gz\"," + rewritten << " verified: \"#{verified_host}\"" + skip_verified = true + next + end + + if skip_verified && stripped.start_with?('verified: "ci.dot.net/public/aspire/"') + skip_verified = false + next + end + + skip_verified = false + rewritten << line +end + +File.write(cask_path, rewritten.join("\n") + "\n") +RUBY +} + +TAP_NAME="local/aspire" +TAP_ROOT="" +ARCHIVE_SERVER_PID="" +TEMP_DIR="" + +remove_tap() { + local tap_name="$1" + + brew untap "$tap_name" >/dev/null 2>&1 || true + + local tap_org="${tap_name%%/*}" + local tap_repo="${tap_name##*/}" + local tap_root + tap_root="$(brew --repository)/Library/Taps/$tap_org/homebrew-$tap_repo" + rm -rf "$tap_root" +} + +cleanup() { + remove_tap "$TAP_NAME" + remove_tap "local/aspire-test" + + if [[ -n "$ARCHIVE_SERVER_PID" ]]; then + kill "$ARCHIVE_SERVER_PID" >/dev/null 2>&1 || true + wait "$ARCHIVE_SERVER_PID" >/dev/null 2>&1 || true + fi + + if [[ -n "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} + +echo "Validating Ruby syntax..." +ruby -c "$CASK_FILE" +echo "ruby -c aspire.rb succeeded." + +if ! command -v brew >/dev/null 2>&1; then + if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "Error: brew is required for Full validation mode, but it was not found in PATH." >&2 + exit 1 + fi + + echo "Warning: brew was not found in PATH; skipping Homebrew style and audit validation." >&2 + exit 0 +fi + +trap cleanup EXIT +remove_tap "$TAP_NAME" +brew tap-new --no-git "$TAP_NAME" + +TAP_ROOT="$(brew --repository)/Library/Taps/local/homebrew-aspire" +mkdir -p "$TAP_ROOT/Casks" +TAPPED_CASK_PATH="$TAP_ROOT/Casks/aspire.rb" +cp "$CASK_FILE" "$TAPPED_CASK_PATH" + +echo "" +echo "Applying Homebrew style fixes in local tap..." +brew style --fix "$TAPPED_CASK_PATH" +cp "$TAPPED_CASK_PATH" "$CASK_FILE" +brew style --cask "$TAPPED_CASK_PATH" +echo "brew style --fix reported no offenses after copying aspire.rb into a temporary local tap." + +AUDIT_CASK_PATH="$TAPPED_CASK_PATH" +AUDIT_NOTE="" + +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + if [[ -z "$ARCHIVE_ROOT" ]]; then + echo "Skipping online audit because validation mode is Offline and no archive root was provided." + trap - EXIT + cleanup + exit 0 + fi + + if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required to run Offline online audit against local archive URLs." >&2 + exit 1 + fi + + cask_version="$(read_cask_version "$CASK_FILE")" + if [[ -z "$cask_version" ]]; then + echo "Error: could not read version from $CASK_FILE" >&2 + exit 1 + fi + + TEMP_DIR="$(mktemp -d)" + port_file="$TEMP_DIR/archive-server.port" + start_archive_server "$ARCHIVE_ROOT" "$cask_version" "$TEMP_DIR/archives" "$port_file" + archive_port="$(cat "$port_file")" + + cp "$CASK_FILE" "$AUDIT_CASK_PATH" + rewrite_cask_for_local_archives "$AUDIT_CASK_PATH" "http://127.0.0.1:$archive_port" "127.0.0.1:$archive_port/" + AUDIT_NOTE=" against local archive URLs" +fi + +echo "" +echo "Determining whether $CASK_NAME is a new upstream cask..." +IS_NEW_CASK="$(detect_upstream_cask_is_new "$CASK_NAME")" +if [[ "$IS_NEW_CASK" == "true" ]]; then + echo "Detected new upstream cask; running new-cask audit." +else + echo "Detected existing upstream cask; running standard audit." +fi + +echo "" +echo "Auditing cask via local tap..." +audit_args=(--cask --online) +if [[ "$IS_NEW_CASK" == "true" ]]; then + audit_args+=(--new) +fi +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + audit_args+=(--no-signing) +fi +audit_args+=("$TAP_NAME/$CASK_NAME") +audit_command="brew audit ${audit_args[*]}" +brew audit "${audit_args[@]}" +echo "$audit_command worked successfully$AUDIT_NOTE." + +if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "" + echo "Testing cask install/uninstall: $CASK_FILE" + + if command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command is already available before install; test environment is not clean." >&2 + exit 1 + fi + + test_tap_name="local/aspire-test" + test_tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" + test_cask_ref="$test_tap_name/$CASK_NAME" + test_cask_installed=false + + cleanup_test_install() { + if [[ "$test_cask_installed" == true ]]; then + brew uninstall --cask "$test_cask_ref" >/dev/null 2>&1 || true + fi + } + + trap 'cleanup_test_install; cleanup' EXIT + + brew tap-new --no-git "$test_tap_name" + mkdir -p "$test_tap_root/Casks" + cp "$CASK_FILE" "$test_tap_root/Casks/aspire.rb" + + test_cask_installed=true + HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$test_cask_ref" + + if ! command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command not found in PATH after install." >&2 + brew info --cask "$test_cask_ref" || true + exit 1 + fi + + echo " Path: $(command -v aspire)" + aspire_version="$(aspire --version 2>&1)" + echo " Version: $aspire_version" + + brew uninstall --cask "$test_cask_ref" + test_cask_installed=false + + if command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command still found in PATH after uninstall." >&2 + exit 1 + fi +fi + +if [[ -n "$SUMMARY_PATH" && "$VALIDATION_MODE" == "Full" ]]; then + if [[ "$CHANNEL" == "stable" ]]; then + is_stable_release=true + else + is_stable_release=false + fi + + mkdir -p "$(dirname "$SUMMARY_PATH")" + cat > "$SUMMARY_PATH" < Date: Wed, 20 May 2026 03:58:17 -0400 Subject: [PATCH 09/21] fix(installer): keep Homebrew dogfood compatible with hosted macOS Remove the Homebrew cask macOS dependency from the generated Aspire cask. The dependency is optional metadata, and GitHub's macOS 15 runner rejected local dogfood installs with "This software does not run on macOS versions other than Monterey." Keeping the cask dependency-free preserves the existing dogfood behavior while the shared cask validation still catches syntax, style, audit, download, and extraction issues. Also suppress benign connection reset traces from the loopback archive server used during offline Homebrew audit validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/aspire.rb.template | 2 -- eng/homebrew/validate-cask-artifact.sh | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index 7c9b9661573..8f6c1d584bd 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -15,8 +15,6 @@ cask "aspire" do skip "ci.dot.net artifact storage has no version index; Aspire release automation manages cask updates" end - depends_on macos: :monterey - binary "aspire" # Write the sidecar file diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh index 8f205a6f672..fc9b2d2e4ef 100755 --- a/eng/homebrew/validate-cask-artifact.sh +++ b/eng/homebrew/validate-cask-artifact.sh @@ -162,7 +162,7 @@ class ArchiveHandler(http.server.SimpleHTTPRequestHandler): def copyfile(self, source, outputfile): try: super().copyfile(source, outputfile) - except BrokenPipeError: + except (BrokenPipeError, ConnectionResetError): pass class ArchiveServer(socketserver.TCPServer): From e8fce4f2d6e2f0d5ce4fcbb2cdd7ee2a30b4e6a9 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 17:26:57 -0400 Subject: [PATCH 10/21] ci(release): guard WinGet/Homebrew submit on production branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `🟣Submit to WinGet` and `🟣Submit PR to Homebrew/homebrew-cask` steps in `release-publish-nuget.yml` previously ran on any branch when `dryRun=false`. 1ES PT's branch validation logs a non-blocking warning when the source branch isn't a production branch, but the submission still goes through — so a misqueued run on a feature, fork, or dogfood branch could open a PR upstream on Homebrew/homebrew-cask or microsoft/winget-pkgs. Add a hard condition on each submit step that only allows execution when `Build.SourceBranch` is `refs/heads/main`, `refs/heads/release/*` or `refs/heads/internal/release/*` — matching the existing "production branch" predicate already used elsewhere in the pipeline YAML (`azure-pipelines.yml` `_RunAsInternalBuild` / `$isReleaseBranch`). On a non-production branch the steps now skip cleanly, with the displayed step name in the run summary making the gating visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/publish-homebrew.yml | 14 +++++++++++- eng/pipelines/templates/publish-winget.yml | 24 +++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/eng/pipelines/templates/publish-homebrew.yml b/eng/pipelines/templates/publish-homebrew.yml index fc26076ca9a..1b22ceac790 100644 --- a/eng/pipelines/templates/publish-homebrew.yml +++ b/eng/pipelines/templates/publish-homebrew.yml @@ -505,6 +505,18 @@ steps: Write-Host "Draft PR created successfully: #$($createdPr.number) $($createdPr.html_url)" } displayName: '🟣Submit PR to Homebrew/homebrew-cask' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Guard: only submit to Homebrew from production branches (main / release/* / internal/release/*). + # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; + # a hard condition stops accidental submissions from feature, fork or dogfood branches. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + or( + eq(variables['Build.SourceBranch'], 'refs/heads/main'), + startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), + startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/') + ) + ) env: HOMEBREW_CASK_GITHUB_TOKEN: $(aspire-homebrew-bot-pat) diff --git a/eng/pipelines/templates/publish-winget.yml b/eng/pipelines/templates/publish-winget.yml index c219ef38374..5b1c7622fa6 100644 --- a/eng/pipelines/templates/publish-winget.yml +++ b/eng/pipelines/templates/publish-winget.yml @@ -174,6 +174,16 @@ steps: Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile "$(Build.StagingDirectory)/wingetcreate.exe" Write-Host "wingetcreate downloaded successfully" displayName: '🟣Install wingetcreate' + # Skip when no Submit step will run. wingetcreate is only used to submit to + # the upstream winget-pkgs repo, so dry-run and non-production-branch builds + # gain nothing from downloading it and instead pick up an aka.ms-redirect + # failure surface for free. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -342,6 +352,18 @@ steps: Write-Host "WinGet PR #$pullRequestNumber is draft: $pullRequestUrl" Write-Host "Successfully submitted WinGet manifest for $packageId $version" displayName: '🟣Submit to WinGet' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Guard: only submit to WinGet from production branches (main / release/* / internal/release/*). + # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; + # a hard condition stops accidental submissions from feature, fork or dogfood branches. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + or( + eq(variables['Build.SourceBranch'], 'refs/heads/main'), + startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), + startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/') + ) + ) env: WINGET_CREATE_GITHUB_TOKEN: $(aspire-winget-bot-pat) From 95b8713c4710e834b1e88d1662ee512451e00780 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 14:52:06 -0400 Subject: [PATCH 11/21] Add empty zap stanza to Homebrew cask template Upstream homebrew/cask labels generated PRs with `missing-zap` when no zap stanza is present. Add an explicit `zap trash: []` to silence the label without removing any user files. `~/.aspire` is shared with non-Homebrew Aspire state (PR hives, dev certs, telemetry, runtime sockets, manually installed CLIs) that must outlive `brew uninstall --zap aspire`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/aspire.rb.template | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index 8f6c1d584bd..326b489b494 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -21,4 +21,11 @@ cask "aspire" do postflight do File.write("#{staged_path}/.aspire-install.json", %Q({"source":"brew"}\n)) end + + # Intentionally empty: `brew uninstall --zap aspire` should not remove + # `~/.aspire` because that directory is shared with non-Homebrew Aspire + # state (PR hives, dev certs, telemetry, runtime sockets, manually + # installed CLIs). An explicit empty `zap` silences the `missing-zap` + # audit warning and the corresponding upstream homebrew/cask PR label. + zap trash: [] end From 2d906251535d307c95ea1d01febc2a4a68b090d3 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 19:11:34 -0400 Subject: [PATCH 12/21] ci(installers): share production-branch guard via _IsProductionBranch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set of branches that publish — main, release/*, internal/release/* — was hard-coded in three places: * publish-homebrew.yml condition guard * publish-winget.yml condition guard * _PackagesPublished in azure-pipelines.yml Adding or removing a publishing branch required editing all three. The two publish-template guards in particular drift independently because they're far from the build-pipeline definition. Hoist the branch set into _IsProductionBranch in common-variables.yml. _PackagesPublished now composes it with the existing non-PR / internal checks, and the publish-template guards reduce to one line: eq(variables['_IsProductionBranch'], 'true') release-publish-nuget.yml already imports common-variables.yml, so the publish templates see the new variable. Cross-file template-variable references inside ${{ }} expressions already work here — the previous _PackagesPublished definition read _RunAsPublic and Build.Reason the same way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/azure-pipelines.yml | 9 +++++---- eng/pipelines/common-variables.yml | 8 ++++++++ eng/pipelines/templates/publish-homebrew.yml | 9 +++------ eng/pipelines/templates/publish-winget.yml | 9 +++------ 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 691bf80f91a..f10008de840 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -95,11 +95,12 @@ variables: - name: _Sign value: true - # Packages are published only on internal, non-PR builds on publishing branches - # (main, release/*, internal/release/*). Feature branches and PR builds don't - # have Maestro default channels so archive URLs won't be valid. + # Packages are published only on internal, non-PR builds on publishing branches. + # Feature branches and PR builds don't have Maestro default channels so archive + # URLs won't be valid. The branch set lives in common-variables.yml as + # _IsProductionBranch. - name: _PackagesPublished - value: ${{ and(ne(variables['_RunAsPublic'], 'true'), ne(variables['Build.Reason'], 'PullRequest'), or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'))) }} + value: ${{ and(ne(variables['_RunAsPublic'], 'true'), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['_IsProductionBranch'], 'true')) }} resources: repositories: diff --git a/eng/pipelines/common-variables.yml b/eng/pipelines/common-variables.yml index 10cfcc1a309..ec6d5519da7 100644 --- a/eng/pipelines/common-variables.yml +++ b/eng/pipelines/common-variables.yml @@ -33,3 +33,11 @@ variables: - ${{ else }}: - name: PostBuildSign value: true + + # Single source of truth for "this build is on a branch where Aspire signs, + # publishes archives to ci.dot.net, and submits to upstream installer + # repositories." Composed into _PackagesPublished (which adds non-PR/internal + # checks) and consumed directly by publish-homebrew.yml / publish-winget.yml + # to guard upstream submission. + - name: _IsProductionBranch + value: ${{ or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/')) }} diff --git a/eng/pipelines/templates/publish-homebrew.yml b/eng/pipelines/templates/publish-homebrew.yml index 1b22ceac790..d1e556bc3d0 100644 --- a/eng/pipelines/templates/publish-homebrew.yml +++ b/eng/pipelines/templates/publish-homebrew.yml @@ -505,18 +505,15 @@ steps: Write-Host "Draft PR created successfully: #$($createdPr.number) $($createdPr.html_url)" } displayName: '🟣Submit PR to Homebrew/homebrew-cask' - # Guard: only submit to Homebrew from production branches (main / release/* / internal/release/*). + # Guard: only submit to Homebrew from production branches. # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; # a hard condition stops accidental submissions from feature, fork or dogfood branches. + # _IsProductionBranch is defined in eng/pipelines/common-variables.yml. condition: | and( succeeded(), eq('${{ parameters.dryRun }}', 'false'), - or( - eq(variables['Build.SourceBranch'], 'refs/heads/main'), - startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), - startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/') - ) + eq(variables['_IsProductionBranch'], 'true') ) env: HOMEBREW_CASK_GITHUB_TOKEN: $(aspire-homebrew-bot-pat) diff --git a/eng/pipelines/templates/publish-winget.yml b/eng/pipelines/templates/publish-winget.yml index 5b1c7622fa6..e89085628ed 100644 --- a/eng/pipelines/templates/publish-winget.yml +++ b/eng/pipelines/templates/publish-winget.yml @@ -352,18 +352,15 @@ steps: Write-Host "WinGet PR #$pullRequestNumber is draft: $pullRequestUrl" Write-Host "Successfully submitted WinGet manifest for $packageId $version" displayName: '🟣Submit to WinGet' - # Guard: only submit to WinGet from production branches (main / release/* / internal/release/*). + # Guard: only submit to WinGet from production branches. # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; # a hard condition stops accidental submissions from feature, fork or dogfood branches. + # _IsProductionBranch is defined in eng/pipelines/common-variables.yml. condition: | and( succeeded(), eq('${{ parameters.dryRun }}', 'false'), - or( - eq(variables['Build.SourceBranch'], 'refs/heads/main'), - startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'), - startsWith(variables['Build.SourceBranch'], 'refs/heads/internal/release/') - ) + eq(variables['_IsProductionBranch'], 'true') ) env: WINGET_CREATE_GITHUB_TOKEN: $(aspire-winget-bot-pat) From 3763a14c103bab93c8fe5d23da8ce91bcdbab42a Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 19:11:45 -0400 Subject: [PATCH 13/21] ci(installers): extract installed-CLI smoke test to shared scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare-installer-artifacts.yml carried two near-identical inline smoke tests — one pwsh, one bash — that scaffold an aspire-starter project and run restore against the just-installed CLI. The two blocks drift independently and there is no equivalent in AzDO: the only post-install check inside validate-cask-artifact.sh and prepare-manifest-artifact.ps1 is `aspire --version`. If AzDO ever wants the same coverage there will be a third copy. Extract to eng/scripts/smoke-installed-cli.{sh,ps1}. The workflow now calls them after the installer-specific PATH refresh, which stays inline because it describes the GitHub Actions session state, not the smoke logic. AzDO is not adopted in this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/prepare-installer-artifacts.yml | 23 +-------- eng/scripts/smoke-installed-cli.ps1 | 45 +++++++++++++++++ eng/scripts/smoke-installed-cli.sh | 49 +++++++++++++++++++ 3 files changed, 96 insertions(+), 21 deletions(-) create mode 100644 eng/scripts/smoke-installed-cli.ps1 create mode 100755 eng/scripts/smoke-installed-cli.sh diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml index ef4531c7890..2119d19929d 100644 --- a/.github/workflows/prepare-installer-artifacts.yml +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -76,20 +76,7 @@ jobs: # preceding step in this job. The runner shell that started before the install # does not yet have that directory on PATH, so prepend it here. $env:PATH = "$env:LOCALAPPDATA\Microsoft\WinGet\Links;" + $env:PATH - aspire --version - $work = Join-Path $env:RUNNER_TEMP "aspire-cli-smoke" - if (Test-Path $work) { Remove-Item -Recurse -Force $work } - New-Item -ItemType Directory -Path $work | Out-Null - Push-Location $work - try { - aspire --log-level trace new aspire-starter --name SmokeApp --output . --non-interactive --nologo --suppress-agent-init - if ($LASTEXITCODE -ne 0) { throw "aspire new failed: exit $LASTEXITCODE" } - aspire --log-level trace restore --non-interactive --nologo - if ($LASTEXITCODE -ne 0) { throw "aspire restore failed: exit $LASTEXITCODE" } - } - finally { - Pop-Location - } + & "$env:GITHUB_WORKSPACE/eng/scripts/smoke-installed-cli.ps1" - name: Smoke test installed CLI (aspire new + restore) if: ${{ inputs.installer == 'homebrew' }} @@ -97,13 +84,7 @@ jobs: run: | set -euo pipefail # brew linked the binary into /opt/homebrew/bin which is already on PATH. - aspire --version - work="${RUNNER_TEMP}/aspire-cli-smoke" - rm -rf "$work" - mkdir -p "$work" - cd "$work" - aspire --log-level trace new aspire-starter --name SmokeApp --output . --non-interactive --nologo --suppress-agent-init - aspire --log-level trace restore --non-interactive --nologo + eng/scripts/smoke-installed-cli.sh - name: Collect Aspire CLI logs (Windows) if: ${{ always() && inputs.installer == 'winget' }} diff --git a/eng/scripts/smoke-installed-cli.ps1 b/eng/scripts/smoke-installed-cli.ps1 new file mode 100644 index 00000000000..50bd8e70a3f --- /dev/null +++ b/eng/scripts/smoke-installed-cli.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS + Smoke-tests the already-installed aspire CLI. + +.DESCRIPTION + Scaffolds an aspire-starter project and runs its restore, against whatever + 'aspire' is first on PATH. Assumes the CLI has already been installed (via + WinGet manifest, dotnet-tool, Homebrew cask, archive script, etc.). + + Catches regressions that only show up once the installed bits actually + launch — broken launcher resolution, missing layout assets, packaging-time + PATH issues, etc. +#> + +[CmdletBinding()] +param( + [string]$WorkDir, + [string]$ProjectName = 'SmokeApp', + [string]$LogLevel = 'trace' +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +if ([string]::IsNullOrWhiteSpace($WorkDir)) { + $base = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $WorkDir = Join-Path $base 'aspire-cli-smoke' +} + +aspire --version + +if (Test-Path $WorkDir) { + Remove-Item -Recurse -Force $WorkDir +} + +New-Item -ItemType Directory -Path $WorkDir | Out-Null + +Push-Location $WorkDir +try { + aspire --log-level $LogLevel new aspire-starter --name $ProjectName --output . --non-interactive --nologo --suppress-agent-init + aspire --log-level $LogLevel restore --non-interactive --nologo +} +finally { + Pop-Location +} diff --git a/eng/scripts/smoke-installed-cli.sh b/eng/scripts/smoke-installed-cli.sh new file mode 100755 index 00000000000..4a1e8132fd2 --- /dev/null +++ b/eng/scripts/smoke-installed-cli.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Smoke-tests the already-installed `aspire` CLI by scaffolding a starter +# project and running its restore. Assumes `aspire` is on PATH. +# +# Used by CI after a real installer run (Homebrew cask / WinGet manifest / +# dotnet-tool / archive script) to catch regressions that only show up once the +# installed bits actually launch — broken launcher resolution, missing layout +# assets, packaging-time PATH issues, etc. +set -euo pipefail + +usage() { + cat <&2; usage >&2; exit 1 ;; + esac +done + +if [[ -z "$WORK_DIR" ]]; then + WORK_DIR="${RUNNER_TEMP:-/tmp}/aspire-cli-smoke" +fi + +aspire --version + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" +cd "$WORK_DIR" + +aspire --log-level "$LOG_LEVEL" new aspire-starter --name "$PROJECT_NAME" --output . --non-interactive --nologo --suppress-agent-init +aspire --log-level "$LOG_LEVEL" restore --non-interactive --nologo From cdab0f8b94fb43d5f57cc4cde1eb96c5f4cbe927 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 20:45:47 -0400 Subject: [PATCH 14/21] ci(homebrew): authenticate the upstream-cask existence probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Prepare Homebrew cask artifact" job in prepare-installer-artifacts.yml failed on PRs with: Determining whether aspire is a new upstream cask... Error: could not determine whether Casks/a/aspire.rb exists upstream (HTTP 403) ##[error]Process completed with exit code 1. The probe in validate-cask-artifact.sh::detect_upstream_cask_is_new makes an unauthenticated GET to api.github.com to classify the cask as new vs. update. Unauthenticated requests share a 60/hour rate-limit pool across the runner IP — easy to exhaust on GH-hosted macOS — and return 403 when throttled. The script treats any non-200/404 as a fatal misclassification (the audit mode and the eventual bot PR body both key off this signal), so the 403 fails the whole job. Send an `Authorization: Bearer ` header when GH_TOKEN or GITHUB_TOKEN is present (auth raises the limit to 1000/h+). When neither is set, fall back to unauthenticated — preserving prior behavior for callers without a token in scope. Pass `secrets.GITHUB_TOKEN` into the Homebrew step via GH_TOKEN env so the script picks it up. AzDO callers of the same script have no GH token in scope today and continue running unauthenticated; the limit is per-IP and the AzDO Microsoft-hosted pool generally has plenty of headroom there. Failing run for reference: https://github.com/microsoft/aspire/actions/runs/26195583728/job/77075657321 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/prepare-installer-artifacts.yml | 5 +++++ eng/homebrew/validate-cask-artifact.sh | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml index 2119d19929d..c53d5701aa3 100644 --- a/.github/workflows/prepare-installer-artifacts.yml +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -41,6 +41,11 @@ jobs: - name: Prepare Homebrew cask artifact if: ${{ inputs.installer == 'homebrew' }} shell: bash + env: + # Authenticate the upstream-cask existence check inside + # validate-cask-artifact.sh; unauthenticated requests hit the 60/hour + # shared rate limit and return 403. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: > eng/homebrew/prepare-cask-artifact.sh --channel prerelease diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh index fc9b2d2e4ef..40a712542a4 100755 --- a/eng/homebrew/validate-cask-artifact.sh +++ b/eng/homebrew/validate-cask-artifact.sh @@ -96,8 +96,19 @@ detect_upstream_cask_is_new() { local target_path="Casks/$first_letter/$cask_name.rb" local api_url="https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path" local status_code + local curl_args=(-sS -o /dev/null -w "%{http_code}") + + # Authenticate when a token is available. Unauthenticated GitHub API requests + # are throttled to 60/hour shared across the runner IP pool, which routinely + # produces 403s on hosted CI; an installation/PAT/Actions token raises that + # to 1000/hour or more. GH_TOKEN is the convention used by the `gh` CLI and + # by most repo scripts; GITHUB_TOKEN is the default exposed by GitHub Actions. + local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [[ -n "$token" ]]; then + curl_args+=(-H "Authorization: Bearer $token" -H "X-GitHub-Api-Version: 2022-11-28") + fi - status_code="$(curl -sS -o /dev/null -w "%{http_code}" "$api_url")" + status_code="$(curl "${curl_args[@]}" "$api_url")" case "$status_code" in 200) echo "false" ;; 404) echo "true" ;; From eb507fd4792f34100ca70ef038f9f5768f9d32bf Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 20 May 2026 21:29:55 -0400 Subject: [PATCH 15/21] ci(installers): gate URL/summary validation on production branches The "Validate installer URLs from manifest", "Validate ReleaseNotesUrl", "Validate Homebrew validation summary", and "Validate download URLs from cask" steps in the publish-winget/publish-homebrew templates run inside the publish job to surface broken artifact URLs or missing prepare-stage checks before submitting an upstream PR. These checks only make sense when we're actually about to submit: - The URL probes hit canonical ci.dot.net paths that exist only after packages have been published on a production release build. On a non-production branch the prerelease artifacts go to different paths (or aren't published to ci.dot.net at all), so the probes fail with 404s before the publish-template's submit-step guard is reached. - The Homebrew validation-summary check requires brewInstall/brewUninstall to be marked 'passed', which only happens when the prepare stage runs with runInstallTest=true -- true only for production-publishing builds. On a non-prod prepare the required checks are absent and the validator fails before our Submit guard runs. Same effect as the existing dryRun=false clause that already gated the Homebrew summary validator: in dry-run we're not submitting, so URL reachability and summary completeness aren't useful signals. Reuse the existing Submit-step condition verbatim (dryRun=false AND _IsProductionBranch=true) so these checks run if and only if we'd actually submit a PR upstream. Observed in dogfood release-publish-nuget run on ankj/homebrew-release: both validators failed -> Submit step never reached -> no upstream PRs opened (which the existing Submit-step guard would have produced anyway), but the spurious red made the dogfood run unreadable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/publish-homebrew.yml | 20 +++++++++++++++++++- eng/pipelines/templates/publish-winget.yml | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/eng/pipelines/templates/publish-homebrew.yml b/eng/pipelines/templates/publish-homebrew.yml index d1e556bc3d0..36f58ca04ed 100644 --- a/eng/pipelines/templates/publish-homebrew.yml +++ b/eng/pipelines/templates/publish-homebrew.yml @@ -100,7 +100,16 @@ steps: Write-Host "Validated Homebrew summary: $summaryPath" Write-Host "##vso[task.setvariable variable=HomebrewValidationSummaryPath]$summaryPath" displayName: '🟣Validate Homebrew validation summary' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Same gate as the Submit step below. The summary's brewInstall/brewUninstall + # checks only pass when the prepare stage ran with runInstallTest=true, which + # in turn only happens on production-publishing builds, so on non-production + # branches the required checks are absent and this would fail meaninglessly. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -147,6 +156,15 @@ steps: } Write-Host "All download URLs are accessible." displayName: '🟣Validate download URLs from cask' + # Same gate as the Submit step below. Non-production casks point to artifacts + # that aren't published at their canonical ci.dot.net paths, and in dry-run + # we're not submitting anything; in either case the reachability probe is moot. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' diff --git a/eng/pipelines/templates/publish-winget.yml b/eng/pipelines/templates/publish-winget.yml index e89085628ed..fddad81c2cd 100644 --- a/eng/pipelines/templates/publish-winget.yml +++ b/eng/pipelines/templates/publish-winget.yml @@ -132,6 +132,17 @@ steps: } Write-Host "All installer URLs are accessible." displayName: '🟣Validate installer URLs from manifest' + # Only run when we'd actually submit (matches the Submit step condition). + # On non-production branches the manifest URLs point to artifacts that aren't + # published at their canonical ci.dot.net paths, so reachability checks fail + # meaninglessly; in dry-run we're not submitting anything so URL probing is + # equally moot. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -167,6 +178,13 @@ steps: Write-Host "ReleaseNotesUrl is valid." displayName: '🟣Validate ReleaseNotesUrl' + # Same gate as the installer-URL validation above and the Submit step below. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' From 8eb08aa3abcaf7be470eafc4e8380c6775a4d240 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 21 May 2026 02:53:00 -0400 Subject: [PATCH 16/21] docs(dogfood): drop unsupported x86 arch from PR-dogfood reference The PR dogfood scripts only support x64 and arm64: * eng/scripts/get-aspire-cli-pr.ps1 uses ValidateSet("", "x64", "arm64") for -Architecture. * eng/scripts/get-aspire-cli-pr.sh's get_cli_architecture_from_architecture accepts only amd64/x64 and arm64; anything else hits the "Architecture ... not supported" error path. The reference table in docs/dogfooding-pull-requests.md previously listed `x86` as an allowed --arch / -Architecture value, which would mislead anyone trying to cross-target it: the scripts would hard-fail rather than fall back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dogfooding-pull-requests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index 3a8ef6b2a90..dafd90fe7d1 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -200,7 +200,7 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` - Override OS and architecture (auto-detected by default): - Allowed OS values: `win`, `linux`, `linux-musl`, `osx` - - Allowed arch values: `x64`, `x86`, `arm64` + - Allowed arch values: `x64`, `arm64` - Tool mode uses `dotnet tool install`, which resolves RID-specific packages for the current host. Use OS/architecture overrides with archive mode, not to cross-install a dotnet tool for another RID. - Bash: ```bash From 548668bd25c61f8998ae544129e39a17f7e4932c Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 21 May 2026 03:09:14 -0400 Subject: [PATCH 17/21] fix(installer): drop destructive rm -rf from smoke helpers; clarify cask audit doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smoke-installed-cli.sh` and `smoke-installed-cli.ps1` previously wiped a configurable `--work-dir` with `rm -rf` / `Remove-Item -Recurse -Force` to make reruns idempotent. The script is only ever invoked from `prepare-installer-artifacts.yml` with no arguments, so the destructive path only mattered as defense against caller misuse — but a passed-in sensitive path (e.g. `/`, `$HOME`, a drive root) would still be wiped without warning. Restructure both scripts so `--work-dir` / `-WorkDir` is documented as a *parent* directory under which a fresh `aspire-cli-smoke.XXXXXX` subdirectory is created via `mktemp` (or `[guid]::NewGuid()` on PowerShell). The destructive operation is removed entirely; nothing that the caller passes in is ever deleted. CI tears down `RUNNER_TEMP` between jobs, so the lack of an explicit cleanup is fine in CI and friendlier for local interactive use (the scaffolded project survives until the user removes it). Also corrects `eng/homebrew/README.md` to match the actual `validate-cask-artifact.sh` behavior: `brew audit` only passes `--new` when the cask is absent upstream (existing casks fail the additional `--new` checks), and `--no-signing` is added in offline mode (used for PR-artifact validation against unsigned local archives), not unconditionally. The previous wording would have mis-guided anyone reading the doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/README.md | 9 +++++---- eng/scripts/smoke-installed-cli.ps1 | 21 ++++++++++++--------- eng/scripts/smoke-installed-cli.sh | 24 +++++++++++++++--------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index f94333ec444..47e83a8d75f 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -72,13 +72,14 @@ Prepare validation currently runs: 1. `ruby -c` for syntax validation 2. `brew style --fix` on the generated cask -3. `brew audit --cask --new --online local/aspire/aspire` +3. `brew audit --cask --online local/aspire/aspire` + - Adds `--new` only when the cask is absent upstream (existing casks fail the additional `--new` checks). + - Adds `--no-signing` in offline mode (PR-artifact validation), because the served archives are local loopback URLs of unsigned PR builds rather than notarized release assets. 4. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask ...` followed by uninstall validation PR artifact validation uses the same shared script and local tap, but rewrites -the cask URLs to loopback archive URLs and passes `--no-signing` to -`brew audit` because PR archives are not notarized release assets. Release -preparation keeps the full signing audit. +the cask URLs to loopback archive URLs and runs in offline mode (see above). +Release preparation keeps the full online signing audit. To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease` artifact and the `cli-native-archives-osx-*` artifacts into the same parent directory, then run: diff --git a/eng/scripts/smoke-installed-cli.ps1 b/eng/scripts/smoke-installed-cli.ps1 index 50bd8e70a3f..0ad304531a6 100644 --- a/eng/scripts/smoke-installed-cli.ps1 +++ b/eng/scripts/smoke-installed-cli.ps1 @@ -23,19 +23,22 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true if ([string]::IsNullOrWhiteSpace($WorkDir)) { - $base = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } - $WorkDir = Join-Path $base 'aspire-cli-smoke' + $WorkDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } } +New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null -aspire --version - -if (Test-Path $WorkDir) { - Remove-Item -Recurse -Force $WorkDir -} +# Always scaffold into a fresh subdirectory created under $WorkDir. This +# deliberately avoids ever Remove-Item -Recurse -Force'ing a caller-provided +# path: even if -WorkDir points at a sensitive directory, the worst case is a +# new empty aspire-cli-smoke.XXXXXXXX subdirectory being created underneath. +# CI tears down $env:RUNNER_TEMP between jobs; local users can clean up whenever. +$scaffoldDir = Join-Path $WorkDir ("aspire-cli-smoke." + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $scaffoldDir | Out-Null +Write-Host "Scaffolding into: $scaffoldDir" -New-Item -ItemType Directory -Path $WorkDir | Out-Null +aspire --version -Push-Location $WorkDir +Push-Location $scaffoldDir try { aspire --log-level $LogLevel new aspire-starter --name $ProjectName --output . --non-interactive --nologo --suppress-agent-init aspire --log-level $LogLevel restore --non-interactive --nologo diff --git a/eng/scripts/smoke-installed-cli.sh b/eng/scripts/smoke-installed-cli.sh index 4a1e8132fd2..66fe7685033 100755 --- a/eng/scripts/smoke-installed-cli.sh +++ b/eng/scripts/smoke-installed-cli.sh @@ -13,8 +13,10 @@ usage() { Usage: $(basename "$0") [OPTIONS] Options: - --work-dir PATH Directory to scaffold into (will be removed if it exists). - Default: \${RUNNER_TEMP:-/tmp}/aspire-cli-smoke + --work-dir PATH Parent directory in which to create a fresh scaffold + subdirectory. A unique subdirectory is always created + inside it; nothing in PATH is removed. + Default: \${RUNNER_TEMP:-/tmp} --project-name NAME Project name passed to 'aspire new'. Default: SmokeApp --log-level LEVEL --log-level value passed to aspire commands. Default: trace --help Show this help message @@ -35,15 +37,19 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$WORK_DIR" ]]; then - WORK_DIR="${RUNNER_TEMP:-/tmp}/aspire-cli-smoke" -fi +PARENT_DIR="${WORK_DIR:-${RUNNER_TEMP:-/tmp}}" +mkdir -p "$PARENT_DIR" -aspire --version +# Always scaffold into a fresh subdirectory created with mktemp under +# $PARENT_DIR. This deliberately avoids ever rm -rf'ing a caller-provided +# path: even if --work-dir points at a sensitive directory, the worst case is +# a new empty aspire-cli-smoke.XXXXXX subdirectory being created underneath. +# CI tears down RUNNER_TEMP between jobs; local users can clean up whenever. +scaffold_dir="$(mktemp -d "$PARENT_DIR/aspire-cli-smoke.XXXXXX")" +echo "Scaffolding into: $scaffold_dir" -rm -rf "$WORK_DIR" -mkdir -p "$WORK_DIR" -cd "$WORK_DIR" +aspire --version +cd "$scaffold_dir" aspire --log-level "$LOG_LEVEL" new aspire-starter --name "$PROJECT_NAME" --output . --non-interactive --nologo --suppress-agent-init aspire --log-level "$LOG_LEVEL" restore --non-interactive --nologo From a08bf7eadde11363762f31ce577657cf0c225894 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 21 May 2026 03:39:31 -0400 Subject: [PATCH 18/21] docs(installer): fix dogfood.ps1 diag-log stream comment `Show-LatestWinGetDiagLog` uses `Write-Host` (matching the rest of `eng/winget/dogfood.ps1`, where Write-Host is the prevailing convention), not stderr. The earlier comment claimed the newest diag log was printed to stderr, which was inaccurate and could mislead a future reader trying to capture / filter diagnostic output. Update the comment to match the code: the log is written to the host console via Write-Host, which GitHub Actions and the host capture into the same job log as the surrounding install output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/winget/dogfood.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eng/winget/dogfood.ps1 b/eng/winget/dogfood.ps1 index f1b333655e7..fc8f02695f2 100644 --- a/eng/winget/dogfood.ps1 +++ b/eng/winget/dogfood.ps1 @@ -255,7 +255,10 @@ function Show-LatestWinGetDiagLog { # When `winget install --manifest` exits non-zero, the diag log is almost always # the only place that says *why* (the console output is often truncated or empty, # and `winget` exit codes like 0x8A150001 are generic). Print the newest log to - # stderr so dogfood failures are diagnosable without separately hunting it down. + # the host console (Write-Host, matching the rest of this script) so dogfood + # failures are diagnosable without separately hunting it down. In CI runs the + # host output is captured into the job log, so this lands in the same place as + # the surrounding install output. # # Tests set ASPIRE_TEST_MODE=true and run a mock winget that doesn't produce diag # logs, so skip this in test mode to avoid emitting unrelated stale logs. From c621e1d8f97f21bf09aac4dd8dd77d8cce8544d6 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 21 May 2026 04:43:00 -0400 Subject: [PATCH 19/21] test(cli/acquisition): widen PeerInstallProbeTests timeouts and rename misleading test Two distinct flakes were observed in `Tests / Cli / Cli (windows-latest)` on PR #17115 (CI run 26212960557, attempt 1): 1. `ProbeAsync_PeerOmitsPathStatus_DefaultsToNotOnPath` failed with `Expected PeerProbeResult.Ok, got PeerProbeResult.Failed. Reason: Peer produced no usable output (and --version fallback).` Under heavy CI load (the failing run logged 87.5% CPU and saturated disk via the HEARTBEAT line) the cmd.exe-based fake peer script takes longer than the production 5s probe timeout just to spawn, so the probe synthesizes `Failed: timed out after 5.0s` before the peer prints stdout. This is the same class of CI flake that commit 858825b62 fixed for the negative-path stderr tests via `ProbeFakeFailureAsync`; that fix did not cover the positive-path tests, which still used the default 5s ctor. 2. `ProbeAsync_PeerHangs_TimesOutAndKills` failed with `Expected probe to return within a few seconds after timing out; took 00:00:05.0820126.` The probe configured a 300ms internal timeout and the test asserted the wallclock came back in under 5s. On Windows under load the spawn -> ping -> terminate round trip alone ate 5.08s, exceeding the budget by 80ms. The 5s outer budget is too tight for what the test claims to catch: a probe that ignores its configured timeout would still trip a 15s bound, while the real production timeout path (which is what the `AndKills` suffix was meant to cover) is bounded by the 300ms ctor argument and synthesizes the `Failed` result regardless of the outer wallclock. The `AndKills` part of the name also overstates what the test asserts. The fake peer process cleanup is via `using var fakePeer` disposal, not a direct assertion against the production probe. The process-termination semantic is rigorously covered by `ProbeAsync_CallerCancels_KillsSpawnedProcess` in the same file, which uses a PID file + `Process.HasExited`. Rename to `ProbeAsync_PeerHangs_TimesOutAndReturnsFailed` to match what is actually checked. Changes: - Introduce `CreateProbeWithGenerousTimeout()` helper mirroring the pattern from `ProbeFakeFailureAsync` (30s internal timeout, well above the production 5s default). - Replace all 10 positive-path uses of the default `new PeerInstallProbe(ProbeLogger)` ctor with the helper. The timeout-path tests (`ProbeAsync_PeerHangs_TimesOutAndReturnsFailed` with the 300ms ctor, and `ProbeAsync_CallerCancels_KillsSpawnedProcess` with the 30s ctor) keep their dedicated constructors. - Widen the outer-wallclock budget in `ProbeAsync_PeerHangs_TimesOutAndReturnsFailed` from 5s to 15s, and rename it from the `...AndKills` form that overstated the assertion. - Update the cross-reference comment in `ProbeFakeFailureAsync` to the new test name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Acquisition/PeerInstallProbeTests.cs | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs index ab636a43025..45dff134e58 100644 --- a/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs +++ b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs @@ -42,10 +42,29 @@ private static PeerProbeResult.Ok AssertProbeOk(PeerProbeResult result) return Assert.IsType(result); } + // Construct a probe with a much wider timeout than production's 5s default. + // + // These positive-path tests assert how the probe interprets a successful + // peer's output, not the timeout behavior — but the FakePeerScript helper + // on Windows shells out to cmd.exe (and powershell.exe in the stderr + // variant), which under heavy CI load (saturated CPU, slow disk) can take + // several seconds just to start. With the production 5s timeout we + // intermittently see the probe synthesize + // `Failed: "Peer probe timed out after 5.0s."` before the fake peer even + // produces stdout, even though the peer would complete instantly given a + // bit more wallclock. + // + // The timeout path itself is covered by ProbeAsync_PeerHangs_TimesOutAndReturnsFailed + // and ProbeAsync_CallerCancels_KillsSpawnedProcess, so widening the + // budget here removes the CI flake without losing coverage of the 5s + // production behavior. + private PeerInstallProbe CreateProbeWithGenerousTimeout() + => new(TimeSpan.FromSeconds(30), ProbeLogger); + [Fact] public async Task ProbeAsync_BinaryNotFound_ReturnsFailed() { - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); using var workspace = TemporaryWorkspace.Create(outputHelper); var missing = Path.Combine(workspace.WorkspaceRoot.FullName, "does-not-exist"); @@ -66,7 +85,7 @@ public async Task ProbeAsync_InvokesPeerWithDoctorSelfFormatJson() // default when `--format` is omitted. using var fakePeer = FakePeerScript.BuildArgvRecorder(outputHelper); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); AssertProbeOk(result); @@ -99,7 +118,7 @@ public async Task ProbeAsync_PeerEmitsValidJsonArray_ReturnsOk() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -125,7 +144,7 @@ public async Task ProbeAsync_PeerOmitsPathStatus_DefaultsToNotOnPath() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -149,7 +168,7 @@ public async Task ProbeAsync_PeerEmitsInvalidPathStatus_DefaultsToNotOnPath() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -238,7 +257,7 @@ public async Task ProbeAsync_PeerExitsNonZero_FallsBackToVersionAndReturnsPartia versionStdout: "13.4.0-pr.16817.g790d6fa3\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -261,7 +280,7 @@ public async Task ProbeAsync_BothInfoAndVersionFail_ReturnsFailed() versionStdout: string.Empty, versionExitCode: 1); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var failed = Assert.IsType(result); @@ -281,7 +300,7 @@ public async Task ProbeAsync_PeerEmitsEmptyArray_FallsBackToVersion() versionStdout: string.Empty, versionExitCode: 1); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); Assert.IsType(result); @@ -300,7 +319,7 @@ public async Task ProbeAsync_PeerEmitsInvalidJson_FallsBackToVersion() versionStdout: "9.0.0\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -328,7 +347,7 @@ public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackTo versionStdout: "9.0.0\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -336,7 +355,7 @@ public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackTo } [Fact] - public async Task ProbeAsync_PeerHangs_TimesOutAndKills() + public async Task ProbeAsync_PeerHangs_TimesOutAndReturnsFailed() { // Sleep significantly longer than the probe timeout we configure so // the timeout path is the one that completes the await. @@ -351,8 +370,14 @@ public async Task ProbeAsync_PeerHangs_TimesOutAndKills() var failed = Assert.IsType(result); Assert.Contains("timed out", failed.Reason, StringComparison.OrdinalIgnoreCase); - Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), - $"Expected probe to return within a few seconds after timing out; took {sw.Elapsed}."); + // The probe is configured with a 300ms timeout; the outer budget here + // is a sanity bound against a probe that ignores its configured + // timeout entirely (the bug class this test catches). Windows CI under + // saturated CPU / slow disk has been observed to take ~5s just for the + // fake-peer cmd.exe spawn + kill round-trip, so the budget needs to be + // well above 5s to avoid noise without losing the bound. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), + $"Expected probe to return within 15s after its 300ms timeout; took {sw.Elapsed}."); } [Fact] @@ -389,7 +414,7 @@ public async Task ProbeAsync_CallerCancels_KillsSpawnedProcess() // Windows shells out to powershell.exe to emit raw stderr bytes — can // take several seconds just to start. These tests are about how the // probe formats a Failed result from real peer stderr/exit semantics, - // not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndKills + // not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndReturnsFailed // for that). A wider budget here removes the CI flake without changing // what's being tested. var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger); From eba8cfc27ed8463f660c6ab1987176db0cc63614 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Thu, 21 May 2026 22:53:46 -0400 Subject: [PATCH 20/21] fix(installer): address installer review feedback Avoid using a local PowerShell $matches variable in installer helper scripts because it shadows PowerShell's automatic $Matches variable used by regex operators. The current code did not invoke regex matching in the same scopes, but the name made future edits fragile. Keep Homebrew Offline validation self-contained by skipping the upstream Homebrew cask probe in Offline mode. The cask name is fixed to aspire, so Offline PR validation can run the standard audit path without spending a GitHub API request or requiring network access beyond local archive URLs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/homebrew/validate-cask-artifact.sh | 15 ++++++++++----- eng/scripts/get-aspire-cli-pr.ps1 | 10 +++++----- eng/winget/dogfood.ps1 | 10 +++++----- eng/winget/prepare-manifest-artifact.ps1 | 10 +++++----- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh index 40a712542a4..ec8b5b345b6 100755 --- a/eng/homebrew/validate-cask-artifact.sh +++ b/eng/homebrew/validate-cask-artifact.sh @@ -327,12 +327,17 @@ if [[ "$VALIDATION_MODE" == "Offline" ]]; then fi echo "" -echo "Determining whether $CASK_NAME is a new upstream cask..." -IS_NEW_CASK="$(detect_upstream_cask_is_new "$CASK_NAME")" -if [[ "$IS_NEW_CASK" == "true" ]]; then - echo "Detected new upstream cask; running new-cask audit." +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + echo "Skipping upstream cask probe because validation mode is Offline; running standard audit." + IS_NEW_CASK="false" else - echo "Detected existing upstream cask; running standard audit." + echo "Determining whether $CASK_NAME is a new upstream cask..." + IS_NEW_CASK="$(detect_upstream_cask_is_new "$CASK_NAME")" + if [[ "$IS_NEW_CASK" == "true" ]]; then + echo "Detected new upstream cask; running new-cask audit." + else + echo "Detected existing upstream cask; running standard audit." + fi fi echo "" diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 1ae22dae739..1394a1a6d02 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -1439,7 +1439,7 @@ function Find-InstallerDogfoodScript { default { throw "Install mode '$InstallMode' does not use a dogfood script." } } - $matches = @( + $matchedItems = @( Get-ChildItem -Path $ArtifactDir -File -Recurse -Filter $scriptName -ErrorAction SilentlyContinue | Where-Object { Get-ChildItem -Path $_.Directory.FullName -File -Filter $companionPattern -ErrorAction SilentlyContinue | @@ -1448,16 +1448,16 @@ function Find-InstallerDogfoodScript { Sort-Object FullName ) - if ($matches.Count -eq 0) { + if ($matchedItems.Count -eq 0) { throw "Could not find $scriptName co-located with $companionPattern under: $ArtifactDir" } - if ($matches.Count -gt 1) { - $matchList = ($matches | ForEach-Object { " $($_.FullName)" }) -join [Environment]::NewLine + if ($matchedItems.Count -gt 1) { + $matchList = ($matchedItems | ForEach-Object { " $($_.FullName)" }) -join [Environment]::NewLine throw "Found multiple $scriptName files co-located with $companionPattern under ${ArtifactDir}:$([Environment]::NewLine)$matchList" } - return $matches[0].FullName + return $matchedItems[0].FullName } function Install-AspireCliWithInstallerArtifact { diff --git a/eng/winget/dogfood.ps1 b/eng/winget/dogfood.ps1 index fc8f02695f2..2495901240b 100644 --- a/eng/winget/dogfood.ps1 +++ b/eng/winget/dogfood.ps1 @@ -98,19 +98,19 @@ function Find-Archive { [string]$ArchiveName ) - $matches = @(Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | Sort-Object FullName) - if ($matches.Count -eq 0) { + $matchedItems = @(Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($matchedItems.Count -eq 0) { Write-Error "Could not find $ArchiveName under $Root." exit 1 } - if ($matches.Count -gt 1) { - $matchList = $matches | ForEach-Object { " $($_.FullName)" } + if ($matchedItems.Count -gt 1) { + $matchList = $matchedItems | ForEach-Object { " $($_.FullName)" } Write-Error "Found multiple $ArchiveName archives under ${Root}:`n$($matchList -join "`n")" exit 1 } - return $matches[0].FullName + return $matchedItems[0].FullName } function Get-DefaultArchiveRoot { diff --git a/eng/winget/prepare-manifest-artifact.ps1 b/eng/winget/prepare-manifest-artifact.ps1 index ea34d7b6f78..45ad3df00e3 100644 --- a/eng/winget/prepare-manifest-artifact.ps1 +++ b/eng/winget/prepare-manifest-artifact.ps1 @@ -58,20 +58,20 @@ function Get-ArchiveVersion { $prefix = "aspire-cli-$Rid-" $suffix = ".zip" - $matches = @(Get-ChildItem -Path $ArchiveRoot -File -Recurse -Filter "$prefix*$suffix" | Sort-Object FullName) + $matchedItems = @(Get-ChildItem -Path $ArchiveRoot -File -Recurse -Filter "$prefix*$suffix" | Sort-Object FullName) - if ($matches.Count -eq 0) { + if ($matchedItems.Count -eq 0) { Write-Error "Could not find archive '$prefix*$suffix' under '$ArchiveRoot' to infer the Aspire CLI version." exit 1 } - if ($matches.Count -gt 1) { - $matchList = $matches | ForEach-Object { " $($_.FullName)" } + if ($matchedItems.Count -gt 1) { + $matchList = $matchedItems | ForEach-Object { " $($_.FullName)" } Write-Error "Found multiple archives matching '$prefix*$suffix' under '$ArchiveRoot':`n$($matchList -join "`n")" exit 1 } - $fileName = $matches[0].Name + $fileName = $matchedItems[0].Name return $fileName.Substring($prefix.Length, $fileName.Length - $prefix.Length - $suffix.Length) } From b6cd2384c40e8b8259385cdd29e336184e5abe03 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Fri, 22 May 2026 15:27:03 -0400 Subject: [PATCH 21/21] chore(installer): remove stale Homebrew PR token wiring Offline Homebrew validation no longer probes the upstream cask through the GitHub API, so the GitHub Actions PR artifact workflow does not need to pass GH_TOKEN to the prepare script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/prepare-installer-artifacts.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml index c53d5701aa3..2119d19929d 100644 --- a/.github/workflows/prepare-installer-artifacts.yml +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -41,11 +41,6 @@ jobs: - name: Prepare Homebrew cask artifact if: ${{ inputs.installer == 'homebrew' }} shell: bash - env: - # Authenticate the upstream-cask existence check inside - # validate-cask-artifact.sh; unauthenticated requests hit the 60/hour - # shared rate limit and return 403. - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: > eng/homebrew/prepare-cask-artifact.sh --channel prerelease