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 ""