diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 672ade0bd2d..3b821b87d84 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -2,6 +2,10 @@ name: Build native CLI archives on: workflow_call: + inputs: + versionOverrideArg: + required: false + type: string jobs: @@ -36,6 +40,7 @@ jobs: /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} + ${{ inputs.versionOverrideArg }} - name: Build CLI packages (Unix) env: @@ -51,6 +56,7 @@ jobs: /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} + ${{ inputs.versionOverrideArg }} - name: Upload logs if: always() diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml new file mode 100644 index 00000000000..25bded8e636 --- /dev/null +++ b/.github/workflows/build-packages.yml @@ -0,0 +1,120 @@ +name: Build Packages (Reusable) + +on: + workflow_call: + inputs: + versionOverrideArg: + required: false + type: string + outputs: + arch_rids: + description: JSON array of architecture-specific RIDs discovered during packaging + value: ${{ jobs.build_packages.outputs.arch_rids }} + +jobs: + build_packages: + name: Build packages + runs-on: ubuntu-latest + outputs: + arch_rids: ${{ steps.stage_rid_specific.outputs.rids }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Build with packages + env: + CI: false + run: ./build.sh -restore -build -ci -pack -bl -p:InstallBrowsersForPlaywright=false -p:SkipTestProjects=true ${{ inputs.versionOverrideArg }} + + - name: Stage RID-specific NuGets and remaining packages + id: stage_rid_specific + shell: bash + run: | + set -euo pipefail + set -x + shopt -s nullglob + mkdir -p staging/built-nugets staging/rid + # Copy full packages tree first (so structure expected by downstream tests is preserved) + rsync -a artifacts/packages/ staging/built-nugets/ + + declare -A RID_SET=() + # Find target packages (dcp and dashboard) + while IFS= read -r -d '' pkg; do + bn="$(basename "$pkg")" + rid="" + if [[ $bn =~ ^Aspire\.Hosting\.Orchestration\.([^.]+)\..*\.nupkg$ ]]; then + rid="${BASH_REMATCH[1]}" + elif [[ $bn =~ ^Aspire\.Dashboard\.Sdk\.([^.]+)\..*\.nupkg$ ]]; then + rid="${BASH_REMATCH[1]}" + else + continue + fi + if [[ -n $rid ]]; then + RID_SET["$rid"]=1 + mkdir -p "staging/rid/$rid" + cp "$pkg" "staging/rid/$rid/" + # Remove from built-nugets staging copy so it is excluded there + rel="${pkg#artifacts/packages/}" + rm -f "staging/built-nugets/$rel" || true + fi + done < <(find artifacts/packages -type f \( -name 'Aspire.Hosting.Orchestration.*.nupkg' -o -name 'Aspire.Dashboard.Sdk.*.nupkg' \) -print0) + + # Build JSON array of RIDs + if (( ${#RID_SET[@]} )); then + printf '%s\n' "${!RID_SET[@]}" | sort -u > /tmp/rids.txt + # Build a compact single-line JSON array (avoid pretty-print newlines which break $GITHUB_OUTPUT) + json=$(jq -R . < /tmp/rids.txt | jq -s -c .) + else + json='[]' + fi + echo "Discovered RIDs: $json" + # Use printf to safely write (single line) to GITHUB_OUTPUT + printf 'rids=%s\n' "$json" >> "$GITHUB_OUTPUT" + + - name: Upload built NuGets (excluding RID-specific orchestration/dashboard) + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: built-nugets + path: staging/built-nugets + if-no-files-found: error + retention-days: 15 + + - name: Upload consolidated RID-specific NuGets + if: ${{ steps.stage_rid_specific.outputs.rids != '[]' }} + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: built-nugets-for-rid-all + path: staging/rid + if-no-files-found: error + retention-days: 1 + + - name: Upload logs + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: build_packages_logs + path: artifacts/log + + upload_arch_specific_nugets: + name: Upload arch-specific NuGets + needs: build_packages + if: ${{ needs.build_packages.outputs.arch_rids != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + rid: ${{ fromJson(needs.build_packages.outputs.arch_rids) }} + steps: + - name: Download consolidated RID-specific NuGets + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 + with: + name: built-nugets-for-rid-all + path: rid-nugets + + - name: Upload per-RID NuGets + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: built-nugets-for-${{ matrix.rid }} + path: rid-nugets/${{ matrix.rid }}/*.nupkg + if-no-files-found: error + retention-days: 15 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2a8dcd4602..c5911366e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,14 +15,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: - check_changes: + prepare_for_ci: runs-on: ubuntu-latest - name: Check Changed Files + name: Prepare for CI if: ${{ github.repository_owner == 'dotnet' }} outputs: skip_workflow: ${{ (steps.check_docs.outputs.no_changes == 'true' || steps.check_docs.outputs.only_changed == 'true') && 'true' || 'false' }} + VERSION_SUFFIX_OVERRIDE: ${{ steps.compute_version_suffix.outputs.VERSION_SUFFIX_OVERRIDE }} + steps: - name: Checkout code if: ${{ github.event_name == 'pull_request' }} @@ -38,24 +41,47 @@ jobs: patterns: | \.md$ + - id: compute_version_suffix + name: Compute version suffix for PRs + if: ${{ github.event_name == 'pull_requestx' }} + shell: pwsh + env: + # Use the pull request head SHA instead of GITHUB_SHA (which can be a merge commit) + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.number }} + run: | + Write-Host "Determining VERSION_SUFFIX_OVERRIDE (PR only step)..." + if ([string]::IsNullOrWhiteSpace($Env:PR_HEAD_SHA)) { + Write-Error "PR_HEAD_SHA not set; cannot compute version suffix." + exit 1 + } + $SHORT_SHA = $Env:PR_HEAD_SHA.Substring(0,8) + $VERSION_SUFFIX = "/p:VersionSuffix=pr.$($Env:PR_NUMBER).$SHORT_SHA" + Write-Host "Computed VERSION_SUFFIX_OVERRIDE=$VERSION_SUFFIX" + "VERSION_SUFFIX_OVERRIDE=$VERSION_SUFFIX" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + tests: uses: ./.github/workflows/tests.yml name: Tests - needs: [check_changes] - if: ${{ github.repository_owner == 'dotnet' && needs.check_changes.outputs.skip_workflow != 'true' }} + needs: [prepare_for_ci] + if: ${{ github.repository_owner == 'dotnet' && needs.prepare_for_ci.outputs.skip_workflow != 'true' }} + with: + versionOverrideArg: ${{ needs.prepare_for_ci.outputs.VERSION_SUFFIX_OVERRIDE }} build_cli_archives: uses: ./.github/workflows/build-cli-native-archives.yml name: Build native CLI archives - needs: [check_changes] - if: ${{ github.repository_owner == 'dotnet' && needs.check_changes.outputs.skip_workflow != 'true' }} + needs: [prepare_for_ci] + if: ${{ github.repository_owner == 'dotnet' && needs.prepare_for_ci.outputs.skip_workflow != 'true' }} + with: + versionOverrideArg: ${{ needs.prepare_for_ci.outputs.VERSION_SUFFIX_OVERRIDE }} # This job is used for branch protection. It fails if any of the dependent jobs failed results: if: ${{ always() && github.repository_owner == 'dotnet' }} runs-on: ubuntu-latest name: Final Results - needs: [check_changes, tests, build_cli_archives] + needs: [prepare_for_ci, tests, build_cli_archives] steps: - name: Fail if any of the dependent jobs failed @@ -66,7 +92,7 @@ jobs: # 'skipped' if: >- ${{ always() && - needs.check_changes.outputs.skip_workflow != 'true' && + needs.prepare_for_ci.outputs.skip_workflow != 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')) }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2eae72a6006..ea3dd0dc262 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -24,6 +24,9 @@ on: extraTestArgs: required: false type: string + versionOverrideArg: + required: false + type: string # Triggers downloading the built nugets, and running the tests # from the archive outside the repo requiresNugets: @@ -113,6 +116,50 @@ jobs: run: Move-Item -Path "${{ github.workspace }}/artifacts/packages/built-nugets/Debug" -Destination "${{ github.workspace }}/artifacts/packages" + - name: Compute RID for arch-specific packages + if: ${{ inputs.requiresNugets }} + id: compute_rid + shell: pwsh + run: | + $os = "${{ inputs.os }}" + switch ($os) { + 'ubuntu-latest' { $rid = 'linux-x64' } + 'macos-latest' { $rid = 'osx-arm64' } + 'windows-latest'{ $rid = 'win-x64' } + Default { Write-Error "Unknown OS '$os' for RID mapping"; exit 1 } + } + Write-Host "Using RID=$rid" + "RID=$rid" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "rid=$rid" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Download arch-specific nugets + if: ${{ inputs.requiresNugets }} + continue-on-error: true + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 + with: + name: built-nugets-for-${{ steps.compute_rid.outputs.rid }} + path: ${{ github.workspace }}/arch-specific + + - name: Merge arch-specific nugets into package feed + if: ${{ inputs.requiresNugets }} + shell: pwsh + run: | + $dest = "${{ github.workspace }}/artifacts/packages/Debug/Shipping" + if (-not (Test-Path $dest)) { New-Item -ItemType Directory -Path $dest -Force | Out-Null } + $source = "${{ github.workspace }}/arch-specific" + if (-not (Test-Path $source)) { + Write-Error "Source folder '$source' does not exist."; exit 1 + } + $nupkgs = Get-ChildItem -Path $source -Filter *.nupkg -ErrorAction SilentlyContinue + if (-not $nupkgs -or $nupkgs.Count -eq 0) { + Write-Error "No arch-specific nugets found in '$source'"; exit 1 + } else { + foreach ($pkg in $nupkgs) { + Copy-Item -Path $pkg.FullName -Destination $dest -Force + } + Write-Host "Merged $($nupkgs.Count) arch-specific nugets for RID=$env:RID into $dest" + } + - name: Install sdk for nuget based testing if: ${{ inputs.requiresTestSdk }} env: @@ -159,7 +206,7 @@ jobs: env: CI: false run: | - ${{ env.BUILD_SCRIPT }} -restore -ci -build -projects ${{ env.TEST_PROJECT_PATH }} + ${{ env.BUILD_SCRIPT }} -restore -ci -build -projects ${{ env.TEST_PROJECT_PATH }} ${{ inputs.versionOverrideArg }} - name: Build and archive test project if: ${{ inputs.requiresNugets }} @@ -169,6 +216,7 @@ jobs: ${{ env.BUILD_SCRIPT }} -restore -ci -build -projects ${{ env.TEST_PROJECT_PATH }} /p:PrepareForHelix=true /bl:${{ github.workspace }}/artifacts/log/Debug/PrepareForHelix.binlog + ${{ inputs.versionOverrideArg }} # Workaround for bug in Azure Functions Worker SDK. See https://github.com/Azure/azure-functions-dotnet-worker/issues/2969. - name: Rebuild for Azure Functions project diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3f6ca736618..5870b93c018 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,11 @@ name: Tests on: - workflow_call + workflow_call: + inputs: + versionOverrideArg: + required: false + type: string jobs: # Duplicated jobs so their dependencies are not blocked on both the @@ -55,28 +59,9 @@ jobs: build_packages: name: Build packages - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Build with packages - env: - CI: false - run: ./build.sh -restore -build -ci -pack -bl -p:InstallBrowsersForPlaywright=false -p:SkipTestProjects=true - - - name: Upload built NuGets - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: built-nugets - path: artifacts/packages - retention-days: 15 - - - name: Upload logs - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: build_packages_logs - path: artifacts/log + uses: ./.github/workflows/build-packages.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} integrations_test_lin: uses: ./.github/workflows/run-tests.yml @@ -90,6 +75,7 @@ jobs: testShortName: ${{ matrix.shortname }} os: "ubuntu-latest" extraTestArgs: "--filter-not-trait \"quarantined=true\"" + versionOverrideArg: ${{ inputs.versionOverrideArg }} integrations_test_macos: uses: ./.github/workflows/run-tests.yml @@ -103,6 +89,7 @@ jobs: testShortName: ${{ matrix.shortname }} os: "macos-latest" extraTestArgs: "--filter-not-trait \"quarantined=true\"" + versionOverrideArg: ${{ inputs.versionOverrideArg }} integrations_test_win: uses: ./.github/workflows/run-tests.yml @@ -116,6 +103,7 @@ jobs: testShortName: ${{ matrix.shortname }} os: "windows-latest" extraTestArgs: "--filter-not-trait \"quarantined=true\"" + versionOverrideArg: ${{ inputs.versionOverrideArg }} templates_test_lin: name: Templates Linux @@ -131,6 +119,7 @@ jobs: testSessionTimeout: 20m testHangTimeout: 12m extraTestArgs: "--filter-not-trait quarantined=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" + versionOverrideArg: ${{ inputs.versionOverrideArg }} requiresNugets: true requiresTestSdk: true @@ -148,6 +137,7 @@ jobs: testSessionTimeout: 20m testHangTimeout: 12m extraTestArgs: "--filter-not-trait quarantined=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" + versionOverrideArg: ${{ inputs.versionOverrideArg }} requiresNugets: true requiresTestSdk: true @@ -165,6 +155,7 @@ jobs: testSessionTimeout: 20m testHangTimeout: 12m extraTestArgs: "--filter-not-trait quarantined=true --filter-class Aspire.Templates.Tests.${{ matrix.shortname }}" + versionOverrideArg: ${{ inputs.versionOverrideArg }} requiresNugets: true requiresTestSdk: true @@ -178,6 +169,7 @@ jobs: os: ubuntu-latest testProjectPath: tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj requiresNugets: true + versionOverrideArg: ${{ inputs.versionOverrideArg }} extension_tests_win: name: Run VS Code extension tests (Windows) diff --git a/eng/scripts/README.md b/eng/scripts/README.md index 870ca250392..3f2f95024c3 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -160,3 +160,38 @@ When modifying these scripts, ensure: - Error handling is comprehensive and user-friendly - Platform detection logic is robust - Security best practices are followed for downloads and file handling + +## PR Artifact Retrieval Scripts + +Additional scripts exist to fetch CLI and NuGet artifacts from a pull request build: + +- `get-aspire-cli-pr.sh` +- `get-aspire-cli-pr.ps1` + +Quick fetch (Bash): +```bash +curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- +``` + +Quick fetch (PowerShell): +```powershell +iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } " +``` + +NuGet hive path pattern: `~/.aspire/hives/pr-/packages` + +### Repository Override + +You can point the PR artifact retrieval scripts at a fork by setting the `ASPIRE_REPO` environment variable to `owner/name` before invoking the script (defaults to `dotnet/aspire`). + +Examples: + +```bash +export ASPIRE_REPO=myfork/aspire +./get-aspire-cli-pr.sh 1234 +``` + +```powershell +$env:ASPIRE_REPO = 'myfork/aspire' +./get-aspire-cli-pr.ps1 1234 +``` diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 new file mode 100755 index 00000000000..afbe07e9590 --- /dev/null +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -0,0 +1,974 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Download and unpack the Aspire CLI from a specific PR's build artifacts + +.DESCRIPTION + Downloads and installs the Aspire CLI from a specific pull request's latest successful build. + Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. + + The script queries the GitHub API to find the latest successful run of the 'ci.yml' workflow + for the specified PR, then downloads and extracts the CLI archive for your platform using 'gh run download'. + + Alternatively, you can specify a workflow run ID directly to download from a specific build. + +.PARAMETER PRNumber + Pull request number (required) + +.PARAMETER WorkflowRunId + Workflow run ID to download from (optional) + +.PARAMETER InstallPath + Directory prefix to install (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) + CLI will be installed to InstallPath\bin (or InstallPath/bin on Unix) + NuGet packages will be installed to InstallPath\hives\pr-PRNUMBER\packages + +.PARAMETER OS + Override OS detection (win, linux, linux-musl, osx) + +.PARAMETER Architecture + Override architecture detection (x64, x86, arm64) + +.PARAMETER HiveOnly + Only install NuGet packages to the hive, skip CLI download + +.PARAMETER KeepArchive + Keep downloaded archive files after installation + +.PARAMETER Help + Show this help message + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -WorkflowRunId 12345678 + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -InstallPath "C:\my-aspire" + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -OS linux -Architecture arm64 -Verbose + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -HiveOnly + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -WhatIf + +.EXAMPLE + Piped execution + iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } + +.NOTES + Requires GitHub CLI (gh) to be installed and authenticated + Requires appropriate permissions to download artifacts from target repository + +.PARAMETER ASPIRE_REPO (environment variable) + Override repository (owner/name). Default: dotnet/aspire + Example: $env:ASPIRE_REPO = 'myfork/aspire' +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Position = 0, HelpMessage = "Pull request number")] + [ValidateRange(1, [int]::MaxValue)] + [int]$PRNumber, + + [Parameter(HelpMessage = "Workflow run ID to download from")] + [ValidateRange(1, [long]::MaxValue)] + [long]$WorkflowRunId, + + [Parameter(HelpMessage = "Directory prefix to install")] + [string]$InstallPath = "", + + [Parameter(HelpMessage = "Override OS detection")] + [ValidateSet("", "win", "linux", "linux-musl", "osx")] + [string]$OS = "", + + [Parameter(HelpMessage = "Override architecture detection")] + [ValidateSet("", "x64", "x86", "arm64")] + [string]$Architecture = "", + + [Parameter(HelpMessage = "Only install NuGet packages to the hive, skip CLI download")] + [switch]$HiveOnly, + + [Parameter(HelpMessage = "Keep downloaded archive files after installation")] + [switch]$KeepArchive +) + +# Global constants +$Script:BuiltNugetsArtifactName = "built-nugets" +$Script:BuiltNugetsRidArtifactName = "built-nugets-for" +$Script:CliArchiveArtifactNamePrefix = "cli-native-archives" +$Script:AspireCliArtifactNamePrefix = "aspire-cli" +$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 { 'dotnet/aspire' } +$Script:GHReposBase = "repos/$($Script:Repository)" + +# True if the script is executed from a file (pwsh -File … or .\get-aspire-cli-pr.ps1) +# False if the body is piped / dot‑sourced / iex'd into the current session. +$InvokedFromFile = -not [string]::IsNullOrEmpty($PSCommandPath) + +# ============================================================================= +# START: Shared code +# ============================================================================= + +# Consolidated output function with fallback for platforms that don't support Write-Host +function Write-Message { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Message, + + [Parameter()] + [ValidateSet("Verbose", "Info", "Success", "Warning", "Error")] + [string]$Level = "Info" + ) + + $hasWriteHost = Get-Command Write-Host -ErrorAction SilentlyContinue + + switch ($Level) { + "Verbose" { + if ($VerbosePreference -ne "SilentlyContinue") { + Write-Verbose $Message + } + } + "Info" { + if ($hasWriteHost) { + Write-Host $Message -ForegroundColor White + } else { + Write-Output $Message + } + } + "Success" { + if ($hasWriteHost) { + Write-Host $Message -ForegroundColor Green + } else { + Write-Output "SUCCESS: $Message" + } + } + "Warning" { + Write-Warning $Message + } + "Error" { + Write-Error $Message + } + } +} + +# Helper function for PowerShell version-specific operations +function Invoke-WithPowerShellVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [scriptblock]$ModernAction, + + [Parameter(Mandatory = $true)] + [scriptblock]$LegacyAction + ) + + if ($Script:IsModernPowerShell) { + & $ModernAction + } else { + & $LegacyAction + } +} + +# Function to detect OS +function Get-OperatingSystem { + [CmdletBinding()] + [OutputType([string])] + param() + + Write-Message "Detecting OS" -Level Verbose + try { + return Invoke-WithPowerShellVersion -ModernAction { + if ($IsWindows) { + return "win" + } + elseif ($IsLinux) { + try { + $lddOutput = & ldd --version 2>&1 | Out-String + return if ($lddOutput -match "musl") { "linux-musl" } else { "linux" } + } + catch { return "linux" } + } + elseif ($IsMacOS) { + return "osx" + } + else { + return "unsupported" + } + } -LegacyAction { + # PowerShell 5.1 and earlier - more reliable Windows detection + if ($env:OS -eq "Windows_NT" -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + return "win" + } + + $platform = [System.Environment]::OSVersion.Platform + switch ($platform) { + { $_ -in @([System.PlatformID]::Unix, 4, 6) } { return "linux" } + { $_ -in @([System.PlatformID]::MacOSX, 128) } { return "osx" } + default { return "unsupported" } + } + } + } + catch { + Write-Message "Failed to detect operating system: $($_.Exception.Message)" -Level Warning + return "unsupported" + } +} + +# Enhanced function for cross-platform architecture detection +function Get-MachineArchitecture { + [CmdletBinding()] + [OutputType([string])] + param() + + Write-Message "Detecting machine architecture" -Level Verbose + + try { + # On Windows PowerShell, use environment variables + if (-not $Script:IsModernPowerShell -or $IsWindows) { + # On PS x86, PROCESSOR_ARCHITECTURE reports x86 even on x64 systems. + # To get the correct architecture, we need to use PROCESSOR_ARCHITEW6432. + # PS x64 doesn't define this, so we fall back to PROCESSOR_ARCHITECTURE. + # Possible values: amd64, x64, x86, arm64, arm + if ( $null -ne $ENV:PROCESSOR_ARCHITEW6432 ) { + return $ENV:PROCESSOR_ARCHITEW6432 + } + + try { + $osInfo = Get-CimInstance -ClassName CIM_OperatingSystem -ErrorAction Stop + if ($osInfo.OSArchitecture -like "ARM*") { + if ([Environment]::Is64BitOperatingSystem) { + return "arm64" + } + return "arm" + } + } + catch { + Write-Message "Failed to get CIM instance: $($_.Exception.Message)" -Level Verbose + } + + if ( $null -ne $ENV:PROCESSOR_ARCHITECTURE ) { + return $ENV:PROCESSOR_ARCHITECTURE + } + } + + # For PowerShell 6+ on Unix systems, use .NET runtime information + if ($Script:IsModernPowerShell) { + try { + $runtimeArch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + switch ($runtimeArch) { + "X64" { return "x64" } + "X86" { return "x86" } + "Arm64" { return "arm64" } + default { + Write-Message "Unknown runtime architecture: $runtimeArch" -Level Verbose + # Fall back to uname if available + if (Get-Command uname -ErrorAction SilentlyContinue) { + $unameArch = & uname -m + switch ($unameArch) { + { @("x86_64", "amd64") -contains $_ } { return "x64" } + { @("aarch64", "arm64") -contains $_ } { return "arm64" } + { @("i386", "i686") -contains $_ } { return "x86" } + default { + throw "Architecture '$unameArch' not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + } + } + } else { + throw "Architecture '$runtimeArch' not supported (uname unavailable). If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + } + } + } + } + catch { + throw "Architecture detection failed: $($_.Exception.Message)" + } + } + + throw "Architecture detection failed (no supported detection path). If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + } + catch { + throw "Architecture detection failed: $($_.Exception.Message)" + } +} + +# Convert architecture to CLI architecture format +function Get-CLIArchitectureFromArchitecture { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Architecture + ) + + if ($Architecture -eq "") { + $Architecture = Get-MachineArchitecture + } + + $normalizedArch = $Architecture.ToLowerInvariant() + switch ($normalizedArch) { + { @("amd64", "x64") -contains $_ } { + return "x64" + } + { $_ -eq "x86" } { + return "x86" + } + { $_ -eq "arm64" } { + return "arm64" + } + default { + throw "Architecture '$Architecture' not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + } + } +} + +function Get-RuntimeIdentifier { + [CmdletBinding()] + [OutputType([string])] + param( + [string]$_OS, + [string]$_Architecture + ) + + # Determine OS and architecture (either detected or user-specified) + $computedTargetOS = if ([string]::IsNullOrWhiteSpace($_OS)) { $Script:HostOS } else { $_OS } + + # Check for unsupported OS + if ($computedTargetOS -eq "unsupported") { + throw "Unsupported operating system. Current platform: $([System.Environment]::OSVersion.Platform)" + } + + $computedTargetArch = if ([string]::IsNullOrWhiteSpace($_Architecture)) { Get-CLIArchitectureFromArchitecture "" } else { Get-CLIArchitectureFromArchitecture $_Architecture } + + return "${computedTargetOS}-${computedTargetArch}" +} + +function Expand-AspireCliArchive { + [CmdletBinding(SupportsShouldProcess)] + param( + [string]$ArchiveFile, + [string]$DestinationPath + ) + + if (-not $PSCmdlet.ShouldProcess($DestinationPath, "Expand archive $ArchiveFile to $DestinationPath")) { + return + } + + Write-Message "Unpacking archive to: $DestinationPath" -Level Verbose + + # Create destination directory if it doesn't exist + if (-not (Test-Path $DestinationPath)) { + Write-Message "Creating destination directory: $DestinationPath" -Level Verbose + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + } + + # Check archive format based on file extension and extract accordingly + if ($ArchiveFile -match "\.zip$") { + # Use Expand-Archive for ZIP files + if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { + throw "Expand-Archive cmdlet not found. Please use PowerShell 5.0 or later to extract ZIP files." + } + + try { + Expand-Archive -Path $ArchiveFile -DestinationPath $DestinationPath -Force + } + catch { + throw "Failed to unpack archive: $($_.Exception.Message)" + } + } + elseif ($ArchiveFile -match "\.tar\.gz$") { + # Use tar for tar.gz files + if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + throw "tar command not found. Please install tar to extract tar.gz files." + } + + $currentLocation = Get-Location + try { + Set-Location $DestinationPath + & tar -xzf $ArchiveFile + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract tar.gz archive: $ArchiveFile. tar command returned exit code $LASTEXITCODE" + } + } + finally { + Set-Location $currentLocation + } + } + else { + throw "Unsupported archive format: $ArchiveFile. Only .zip and .tar.gz files are supported." + } + + Write-Message "Successfully unpacked archive" -Level Verbose +} + +# Simplified installation path determination +function Get-DefaultInstallPrefix { + [CmdletBinding()] + [OutputType([string])] + param() + + # Get home directory cross-platform + $homeDirectory = Invoke-WithPowerShellVersion -ModernAction { + if ($env:HOME) { + $env:HOME + } elseif ($IsWindows -and $env:USERPROFILE) { + $env:USERPROFILE + } elseif ($env:USERPROFILE) { + $env:USERPROFILE + } else { + $null + } + } -LegacyAction { + if ($env:USERPROFILE) { + $env:USERPROFILE + } elseif ($env:HOME) { + $env:HOME + } else { + $null + } + } + + if ([string]::IsNullOrWhiteSpace($homeDirectory)) { + throw "Unable to determine user home directory. Please specify -InstallPath parameter." + } + + $defaultPath = Join-Path $homeDirectory ".aspire" + return [System.IO.Path]::GetFullPath($defaultPath) +} + +# Simplified PATH environment update +function Update-PathEnvironment { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$CliBinDir + ) + + $pathSeparator = [System.IO.Path]::PathSeparator + + # Update current session PATH + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $CliBinDir) { + if ($PSCmdlet.ShouldProcess("PATH environment variable", "Add $CliBinDir to current session")) { + $env:PATH = (@($CliBinDir) + $currentPathArray) -join $pathSeparator + Write-Message "Added $CliBinDir to PATH for current session" -Level Info + } + } + + # Update persistent PATH for Windows + if ($Script:HostOS -eq "win") { + try { + $userPath = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User) + if (-not $userPath) { $userPath = "" } + $userPathArray = if ($userPath) { $userPath.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) } else { @() } + if ($userPathArray -notcontains $CliBinDir) { + if ($PSCmdlet.ShouldProcess("User PATH environment variable", "Add $CliBinDir")) { + $newUserPath = (@($CliBinDir) + $userPathArray) -join $pathSeparator + [Environment]::SetEnvironmentVariable("PATH", $newUserPath, [EnvironmentVariableTarget]::User) + Write-Message "Added $CliBinDir to user PATH environment variable" -Level Info + } + } + + Write-Message "" -Level Info + Write-Message "The aspire cli is now available for use in this and new sessions." -Level Success + } + catch { + Write-Message "Failed to update persistent PATH environment variable: $($_.Exception.Message)" -Level Warning + Write-Message "You may need to manually add $CliBinDir to your PATH environment variable" -Level Info + } + } + + # GitHub Actions support + if ($env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_PATH) { + try { + if ($PSCmdlet.ShouldProcess("GITHUB_PATH environment variable", "Add $CliBinDir to GITHUB_PATH")) { + Add-Content -Path $env:GITHUB_PATH -Value $CliBinDir + Write-Message "Added $CliBinDir to GITHUB_PATH for GitHub Actions" -Level Success + } + } + catch { + Write-Message "Failed to update GITHUB_PATH: $($_.Exception.Message)" -Level Warning + } + } +} + +# Function to create a temporary directory with conflict resolution +function New-TempDirectory { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Prefix + ) + + if ($PSCmdlet.ShouldProcess("temporary directory", "Create temporary directory with prefix '$Prefix'")) { + # Create a temporary directory for downloads with conflict resolution + $tempBaseName = "$Prefix-$([System.Guid]::NewGuid().ToString("N").Substring(0, 8))" + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) $tempBaseName + + # Handle potential conflicts + $attempt = 1 + while (Test-Path $tempDir) { + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "$tempBaseName-$attempt" + $attempt++ + if ($attempt -gt 10) { + throw "Unable to create temporary directory after 10 attempts" + } + } + + Write-Message "Creating temporary directory: $tempDir" -Level Verbose + try { + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + return $tempDir + } + catch { + throw "Failed to create temporary directory: $tempDir - $($_.Exception.Message)" + } + } + else { + # Return a WhatIf path when -WhatIf is used + return Join-Path ([System.IO.Path]::GetTempPath()) "$Prefix-whatif" + } +} + +# Cleanup function for temporary directory +function Remove-TempDirectory { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [string]$TempDir + ) + + if (-not [string]::IsNullOrWhiteSpace($TempDir) -and (Test-Path $TempDir)) { + if (-not $KeepArchive) { + Write-Message "Cleaning up temporary files..." -Level Verbose + try { + if ($PSCmdlet.ShouldProcess($TempDir, "Remove temporary directory")) { + Remove-Item $TempDir -Recurse -Force + } + } + catch { + Write-Message "Failed to clean up temporary directory: $TempDir - $($_.Exception.Message)" -Level Warning + } + } + else { + Write-Message "Archive files kept in: $TempDir" -Level Info + } + } +} + +# ============================================================================= +# END: Shared code +# ============================================================================= + +# Function to check if gh command is available +function Test-GitHubCLIDependency { + [CmdletBinding()] + param() + + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Message "GitHub CLI (gh) is required but not installed. Please install it first." -Level Error + Write-Message "Installation instructions: https://cli.github.com/" -Level Info + throw "GitHub CLI (gh) dependency not met" + } + + $ghVersion = & gh --version 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "GitHub CLI (gh) command failed with exit code $LASTEXITCODE`: $ghVersion" + } else { + $firstLine = ($ghVersion | Select-Object -First 1) + Write-Message "GitHub CLI (gh) found: $firstLine" -Level Verbose + } +} + +# Simplified installation path determination +function Get-InstallPrefix { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [string]$InstallPrefix + ) + + if (-not [string]::IsNullOrWhiteSpace($InstallPrefix)) { + # Validate that the path is not just whitespace and can be created + try { + $resolvedPath = [System.IO.Path]::GetFullPath($InstallPrefix) + return $resolvedPath + } + catch { + throw "Invalid installation path: $InstallPrefix - $($_.Exception.Message)" + } + } + + return Get-DefaultInstallPrefix +} + +# Function to make GitHub API calls with proper error handling +function Invoke-GitHubAPICall { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Endpoint, + + [Parameter()] + [string]$JqFilter = "", + + [Parameter()] + [string]$ErrorMessage = "Failed to call GitHub API" + ) + + $ghCommand = @("gh", "api", $Endpoint) + + if (-not [string]::IsNullOrWhiteSpace($JqFilter)) { + $ghCommand += @("--jq", $JqFilter) + } + + Write-Message "Calling GitHub API: $($ghCommand -join ' ')" -Level Verbose + + $output = & $ghCommand[0] $ghCommand[1..($ghCommand.Length-1)] 2>&1 + + if ($LASTEXITCODE -ne 0) { + throw "$ErrorMessage (API endpoint: $Endpoint): $output" + } + + return $output +} + +# Function to get PR head SHA +function Get-PRHeadSHA { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [int]$PRNumber + ) + + Write-Message "Getting HEAD SHA for PR #$PRNumber" -Level Verbose + + $headSha = Invoke-GitHubAPICall -Endpoint "$Script:GHReposBase/pulls/$PRNumber" -JqFilter ".head.sha" -ErrorMessage "Failed to get HEAD SHA for PR #$PRNumber" + if ([string]::IsNullOrWhiteSpace($headSha) -or $headSha -eq "null") { + Write-Message "This could mean:" -Level Info + Write-Message " - The PR number does not exist" -Level Info + Write-Message " - You don't have access to the repository" -Level Info + throw "Could not retrieve HEAD SHA for PR #$PRNumber" + } + + Write-Message "PR #$PRNumber HEAD SHA: $headSha" -Level Verbose + return $headSha.Trim() +} + +# Function to find workflow run for SHA +function Find-WorkflowRun { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$HeadSHA + ) + + Write-Message "Finding ci.yml workflow run for SHA: $HeadSHA" -Level Verbose + + $runId = Invoke-GitHubAPICall -Endpoint "$Script:GHReposBase/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$HeadSHA" -JqFilter ".workflow_runs | sort_by(.created_at) | reverse | .[0].id" -ErrorMessage "Failed to query workflow runs for SHA: $HeadSHA" + + if ([string]::IsNullOrWhiteSpace($runId) -or $runId -eq "null") { + throw "No ci.yml workflow run found for PR SHA: $HeadSHA. This could mean no workflow has been triggered for this SHA $HeadSHA . Check at https://github.com/dotnet/aspire/actions/workflows/ci.yml" + } + + Write-Message "Found workflow run ID: $runId" -Level Verbose + return $runId.Trim() +} + +# Function to download artifact using gh run download +function Invoke-ArtifactDownload { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$ArtifactName, + + [Parameter(Mandatory = $true)] + [string]$DownloadDirectory + ) + + $downloadCommand = @("gh", "run", "download", $RunId, "-R", $Script:Repository, "--name", $ArtifactName, "-D", $DownloadDirectory) + + if ($PSCmdlet.ShouldProcess($ArtifactName, "Download $ArtifactName with $($downloadCommand -join ' ')")) { + Write-Message "Downloading with: $($downloadCommand -join ' ')" -Level Verbose + + & $downloadCommand[0] $downloadCommand[1..($downloadCommand.Length-1)] + + if ($LASTEXITCODE -ne 0) { + Write-Message "gh run download command failed with exit code $LASTEXITCODE . Command: $($downloadCommand -join ' ')" -Level Verbose + throw "Failed to download artifact '$ArtifactName' from run: $RunId . If the workflow is still running then the artifact named '$ArtifactName' may not be available yet. Check at https://github.com/dotnet/aspire/actions/runs/$RunId#artifacts" + } + } +} + +# Function to download built-nugets artifact +function Get-BuiltNugets { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$RID, + + [Parameter(Mandatory = $true)] + [string]$TempDir + ) + + $downloadDir = Join-Path $TempDir $Script:BuiltNugetsArtifactName + Write-Message "Downloading built nugets artifact - $Script:BuiltNugetsArtifactName ..." -Level Info + Invoke-ArtifactDownload -RunId $RunId -ArtifactName $Script:BuiltNugetsArtifactName -DownloadDirectory $downloadDir + + $builtNugetRidName = "$($Script:BuiltNugetsRidArtifactName)-$RID" + Write-Message "Downloading rid specific built nugets artifact - $builtNugetRidName ..." -Level Info + Invoke-ArtifactDownload -RunId $RunId -ArtifactName $builtNugetRidName -DownloadDirectory $downloadDir + + return $downloadDir +} + +# Function to install built-nugets artifact +function Install-BuiltNugets { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$DownloadDir, + + [Parameter(Mandatory = $true)] + [string]$NugetHiveDir + ) + + if (!$PSCmdlet.ShouldProcess($NugetHiveDir, "Copying built nugets")) { + return + } + + # Remove and recreate the target directory to ensure clean state + if (Test-Path $NugetHiveDir) { + Write-Message "Removing existing nuget directory: $NugetHiveDir" -Level Verbose + if ($PSCmdlet.ShouldProcess($NugetHiveDir, "Remove existing directory")) { + Remove-Item $NugetHiveDir -Recurse -Force + } + } + + if ($PSCmdlet.ShouldProcess($NugetHiveDir, "Create directory")) { + New-Item -ItemType Directory -Path $NugetHiveDir -Force | Out-Null + } + + Write-Message "Copying nugets from $DownloadDir to $NugetHiveDir" -Level Verbose + + # Copy all .nupkg files from the artifact directory to the target directory + try { + $nupkgFiles = Get-ChildItem -Path $DownloadDir -Filter "*.nupkg" -Recurse + + if ($nupkgFiles.Count -eq 0) { + Write-Message "No .nupkg files found in downloaded artifact" -Level Warning + return + } + + foreach ($file in $nupkgFiles) { + if ($PSCmdlet.ShouldProcess($file.FullName, "Copy to $NugetHiveDir")) { + Copy-Item $file.FullName -Destination $NugetHiveDir + } + } + + Write-Message "Successfully installed nuget packages to: $NugetHiveDir" -Level Verbose + Write-Message "NuGet packages successfully installed to: $NugetHiveDir" -Level Success + } + catch { + Write-Message "Failed to copy nuget artifact files: $($_.Exception.Message)" -Level Error + throw + } +} + +# Function to download Aspire CLI artifact +function Get-AspireCliFromArtifact { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$RID, + + [Parameter(Mandatory = $true)] + [string]$TempDir + ) + + $cliArchiveName = "$($Script:CliArchiveArtifactNamePrefix)-$RID" + $downloadDir = Join-Path $TempDir "cli" + Write-Message "Downloading CLI from GitHub - $cliArchiveName ..." -Level Info + Invoke-ArtifactDownload -RunId $RunId -ArtifactName $cliArchiveName -DownloadDirectory $downloadDir + + return $downloadDir +} + +# Function to install downloaded Aspire CLI +function Install-AspireCliFromDownload { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$DownloadDir, + + [Parameter(Mandatory = $true)] + [string]$CliBinDir + ) + + if (!$PSCmdlet.ShouldProcess($CliBinDir, "Installing Aspire CLI to $CliBinDir")) { + return + } + + $cliFiles = Get-ChildItem -Path $DownloadDir -File -Recurse | Where-Object { $_.Name -match "^$Script:AspireCliArtifactNamePrefix-.*\.(tar\.gz|zip)$" } + + if ($cliFiles.Count -eq 0) { + Write-Message "No CLI archive found. Expected a single $(${Script:AspireCliArtifactNamePrefix})-*.tar.gz or $(${Script:AspireCliArtifactNamePrefix})-*.zip file in artifact root: $DownloadDir" -Level Error + Write-Message "Candidate files present (root only):" -Level Info + Get-ChildItem -Path $DownloadDir -File -Recurse | Select-Object -First 20 | ForEach-Object { Write-Message " $($_.Name)" -Level Info } + throw "CLI archive not found" + } + elseif ($cliFiles.Count -gt 1) { + Write-Message "Multiple CLI archives found (expected exactly one):" -Level Error + $cliFiles | ForEach-Object { Write-Message " $($_.FullName)" -Level Error } + throw "Multiple CLI archives found" + } + + $cliArchivePath = $cliFiles[0].FullName + + # Install the archive + Expand-AspireCliArchive -ArchiveFile $cliArchivePath -DestinationPath $CliBinDir + + # Check which aspire executable exists and set the path accordingly + $aspireExePath = Join-Path $CliBinDir "aspire.exe" + $aspirePath = Join-Path $CliBinDir "aspire" + + if (Test-Path $aspireExePath) { + $cliPath = $aspireExePath + } + elseif (Test-Path $aspirePath) { + $cliPath = $aspirePath + } + else { + throw "Neither aspire.exe nor aspire executable found in $CliBinDir" + } + + Write-Message "Aspire CLI successfully installed to: $cliPath" -Level Success +} + +# Main function to download and install from PR or workflow run ID +function Start-DownloadAndInstall { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$TempDir + ) + + if ($WorkflowRunId) { + # When workflow ID is provided, use it directly + Write-Message "Starting download and installation for PR #$PRNumber with workflow run ID: $WorkflowRunId" -Level Info + $runId = $WorkflowRunId.ToString() + } + else { + # When only PR number is provided, find the workflow run + Write-Message "Starting download and installation for PR #$PRNumber" -Level Info + + # Get the PR head SHA + $headSha = Get-PRHeadSHA -PRNumber $PRNumber + + # Find the workflow run + $runId = Find-WorkflowRun -HeadSHA $headSha + } + + Write-Message "Using workflow run https://github.com/$Script:Repository/actions/runs/$runId" -Level Info + + # Set installation paths + $cliBinDir = Join-Path $resolvedInstallPrefix "bin" + $nugetHiveDir = Join-Path $resolvedInstallPrefix "hives" "pr-$PRNumber" "packages" + + $rid = Get-RuntimeIdentifier $OS $Architecture + + # First, download artifacts + if ($HiveOnly) { + Write-Message "Skipping CLI download due to -HiveOnly flag" -Level Info + } else { + $cliDownloadDir = Get-AspireCliFromArtifact -RunId $runId -RID $rid -TempDir $TempDir + } + $nugetDownloadDir = Get-BuiltNugets -RunId $runId -RID $rid -TempDir $TempDir + + # Then, install artifacts + Write-Message "Installing artifacts..." -Level Info + if ($HiveOnly) { + Write-Message "Skipping CLI installation due to -HiveOnly flag" -Level Info + } else { + Install-AspireCliFromDownload -DownloadDir $cliDownloadDir -CliBinDir $cliBinDir + } + Install-BuiltNugets -DownloadDir $nugetDownloadDir -NugetHiveDir $nugetHiveDir + + # Update PATH environment variables + if (-not $HiveOnly) { + Update-PathEnvironment -CliBinDir $cliBinDir + } +} + +# ============================================================================= +# Main Execution +# ============================================================================= + +try { + # Validate PRNumber is provided when not showing help + if ($PRNumber -le 0) { + Write-Message "Error: PRNumber parameter is required" -Level Error + Write-Message "Use -Help for usage information" -Level Info + if ($InvokedFromFile) { exit 1 } else { return 1 } + } + + # Set host OS for PATH environment updates + $script:HostOS = Get-OperatingSystem + + # Check gh dependency + Test-GitHubCLIDependency + + # Set default install prefix if not provided + $resolvedInstallPrefix = Get-InstallPrefix -InstallPrefix $InstallPath + + # Create a temporary directory for downloads + $tempDir = New-TempDirectory -Prefix "aspire-cli-pr-download" + + try { + # Download and install from PR or workflow run ID + Start-DownloadAndInstall -TempDir $tempDir + + $exitCode = 0 + } + finally { + # Clean up temporary directory + Remove-TempDirectory -TempDir $tempDir + } +} +catch { + Write-Message "Error: $($_.Exception.Message)" -Level Error + if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Message "StackTrace: $($_.Exception.StackTrace)" -Level Verbose + } + $exitCode = 1 +} + +if ($InvokedFromFile) { + exit $exitCode +} +else { + if ($exitCode -ne 0) { + return $exitCode + } +} diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh new file mode 100755 index 00000000000..1554300cb65 --- /dev/null +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -0,0 +1,889 @@ +#!/usr/bin/env bash + +# get-aspire-cli-pr.sh - Download and unpack the Aspire CLI from a specific PR's build artifacts +# Usage: ./get-aspire-cli-pr.sh PR_NUMBER [OPTIONS] + +set -euo pipefail + +# Global constants / defaults +readonly BUILT_NUGETS_ARTIFACT_NAME="built-nugets" +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" + +# Repository: Allow override via ASPIRE_REPO env var (owner/name). Default: dotnet/aspire +readonly REPO="${ASPIRE_REPO:-dotnet/aspire}" +readonly GH_REPOS_BASE="repos/${REPO}" + +# Global constants +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RESET='\033[0m' + +# Variables (defaults set after parsing arguments) +INSTALL_PREFIX="" +PR_NUMBER="" +WORKFLOW_RUN_ID="" +OS_ARG="" +ARCH_ARG="" +SHOW_HELP=false +VERBOSE=false +KEEP_ARCHIVE=false +DRY_RUN=false +HIVE_ONLY=false +HOST_OS="unset" + +# Function to show help +show_help() { + cat << 'EOF' +Aspire CLI PR Download Script + +DESCRIPTION: + Downloads and installs the Aspire CLI from a specific pull request's latest successful build. + Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. + + The script queries the GitHub API to find the latest successful run of the 'ci.yml' workflow + for the specified PR, then downloads and extracts the CLI archive for your platform using 'gh run download'. + + Alternatively, you can specify a workflow run ID directly to download from a specific build. + +USAGE: + ./get-aspire-cli-pr.sh PR_NUMBER [OPTIONS] + ./get-aspire-cli-pr.sh PR_NUMBER --run-id WORKFLOW_RUN_ID [OPTIONS] + + PR_NUMBER Pull request number (required) + --run-id, -r WORKFLOW_ID Workflow run ID to download from (optional) + -i, --install-path PATH Directory prefix to install (default: ~/.aspire) + CLI installs to: /bin + NuGet hive: /hives/pr-/packages + --os OS Override OS detection (win, linux, linux-musl, osx) + --arch ARCH Override architecture detection (x64, x86, arm64) + --hive-only Only install NuGet packages to the hive, skip CLI download + -v, --verbose Enable verbose output + -k, --keep-archive Keep downloaded archive files after installation + --dry-run Show what would be done without performing actions + -h, --help Show this help message + +EXAMPLES: + ./get-aspire-cli-pr.sh 1234 + ./get-aspire-cli-pr.sh 1234 --run-id 12345678 + ./get-aspire-cli-pr.sh 1234 --install-path ~/my-aspire + ./get-aspire-cli-pr.sh 1234 --os linux --arch arm64 --verbose + ./get-aspire-cli-pr.sh 1234 --hive-only + ./get-aspire-cli-pr.sh 1234 --dry-run + + curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- + +REQUIREMENTS: + - GitHub CLI (gh) must be installed and authenticated + - Permissions to download artifacts from the target repository + +ENVIRONMENT VARIABLES: + ASPIRE_REPO Override repository (owner/name). Default: dotnet/aspire + Example: export ASPIRE_REPO=myfork/aspire + +EOF +} + +# Function to parse command line arguments +parse_args() { + # Check for help flag first (can be anywhere in arguments) + for arg in "$@"; do + if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then + SHOW_HELP=true + return 0 # Exit early, help will be handled in main + fi + done + + # Check that at least one argument is provided + if [[ $# -lt 1 ]]; then + say_error "At least one argument is required. The first argument must be a PR number." + say_info "Use --help for usage information." + exit 1 + fi + + # First argument must be the PR number (cannot start with --) + if [[ "$1" == --* ]]; then + say_error "First argument must be a PR number, not an option. Got: '$1'" + say_info "Use --help for usage information." + exit 1 + fi + + # Validate that the first argument is a valid PR number (positive integer) + if [[ "$1" =~ ^[1-9][0-9]*$ ]]; then + PR_NUMBER="$1" + shift + else + say_error "First argument must be a valid PR number" + say_info "Use --help for usage information." + exit 1 + fi + + while [[ $# -gt 0 ]]; do + case $1 in + --run-id|-r) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + # Validate that the run ID is a number + if [[ ! "$2" =~ ^[0-9]+$ ]]; then + say_error "Run ID must be a number. Got: '$2'" + say_info "Use --help for usage information." + exit 1 + fi + WORKFLOW_RUN_ID="$2" + shift 2 + ;; + -i|--install-path) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + INSTALL_PREFIX="$2" + shift 2 + ;; + --os) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + OS_ARG="$2" + shift 2 + ;; + --arch) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + say_info "Use --help for usage information." + exit 1 + fi + ARCH_ARG="$2" + shift 2 + ;; + -k|--keep-archive) + KEEP_ARCHIVE=true + shift + ;; + --hive-only) + HIVE_ONLY=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + *) + say_error "Unknown option '$1'" + say_info "Use --help for usage information." + exit 1 + ;; + esac + done +} + +# ============================================================================= +# START: Shared code +# ============================================================================= + +# Function for verbose logging +say_verbose() { + if [[ "$VERBOSE" == true ]]; then + echo -e "${YELLOW}$1${RESET}" >&2 + fi +} + +say_error() { + echo -e "${RED}Error: $1${RESET}" >&2 +} + +say_warn() { + echo -e "${YELLOW}Warning: $1${RESET}" >&2 +} + +say_info() { + echo -e "$1" >&2 +} + +detect_os() { + local uname_s + uname_s=$(uname -s) + + case "$uname_s" in + Darwin*) + printf "osx" + ;; + Linux*) + # Check if it's musl-based (Alpine, etc.) + if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -q musl; then + printf "linux-musl" + else + printf "linux" + fi + ;; + CYGWIN*|MINGW*|MSYS*) + printf "win" + ;; + *) + printf "unsupported" + return 1 + ;; + esac +} + +# Function to validate and normalize architecture +get_cli_architecture_from_architecture() { + local architecture="$1" + + if [[ "$architecture" == "" ]]; then + architecture=$(detect_architecture) + fi + + case "$(echo "$architecture" | tr '[:upper:]' '[:lower:]')" in + amd64|x64) + printf "x64" + ;; + x86) + printf "x86" + ;; + arm64) + printf "arm64" + ;; + *) + say_error "Architecture $architecture not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + return 1 + ;; + esac +} + +detect_architecture() { + local uname_m + uname_m=$(uname -m) + + case "$uname_m" in + x86_64|amd64) + printf "x64" + ;; + aarch64|arm64) + printf "arm64" + ;; + i386|i686) + printf "x86" + ;; + *) + say_error "Architecture $uname_m not supported. If you think this is a bug, report it at https://github.com/dotnet/aspire/issues" + return 1 + ;; + esac +} + +# Function to compute the Runtime Identifier (RID) +get_runtime_identifier() { + # set target_os to $1 and default to HOST_OS + local target_os="$1" + local target_arch="$2" + + if [[ -z "$target_os" ]]; then + target_os=$HOST_OS + fi + + if [[ -z "$target_arch" ]]; then + if ! target_arch=$(get_cli_architecture_from_architecture ""); then + return 1 + fi + else + if ! target_arch=$(get_cli_architecture_from_architecture "$target_arch"); then + return 1 + fi + fi + + printf "%s" "${target_os}-${target_arch}" +} + +# Create a temporary directory with a prefix. Honors DRY_RUN +new_temp_dir() { + local prefix="$1" + if [[ "$DRY_RUN" == true ]]; then + printf "/tmp/%s-whatif" "$prefix" + return 0 + fi + local dir + if ! dir=$(mktemp -d -t "${prefix}-XXXXXXXX"); then + say_error "Unable to create temporary directory" + return 1 + fi + say_verbose "Creating temporary directory: $dir" + printf "%s" "$dir" +} + +# Remove a temporary directory unless KEEP_ARCHIVE is set. Honors DRY_RUN +remove_temp_dir() { + local dir="$1" + if [[ -z "$dir" || ! -d "$dir" ]]; then + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + if [[ "$KEEP_ARCHIVE" != true ]]; then + say_verbose "Cleaning up temporary files..." + rm -rf "$dir" || say_warn "Failed to clean up temporary directory: $dir" + else + printf "Archive files kept in: %s\n" "$dir" + fi +} + +# Function to install/unpack archive files +install_archive() { + local archive_file="$1" + local destination_path="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install archive $archive_file to $destination_path" + return 0 + fi + + say_verbose "Installing archive to: $destination_path" + + # Create install directory if it doesn't exist + if [[ ! -d "$destination_path" ]]; then + say_verbose "Creating install directory: $destination_path" + mkdir -p "$destination_path" + fi + + # Check archive format and extract accordingly + if [[ "$archive_file" =~ \.zip$ ]]; then + if ! command -v unzip >/dev/null 2>&1; then + say_error "unzip command not found. Please install unzip to extract ZIP files." + return 1 + fi + if ! unzip -o "$archive_file" -d "$destination_path"; then + say_error "Failed to extract ZIP archive: $archive_file" + return 1 + fi + elif [[ "$archive_file" =~ \.tar\.gz$ ]]; then + if ! command -v tar >/dev/null 2>&1; then + say_error "tar command not found. Please install tar to extract tar.gz files." + return 1 + fi + if ! tar -xzf "$archive_file" -C "$destination_path"; then + say_error "Failed to extract tar.gz archive: $archive_file" + return 1 + fi + else + say_error "Unsupported archive format: $archive_file. Only .zip and .tar.gz files are supported." + return 1 + fi + + say_verbose "Successfully installed archive" +} + +# Function to add PATH to shell configuration file +# Parameters: +# $1 - config_file: Path to the shell configuration file +# $2 - bin_path: The binary path to add to PATH +# $3 - command: The command to add to the configuration file +add_to_path() +{ + local config_file="$1" + local bin_path="$2" + local command="$3" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would check if $bin_path is already in \$PATH" + say_info "[DRY RUN] Would add '$command' to $config_file if not already present" + return 0 + fi + + if [[ ":$PATH:" == *":$bin_path:"* ]]; then + say_info "Path $bin_path already exists in \$PATH, skipping addition" + elif [[ -f "$config_file" ]] && grep -Fxq "$command" "$config_file"; then + say_info "Command already exists in $config_file, skipping addition" + elif [[ -w $config_file ]]; then + echo -e "\n# Added by get-aspire-cli*.sh script" >> "$config_file" + echo "$command" >> "$config_file" + say_info "Successfully added aspire to \$PATH in $config_file" + else + say_info "Manually add the following to $config_file (or similar):" + say_info " $command" + fi +} + +# Function to add PATH to shell profile +add_to_shell_profile() { + local bin_path="$1" + local bin_path_unexpanded="$2" + local xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + + # Detect the current shell + local shell_name + + # Try to get shell from SHELL environment variable + if [[ -n "${SHELL:-}" ]]; then + shell_name=$(basename "$SHELL") + else + # Fallback to detecting from process + shell_name=$(ps -p $$ -o comm= 2>/dev/null || echo "sh") + fi + + # Normalize shell name + case "$shell_name" in + bash|zsh|fish) + ;; + sh|dash|ash) + shell_name="sh" + ;; + *) + # Default to bash for unknown shells + shell_name="bash" + ;; + esac + + say_verbose "Detected shell: $shell_name" + + local config_files + case "$shell_name" in + bash) + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $xdg_config_home/bash/.bashrc $xdg_config_home/bash/.bash_profile" + ;; + zsh) + config_files="$HOME/.zshrc $HOME/.zshenv $xdg_config_home/zsh/.zshrc $xdg_config_home/zsh/.zshenv" + ;; + fish) + config_files="$HOME/.config/fish/config.fish" + ;; + sh) + config_files="$HOME/.profile /etc/profile" + ;; + *) + # Default to bash files for unknown shells + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" + ;; + esac + + # Get the appropriate shell config file + local config_file + + # Find the first existing config file + for file in $config_files; do + if [[ -f "$file" ]]; then + config_file="$file" + break + fi + done + + if [[ -z $config_file ]]; then + say_warn "No existing shell profile file found for $shell_name (checked: $config_files). Not adding to PATH automatically." + say_info "Add Aspire CLI to PATH manually by adding:" + say_info " export PATH=\"$bin_path_unexpanded:\$PATH\"" + return 0 + fi + + case "$shell_name" in + bash|zsh|sh) + add_to_path "$config_file" "$bin_path" "export PATH=\"$bin_path_unexpanded:\$PATH\"" + ;; + fish) + add_to_path "$config_file" "$bin_path" "fish_add_path $bin_path_unexpanded" + ;; + *) + say_error "Unsupported shell type $shell_name. Please add the path $bin_path_unexpanded manually to \$PATH in your profile." + return 1 + ;; + esac + + if [[ "$DRY_RUN" != true ]]; then + printf "\nTo use the Aspire CLI in new terminal sessions, restart your terminal or run:\n" + say_info " source $config_file" + fi + + return 0 +} + +# ============================================================================= +# END: Shared code +# ============================================================================= + +# Function to check if gh command is available +check_gh_dependency() { + if ! command -v gh >/dev/null 2>&1; then + say_error "GitHub CLI (gh) is required but not installed. Please install it first." + say_info "Installation instructions: https://cli.github.com/" + return 1 + fi + + if ! gh_version_output=$(gh --version 2>&1); then + say_error "GitHub CLI (gh) command failed: $gh_version_output" + return 1 + fi + + say_verbose "GitHub CLI (gh) found: $(echo "$gh_version_output" | head -1)" +} + +# Function to make GitHub API calls with proper error handling +# Parameters: +# $1 - endpoint: The GitHub API endpoint (e.g., "repos/dotnet/aspire/pulls/123") +# $2 - jq_filter: Optional jq filter to apply to the response (e.g., ".head.sha") +# $3 - error_message: Optional custom error message prefix +# Returns: +# 0 on success (output written to stdout) +# 1 on failure (error message written to stderr) +gh_api_call() { + local endpoint="$1" + local jq_filter="${2:-}" + local error_message="${3:-Failed to call GitHub API}" + local gh_cmd=(gh api "$endpoint") + if [[ -n "$jq_filter" ]]; then + gh_cmd+=(--jq "$jq_filter") + fi + say_verbose "Calling GitHub API: ${gh_cmd[*]}" + local api_output + if ! api_output=$("${gh_cmd[@]}" 2>&1); then + say_error "$error_message (API endpoint: $endpoint): $api_output" + return 1 + fi + printf "%s" "$api_output" +} + +# Function to get PR head SHA +get_pr_head_sha() { + local pr_number="$1" + + say_verbose "Getting HEAD SHA for PR #$pr_number" + + local head_sha + if ! head_sha=$(gh_api_call "${GH_REPOS_BASE}/pulls/$pr_number" ".head.sha" "Failed to get HEAD SHA for PR #$pr_number"); then + say_info "This could mean:" + say_info " - The PR number does not exist" + say_info " - You don't have access to the repository" + exit 1 + fi + + if [[ -z "$head_sha" || "$head_sha" == "null" ]]; then + say_error "Could not retrieve HEAD SHA for PR #$pr_number" + exit 1 + fi + + say_verbose "PR #$pr_number HEAD SHA: $head_sha" + printf "%s" "$head_sha" +} + +# Function to find workflow run for SHA +find_workflow_run() { + local head_sha="$1" + + # https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository + say_verbose "Finding ci.yml workflow run for SHA: $head_sha" + + local workflow_run_id + if ! workflow_run_id=$(gh_api_call "${GH_REPOS_BASE}/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$head_sha" ".workflow_runs | sort_by(.created_at) | reverse | .[0].id" "Failed to query workflow runs for SHA: $head_sha"); then + return 1 + fi + + if [[ -z "$workflow_run_id" || "$workflow_run_id" == "null" ]]; then + say_error "No ci.yml workflow run found for PR SHA: $head_sha. This could mean no workflow has been triggered for this SHA $head_sha . Check at https://github.com/${REPO}/actions/workflows/ci.yml" + return 1 + fi + + say_verbose "Found workflow run ID: $workflow_run_id" + printf "%s" "$workflow_run_id" +} + +# Function to download built-nugets artifact +download_built_nugets() { + # Parameters: + # $1 - workflow_run_id + # $2 - rid (e.g. osx-arm64) + # $3 - temp_dir + local workflow_run_id="$1" + local rid="$2" + local temp_dir="$3" + + local download_dir="${temp_dir}/built-nugets" + local nugets_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$BUILT_NUGETS_ARTIFACT_NAME" -D "$download_dir") + local nugets_rid_filename="$BUILT_NUGETS_RID_ARTIFACT_NAME-${rid}" + local nugets_rid_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$nugets_rid_filename" -D "$download_dir") + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download built nugets with: ${nugets_download_command[*]}" + say_info "[DRY RUN] Would download rid specific built nugets with: ${nugets_rid_download_command[*]}" + printf "%s" "$download_dir" + return 0 + fi + + say_info "Downloading $BUILT_NUGETS_ARTIFACT_NAME artifact." + say_verbose "Downloading with: ${nugets_download_command[*]}" + + if ! "${nugets_download_command[@]}"; then + say_verbose "gh run download command failed. Command: ${nugets_download_command[*]}" + say_error "Failed to download artifact '$BUILT_NUGETS_ARTIFACT_NAME' from run: $workflow_run_id . If the workflow is still running then the artifact named '$BUILT_NUGETS_ARTIFACT_NAME' may not be available yet. Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + return 1 + fi + + say_info "Downloading $nugets_rid_filename artifact." + say_verbose "Downloading with: ${nugets_rid_download_command[*]}" + + if ! "${nugets_rid_download_command[@]}"; then + say_verbose "gh run download command failed. Command: ${nugets_rid_download_command[*]}" + say_error "Failed to download artifact '$nugets_rid_filename' from run: $workflow_run_id . If the workflow is still running then the artifact named '$nugets_rid_filename' may not be available yet. Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + return 1 + fi + + say_verbose "Successfully downloaded nuget packages to: $download_dir" + printf "%s" "$download_dir" + return 0 +} + +# Function to install built-nugets +install_built_nugets() { + local download_dir="$1" + local nuget_install_dir="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would copy nugets to $nuget_install_dir" + return 0 + fi + + # Remove and recreate the target directory to ensure clean state + if [[ -d "$nuget_install_dir" ]]; then + say_verbose "Removing existing nuget directory: $nuget_install_dir" + rm -rf "$nuget_install_dir" + fi + mkdir -p "$nuget_install_dir" + + say_verbose "Copying nugets from $download_dir to $nuget_install_dir" + + # Copy all files from the artifact directory to the target directory + if ! find "$download_dir" -name "*.nupkg" -exec cp -R {} "$nuget_install_dir"/ \;; then + say_error "Failed to copy nuget artifact files" + return 1 + fi + + say_verbose "Successfully installed nuget packages to: $nuget_install_dir" + say_info "NuGet packages successfully installed to: ${GREEN}$nuget_install_dir${RESET}" + return 0 +} + +download_aspire_cli() { + # Parameters: + # $1 - workflow_run_id + # $2 - rid + # $3 - temp_dir + local workflow_run_id="$1" + local rid="$2" + local temp_dir="$3" + local cli_archive_name + cli_archive_name="$CLI_ARCHIVE_ARTIFACT_NAME_PREFIX-${rid}" + + local download_dir="${temp_dir}/cli" + local download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$cli_archive_name" -D "$download_dir") + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download $cli_archive_name with: ${download_command[*]}" + printf "%s" "/tmp/fake-cli-path" + return 0 + fi + + say_info "Downloading CLI from GitHub ..." + 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 '$cli_archive_name' from run: $workflow_run_id . If the workflow is still running then the artifact named '$cli_archive_name' may not be available yet. Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + return 1 + fi + + local cli_archive_path + local -a cli_files=() + shopt -s nullglob + for f in "$download_dir"/${ASPIRE_CLI_ARTIFACT_NAME_PREFIX}-*.tar.gz "$download_dir"/${ASPIRE_CLI_ARTIFACT_NAME_PREFIX}-*.zip; do + [[ -f "$f" ]] && cli_files+=("$f") + done + shopt -u nullglob + + if [[ ${#cli_files[@]} -eq 0 ]]; then + say_error "No CLI archive found. Expected a single ${ASPIRE_CLI_ARTIFACT_NAME_PREFIX}-*.tar.gz or ${ASPIRE_CLI_ARTIFACT_NAME_PREFIX}-*.zip file in artifact root: $download_dir" + say_info "Candidate files present (root only):" + ls -1 "$download_dir" 2>/dev/null | head -20 | sed 's/^/ /' + return 1 + fi + if [[ ${#cli_files[@]} -gt 1 ]]; then + say_error "Multiple CLI archives found (expected exactly one):" + printf ' %s\n' "${cli_files[@]}" + return 1 + fi + cli_archive_path="${cli_files[0]}" + say_verbose "Successfully downloaded CLI archive to: $cli_archive_path" + + # Export the path for the caller to use + printf "%s" "$cli_archive_path" + return 0 +} + +# Function to install downloaded CLI +install_aspire_cli() { + local cli_archive_path="$1" + local cli_install_dir="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install CLI archive to: $cli_install_dir" + return 0 + fi + + if ! install_archive "$cli_archive_path" "$cli_install_dir"; then + return 1 + fi + + # Determine CLI executable name and path + local cli_path + # Check whether aspire.exe or aspire exists on disk, and use that + if [[ -f "$cli_install_dir/aspire.exe" ]]; then + cli_path="$cli_install_dir/aspire.exe" + else + cli_path="$cli_install_dir/aspire" + fi + + say_info "Aspire CLI successfully installed to: ${GREEN}$cli_path${RESET}" + return 0 +} + +# Main function to download and install from PR or workflow run ID +download_and_install_from_pr() { + # Parameters: + # $1 - temp_dir (required) + local temp_dir="$1" + local head_sha workflow_run_id rid + + # If a workflow run ID was explicitly provided via arguments, use that directly. + # (Previously this checked the uninitialized local variable 'workflow_run_id', which was always empty.) + if [[ -n "$WORKFLOW_RUN_ID" ]]; then + say_info "Starting download and installation for PR #$PR_NUMBER with workflow run ID: $WORKFLOW_RUN_ID" + workflow_run_id="$WORKFLOW_RUN_ID" + else + # When only PR number is provided, find the workflow run + say_info "Starting download and installation for PR #$PR_NUMBER" + + # Find the workflow run + if ! head_sha=$(get_pr_head_sha "$PR_NUMBER"); then + return 1 + fi + + if ! workflow_run_id=$(find_workflow_run "$head_sha"); then + return 1 + fi + fi + + say_info "Using workflow run https://github.com/${REPO}/actions/runs/$workflow_run_id" + + # Set installation paths + local cli_install_dir="$INSTALL_PREFIX/bin" + local nuget_hive_dir="$INSTALL_PREFIX/hives/pr-$PR_NUMBER/packages" + + # First, download both artifacts + local cli_archive_path nuget_download_dir + # Compute RID once + if ! rid=$(get_runtime_identifier "$OS_ARG" "$ARCH_ARG"); then + return 1 + fi + say_verbose "Computed RID: $rid" + if [[ "$HIVE_ONLY" == true ]]; then + say_info "Skipping CLI download due to --hive-only flag" + else + if ! cli_archive_path=$(download_aspire_cli "$workflow_run_id" "$rid" "$temp_dir"); then + return 1 + fi + fi + + if ! nuget_download_dir=$(download_built_nugets "$workflow_run_id" "$rid" "$temp_dir"); then + say_error "Failed to download nuget packages" + return 1 + fi + + # Then, install both artifacts + say_info "Installing artifacts..." + if [[ "$HIVE_ONLY" == true ]]; then + say_info "Skipping CLI installation due to --hive-only flag" + else + if ! install_aspire_cli "$cli_archive_path" "$cli_install_dir"; then + return 1 + fi + fi + + if ! install_built_nugets "$nuget_download_dir" "$nuget_hive_dir"; then + say_error "Failed to install nuget packages" + return 1 + fi +} + +# ============================================================================= +# Main Execution +# ============================================================================= + +# Parse command line arguments +parse_args "$@" + +if [[ "$SHOW_HELP" == true ]]; then + show_help + exit 0 +fi + +HOST_OS=$(detect_os) + +if [[ "$HOST_OS" == "unsupported" ]]; then + say_error "Unsupported operating system detected: $(uname -s). Supported values: win (Git Bash/MinGW/MSYS), linux, linux-musl, osx. Use --os to override when appropriate." + exit 1 +fi + +# Check gh dependency +check_gh_dependency + +# Set default install prefix if not provided +if [[ -z "$INSTALL_PREFIX" ]]; then + INSTALL_PREFIX="$HOME/.aspire" + INSTALL_PREFIX_UNEXPANDED="\$HOME/.aspire" +else + INSTALL_PREFIX_UNEXPANDED="$INSTALL_PREFIX" +fi + +# Set paths based on install prefix +cli_install_dir="$INSTALL_PREFIX/bin" +INSTALL_PATH_UNEXPANDED="$INSTALL_PREFIX_UNEXPANDED/bin" + +# Create a temporary directory for downloads +if [[ "$DRY_RUN" == true ]]; then + temp_dir="/tmp/aspire-cli-pr-dry-run" +else + temp_dir=$(mktemp -d -t aspire-cli-pr-download-XXXXXX) + say_verbose "Creating temporary directory: $temp_dir" +fi + +# Set trap for cleanup on exit +cleanup() { + remove_temp_dir "$temp_dir" +} +trap cleanup EXIT + +# Download and install from PR or workflow run ID +if ! download_and_install_from_pr "$temp_dir"; then + exit 1 +fi + +# Add to shell profile for persistent PATH +if [[ "$HIVE_ONLY" != true ]]; then + add_to_shell_profile "$cli_install_dir" "$INSTALL_PATH_UNEXPANDED" + + # Add to current session PATH, if the path is not already in PATH + if [[ ":$PATH:" != *":$cli_install_dir:"* ]]; then + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would add $cli_install_dir to PATH" + else + export PATH="$cli_install_dir:$PATH" + fi + fi +fi