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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions eng/pipelines/release-publish-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>/gh_<version>_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_<version>_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
Expand Down
118 changes: 100 additions & 18 deletions eng/pipelines/scripts/publish-release-cli-assets.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 ==="
Expand Down Expand Up @@ -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) {
Expand All @@ -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 }
Expand Down
Loading