diff --git a/eng/aspire-managed-entitlements.plist b/eng/aspire-managed-entitlements.plist new file mode 100644 index 00000000000..fde7a18e473 --- /dev/null +++ b/eng/aspire-managed-entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index ff7be8d0c9c..bb54e4ddeb3 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -92,6 +92,27 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # Verify the signed win-x64 CLI archive works (version check + project creation) + - pwsh: | + $ErrorActionPreference = 'Stop' + $archiveDir = "${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}" + $archive = Get-ChildItem -Path $archiveDir -Filter "aspire-cli-win-x64-*.zip" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $archive) { + Write-Host "##[warning]No win-x64 CLI archive found - skipping verification" + exit 0 + } + Write-Host "Found archive: $($archive.FullName)" + & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.ps1" -ArchivePath $archive.FullName + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]CLI archive verification failed" + exit 1 + } + displayName: 🟣Verify CLI archive (win-x64) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 39d92ec148a..780259f97f6 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -81,6 +81,18 @@ jobs: /bl:$(Build.Arcade.LogsPath)PublishManaged.binlog displayName: 🟣Publish aspire-managed + # On macOS, ad-hoc codesign aspire-managed with JIT entitlements BEFORE Arcade signing. + # MicroBuild (MacDeveloperHardenWithNotarization) preserves entitlements from the prior + # ad-hoc signature when re-signing with the real certificate. Without this step, + # hardened runtime blocks CoreCLR JIT (W^X memory mapping) causing HRESULT: 0x80070008. + # This follows the same pattern used by dotnet/sdk for managed binaries (roslyn-entitlements.plist). + - ${{ if eq(parameters.agentOs, 'macos') }}: + - script: >- + codesign --sign - --force + --entitlements $(Build.SourcesDirectory)/eng/aspire-managed-entitlements.plist + $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Ad-hoc codesign aspire-managed with JIT entitlements + - ${{ if eq(parameters.codeSign, true) }}: - script: >- $(Build.SourcesDirectory)/$(scriptName) @@ -92,6 +104,15 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + # On macOS/Linux, restore the execute bit after Arcade signing. + # MicroBuild rewrites the binary file during signing, which resets permissions + # to the default umask (typically 644). The execute bit must be restored before + # CreateLayout packs the binary into the bundle archive. + - ${{ if and(eq(parameters.codeSign, true), ne(parameters.agentOs, 'windows')) }}: + - script: >- + chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed + displayName: 🟣Restore execute permission on aspire-managed + - script: >- $(Build.SourcesDirectory)/$(dotnetScript) msbuild @@ -119,6 +140,29 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Needed for the signing task + # Verify the signed CLI archive can execute and create a project. + # Run for RIDs that can fully execute on the build agent: + # macOS (Apple Silicon): osx-arm64 only (osx-x64 via Rosetta can run the CLI + # but aspire-managed template resolution fails) + # Linux (amd64): linux-x64 only (linux-arm64/linux-musl-x64 are cross-compiled) + - ${{ if or(and(eq(parameters.agentOs, 'macos'), eq(targetRid, 'osx-arm64')), and(eq(parameters.agentOs, 'linux'), eq(targetRid, 'linux-x64'))) }}: + - script: | + set -euo pipefail + echo "Finding CLI archive for ${{ targetRid }}..." + ARCHIVE=$(find "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)" -name "aspire-cli-${{ targetRid }}-*.tar.gz" -type f | head -1) + if [ -z "$ARCHIVE" ]; then + echo "##[error]No CLI archive found for ${{ targetRid }}" + exit 1 + fi + echo "Found archive: $ARCHIVE" + chmod +x "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.sh" + "$(Build.SourcesDirectory)/eng/scripts/verify-cli-archive.sh" "$ARCHIVE" + displayName: 🟣Verify CLI archive (${{ targetRid }}) + condition: succeeded() + env: + ASPIRE_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish Artifacts condition: always() diff --git a/eng/scripts/verify-cli-archive.ps1 b/eng/scripts/verify-cli-archive.ps1 new file mode 100644 index 00000000000..69699c45f10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Verify that a signed Aspire CLI archive produces a working binary. + +.DESCRIPTION + This script: + 1. Cleans ~/.aspire to ensure no stale state + 2. Extracts the CLI archive to a temp location + 3. Runs 'aspire --version' to validate the binary executes + 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation + 5. Cleans up temp directories + +.PARAMETER ArchivePath + Path to the CLI archive (.zip or .tar.gz) + +.EXAMPLE + .\verify-cli-archive.ps1 -ArchivePath "artifacts\packages\Release\Shipping\aspire-cli-win-x64-10.0.0.zip" +#> + +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$ArchivePath +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { param([string]$msg) Write-Host "▶ $msg" -ForegroundColor Cyan } +function Write-Ok { param([string]$msg) Write-Host "✅ $msg" -ForegroundColor Green } +function Write-Err { param([string]$msg) Write-Host "❌ $msg" -ForegroundColor Red } + +$verifyTmpDir = $null +$aspireBackup = $null + +function Invoke-Cleanup { + if ($verifyTmpDir -and (Test-Path $verifyTmpDir)) { + Write-Step "Cleaning up temp directory: $verifyTmpDir" + Remove-Item -Recurse -Force $verifyTmpDir -ErrorAction SilentlyContinue + } + # Restore ~/.aspire if we backed it up + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if ($aspireBackup -and (Test-Path $aspireBackup)) { + if (Test-Path $aspireDir) { + Remove-Item -Recurse -Force $aspireDir -ErrorAction SilentlyContinue + } + Move-Item $aspireBackup $aspireDir + Write-Step "Restored original ~/.aspire" + } +} + +try { + # Validate archive exists + if (-not (Test-Path $ArchivePath)) { + Write-Err "Archive not found: $ArchivePath" + exit 1 + } + + $ArchivePath = (Resolve-Path $ArchivePath).Path + + # Suppress interactive prompts and telemetry + $env:ASPIRE_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_CLI_TELEMETRY_OPTOUT = "true" + $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "true" + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + + Write-Host "" + Write-Host "==========================================" + Write-Host " Aspire CLI Archive Verification" + Write-Host "==========================================" + Write-Host " Archive: $ArchivePath" + Write-Host "==========================================" + Write-Host "" + + # Step 1: Back up and clean ~/.aspire + Write-Step "Cleaning ~/.aspire state..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + if (Test-Path $aspireDir) { + $aspireBackup = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-backup-$([System.IO.Path]::GetRandomFileName())" + Move-Item $aspireDir $aspireBackup + Write-Step "Backed up existing ~/.aspire to $aspireBackup" + } + Write-Ok "Clean ~/.aspire state" + + # Step 2: Extract the archive + $verifyTmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-verify-$([System.IO.Path]::GetRandomFileName())" + $extractDir = Join-Path $verifyTmpDir "cli" + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + Write-Step "Extracting archive to $extractDir..." + if ($ArchivePath.EndsWith(".zip")) { + Expand-Archive -Path $ArchivePath -DestinationPath $extractDir + } + elseif ($ArchivePath.EndsWith(".tar.gz")) { + tar -xzf $ArchivePath -C $extractDir + if ($LASTEXITCODE -ne 0) { + Write-Err "Failed to extract tar.gz archive" + exit 1 + } + } + else { + Write-Err "Unsupported archive format: $ArchivePath (expected .zip or .tar.gz)" + exit 1 + } + + # Find the aspire binary + $aspireBin = Join-Path $extractDir "aspire.exe" + if (-not (Test-Path $aspireBin)) { + $aspireBin = Join-Path $extractDir "aspire" + if (-not (Test-Path $aspireBin)) { + Write-Err "Could not find 'aspire' binary in extracted archive." + Get-ChildItem $extractDir | Format-Table + exit 1 + } + } + Write-Ok "Extracted CLI binary: $aspireBin" + + # Install to ~/.aspire/bin so self-extraction works correctly + Write-Step "Installing CLI to ~/.aspire/bin..." + $aspireDir = Join-Path $env:USERPROFILE ".aspire" + $aspireBinDir = Join-Path $aspireDir "bin" + New-Item -ItemType Directory -Path $aspireBinDir -Force | Out-Null + Copy-Item $aspireBin (Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf)) + $aspireBin = Join-Path $aspireBinDir (Split-Path $aspireBin -Leaf) + $env:PATH = "$aspireBinDir;$env:PATH" + Write-Ok "CLI installed to ~/.aspire/bin" + + # Step 3: Verify aspire --version + Write-Step "Running 'aspire --version'..." + $versionOutput = & $aspireBin --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire --version' failed with exit code $LASTEXITCODE" + Write-Host "Output: $versionOutput" + exit 1 + } + Write-Host " Version: $versionOutput" + Write-Ok "'aspire --version' succeeded" + + # Step 4: Create a new project with aspire new + # This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) + $projectDir = Join-Path $verifyTmpDir "VerifyApp" + New-Item -ItemType Directory -Path $projectDir -Force | Out-Null + + Write-Step "Running 'aspire new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo'..." + & $aspireBin new aspire-starter --name VerifyApp --output $projectDir --non-interactive --nologo 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Err "'aspire new' failed with exit code $LASTEXITCODE" + exit 1 + } + + # Verify the project was created + $appHostDir = Join-Path $projectDir "VerifyApp.AppHost" + if (-not (Test-Path $appHostDir)) { + Write-Err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + Get-ChildItem $projectDir | Format-Table + exit 1 + } + Write-Ok "'aspire new' created project successfully" + + Write-Host "" + Write-Host "==========================================" + Write-Host " All verification checks passed!" -ForegroundColor Green + Write-Host "==========================================" + Write-Host "" +} +catch { + Write-Err "Verification failed: $_" + Write-Host $_.ScriptStackTrace + exit 1 +} +finally { + Invoke-Cleanup +} diff --git a/eng/scripts/verify-cli-archive.sh b/eng/scripts/verify-cli-archive.sh new file mode 100755 index 00000000000..40676d1ba10 --- /dev/null +++ b/eng/scripts/verify-cli-archive.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash + +# verify-cli-archive.sh - Verify that a signed Aspire CLI archive produces a working binary. +# +# Usage: ./verify-cli-archive.sh +# +# This script: +# 1. Cleans ~/.aspire to ensure no stale state +# 2. Extracts the CLI archive to a temp location +# 3. Runs 'aspire --version' to validate the binary executes +# 4. Runs 'aspire new aspire-starter' to test bundle self-extraction + project creation +# 5. Cleans up temp directories + +set -euo pipefail + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly CYAN='\033[0;36m' +readonly RESET='\033[0m' + +ARCHIVE_PATH="" + +log_step() { echo -e "${CYAN}▶ $1${RESET}"; } +log_ok() { echo -e "${GREEN}✅ $1${RESET}"; } +log_err() { echo -e "${RED}❌ $1${RESET}"; } + +show_help() { + cat << 'EOF' +Aspire CLI Archive Verification Script + +USAGE: + verify-cli-archive.sh + +ARGUMENTS: + Path to the CLI archive (.tar.gz or .zip) + +OPTIONS: + -h, --help Show this help message + +DESCRIPTION: + Verifies that a signed Aspire CLI archive produces a working binary by: + 1. Extracting the archive + 2. Running 'aspire --version' + 3. Creating a new project with 'aspire new' +EOF + exit 0 +} + +cleanup() { + local exit_code=$? + if [[ -n "${VERIFY_TMPDIR:-}" ]] && [[ -d "${VERIFY_TMPDIR}" ]]; then + log_step "Cleaning up temp directory: ${VERIFY_TMPDIR}" + rm -rf "${VERIFY_TMPDIR}" + fi + # Restore ~/.aspire if we backed it up + if [[ -n "${ASPIRE_BACKUP:-}" ]] && [[ -d "${ASPIRE_BACKUP}" ]]; then + if [[ -d "$HOME/.aspire" ]]; then + rm -rf "$HOME/.aspire" + fi + mv "${ASPIRE_BACKUP}" "$HOME/.aspire" + log_step "Restored original ~/.aspire" + fi + if [[ $exit_code -ne 0 ]]; then + log_err "Verification FAILED (exit code: $exit_code)" + fi + exit $exit_code +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + show_help + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + if [[ -z "$ARCHIVE_PATH" ]]; then + ARCHIVE_PATH="$1" + else + echo "Unexpected argument: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$ARCHIVE_PATH" ]]; then + echo "Error: archive path is required." >&2 + echo "Usage: verify-cli-archive.sh " >&2 + exit 1 +fi + +if [[ ! -f "$ARCHIVE_PATH" ]]; then + log_err "Archive not found: $ARCHIVE_PATH" + exit 1 +fi + +echo "" +echo "==========================================" +echo " Aspire CLI Archive Verification" +echo "==========================================" +echo " Archive: $ARCHIVE_PATH" +echo "==========================================" +echo "" + +# Suppress interactive prompts and telemetry +export ASPIRE_CLI_TELEMETRY_OPTOUT=true +export DOTNET_CLI_TELEMETRY_OPTOUT=true +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true +export DOTNET_GENERATE_ASPNET_CERTIFICATE=false + +VERIFY_TMPDIR="$(mktemp -d)" + +# Step 1: Back up and clean ~/.aspire +log_step "Cleaning ~/.aspire state..." +ASPIRE_BACKUP="" +if [[ -d "$HOME/.aspire" ]]; then + ASPIRE_BACKUP="${VERIFY_TMPDIR}/aspire-backup/.aspire" + mkdir -p "${VERIFY_TMPDIR}/aspire-backup" + mv "$HOME/.aspire" "${ASPIRE_BACKUP}" + log_step "Backed up existing ~/.aspire to ${ASPIRE_BACKUP}" +fi +log_ok "Clean ~/.aspire state" + +# Step 2: Extract the archive +EXTRACT_DIR="${VERIFY_TMPDIR}/cli" +mkdir -p "$EXTRACT_DIR" + +log_step "Extracting archive to ${EXTRACT_DIR}..." +if [[ "$ARCHIVE_PATH" == *.tar.gz ]]; then + tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" +elif [[ "$ARCHIVE_PATH" == *.zip ]]; then + unzip -q "$ARCHIVE_PATH" -d "$EXTRACT_DIR" +else + log_err "Unsupported archive format: $ARCHIVE_PATH (expected .tar.gz or .zip)" + exit 1 +fi + +# Find the aspire binary +ASPIRE_BIN="" +if [[ -f "$EXTRACT_DIR/aspire" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire" +elif [[ -f "$EXTRACT_DIR/aspire.exe" ]]; then + ASPIRE_BIN="$EXTRACT_DIR/aspire.exe" +else + log_err "Could not find 'aspire' binary in extracted archive. Contents:" + ls -la "$EXTRACT_DIR" + exit 1 +fi + +chmod +x "$ASPIRE_BIN" +log_ok "Extracted CLI binary: $ASPIRE_BIN" + +# Install the CLI to ~/.aspire/bin so self-extraction works correctly +log_step "Installing CLI to ~/.aspire/bin..." +mkdir -p "$HOME/.aspire/bin" +cp "$ASPIRE_BIN" "$HOME/.aspire/bin/" +ASPIRE_BIN="$HOME/.aspire/bin/$(basename "$ASPIRE_BIN")" +chmod +x "$ASPIRE_BIN" +export PATH="$HOME/.aspire/bin:$PATH" +log_ok "CLI installed to ~/.aspire/bin" + +# Step 3: Verify aspire --version +log_step "Running 'aspire --version'..." +VERSION_OUTPUT=$("$ASPIRE_BIN" --version 2>&1) || { + log_err "'aspire --version' failed with exit code $?" + echo "Output: $VERSION_OUTPUT" + exit 1 +} +echo " Version: $VERSION_OUTPUT" +log_ok "'aspire --version' succeeded" + +# Step 4: Create a new project with aspire new +# This exercises bundle self-extraction and aspire-managed (template search + download + scaffolding) +PROJECT_DIR="${VERIFY_TMPDIR}/VerifyApp" +mkdir -p "$PROJECT_DIR" + +log_step "Running 'aspire new aspire-starter --name VerifyApp --output $PROJECT_DIR'..." +"$ASPIRE_BIN" new aspire-starter --name VerifyApp --output "$PROJECT_DIR" --non-interactive --nologo 2>&1 || { + log_err "'aspire new' failed" + echo "Contents of project directory:" + find "$PROJECT_DIR" -maxdepth 3 -type f 2>/dev/null || true + exit 1 +} + +# Verify the project was actually created +if [[ ! -d "$PROJECT_DIR/VerifyApp.AppHost" ]]; then + log_err "Expected project directory 'VerifyApp.AppHost' not found after 'aspire new'" + echo "Contents of project directory:" + ls -la "$PROJECT_DIR" + exit 1 +fi +log_ok "'aspire new' created project successfully" + +echo "" +echo "==========================================" +echo -e " ${GREEN}All verification checks passed!${RESET}" +echo "==========================================" +echo ""