diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index c80b92d45e9..1a326ea8330 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -905,6 +905,59 @@ extends: # to match files under the artifact root. patterns: '**/aspire-cli-*' + # Install the GitHub CLI directly from cli/cli release artifacts. + # Reasons for downloading the official zip directly: + # - The 1ES scout image used by this pool does not ship gh + # on PATH (the previous `gh release upload --clobber` + # failed with CommandNotFoundException). + # - Avoids a hard dependency on choco/winget being present. + # Version + zip SHA256 are pinned for determinism and supply- + # chain integrity — bump together when updating: + # - releases: https://github.com/cli/cli/releases + # - checksums: https://github.com/cli/cli/releases/download/v/gh__checksums.txt + - pwsh: | + $ErrorActionPreference = 'Stop' + $version = '2.92.0' + # SHA256 of gh_2.92.0_windows_amd64.zip from the cli/cli + # v2.92.0 checksums.txt. Verifying this locally keeps the + # release-publish pipeline from trusting GitHub's TLS alone + # for the binary that performs the privileged release upload. + $expectedSha256 = 'b6a8df3c8c6b9c80f290906387673bc4d272840f3789c5650e0e4e6e75522785' + $url = "https://github.com/cli/cli/releases/download/v$version/gh_${version}_windows_amd64.zip" + $dest = Join-Path $env:AGENT_TEMPDIRECTORY 'gh' + $zipPath = Join-Path $dest 'gh.zip' + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Write-Host "Downloading $url..." + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + $actualSha256 = (Get-FileHash -Algorithm SHA256 $zipPath).Hash.ToLower() + if ($actualSha256 -ne $expectedSha256) { + Write-Error "SHA256 mismatch for $url. Expected $expectedSha256 but got $actualSha256. If gh $version was re-released, update the pinned hash from the per-release checksums.txt." + exit 1 + } + Write-Host "✓ Verified gh.zip SHA256 against pinned value." + Expand-Archive -LiteralPath $zipPath -DestinationPath $dest -Force + # The zip extracts to gh__windows_amd64/bin/gh.exe. + $ghExe = Get-ChildItem -Path $dest -Recurse -Filter gh.exe | Select-Object -First 1 + if (-not $ghExe) { + Write-Error "gh.exe not found after extracting $url." + exit 1 + } + $binDir = $ghExe.Directory.FullName + Write-Host "Prepending $binDir to PATH for subsequent steps." + Write-Host "##vso[task.prependpath]$binDir" + # Verify on the current step's PATH too (prependpath only + # affects subsequent steps). $ErrorActionPreference='Stop' + # catches cmdlet errors but NOT native-exe non-zero exits, + # so check $LASTEXITCODE explicitly — otherwise a corrupt + # extract or missing dependency DLL would pass this step + # and surface as a confusing failure in publish-release-cli-assets.ps1. + & "$binDir/gh.exe" --version + if ($LASTEXITCODE -ne 0) { + Write-Error "gh.exe --version exited with code $LASTEXITCODE — the installed binary is on disk but failed to run." + exit $LASTEXITCODE + } + displayName: 'Install GitHub CLI from cli/cli release' + # pwsh (not powershell): publish-release-cli-assets.ps1 calls # Get-AspireBotInstallationToken.ps1, which uses RSA.ImportFromPem # — only available in .NET Core 3.0+ / .NET 5+, not in the .NET diff --git a/eng/pipelines/scripts/publish-release-cli-assets.ps1 b/eng/pipelines/scripts/publish-release-cli-assets.ps1 index b4be5d92a48..68b3855e657 100644 --- a/eng/pipelines/scripts/publish-release-cli-assets.ps1 +++ b/eng/pipelines/scripts/publish-release-cli-assets.ps1 @@ -12,24 +12,40 @@ # -AssetsDir : Directory containing aspire-cli-* archives and their # matching .sha512 companion files. # -Tag : The release tag to upload to (e.g. v13.0.0). -# -AppId : GitHub App id for aspire-repo-bot. -# -PrivateKeyPem: PEM private key for the App. +# -AppId : GitHub App id for aspire-repo-bot. Required for live +# upload; optional in -DryRun (when omitted, the auth +# chain is skipped but every other check still runs). +# -PrivateKeyPem: PEM private key for the App. Same rules as -AppId. # -Owner / -Repo: GitHub repo coordinates (default microsoft/aspire). -# -DryRun : Verify only, skip the upload. +# -DryRun : Verify only, skip the gh release upload. # # Behavior: -# - Lists every aspire-cli-*.sha512 in $AssetsDir, verifies its archive's -# hash, and fails fast on the first mismatch (corruption shouldn't ship). -# - In DryRun mode, prints what would be uploaded and exits 0. -# - Otherwise mints an installation token via Get-AspireBotInstallationToken.ps1 -# and uses `gh release upload --clobber` so re-runs are idempotent. +# 1. Lists every aspire-cli-*.sha512 in $AssetsDir, verifies its archive's +# hash, and fails fast on the first mismatch (corruption shouldn't ship). +# 2. Runs `gh --version` to confirm the GitHub CLI is installed on the +# agent — the original failure that motivated this check was a missing +# `gh` on the AzDO release pool image. +# 3. If credentials are provided, mints an installation access token via +# Get-AspireBotInstallationToken.ps1 and runs `gh auth status` to +# confirm the bot can authenticate to github.com. (This only validates +# auth against github.com, not release-level permissions on a specific +# tag; checking release-level perms reliably would require the release +# to already exist, which is not guaranteed at dry-run time because +# release-github-tasks.yml creates it in a sibling job.) +# 4. In DryRun mode, prints what would be uploaded and exits 0. +# In live mode, runs `gh release upload --clobber` so re-runs are +# idempotent. Credentials are required for live upload. +# +# Dry-run runs as much as possible without credentials so the script can be +# exercised on the AzDO agent itself to confirm host prerequisites (archive +# verification, gh on PATH) before a live publish run. When the pipeline +# invokes dry-run it does pass the credentials in, so CI dry-runs continue +# to exercise the full auth chain. [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$AssetsDir, [Parameter(Mandatory = $true)][string]$Tag, - # AppId / PrivateKeyPem are only required for the actual upload — a dry-run - # only verifies SHA512s and never needs to mint a token. [Parameter()][string]$AppId, [Parameter()][string]$PrivateKeyPem, [Parameter()][string]$Owner = 'microsoft', @@ -39,11 +55,27 @@ param( $ErrorActionPreference = 'Stop' -if (-not $DryRun) { - if ([string]::IsNullOrWhiteSpace($AppId) -or [string]::IsNullOrWhiteSpace($PrivateKeyPem)) { - Write-Error "AppId and PrivateKeyPem are required when not running in -DryRun mode." - exit 1 - } +$appIdProvided = -not [string]::IsNullOrWhiteSpace($AppId) +$keyProvided = -not [string]::IsNullOrWhiteSpace($PrivateKeyPem) +$hasCredentials = $appIdProvided -and $keyProvided + +# Reject partial credentials before either branch below can swallow them. +# Without this check, an AzDO variable-group reference that silently resolves +# empty for just one of the two values would flip $hasCredentials to false, +# and a -DryRun invocation would take the credential-less path and exit 0 — +# masking a broken variable group behind a green dry-run. +if ($appIdProvided -ne $keyProvided) { + $missing = if ($appIdProvided) { 'PrivateKeyPem' } else { 'AppId' } + Write-Error "$missing is empty but the other credential is set. AppId and PrivateKeyPem must be supplied together (or both omitted, only in -DryRun mode). This usually indicates a misconfigured AzDO variable group reference." + exit 1 +} + +if (-not $DryRun -and -not $hasCredentials) { + # Live upload requires real credentials. An AzDO variable group reference + # that resolves to '' (rather than failing outright) would otherwise reach + # the token mint with empty inputs. + Write-Error "AppId and PrivateKeyPem are required when not running in -DryRun mode." + exit 1 } Write-Host "=== Publish Release CLI Assets ===" @@ -104,7 +136,33 @@ Aborting upload — corrupted artifacts should never ship. } Write-Host "Verified $verified archive(s)." -if ($DryRun) { +# Confirm gh is installed and runnable on the agent. Placed after SHA512 +# verification on purpose: corrupted artifacts are a higher-priority diagnostic +# than a missing CLI, so let the more important failure surface first. +# +# The pipeline installs gh and prepends it to PATH in the step preceding this +# script, so in CI this should always succeed. The try/catch + $LASTEXITCODE +# split is there to give a meaningful diagnostic if either the install step +# regresses or the script is invoked locally without gh on PATH. +Write-Host "" +Write-Host "Verifying gh CLI is installed and runnable..." +try { + & gh --version +} +catch [System.Management.Automation.CommandNotFoundException] { + Write-Error "gh is not on PATH. Install GitHub CLI from https://cli.github.com/ (or, in CI, check the 'Install GitHub CLI' pipeline step)." + exit 1 +} +if ($LASTEXITCODE -ne 0) { + Write-Error "gh --version exited with code $LASTEXITCODE. The GitHub CLI is on PATH but failed to run." + exit $LASTEXITCODE +} + +if (-not $hasCredentials) { + # Dry-run without credentials: skip token mint + auth check, but still + # report what would be uploaded so a release manager gets useful output. + Write-Host "" + Write-Host "No credentials provided; skipping installation-token mint and gh auth check (DryRun)." Write-Host "" Write-Host "🔍 [DRY RUN] Would upload the following to release $Tag in ${Owner}/${Repo}:" foreach ($f in $allFiles) { @@ -124,10 +182,34 @@ if ([string]::IsNullOrWhiteSpace($installationToken)) { exit 1 } -# Hand the token to gh via GH_TOKEN. --clobber overwrites existing assets so -# this step is idempotent (matches the local script's behavior). +# Hand the token to gh via GH_TOKEN. --clobber on the live upload makes +# re-runs idempotent. The try/finally spans both the auth check and the +# live upload so the token is scrubbed from the environment even if we +# exit early. $env:GH_TOKEN = $installationToken try { + Write-Host "" + Write-Host "Verifying gh can authenticate to github.com..." + & gh auth status --hostname github.com + if ($LASTEXITCODE -ne 0) { + Write-Error "gh auth status failed with exit code $LASTEXITCODE." + exit $LASTEXITCODE + } + + if ($DryRun) { + Write-Host "" + Write-Host "🔍 [DRY RUN] Would upload the following to release $Tag in ${Owner}/${Repo}:" + foreach ($f in $allFiles) { + $sizeMB = [math]::Round($f.Length / 1MB, 2) + Write-Host " - $($f.Name) ($sizeMB MB)" + } + Write-Host "" + Write-Host "[DRY RUN] Skipping gh release upload." + # Exit inside the try block; PowerShell still runs the finally + # so the token gets cleared from the environment. + exit 0 + } + Write-Host "" Write-Host "Uploading $($allFiles.Count) asset(s) to release $Tag..." $filePaths = $allFiles | ForEach-Object { $_.FullName }