Skip to content
Merged
18 changes: 18 additions & 0 deletions eng/aspire-managed-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for CoreCLR JIT compilation under hardened runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Required for loading .NET runtime libraries -->
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment for com.apple.security.cs.allow-unsigned-executable-memory appears inaccurate: this entitlement is for allowing unsigned executable memory mappings (often needed alongside JIT), not specifically for loading runtime libraries. Please update the comment to reflect the actual purpose so future maintenance doesn’t rely on misleading documentation.

Suggested change
<!-- Required for loading .NET runtime libraries -->
<!-- Required for unsigned executable memory mappings used by JIT-generated code -->

Copilot uses AI. Check for mistakes.
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Required for loading non-Apple-signed .NET libraries -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Required for DYLD_LIBRARY_PATH used by .NET host -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
21 changes: 21 additions & 0 deletions eng/pipelines/templates/BuildAndTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions eng/pipelines/templates/build_sign_native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path used for the ad-hoc codesign step hard-codes both the build configuration (Release) and TFM (net10.0). This makes the pipeline brittle (it will break if _BuildConfig changes or if Aspire.Managed’s TFM changes). Consider locating aspire-managed via $(_BuildConfig) and/or a globbed TFM segment (consistent with the $(ArtifactsBinDir)Aspire.Managed/**/publish/aspire-managed pattern used in eng/Signing.props).

Suggested change
$(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed
$(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/$(_BuildConfig)/*/${{ targetRid }}/publish/aspire-managed

Copilot uses AI. Check for mistakes.
displayName: 🟣Ad-hoc codesign aspire-managed with JIT entitlements

- ${{ if eq(parameters.codeSign, true) }}:
- script: >-
$(Build.SourcesDirectory)/$(scriptName)
Expand All @@ -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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chmod step also hard-codes Release/net10.0 in the publish output path, which can get out of sync with $(_BuildConfig) used earlier and with future TFM changes. Use $(_BuildConfig) (and avoid embedding the TFM in the path, e.g., by globbing the TFM segment) so the step consistently targets the actual publish output.

Suggested change
chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed
chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/$(_BuildConfig)/*/${{ targetRid }}/publish/aspire-managed

Copilot uses AI. Check for mistakes.
displayName: 🟣Restore execute permission on aspire-managed

- script: >-
$(Build.SourcesDirectory)/$(dotnetScript)
msbuild
Expand Down Expand Up @@ -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()
Expand Down
171 changes: 171 additions & 0 deletions eng/scripts/verify-cli-archive.ps1
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading