diff --git a/.github/workflows/prepare-installer-artifacts.yml b/.github/workflows/prepare-installer-artifacts.yml new file mode 100644 index 00000000000..2119d19929d --- /dev/null +++ b/.github/workflows/prepare-installer-artifacts.yml @@ -0,0 +1,132 @@ +name: Prepare installer artifacts + +on: + workflow_call: + inputs: + installer: + required: true + type: string + +jobs: + prepare_installer_artifacts: + name: Prepare ${{ inputs.installer == 'winget' && 'WinGet manifests' || 'Homebrew cask' }} + runs-on: ${{ inputs.installer == 'winget' && 'windows-latest' || 'macos-latest' }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + + - name: Download CLI archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ inputs.installer == 'winget' && 'cli-native-archives-win-*' || 'cli-native-archives-osx-*' }} + path: ${{ github.workspace }}/native-archives + merge-multiple: false + + - name: Prepare WinGet manifest artifact + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & "$env:GITHUB_WORKSPACE/eng/winget/prepare-manifest-artifact.ps1" ` + -Channel prerelease ` + -ArchiveRoot "$env:GITHUB_WORKSPACE/native-archives" ` + -OutputPath "$env:GITHUB_WORKSPACE/artifacts/installers/winget-manifests" ` + -ValidationMode Offline + + - name: Prepare Homebrew cask artifact + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: > + eng/homebrew/prepare-cask-artifact.sh + --channel prerelease + --archive-root "${GITHUB_WORKSPACE}/native-archives" + --output-dir "${GITHUB_WORKSPACE}/artifacts/installers/homebrew-cask" + --validation-mode Offline + + - name: Dogfood install (real winget install) + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & "$env:GITHUB_WORKSPACE/eng/winget/dogfood.ps1" ` + -ManifestPath "$env:GITHUB_WORKSPACE/artifacts/installers/winget-manifests" ` + -ArchiveRoot "$env:GITHUB_WORKSPACE/native-archives" ` + -Force + + - name: Dogfood install (real brew install) + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + eng/homebrew/dogfood.sh \ + --archive-root "${GITHUB_WORKSPACE}/native-archives" \ + "${GITHUB_WORKSPACE}/artifacts/installers/homebrew-cask/aspire.rb" + + - name: Smoke test installed CLI (aspire new + restore) + if: ${{ inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # winget added the shim under %LOCALAPPDATA%\Microsoft\WinGet\Links during the + # preceding step in this job. The runner shell that started before the install + # does not yet have that directory on PATH, so prepend it here. + $env:PATH = "$env:LOCALAPPDATA\Microsoft\WinGet\Links;" + $env:PATH + & "$env:GITHUB_WORKSPACE/eng/scripts/smoke-installed-cli.ps1" + + - name: Smoke test installed CLI (aspire new + restore) + if: ${{ inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + # brew linked the binary into /opt/homebrew/bin which is already on PATH. + eng/scripts/smoke-installed-cli.sh + + - name: Collect Aspire CLI logs (Windows) + if: ${{ always() && inputs.installer == 'winget' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $source = Join-Path $HOME ".aspire\logs" + $destination = Join-Path $env:RUNNER_TEMP "aspire-cli-logs" + if (Test-Path $source) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Copy-Item -Path $source -Destination $destination -Recurse -Force + } else { + Write-Host "No Aspire CLI logs found at $source" + } + + - name: Collect Aspire CLI logs (macOS) + if: ${{ always() && inputs.installer == 'homebrew' }} + shell: bash + run: | + set -euo pipefail + source="${HOME}/.aspire/logs" + destination="${RUNNER_TEMP}/aspire-cli-logs" + if [[ -d "$source" ]]; then + mkdir -p "$destination" + cp -R "$source" "$destination/" + else + echo "No Aspire CLI logs found at $source" + fi + + - name: Upload Aspire CLI logs + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ inputs.installer == 'winget' && 'winget-aspire-cli-logs' || 'homebrew-aspire-cli-logs' }} + path: ${{ runner.temp }}/aspire-cli-logs + if-no-files-found: ignore + retention-days: 15 + + - name: Upload installer artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ inputs.installer == 'winget' && 'winget-manifests-prerelease' || 'homebrew-cask-prerelease' }} + path: ${{ github.workspace }}/${{ inputs.installer == 'winget' && 'artifacts/installers/winget-manifests' || 'artifacts/installers/homebrew-cask' }} + if-no-files-found: error + retention-days: 15 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83bff77de61..b01b2e339e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,6 +82,33 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "macos-latest", "runner": "macos-latest", "rids": "osx-arm64"}]' + build_cli_archive_macos_x64: + name: Build native CLI archive (macOS x64) + uses: ./.github/workflows/build-cli-native-archives.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} + targets: '[{"os": "macos-15-intel", "runner": "macos-15-intel", "rids": "osx-x64"}]' + + prepare_winget_installer_artifacts: + name: Prepare WinGet installer artifacts + uses: ./.github/workflows/prepare-installer-artifacts.yml + needs: [ + build_cli_archive_windows, + build_cli_archive_windows_arm64, + ] + with: + installer: winget + + prepare_homebrew_installer_artifacts: + name: Prepare Homebrew installer artifacts + uses: ./.github/workflows/prepare-installer-artifacts.yml + needs: [ + build_cli_archive_macos, + build_cli_archive_macos_x64, + ] + with: + installer: homebrew + build_cli_e2e_image: name: Build CLI E2E Docker image uses: ./.github/workflows/build-cli-e2e-image.yml @@ -332,6 +359,9 @@ jobs: build_cli_archive_windows, build_cli_archive_windows_arm64, build_cli_archive_macos, + build_cli_archive_macos_x64, + prepare_winget_installer_artifacts, + prepare_homebrew_installer_artifacts, build_cli_e2e_image, extension_tests_win, cli_starter_validation_windows, @@ -349,6 +379,10 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Create test results directory + shell: bash + run: mkdir -p "${{ github.workspace }}/testresults" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: logs-*-ubuntu-latest @@ -421,6 +455,9 @@ jobs: needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || needs.typescript_api_compat.result == 'skipped' || + needs.build_cli_archive_macos_x64.result == 'skipped' || + needs.prepare_winget_installer_artifacts.result == 'skipped' || + needs.prepare_homebrew_installer_artifacts.result == 'skipped' || needs.tests_no_nugets.result == 'skipped' || needs.tests_requires_nugets_linux.result == 'skipped' || needs.tests_requires_nugets_windows.result == 'skipped' || @@ -432,14 +469,17 @@ jobs: needs.polyglot_validation.result == 'skipped')) || (github.event_name != 'pull_request' && (needs.extension_tests_win.result == 'skipped' || - needs.typescript_sdk_tests.result == 'skipped' || - needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || - needs.tests_requires_nugets_windows.result == 'skipped' || - (github.ref == 'refs/heads/main' && - needs.polyglot_validation.result == 'skipped') || - (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && - needs.tests_requires_nugets_macos.result == 'skipped')))) }} + needs.typescript_sdk_tests.result == 'skipped' || + needs.build_cli_archive_macos_x64.result == 'skipped' || + needs.prepare_winget_installer_artifacts.result == 'skipped' || + needs.prepare_homebrew_installer_artifacts.result == 'skipped' || + needs.tests_no_nugets.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_windows.result == 'skipped' || + (github.ref == 'refs/heads/main' && + needs.polyglot_validation.result == 'skipped') || + (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && + needs.tests_requires_nugets_macos.result == 'skipped')))) }} run: | echo "One or more dependent jobs failed." exit 1 diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index 4e279bf6331..dafd90fe7d1 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -6,12 +6,14 @@ Two cross-platform helper scripts are available: - Bash: `eng/scripts/get-aspire-cli-pr.sh` - PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` -They download the correct build artifacts for your OS/architecture and support two CLI install modes: +They download the correct build artifacts for your OS/architecture and support four CLI install modes: - **Archive mode** (default): installs the native CLI archive into an isolated PR-specific directory and populates a PR-scoped NuGet "hive" with the matching packages. - **Tool mode**: installs the `Aspire.Cli` .NET tool from the PR's RID-specific NuGet artifact and populates the same PR-scoped NuGet hive. Use this when you also want to dogfood the dotnet-tool packaging or acquisition route. +- **WinGet mode**: installs from the PR's generated `winget-manifests-prerelease` artifact and local native archive artifacts. +- **Homebrew mode**: installs from the PR's generated `homebrew-cask-prerelease` artifact and local native archive artifacts. -Both modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's NuGet packages via the hive at `~/.aspire/hives/pr-/packages`. The difference is only how the CLI binary itself is acquired. +All modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's NuGet packages via the hive at `~/.aspire/hives/pr-/packages`. The difference is only how the CLI binary itself is acquired. ## Prerequisites @@ -20,6 +22,8 @@ Both modes give `aspire new`, `aspire add`, and `aspire run` access to the PR's - Authenticate: `gh auth login` - Network access to GitHub Actions - .NET SDK available on PATH when using tool mode (`--install-mode tool` or `-InstallMode Tool`) +- WinGet available on Windows when using WinGet mode (`--install-mode winget` or `-InstallMode WinGet`) +- Homebrew available on macOS when using Homebrew mode (`--install-mode homebrew` or `-InstallMode Homebrew`) - Archive tools: - On Unix/macOS: `tar` (and/or `unzip`, depending on the archive format) - On Windows (PowerShell script): built-in extraction is used; for Git Bash + `.sh` script, ensure `unzip` or `tar` is available @@ -65,21 +69,52 @@ To avoid changing the global .NET tool installation, pass a custom install path. ./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode Tool -InstallPath $HOME/.aspire-pr ``` +### WinGet mode + +WinGet mode downloads the PR's generated WinGet manifest artifact, downloads both Windows native archive artifacts, rewrites the manifest to use the local archive files, and installs via WinGet: + +```bash +./eng/scripts/get-aspire-cli-pr.sh 1234 --install-mode winget +``` + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode WinGet +``` + +WinGet mode is supported only on Windows. If `Microsoft.Aspire` is already installed through WinGet, uninstall it first or pass `--force`/`-Force` to allow the dogfood manifest to replace it. + +### Homebrew mode + +Homebrew mode downloads the PR's generated Homebrew cask artifact, downloads both macOS native archive artifacts, rewrites the cask to use the local archive files, and installs via Homebrew: + +```bash +./eng/scripts/get-aspire-cli-pr.sh 1234 --install-mode homebrew +``` + +```powershell +./eng/scripts/get-aspire-cli-pr.ps1 1234 -InstallMode Homebrew +``` + +Homebrew mode is supported only on macOS. If `aspire` is already installed as a Homebrew cask, uninstall it first with `brew uninstall --cask aspire`. + ## What gets installed - Aspire CLI binary: - **Archive mode** default location: `~/.aspire/dogfood/pr-/bin/aspire` (or `aspire.exe` on Windows). - **Tool mode** default location: `dotnet tool install --global` puts the tool on the PATH per the .NET SDK's global-tools convention. When tool mode is combined with `--install-path`, the scripts pass that path to `dotnet tool --tool-path` using the PR-specific `bin` directory under the prefix. + - **WinGet/Homebrew mode** location: managed by the platform package manager. - Important: If you already have an Aspire CLI installed for the same PR under the same prefix, running this script will overwrite that PR installation. Other PR installs and the regular script install under `~/.aspire/bin` are isolated from this path. - PR-scoped NuGet packages "hive": - Default location: `~/.aspire/hives/pr-/packages` - - Populated in both archive and tool mode. This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. + - Populated in all install modes. This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. In archive mode, the scripts attempt to add the PR-specific `bin` directory to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. In default tool mode, PATH setup is left to `dotnet tool install --global`. When tool mode is used with a custom install path, the scripts treat the PR-specific `bin` directory as the tool path and can add it to PATH. +In WinGet and Homebrew mode, PATH setup is left to the platform package manager. + ## Channel names Every Aspire CLI binary is built for a specific channel. The channel name controls which package feed (or local hive) is used by commands like `aspire new` and `aspire add`, and is the value you pass to `aspire update --self --channel`. @@ -165,7 +200,7 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` - Override OS and architecture (auto-detected by default): - Allowed OS values: `win`, `linux`, `linux-musl`, `osx` - - Allowed arch values: `x64`, `x86`, `arm64` + - Allowed arch values: `x64`, `arm64` - Tool mode uses `dotnet tool install`, which resolves RID-specific packages for the current host. Use OS/architecture overrides with archive mode, not to cross-install a dotnet tool for another RID. - Bash: ```bash @@ -186,6 +221,10 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` - PowerShell: `-InstallMode Tool` - If an existing dotnet-tool install blocks the requested version, use Bash `--force` or PowerShell `-Force`. +- Install through a platform package manager: + - WinGet: Bash `--install-mode winget` or PowerShell `-InstallMode WinGet` + - Homebrew: Bash `--install-mode homebrew` or PowerShell `-InstallMode Homebrew` + - Verbose, keep archives, or dry run: - Bash: `-v/--verbose`, `-k/--keep-archive`, `--dry-run` - PowerShell: `-Verbose`, `-WhatIf` (PowerShell's dry-run), or provide equivalent parameters if present diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index 0f5bf88c142..47e83a8d75f 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -16,6 +16,9 @@ brew install --cask aspire # stable |---|---| | `aspire.rb.template` | Cask template for stable releases | | `generate-cask.sh` | Downloads tarballs, computes SHA256 hashes, generates cask from template | +| `prepare-cask-artifact.sh` | Prepares CI artifacts by generating, validating, and adding dogfood helpers | +| `validate-cask-artifact.sh` | Runs shared cask syntax, style, audit, and install validation used by GitHub Actions and Azure DevOps | +| `dogfood.sh` | Installs a generated cask locally, optionally using downloaded native archive artifacts | ### Pipeline templates @@ -52,7 +55,8 @@ Where arch is `arm64` or `x64`. | Pipeline | Prepares | Publishes | |---|---|---| -| `azure-pipelines.yml` (prepare stage) | Stable casks (artifacts only) | — | +| `.github/workflows/tests.yml` | Prerelease casks (artifacts only) | — | +| `azure-pipelines.yml` (prepare stage) | Stable or prerelease casks (artifacts only) | — | | `release-publish-nuget.yml` (release) | — | Stable cask only | Publishing submits a PR to `Homebrew/homebrew-cask` using the GitHub REST API: @@ -68,9 +72,22 @@ Prepare validation currently runs: 1. `ruby -c` for syntax validation 2. `brew style --fix` on the generated cask -3. `brew audit --cask --online`, or `brew audit --cask --new --online` when the cask does not yet exist upstream +3. `brew audit --cask --online local/aspire/aspire` + - Adds `--new` only when the cask is absent upstream (existing casks fail the additional `--new` checks). + - Adds `--no-signing` in offline mode (PR-artifact validation), because the served archives are local loopback URLs of unsigned PR builds rather than notarized release assets. 4. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask ...` followed by uninstall validation +PR artifact validation uses the same shared script and local tap, but rewrites +the cask URLs to loopback archive URLs and runs in offline mode (see above). +Release preparation keeps the full online signing audit. + +To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease` +artifact and the `cli-native-archives-osx-*` artifacts into the same parent directory, then run: + +```bash +./dogfood.sh --archive-root .. +``` + ## Open Items - [ ] Submit initial `aspire` cask PR to `Homebrew/homebrew-cask` for acceptance diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index 8df2cdc59db..326b489b494 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -19,6 +19,13 @@ cask "aspire" do # Write the sidecar file postflight do - File.write("#{staged_path}/.aspire-install.json", %({"source":"brew"}\n)) + File.write("#{staged_path}/.aspire-install.json", %Q({"source":"brew"}\n)) end + + # Intentionally empty: `brew uninstall --zap aspire` should not remove + # `~/.aspire` because that directory is shared with non-Homebrew Aspire + # state (PR hives, dev certs, telemetry, runtime sockets, manually + # installed CLIs). An explicit empty `zap` silences the `missing-zap` + # audit warning and the corresponding upstream homebrew/cask PR label. + zap trash: [] end diff --git a/eng/homebrew/dogfood.sh b/eng/homebrew/dogfood.sh index fbe7ac44689..fc2e0a02fcb 100755 --- a/eng/homebrew/dogfood.sh +++ b/eng/homebrew/dogfood.sh @@ -5,9 +5,10 @@ set -euo pipefail # This script is intended for dogfooding builds before they are published to Homebrew/homebrew-cask. # # Usage: -# ./dogfood.sh # Auto-detects cask file in the same directory -# ./dogfood.sh aspire.rb # Explicit cask file path -# ./dogfood.sh --uninstall # Uninstall a previously dogfooded cask +# ./dogfood.sh # Auto-detects cask file and adjacent archives +# ./dogfood.sh --archive-root ../artifacts # Installs from downloaded native archive artifacts +# ./dogfood.sh aspire.rb # Explicit cask file path +# ./dogfood.sh --uninstall # Uninstall a previously dogfooded cask SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TAP_NAME="local/aspire-dogfood" @@ -22,13 +23,15 @@ Arguments: CASK_FILE Path to the .rb cask file (default: auto-detect in script directory) Options: + --archive-root PATH Root directory containing downloaded aspire-cli-osx-* archives --uninstall Uninstall a previously dogfooded cask and remove the local tap --help Show this help message Examples: - $(basename "$0") # Auto-detect and install - $(basename "$0") ./aspire.rb # Install from specific cask file - $(basename "$0") --uninstall # Clean up dogfood install + $(basename "$0") # Auto-detect cask and adjacent archives + $(basename "$0") --archive-root ../native-archives # Install from downloaded archive artifacts + $(basename "$0") ./aspire.rb # Install from specific cask file + $(basename "$0") --uninstall # Clean up dogfood install EOF exit 0 } @@ -59,11 +62,107 @@ uninstall() { exit 0 } +read_cask_version() { + local caskFile="$1" + awk -F'"' '/^[[:space:]]*version[[:space:]]+"/ { print $2; exit }' "$caskFile" +} + +find_archive_if_present() { + local archiveRoot="$1" + local archiveName="$2" + + find "$archiveRoot" -type f -name "$archiveName" -print -quit 2>/dev/null || true +} + +find_archive() { + local archiveRoot="$1" + local archiveName="$2" + local matches=() + local match + + while IFS= read -r match; do + matches+=("$match") + done < <(find "$archiveRoot" -type f -name "$archiveName" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: Could not find $archiveName under $archiveRoot" >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: Found multiple $archiveName archives under $archiveRoot:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + printf '%s' "${matches[0]}" +} + +detect_archive_root() { + local version="$1" + local candidate + + for candidate in "$SCRIPT_DIR" "$SCRIPT_DIR/.."; do + if [[ -f "$(find_archive_if_present "$candidate" "aspire-cli-osx-arm64-$version.tar.gz")" && + -f "$(find_archive_if_present "$candidate" "aspire-cli-osx-x64-$version.tar.gz")" ]]; then + printf '%s' "$(cd "$candidate" && pwd)" + return + fi + done +} + +rewrite_cask_for_local_archives() { + local caskFile="$1" + local archiveRoot="$2" + local localArchiveDir="$3" + local version="$4" + + local armArchive + local x64Archive + armArchive="$(find_archive "$archiveRoot" "aspire-cli-osx-arm64-$version.tar.gz")" + x64Archive="$(find_archive "$archiveRoot" "aspire-cli-osx-x64-$version.tar.gz")" + + mkdir -p "$localArchiveDir" + cp "$armArchive" "$localArchiveDir/aspire-cli-osx-arm64-$version.tar.gz" + cp "$x64Archive" "$localArchiveDir/aspire-cli-osx-x64-$version.tar.gz" + + local localUrlPrefix="file://$localArchiveDir" + + ruby - "$caskFile" "$localUrlPrefix" <<'RUBY' +cask_path = ARGV[0] +local_url_prefix = ARGV[1] +lines = File.readlines(cask_path, chomp: true) +rewritten = [] +skip_verified = false + +lines.each do |line| + stripped = line.strip + if stripped.start_with?('url "https://ci.dot.net/public/aspire/') + rewritten << " url \"#{local_url_prefix}/aspire-cli-osx-\#{arch}-\#{version}.tar.gz\"" + skip_verified = true + next + end + + if skip_verified && stripped.start_with?('verified: "ci.dot.net/public/aspire/"') + skip_verified = false + next + end + + skip_verified = false + rewritten << line +end + +File.write(cask_path, rewritten.join("\n") + "\n") +RUBY +} + CASK_FILE="" +ARCHIVE_ROOT="" UNINSTALL=false while [[ $# -gt 0 ]]; do case "$1" in + --archive-root) ARCHIVE_ROOT="$2"; shift 2 ;; --uninstall) UNINSTALL=true; shift ;; --help) usage ;; -*) echo "Unknown option: $1"; usage ;; @@ -149,6 +248,24 @@ tapCaskDir="$tapRoot/Casks" mkdir -p "$tapCaskDir" cp "$CASK_FILE" "$tapCaskDir/$CASK_FILENAME" +caskVersion="$(read_cask_version "$CASK_FILE")" +if [[ -z "$caskVersion" ]]; then + echo "Error: Could not read cask version from $CASK_FILE" + exit 1 +fi + +if [[ -z "$ARCHIVE_ROOT" ]]; then + ARCHIVE_ROOT="$(detect_archive_root "$caskVersion")" +fi + +if [[ -n "$ARCHIVE_ROOT" ]]; then + ARCHIVE_ROOT="$(cd "$ARCHIVE_ROOT" && pwd)" + echo "Using local native archive artifacts from: $ARCHIVE_ROOT" + rewrite_cask_for_local_archives "$tapCaskDir/$CASK_FILENAME" "$ARCHIVE_ROOT" "$tapRoot/LocalArtifacts" "$caskVersion" +else + echo "No local native archive artifacts found; installing with URLs from the cask." +fi + # Install echo "" echo "Installing $CASK_NAME from local tap..." @@ -156,17 +273,51 @@ echo "Installing $CASK_NAME from local tap..." # the cask file is picked up, causing a "cask unavailable" error. HOMEBREW_NO_AUTO_UPDATE=1 brew install --cask "$TAP_NAME/$CASK_NAME" +caskRoot="$(brew --prefix)/Caskroom/$CASK_NAME/$caskVersion" +if [[ -d "$caskRoot" ]] && xattr -p com.apple.quarantine "$caskRoot/aspire" &>/dev/null; then + # Local PR artifacts are unsigned ad-hoc signed binaries. Homebrew correctly + # quarantines cask downloads, but macOS kills these dogfood binaries before + # the CLI can print its version. Remove quarantine only from this local + # dogfood install after Homebrew has finished installing it. + xattr -dr com.apple.quarantine "$caskRoot" +fi + # Verify echo "" -if command -v aspire &>/dev/null; then - echo "Installed successfully!" - echo " Path: $(command -v aspire)" - aspireVersion="$(aspire --version 2>&1)" || true - echo " Version: $aspireVersion" -else - echo "Warning: aspire command not found in PATH after install." - echo "You may need to restart your shell or add the install location to your PATH." +if ! command -v aspire &>/dev/null; then + echo "Error: aspire command not found in PATH after install." >&2 + echo "You may need to restart your shell or add the install location to your PATH." >&2 + exit 1 +fi + +# Shadow check — Homebrew symlinks cask binaries into $(brew --prefix)/bin/, so +# the freshly-installed aspire must resolve under the brew prefix. An older +# aspire earlier on PATH (e.g. a dotnet-tool install, or a script install under +# ~/.aspire/bin) would otherwise silently shadow the cask and let this script +# report "Installed successfully!" against the wrong binary, defeating the +# whole point of the dogfood. The WinGet dogfood (eng/winget/dogfood.ps1) has +# the equivalent check via Find-AspireBinaryOnPath -ExpectedVersion. +# Skipped under ASPIRE_TEST_MODE because the test mock brew puts its fake +# aspire alongside the mock brew binary, not under $(brew --prefix)/bin/. +if [[ "${ASPIRE_TEST_MODE:-}" != "true" ]]; then + aspirePath="$(command -v aspire)" + brewPrefix="$(brew --prefix)" + if [[ "$aspirePath" != "$brewPrefix"/* ]]; then + echo "Error: 'aspire' resolved to '$aspirePath', not under the Homebrew prefix '$brewPrefix'." >&2 + echo "An older aspire earlier on PATH is shadowing the Homebrew cask install." >&2 + echo "Either reorder PATH so '$brewPrefix/bin' wins, or uninstall the older copy." >&2 + exit 1 + fi +fi + +echo "Installed successfully!" +echo " Path: $(command -v aspire)" +if ! aspireVersion="$(aspire --version 2>&1)"; then + echo "Error: aspire --version failed after install:" >&2 + echo "$aspireVersion" >&2 + exit 1 fi +echo " Version: $aspireVersion" echo "" echo "To uninstall: $(basename "$0") --uninstall" diff --git a/eng/homebrew/prepare-cask-artifact.sh b/eng/homebrew/prepare-cask-artifact.sh new file mode 100755 index 00000000000..9e1e3bb1ca6 --- /dev/null +++ b/eng/homebrew/prepare-cask-artifact.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepares the Homebrew cask artifact for Aspire CLI builds. +# This script intentionally does not upload artifacts; each CI system owns that step. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <&2; usage ;; + esac +done + +case "$CHANNEL" in + stable|prerelease) ;; + "") echo "Error: --channel is required." >&2; usage ;; + *) echo "Error: --channel must be 'stable' or 'prerelease'." >&2; exit 1 ;; +esac + +case "$VALIDATION_MODE" in + Full|Offline|GenerateOnly) ;; + full) VALIDATION_MODE="Full" ;; + offline) VALIDATION_MODE="Offline" ;; + generateonly|generate-only) VALIDATION_MODE="GenerateOnly" ;; + *) echo "Error: --validation-mode must be Full, Offline, or GenerateOnly." >&2; exit 1 ;; +esac + +if [[ -z "$OUTPUT_DIR" ]]; then + echo "Error: --output-dir is required." >&2 + usage +fi + +infer_version_from_archive() { + local rid="$1" + local prefix="aspire-cli-$rid-" + local suffix=".tar.gz" + local matches=() + local archive_path + + if [[ -z "$ARCHIVE_ROOT" || ! -d "$ARCHIVE_ROOT" ]]; then + echo "Error: --version is required when --archive-root is not specified." >&2 + exit 1 + fi + + while IFS= read -r archive_path; do + matches+=("$archive_path") + done < <(find "$ARCHIVE_ROOT" -type f -name "$prefix*$suffix" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: Could not find archive '$prefix*$suffix' under '$ARCHIVE_ROOT' to infer the Aspire CLI version." >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: Found multiple archives matching '$prefix*$suffix' under '$ARCHIVE_ROOT':" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + local filename="${matches[0]##*/}" + filename="${filename#"$prefix"}" + printf '%s' "${filename%"$suffix"}" +} + +if [[ -z "$VERSION" ]]; then + VERSION="$(infer_version_from_archive "osx-arm64")" +fi + +if [[ -z "$ARTIFACT_VERSION" ]]; then + ARTIFACT_VERSION="$VERSION" +fi + +mkdir -p "$OUTPUT_DIR" +OUTPUT_FILE="$OUTPUT_DIR/aspire.rb" + +echo "Preparing Homebrew cask" +echo " Version: $VERSION" +echo " Channel: $CHANNEL" +echo " Artifact version: $ARTIFACT_VERSION" +echo " Output dir: $OUTPUT_DIR" +echo " Validation mode: $VALIDATION_MODE" + +args=( + --version "$VERSION" + --artifact-version "$ARTIFACT_VERSION" + --output "$OUTPUT_FILE" +) + +if [[ -n "$ARCHIVE_ROOT" ]]; then + args+=(--archive-root "$ARCHIVE_ROOT") +fi + +if [[ "$VALIDATION_MODE" != "Full" ]]; then + args+=(--skip-url-validation) +fi + +"$SCRIPT_DIR/generate-cask.sh" "${args[@]}" + +echo "" +echo "Generated cask:" +cat "$OUTPUT_FILE" + +validation_args=( + --cask-file "$OUTPUT_FILE" + --channel "$CHANNEL" + --validation-mode "$VALIDATION_MODE" +) + +if [[ -n "$ARCHIVE_ROOT" ]]; then + validation_args+=(--archive-root "$ARCHIVE_ROOT") +fi + +if [[ "$VALIDATION_MODE" == "Full" ]]; then + validation_args+=(--summary-path "$OUTPUT_DIR/validation-summary.json") +fi + +"$SCRIPT_DIR/validate-cask-artifact.sh" "${validation_args[@]}" + +cp "$SCRIPT_DIR/dogfood.sh" "$OUTPUT_DIR/dogfood.sh" +chmod +x "$OUTPUT_DIR/dogfood.sh" + +echo "Homebrew cask artifact prepared at: $OUTPUT_DIR" diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh new file mode 100755 index 00000000000..ec8b5b345b6 --- /dev/null +++ b/eng/homebrew/validate-cask-artifact.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates a generated Aspire Homebrew cask. The script intentionally keeps +# upload/submission concerns out of validation so GitHub Actions and Azure +# DevOps can both use the same checks. + +usage() { + cat <&2; usage ;; + esac +done + +case "$VALIDATION_MODE" in + Full|Offline|GenerateOnly) ;; + full) VALIDATION_MODE="Full" ;; + offline) VALIDATION_MODE="Offline" ;; + generateonly|generate-only) VALIDATION_MODE="GenerateOnly" ;; + *) echo "Error: --validation-mode must be Full, Offline, or GenerateOnly." >&2; exit 1 ;; +esac + +case "$CHANNEL" in + stable|prerelease) ;; + *) echo "Error: --channel must be 'stable' or 'prerelease'." >&2; exit 1 ;; +esac + +if [[ -z "$CASK_FILE" ]]; then + echo "Error: --cask-file is required." >&2 + usage +fi + +if [[ ! -f "$CASK_FILE" ]]; then + echo "Error: cask file not found: $CASK_FILE" >&2 + exit 1 +fi + +CASK_FILE="$(cd "$(dirname "$CASK_FILE")" && pwd)/$(basename "$CASK_FILE")" +CASK_NAME="$(basename "$CASK_FILE" .rb)" + +if [[ "$CASK_NAME" != "aspire" ]]; then + echo "Error: only the aspire cask is supported; got '$CASK_NAME'." >&2 + exit 1 +fi + +if [[ "$VALIDATION_MODE" == "GenerateOnly" ]]; then + echo "Skipping Homebrew cask validation because validation mode is GenerateOnly." + exit 0 +fi + +read_cask_version() { + local cask_file="$1" + awk -F'"' '/^[[:space:]]*version[[:space:]]+"/ { print $2; exit }' "$cask_file" +} + +# Detects whether the Aspire cask already exists in the upstream +# Homebrew/homebrew-cask repository. Echoes 'true' if the cask is new (404 from +# the contents API), 'false' if it already exists (200), and exits non-zero on +# any other response so we don't silently mis-classify the audit mode. +# +# brew audit treats new submissions and updates differently: `--new` enables +# extra checks (e.g. canonical naming) that fail on existing casks. The PR body +# template downstream also keys "validated as a new upstream cask" off the same +# signal, so getting this wrong produces misleading review text in the bot PR. +detect_upstream_cask_is_new() { + local cask_name="$1" + local first_letter="${cask_name:0:1}" + local target_path="Casks/$first_letter/$cask_name.rb" + local api_url="https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path" + local status_code + local curl_args=(-sS -o /dev/null -w "%{http_code}") + + # Authenticate when a token is available. Unauthenticated GitHub API requests + # are throttled to 60/hour shared across the runner IP pool, which routinely + # produces 403s on hosted CI; an installation/PAT/Actions token raises that + # to 1000/hour or more. GH_TOKEN is the convention used by the `gh` CLI and + # by most repo scripts; GITHUB_TOKEN is the default exposed by GitHub Actions. + local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [[ -n "$token" ]]; then + curl_args+=(-H "Authorization: Bearer $token" -H "X-GitHub-Api-Version: 2022-11-28") + fi + + status_code="$(curl "${curl_args[@]}" "$api_url")" + case "$status_code" in + 200) echo "false" ;; + 404) echo "true" ;; + *) echo "Error: could not determine whether $target_path exists upstream (HTTP $status_code)" >&2; exit 1 ;; + esac +} + +find_archive() { + local archive_root="$1" + local archive_name="$2" + local matches=() + local match + + while IFS= read -r match; do + matches+=("$match") + done < <(find "$archive_root" -type f -name "$archive_name" -print | LC_ALL=C sort) + + if [[ "${#matches[@]}" -eq 0 ]]; then + echo "Error: could not find $archive_name under $archive_root" >&2 + exit 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + echo "Error: found multiple $archive_name archives under $archive_root:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + exit 1 + fi + + printf '%s' "${matches[0]}" +} + +start_archive_server() { + local archive_root="$1" + local version="$2" + local server_root="$3" + local port_file="$4" + + local arm_archive + local x64_archive + arm_archive="$(find_archive "$archive_root" "aspire-cli-osx-arm64-$version.tar.gz")" + x64_archive="$(find_archive "$archive_root" "aspire-cli-osx-x64-$version.tar.gz")" + + mkdir -p "$server_root" + cp "$arm_archive" "$server_root/aspire-cli-osx-arm64-$version.tar.gz" + cp "$x64_archive" "$server_root/aspire-cli-osx-x64-$version.tar.gz" + + python3 - "$server_root" "$port_file" <<'PY' & +import http.server +import socketserver +import sys + +root = sys.argv[1] +port_file = sys.argv[2] + +class ArchiveHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root, **kwargs) + + def log_message(self, format, *args): + pass + + def copyfile(self, source, outputfile): + try: + super().copyfile(source, outputfile) + except (BrokenPipeError, ConnectionResetError): + pass + +class ArchiveServer(socketserver.TCPServer): + allow_reuse_address = True + +with ArchiveServer(("127.0.0.1", 0), ArchiveHandler) as httpd: + with open(port_file, "w", encoding="utf-8") as f: + f.write(str(httpd.server_address[1])) + httpd.serve_forever() +PY + + ARCHIVE_SERVER_PID=$! + + for _ in {1..100}; do + if [[ -s "$port_file" ]]; then + return + fi + sleep 0.1 + done + + echo "Error: timed out waiting for local archive server to start." >&2 + exit 1 +} + +rewrite_cask_for_local_archives() { + local cask_file="$1" + local local_url_prefix="$2" + local verified_host="$3" + + ruby - "$cask_file" "$local_url_prefix" "$verified_host" <<'RUBY' +cask_path = ARGV[0] +local_url_prefix = ARGV[1] +verified_host = ARGV[2] +lines = File.readlines(cask_path, chomp: true) +rewritten = [] +skip_verified = false + +lines.each do |line| + stripped = line.strip + if stripped.start_with?('url "https://ci.dot.net/public/aspire/') + rewritten << " url \"#{local_url_prefix}/aspire-cli-osx-\#{arch}-\#{version}.tar.gz\"," + rewritten << " verified: \"#{verified_host}\"" + skip_verified = true + next + end + + if skip_verified && stripped.start_with?('verified: "ci.dot.net/public/aspire/"') + skip_verified = false + next + end + + skip_verified = false + rewritten << line +end + +File.write(cask_path, rewritten.join("\n") + "\n") +RUBY +} + +TAP_NAME="local/aspire" +TAP_ROOT="" +ARCHIVE_SERVER_PID="" +TEMP_DIR="" + +remove_tap() { + local tap_name="$1" + + brew untap "$tap_name" >/dev/null 2>&1 || true + + local tap_org="${tap_name%%/*}" + local tap_repo="${tap_name##*/}" + local tap_root + tap_root="$(brew --repository)/Library/Taps/$tap_org/homebrew-$tap_repo" + rm -rf "$tap_root" +} + +cleanup() { + remove_tap "$TAP_NAME" + remove_tap "local/aspire-test" + + if [[ -n "$ARCHIVE_SERVER_PID" ]]; then + kill "$ARCHIVE_SERVER_PID" >/dev/null 2>&1 || true + wait "$ARCHIVE_SERVER_PID" >/dev/null 2>&1 || true + fi + + if [[ -n "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} + +echo "Validating Ruby syntax..." +ruby -c "$CASK_FILE" +echo "ruby -c aspire.rb succeeded." + +if ! command -v brew >/dev/null 2>&1; then + if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "Error: brew is required for Full validation mode, but it was not found in PATH." >&2 + exit 1 + fi + + echo "Warning: brew was not found in PATH; skipping Homebrew style and audit validation." >&2 + exit 0 +fi + +trap cleanup EXIT +remove_tap "$TAP_NAME" +brew tap-new --no-git "$TAP_NAME" + +TAP_ROOT="$(brew --repository)/Library/Taps/local/homebrew-aspire" +mkdir -p "$TAP_ROOT/Casks" +TAPPED_CASK_PATH="$TAP_ROOT/Casks/aspire.rb" +cp "$CASK_FILE" "$TAPPED_CASK_PATH" + +echo "" +echo "Applying Homebrew style fixes in local tap..." +brew style --fix "$TAPPED_CASK_PATH" +cp "$TAPPED_CASK_PATH" "$CASK_FILE" +brew style --cask "$TAPPED_CASK_PATH" +echo "brew style --fix reported no offenses after copying aspire.rb into a temporary local tap." + +AUDIT_CASK_PATH="$TAPPED_CASK_PATH" +AUDIT_NOTE="" + +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + if [[ -z "$ARCHIVE_ROOT" ]]; then + echo "Skipping online audit because validation mode is Offline and no archive root was provided." + trap - EXIT + cleanup + exit 0 + fi + + if ! command -v python3 >/dev/null 2>&1; then + echo "Error: python3 is required to run Offline online audit against local archive URLs." >&2 + exit 1 + fi + + cask_version="$(read_cask_version "$CASK_FILE")" + if [[ -z "$cask_version" ]]; then + echo "Error: could not read version from $CASK_FILE" >&2 + exit 1 + fi + + TEMP_DIR="$(mktemp -d)" + port_file="$TEMP_DIR/archive-server.port" + start_archive_server "$ARCHIVE_ROOT" "$cask_version" "$TEMP_DIR/archives" "$port_file" + archive_port="$(cat "$port_file")" + + cp "$CASK_FILE" "$AUDIT_CASK_PATH" + rewrite_cask_for_local_archives "$AUDIT_CASK_PATH" "http://127.0.0.1:$archive_port" "127.0.0.1:$archive_port/" + AUDIT_NOTE=" against local archive URLs" +fi + +echo "" +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + echo "Skipping upstream cask probe because validation mode is Offline; running standard audit." + IS_NEW_CASK="false" +else + echo "Determining whether $CASK_NAME is a new upstream cask..." + IS_NEW_CASK="$(detect_upstream_cask_is_new "$CASK_NAME")" + if [[ "$IS_NEW_CASK" == "true" ]]; then + echo "Detected new upstream cask; running new-cask audit." + else + echo "Detected existing upstream cask; running standard audit." + fi +fi + +echo "" +echo "Auditing cask via local tap..." +audit_args=(--cask --online) +if [[ "$IS_NEW_CASK" == "true" ]]; then + audit_args+=(--new) +fi +if [[ "$VALIDATION_MODE" == "Offline" ]]; then + audit_args+=(--no-signing) +fi +audit_args+=("$TAP_NAME/$CASK_NAME") +audit_command="brew audit ${audit_args[*]}" +brew audit "${audit_args[@]}" +echo "$audit_command worked successfully$AUDIT_NOTE." + +if [[ "$VALIDATION_MODE" == "Full" ]]; then + echo "" + echo "Testing cask install/uninstall: $CASK_FILE" + + if command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command is already available before install; test environment is not clean." >&2 + exit 1 + fi + + test_tap_name="local/aspire-test" + test_tap_root="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" + test_cask_ref="$test_tap_name/$CASK_NAME" + test_cask_installed=false + + cleanup_test_install() { + if [[ "$test_cask_installed" == true ]]; then + brew uninstall --cask "$test_cask_ref" >/dev/null 2>&1 || true + fi + } + + trap 'cleanup_test_install; cleanup' EXIT + + brew tap-new --no-git "$test_tap_name" + mkdir -p "$test_tap_root/Casks" + cp "$CASK_FILE" "$test_tap_root/Casks/aspire.rb" + + test_cask_installed=true + HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$test_cask_ref" + + if ! command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command not found in PATH after install." >&2 + brew info --cask "$test_cask_ref" || true + exit 1 + fi + + echo " Path: $(command -v aspire)" + aspire_version="$(aspire --version 2>&1)" + echo " Version: $aspire_version" + + brew uninstall --cask "$test_cask_ref" + test_cask_installed=false + + if command -v aspire >/dev/null 2>&1; then + echo "Error: aspire command still found in PATH after uninstall." >&2 + exit 1 + fi +fi + +if [[ -n "$SUMMARY_PATH" && "$VALIDATION_MODE" == "Full" ]]; then + if [[ "$CHANNEL" == "stable" ]]; then + is_stable_release=true + else + is_stable_release=false + fi + + mkdir -p "$(dirname "$SUMMARY_PATH")" + cat > "$SUMMARY_PATH" </dev/null 2>&1 || true - } - - trap cleanup EXIT - - echo "Validating Ruby syntax..." - ruby -c "$caskPath" - echo "Ruby syntax OK" - - brew tap-new --no-git "$tapName" - mkdir -p "$tapRoot/Casks" - cp "$caskPath" "$tapRoot/Casks/$caskFile" - tappedCaskPath="$tapRoot/Casks/$caskFile" - - echo "" - echo "Applying Homebrew style fixes in local tap..." - brew style --fix "$tappedCaskPath" - cp "$tappedCaskPath" "$caskPath" - echo "Homebrew style OK" - - echo "" - echo "Auditing cask via local tap..." - - skipUrlValidation="$(printf '%s' "${SKIP_URL_VALIDATION:-false}" | tr '[:upper:]' '[:lower:]')" - - auditArgs=(--cask) - isNewCask=false - - if [ "$skipUrlValidation" = "true" ]; then - echo "Skipping upstream cask check and online audit (URLs are not expected to be valid for this build)." - else - upstreamStatusCode="$(curl -sS -o /dev/null -w "%{http_code}" "https://api.github.com/repos/Homebrew/homebrew-cask/contents/$targetPath")" - if [ "$upstreamStatusCode" = "404" ]; then - isNewCask=true - echo "Detected new upstream cask; running new-cask audit." - auditArgs+=(--new) - elif [ "$upstreamStatusCode" = "200" ]; then - echo "Detected existing upstream cask; running standard audit." - else - echo "##[error]Could not determine whether $targetPath exists upstream (HTTP $upstreamStatusCode)" - exit 1 - fi - echo "Including --online audit (URLs are expected to be valid)." - auditArgs+=(--online) - fi - - auditArgs+=("$tapName/$caskName") - auditCommand="brew audit ${auditArgs[*]}" - - auditCode=0 - brew audit "${auditArgs[@]}" || auditCode=$? - - if [ $auditCode -ne 0 ]; then - echo "##[error]brew audit failed" - exit $auditCode - fi - echo "##vso[task.setvariable variable=HomebrewAuditCommand]$auditCommand" - echo "##vso[task.setvariable variable=HomebrewIsNewCask]$isNewCask" - echo "brew audit passed" - trap - EXIT - cleanup - displayName: 🟣Validate cask syntax and audit - env: - SKIP_URL_VALIDATION: '${{ parameters.skipUrlValidation }}' - - - bash: | - set -euo pipefail - caskFile="aspire.rb" - caskName="aspire" - caskPath="$(Build.StagingDirectory)/homebrew-cask/$caskFile" - tapName="local/aspire-test" - tapRoot="$(brew --repository)/Library/Taps/local/homebrew-aspire-test" - - echo "Testing cask install/uninstall: $caskPath" - echo "" - - cleanup() { - brew untap "$tapName" >/dev/null 2>&1 || true - } - - trap cleanup EXIT - - # Verify aspire is NOT already installed - echo "Verifying aspire is not already installed..." - if command -v aspire &>/dev/null; then - echo "##[error]aspire command is already available before install — test environment is not clean" - exit 1 - fi - echo " Confirmed: aspire is not in PATH" - - # Set up a local tap so brew accepts the cask - echo "" - echo "Setting up local tap..." - brew tap-new --no-git "$tapName" - mkdir -p "$tapRoot/Casks" - cp "$caskPath" "$tapRoot/Casks/$caskFile" - - # Install from local tap - echo "" - echo "Installing aspire from local tap..." - HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask "$tapName/$caskName" - echo "✅ Install succeeded" - - # Verify aspire is now available and runs - echo "" - echo "Verifying aspire CLI is in PATH..." - if ! command -v aspire &>/dev/null; then - echo "##[error]aspire command not found in PATH after install" - # Show brew info for diagnostics - brew info --cask "$tapName/$caskName" || true - exit 1 - fi - - echo " Path: $(command -v aspire)" - aspireVersion="$(aspire --version 2>&1)" - echo " Version: $aspireVersion" - echo "✅ aspire CLI verified" - - # Uninstall - echo "" - echo "Uninstalling aspire..." - brew uninstall --cask "$tapName/$caskName" - echo "✅ Uninstall succeeded" - - # Clean up tap - trap - EXIT - cleanup - - # Verify aspire is removed - if command -v aspire &>/dev/null; then - echo "##[error]aspire command still found in PATH after uninstall" - exit 1 - else - echo " Confirmed: aspire is no longer in PATH" - fi - displayName: 🟣Test cask install/uninstall - condition: and(succeeded(), eq(${{ parameters.skipUrlValidation }}, false)) - - - bash: | - set -euo pipefail - outputPath="$(Build.StagingDirectory)/homebrew-cask/validation-summary.json" - if [ "$(HomebrewChannel)" = "stable" ]; then - isStableRelease=true - else - isStableRelease=false - fi - cat > "$outputPath" <&1 - if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } - Write-Host " Version: $v" - ' - if ($LASTEXITCODE -ne 0) { - throw "Child process exited with code $LASTEXITCODE" - } - Write-Host "✅ aspire CLI verified" - } catch { - Write-Host "##[error]Failed to verify aspire CLI: $_" - $failed = $true - } - - # Test uninstall (always attempt cleanup even if verification failed) - Write-Host "" - Write-Host "Uninstalling Aspire.Cli..." - winget uninstall --manifest $versionedManifestPath --accept-source-agreements - if ($LASTEXITCODE -ne 0) { - if ($failed) { - Write-Host "##[warning]winget uninstall also failed with exit code $LASTEXITCODE (ignoring since verification already failed)" - } else { - Write-Error "winget uninstall failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE - } - } else { - Write-Host "✅ Uninstall succeeded" - } - - if ($failed) { - exit 1 - } - displayName: 🟣Test WinGet manifest install/uninstall - condition: and(succeeded(), eq(${{ parameters.skipUrlValidation }}, false)) - - - pwsh: | - Copy-Item "$(Build.SourcesDirectory)/eng/winget/dogfood.ps1" "$(Build.StagingDirectory)/winget-manifests/dogfood.ps1" - displayName: 🟣Include dogfood script in artifact + & "$(Build.SourcesDirectory)/eng/winget/prepare-manifest-artifact.ps1" @args + displayName: 🟣Prepare WinGet manifests - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish WinGet manifests diff --git a/eng/pipelines/templates/publish-homebrew.yml b/eng/pipelines/templates/publish-homebrew.yml index b717de4617e..36f58ca04ed 100644 --- a/eng/pipelines/templates/publish-homebrew.yml +++ b/eng/pipelines/templates/publish-homebrew.yml @@ -100,7 +100,16 @@ steps: Write-Host "Validated Homebrew summary: $summaryPath" Write-Host "##vso[task.setvariable variable=HomebrewValidationSummaryPath]$summaryPath" displayName: '🟣Validate Homebrew validation summary' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Same gate as the Submit step below. The summary's brewInstall/brewUninstall + # checks only pass when the prepare stage ran with runInstallTest=true, which + # in turn only happens on production-publishing builds, so on non-production + # branches the required checks are absent and this would fail meaninglessly. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -147,6 +156,15 @@ steps: } Write-Host "All download URLs are accessible." displayName: '🟣Validate download URLs from cask' + # Same gate as the Submit step below. Non-production casks point to artifacts + # that aren't published at their canonical ci.dot.net paths, and in dry-run + # we're not submitting anything; in either case the reachability probe is moot. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -468,8 +486,7 @@ steps: if (-not [bool]$existingOpenPr.draft) { Write-Host "Converting existing PR #$($existingOpenPr.number) to draft..." - $graphQlData = Invoke-GitHubGraphQL -Query @' - mutation ConvertPullRequestToDraft($pullRequestId: ID!) { + $convertPullRequestToDraftMutation = 'mutation ConvertPullRequestToDraft($pullRequestId: ID!) { convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { pullRequest { number @@ -477,8 +494,9 @@ steps: url } } - } - '@ -Variables @{ + }' + + $graphQlData = Invoke-GitHubGraphQL -Query $convertPullRequestToDraftMutation -Variables @{ pullRequestId = $existingOpenPr.node_id } @@ -505,6 +523,15 @@ steps: Write-Host "Draft PR created successfully: #$($createdPr.number) $($createdPr.html_url)" } displayName: '🟣Submit PR to Homebrew/homebrew-cask' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Guard: only submit to Homebrew from production branches. + # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; + # a hard condition stops accidental submissions from feature, fork or dogfood branches. + # _IsProductionBranch is defined in eng/pipelines/common-variables.yml. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) env: HOMEBREW_CASK_GITHUB_TOKEN: $(aspire-homebrew-bot-pat) diff --git a/eng/pipelines/templates/publish-winget.yml b/eng/pipelines/templates/publish-winget.yml index e9676ca5335..fddad81c2cd 100644 --- a/eng/pipelines/templates/publish-winget.yml +++ b/eng/pipelines/templates/publish-winget.yml @@ -47,6 +47,12 @@ steps: Copy-Item -Path $_.FullName -Destination (Join-Path $submissionDir $_.Name) -Force } + Get-ChildItem -Path $submissionDir -File -Filter "*.locale.*.yaml" | ForEach-Object { + $content = Get-Content -Raw -Path $_.FullName + $content = $content -replace '(?m)^(\s*ReleaseNotesUrl:\s*).+$', '${1}https://aspire.dev' + [IO.File]::WriteAllText($_.FullName, $content, [Text.UTF8Encoding]::new($false)) + } + Write-Host "Manifest directory: $submissionDir" Write-Host "##vso[task.setvariable variable=WinGetManifestDir]$submissionDir" @@ -126,6 +132,17 @@ steps: } Write-Host "All installer URLs are accessible." displayName: '🟣Validate installer URLs from manifest' + # Only run when we'd actually submit (matches the Submit step condition). + # On non-production branches the manifest URLs point to artifacts that aren't + # published at their canonical ci.dot.net paths, so reachability checks fail + # meaninglessly; in dry-run we're not submitting anything so URL probing is + # equally moot. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -161,6 +178,13 @@ steps: Write-Host "ReleaseNotesUrl is valid." displayName: '🟣Validate ReleaseNotesUrl' + # Same gate as the installer-URL validation above and the Submit step below. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -168,6 +192,16 @@ steps: Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile "$(Build.StagingDirectory)/wingetcreate.exe" Write-Host "wingetcreate downloaded successfully" displayName: '🟣Install wingetcreate' + # Skip when no Submit step will run. wingetcreate is only used to submit to + # the upstream winget-pkgs repo, so dry-run and non-production-branch builds + # gain nothing from downloading it and instead pick up an aka.ms-redirect + # failure surface for free. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) - powershell: | $ErrorActionPreference = 'Stop' @@ -207,6 +241,78 @@ steps: exit 1 } + $headers = @{ + Authorization = "Bearer $token" + Accept = "application/vnd.github+json" + "User-Agent" = "dotnet-aspire-release-pipeline" + "X-GitHub-Api-Version" = "2022-11-28" + } + + function Invoke-GitHubApi { + param( + [string]$Method, + [string]$Uri, + [object]$Body + ) + + $params = @{ + Method = $Method + Uri = $Uri + Headers = $headers + ErrorAction = 'Stop' + } + + if ($null -ne $Body) { + $params.Body = ($Body | ConvertTo-Json -Depth 10) + $params.ContentType = 'application/json' + } + + return Invoke-RestMethod @params + } + + function Invoke-GitHubGraphQL { + param( + [string]$Query, + [object]$Variables + ) + + $response = Invoke-GitHubApi -Method Post -Uri 'https://api.github.com/graphql' -Body @{ + query = $Query + variables = $Variables + } + + if ($response.errors) { + $messages = ($response.errors | ForEach-Object { $_.message }) -join '; ' + throw "GitHub GraphQL request failed: $messages" + } + + return $response.data + } + + function Invoke-WithRetry { + param( + [scriptblock]$ScriptBlock, + [string]$Operation, + [int]$MaxAttempts = 12, + [int]$DelaySeconds = 10 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return & $ScriptBlock + } + catch { + if ($attempt -eq $MaxAttempts) { + throw + } + + Write-Host "$Operation failed on attempt $attempt/$($MaxAttempts): $($_.Exception.Message)" + Write-Host "Retrying in $DelaySeconds seconds..." + Start-Sleep -Seconds $DelaySeconds + } + } + } + Write-Host "Submitting clean manifest directory to wingetcreate: $manifestDir" $output = & "$(Build.StagingDirectory)/wingetcreate.exe" submit $manifestDir ` --token $token ` @@ -221,8 +327,58 @@ steps: exit $exitCode } + $pullRequestNumbers = @([regex]::Matches($outputText, 'https://github\.com/microsoft/winget-pkgs/pull/(\d+)') | + ForEach-Object { $_.Groups[1].Value } | + Select-Object -Unique + ) + + if ($pullRequestNumbers.Count -ne 1) { + Write-Error "Expected exactly one microsoft/winget-pkgs PR URL in wingetcreate output so it can be converted to draft; found $($pullRequestNumbers.Count)." + exit 1 + } + + $pullRequestNumber = $pullRequestNumbers[0] + $pullRequestUrl = "https://github.com/microsoft/winget-pkgs/pull/$pullRequestNumber" + $pullRequest = Invoke-WithRetry -Operation "Fetching WinGet PR #$pullRequestNumber" -ScriptBlock { + Invoke-GitHubApi -Method Get -Uri "https://api.github.com/repos/microsoft/winget-pkgs/pulls/$pullRequestNumber" -Body $null + } + + if (-not [bool]$pullRequest.draft) { + Write-Host "Converting WinGet PR #$pullRequestNumber to draft..." + + $convertPullRequestToDraftMutation = 'mutation ConvertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { + number + isDraft + url + } + } + }' + + $graphQlData = Invoke-WithRetry -Operation "Converting WinGet PR #$pullRequestNumber to draft" -ScriptBlock { + Invoke-GitHubGraphQL -Query $convertPullRequestToDraftMutation -Variables @{ + pullRequestId = $pullRequest.node_id + } + } + + if (-not $graphQlData.convertPullRequestToDraft.pullRequest.isDraft) { + throw "Failed to convert WinGet PR #$pullRequestNumber to draft." + } + } + + Write-Host "WinGet PR #$pullRequestNumber is draft: $pullRequestUrl" Write-Host "Successfully submitted WinGet manifest for $packageId $version" displayName: '🟣Submit to WinGet' - condition: and(succeeded(), eq('${{ parameters.dryRun }}', 'false')) + # Guard: only submit to WinGet from production branches. + # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; + # a hard condition stops accidental submissions from feature, fork or dogfood branches. + # _IsProductionBranch is defined in eng/pipelines/common-variables.yml. + condition: | + and( + succeeded(), + eq('${{ parameters.dryRun }}', 'false'), + eq(variables['_IsProductionBranch'], 'true') + ) env: WINGET_CREATE_GITHUB_TOKEN: $(aspire-winget-bot-pat) diff --git a/eng/scripts/README.md b/eng/scripts/README.md index bf115146b91..d3bb8849665 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -169,10 +169,12 @@ Additional scripts exist to fetch CLI and NuGet artifacts from a pull request bu - `get-aspire-cli-pr.sh` - `get-aspire-cli-pr.ps1` -The PR scripts support two install modes: +The PR scripts support four install modes: - **Archive mode** (default) installs the PR's native CLI archive under a PR-specific dogfood path and copies PR packages into `~/.aspire/hives/pr-/packages`. - **Tool mode** installs the PR's `Aspire.Cli` package as a .NET tool from the RID-specific NuGet artifact and also populates the same `~/.aspire/hives/pr-/packages` hive. Use this when you also want to dogfood the dotnet-tool packaging or acquisition route. +- **WinGet mode** installs from the PR's generated WinGet manifest artifact and local Windows native archive artifacts. +- **Homebrew mode** installs from the PR's generated Homebrew cask artifact and local macOS native archive artifacts. Quick archive-mode fetch (Bash): ```bash @@ -194,6 +196,16 @@ Quick tool-mode fetch (PowerShell): iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } -InstallMode Tool" ``` +Quick WinGet-mode fetch (Windows): +```powershell +iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } -InstallMode WinGet" +``` + +Quick Homebrew-mode fetch (macOS): +```bash +curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- --install-mode homebrew +``` + NuGet hive path pattern: `~/.aspire/hives/pr-/packages` ### Repository Override diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index db8cb63ae2e..1394a1a6d02 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -39,7 +39,9 @@ .PARAMETER InstallMode How to install the CLI: 'Archive' (default) installs from the cli-native-archives- - artifact, 'Tool' installs the Aspire.Cli dotnet tool from the PR's RID-specific NuGet artifact. + artifact, 'Tool' installs the Aspire.Cli dotnet tool from the PR's RID-specific NuGet artifact, + 'WinGet' installs from the generated WinGet manifest artifact, and 'Homebrew' installs from + the generated Homebrew cask artifact. Alias: -m .PARAMETER Force @@ -106,6 +108,12 @@ .EXAMPLE .\get-aspire-cli-pr.ps1 1234 -InstallMode Tool -Force +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -InstallMode Homebrew + +.EXAMPLE + .\get-aspire-cli-pr.ps1 1234 -InstallMode WinGet + .EXAMPLE .\get-aspire-cli-pr.ps1 -LocalDir "C:\path\to\artifacts" -InstallMode Tool @@ -143,12 +151,12 @@ param( [Alias("i")] [string]$InstallPath = "", - [Parameter(HelpMessage = "How to install the CLI: 'Archive' (default) or 'Tool' (install Aspire.Cli dotnet tool from the PR package source)")] + [Parameter(HelpMessage = "How to install the CLI: 'Archive' (default), 'Tool', 'WinGet', or 'Homebrew'")] [Alias("m")] - [ValidateSet("Archive", "Tool")] + [ValidateSet("Archive", "Tool", "WinGet", "Homebrew")] [string]$InstallMode = "Archive", - [Parameter(HelpMessage = "Tool mode only: when Aspire.Cli is already installed globally, update it to the PR's exact package version (allows downgrades)")] + [Parameter(HelpMessage = "Tool mode updates an existing Aspire.Cli tool; WinGet mode allows replacing an existing Microsoft.Aspire install")] [switch]$Force, [Parameter(HelpMessage = "Override OS detection")] @@ -184,6 +192,8 @@ $Script:BuiltNugetsRidArtifactName = "built-nugets-for" $Script:CliArchiveArtifactNamePrefix = "cli-native-archives" $Script:AspireCliArtifactNamePrefix = "aspire-cli" $Script:ExtensionArtifactName = "aspire-extension" +$Script:WinGetManifestArtifactName = "winget-manifests-prerelease" +$Script:HomebrewCaskArtifactName = "homebrew-cask-prerelease" $Script:IsModernPowerShell = $PSVersionTable.PSVersion.Major -ge 6 -and $PSVersionTable.PSEdition -eq "Core" $Script:HostOS = "unset" $Script:Repository = if ($env:ASPIRE_REPO -and $env:ASPIRE_REPO.Trim()) { $env:ASPIRE_REPO.Trim() } else { 'microsoft/aspire' } @@ -1361,6 +1371,174 @@ function Get-AspireCliFromArtifact { return $downloadDir } +function Test-InstallerMode { + return $InstallMode -eq 'WinGet' -or $InstallMode -eq 'Homebrew' +} + +function Test-ScriptManagesCliPath { + return $InstallMode -eq 'Archive' -or ($InstallMode -eq 'Tool' -and $script:InstallPathExplicit) +} + +function Get-InstallerArtifactName { + switch ($InstallMode) { + 'WinGet' { return $Script:WinGetManifestArtifactName } + 'Homebrew' { return $Script:HomebrewCaskArtifactName } + default { throw "Install mode '$InstallMode' does not use an installer artifact." } + } +} + +function Get-InstallerArchiveRids { + switch ($InstallMode) { + 'WinGet' { return @('win-x64', 'win-arm64') } + 'Homebrew' { return @('osx-arm64', 'osx-x64') } + default { throw "Install mode '$InstallMode' does not use native installer archives." } + } +} + +function Get-InstallerArtifacts { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$RunId, + + [Parameter(Mandatory = $true)] + [string]$TempDir + ) + + $installerArtifactName = Get-InstallerArtifactName + $installerArtifactDir = Join-Path $TempDir "installer-$($InstallMode.ToLowerInvariant())" + $archiveRoot = Join-Path $TempDir "installer-native-archives" + + Invoke-ArtifactDownload -RunId $RunId -ArtifactName $installerArtifactName -DownloadDirectory $installerArtifactDir + + foreach ($rid in Get-InstallerArchiveRids) { + Invoke-ArtifactDownload -RunId $RunId -ArtifactName "$($Script:CliArchiveArtifactNamePrefix)-$rid" -DownloadDirectory $archiveRoot + } + + return [pscustomobject]@{ + ArtifactDir = $installerArtifactDir + ArchiveRoot = $archiveRoot + } +} + +function Find-InstallerDogfoodScript { + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDir + ) + + $scriptName = switch ($InstallMode) { + 'WinGet' { 'dogfood.ps1' } + 'Homebrew' { 'dogfood.sh' } + default { throw "Install mode '$InstallMode' does not use a dogfood script." } + } + + $companionPattern = switch ($InstallMode) { + 'WinGet' { '*.installer.yaml' } + 'Homebrew' { 'aspire.rb' } + default { throw "Install mode '$InstallMode' does not use a dogfood script." } + } + + $matchedItems = @( + Get-ChildItem -Path $ArtifactDir -File -Recurse -Filter $scriptName -ErrorAction SilentlyContinue | + Where-Object { + Get-ChildItem -Path $_.Directory.FullName -File -Filter $companionPattern -ErrorAction SilentlyContinue | + Select-Object -First 1 + } | + Sort-Object FullName + ) + + if ($matchedItems.Count -eq 0) { + throw "Could not find $scriptName co-located with $companionPattern under: $ArtifactDir" + } + + if ($matchedItems.Count -gt 1) { + $matchList = ($matchedItems | ForEach-Object { " $($_.FullName)" }) -join [Environment]::NewLine + throw "Found multiple $scriptName files co-located with $companionPattern under ${ArtifactDir}:$([Environment]::NewLine)$matchList" + } + + return $matchedItems[0].FullName +} + +function Install-AspireCliWithInstallerArtifact { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDir, + + [Parameter(Mandatory = $true)] + [string]$ArchiveRoot + ) + + switch ($InstallMode) { + 'WinGet' { + $dogfoodScript = if ($WhatIfPreference -and -not (Test-Path $ArtifactDir)) { + Join-Path $ArtifactDir 'dogfood.ps1' + } else { + Find-InstallerDogfoodScript -ArtifactDir $ArtifactDir + } + $command = @('pwsh', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $dogfoodScript, '-ArchiveRoot', $ArchiveRoot) + if ($Force) { + $command += '-Force' + } + + if ($PSCmdlet.ShouldProcess($dogfoodScript, "Install Aspire CLI with WinGet artifact: $($command -join ' ')")) { + & $command[0] $command[1..($command.Length - 1)] + if ($LASTEXITCODE -ne 0) { + throw "WinGet dogfood installer failed with exit code $LASTEXITCODE" + } + } + break + } + 'Homebrew' { + $dogfoodScript = if ($WhatIfPreference -and -not (Test-Path $ArtifactDir)) { + Join-Path $ArtifactDir 'dogfood.sh' + } else { + Find-InstallerDogfoodScript -ArtifactDir $ArtifactDir + } + $command = @('bash', $dogfoodScript, '--archive-root', $ArchiveRoot) + if ($PSCmdlet.ShouldProcess($dogfoodScript, "Install Aspire CLI with Homebrew artifact: $($command -join ' ')")) { + & $command[0] $command[1..($command.Length - 1)] + if ($LASTEXITCODE -ne 0) { + throw "Homebrew dogfood installer failed with exit code $LASTEXITCODE" + } + } + break + } + default { + throw "Unsupported installer mode: $InstallMode" + } + } +} + +function Test-InstallerModeEnvironment { + if ($WhatIfPreference) { + return + } + + switch ($InstallMode) { + 'WinGet' { + if ($Script:HostOS -ne 'win') { + throw "-InstallMode WinGet can only be executed on Windows. Use -WhatIf to preview downloads from another OS." + } + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + throw "-InstallMode WinGet requires PowerShell (pwsh) to run the WinGet dogfood installer." + } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw "-InstallMode WinGet requires WinGet to install the generated manifest artifact." + } + } + 'Homebrew' { + if ($Script:HostOS -ne 'osx') { + throw "-InstallMode Homebrew can only be executed on macOS. Use -WhatIf to preview downloads from another OS." + } + if (-not (Get-Command brew -ErrorAction SilentlyContinue)) { + throw "-InstallMode Homebrew requires Homebrew (brew) to install the generated cask artifact." + } + } + } +} + # Writes the PR-route install-source sidecar (.aspire-install.json) next to # the installed binary. Under -WhatIf, prints the target path and skips the # write so a real user's sidecar is never overwritten by a describe pass. @@ -1560,6 +1738,8 @@ function Start-InstallFromLocalDir { Write-Message "Skipping CLI installation due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI archive lookup in local directory (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + Install-AspireCliWithInstallerArtifact -ArtifactDir $LocalDirPath -ArchiveRoot $LocalDirPath } else { $archiveMatch = Get-ChildItem -Path $LocalDirPath -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "^$Script:AspireCliArtifactNamePrefix-.*\.(tar\.gz|zip)$" } | @@ -1616,7 +1796,7 @@ function Start-InstallFromLocalDir { # Update PATH environment variables if (-not $HiveOnly) { - if ($InstallMode -ne 'Tool' -or $script:InstallPathExplicit) { + if (Test-ScriptManagesCliPath) { if ($SkipPath) { Write-Message "Skipping PATH configuration due to -SkipPath flag" -Level Info } else { @@ -1686,6 +1866,8 @@ function Start-DownloadAndInstall { Write-Message "Skipping CLI download due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI native archive download (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + $installerArtifacts = Get-InstallerArtifacts -RunId $runId -TempDir $TempDir } else { $cliDownloadDir = Get-AspireCliFromArtifact -RunId $runId -RID $rid -TempDir $TempDir } @@ -1718,6 +1900,8 @@ function Start-DownloadAndInstall { Write-Message "Skipping CLI installation due to -HiveOnly flag" -Level Info } elseif ($InstallMode -eq 'Tool') { Write-Message "Skipping CLI archive installation (install mode: Tool)" -Level Verbose + } elseif (Test-InstallerMode) { + Install-AspireCliWithInstallerArtifact -ArtifactDir $installerArtifacts.ArtifactDir -ArchiveRoot $installerArtifacts.ArchiveRoot } else { Install-AspireCliFromDownload -DownloadDir $cliDownloadDir -CliBinDir $cliBinDir } @@ -1748,7 +1932,7 @@ function Start-DownloadAndInstall { # Update PATH environment variables if (-not $HiveOnly) { - if ($InstallMode -ne 'Tool' -or $script:InstallPathExplicit) { + if (Test-ScriptManagesCliPath) { if ($SkipPath) { Write-Message "Skipping PATH configuration due to -SkipPath flag" -Level Info } else { @@ -1764,7 +1948,7 @@ function Start-DownloadAndInstall { # The new-PATH expression is emitted with double quotes so PowerShell expands `$env:PATH` # when the user pastes the line into their profile — single quotes would assign the literal # text "$env:PATH" and clobber the existing PATH. - if (-not $HiveOnly -and $PRNumber -gt 0 -and ($InstallMode -eq 'Archive' -or $script:InstallPathExplicit)) { + if (-not $HiveOnly -and $PRNumber -gt 0 -and (Test-ScriptManagesCliPath)) { $pathSep = [System.IO.Path]::PathSeparator Write-Host "Add to your shell profile: `$env:PATH = `"$cliBinDir$pathSep`$env:PATH`";" } @@ -1796,8 +1980,8 @@ OPTIONS: -LocalDir Use pre-downloaded artifacts from a local directory -HiveLabel Override the NuGet hive label -InstallPath Directory prefix to install - -InstallMode How to install the CLI: 'Archive' (default) or 'Tool' - -Force Tool mode only: run dotnet tool update for the PR's exact version + -InstallMode How to install the CLI: 'Archive' (default), 'Tool', 'WinGet', or 'Homebrew' + -Force Tool mode updates the existing tool; WinGet mode allows replacing an existing install -OS Override OS detection (win, linux, linux-musl, osx) -Architecture Override architecture detection (x64, arm64) -HiveOnly For installs from archives only: only install NuGet packages to the hive, skip CLI download @@ -1825,15 +2009,22 @@ OPTIONS: # Set host OS for PATH environment updates $script:HostOS = Get-OperatingSystem - if ($InstallMode -eq 'Tool' -and $HiveOnly) { - Write-Message "Error: -HiveOnly cannot be combined with -InstallMode Tool: -HiveOnly skips the CLI install, but -InstallMode Tool installs Aspire.Cli as a .NET tool." -Level Error - Write-Message "Drop one of the two flags. Both archive and tool modes populate the hive." -Level Info + $InstallMode = switch ($InstallMode.ToLowerInvariant()) { + 'archive' { 'Archive' } + 'tool' { 'Tool' } + 'winget' { 'WinGet' } + 'homebrew' { 'Homebrew' } + } + + if ($HiveOnly -and $InstallMode -ne 'Archive') { + Write-Message "Error: -HiveOnly cannot be combined with -InstallMode ${InstallMode}: -HiveOnly skips the CLI install, but this install mode installs Aspire CLI through a package or tool manager." -Level Error + Write-Message "Drop one of the two flags. All install modes populate the hive." -Level Info if ($InvokedFromFile) { exit 1 } else { return 1 } } - if ($InstallMode -ne 'Tool' -and $Force) { - Write-Message "Error: -Force can only be combined with -InstallMode Tool: archive mode installs from downloaded binaries and does not use dotnet tool update." -Level Error - Write-Message "Use -InstallMode Tool with -Force, or drop -Force." -Level Info + if ($InstallMode -ne 'Tool' -and $InstallMode -ne 'WinGet' -and $Force) { + Write-Message "Error: -Force can only be combined with -InstallMode Tool or -InstallMode WinGet." -Level Error + Write-Message "Use -InstallMode Tool/WinGet with -Force, or drop -Force." -Level Info if ($InvokedFromFile) { exit 1 } else { return 1 } } @@ -1843,6 +2034,10 @@ OPTIONS: Test-DotnetDependency } + if (Test-InstallerMode) { + Test-InstallerModeEnvironment + } + # Check gh dependency (not needed for -LocalDir mode) if (-not $LocalDir) { Test-GitHubCLIDependency diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 9d63ba8b795..d7350ca3558 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -14,6 +14,8 @@ readonly BUILT_NUGETS_RID_ARTIFACT_NAME="built-nugets-for" readonly CLI_ARCHIVE_ARTIFACT_NAME_PREFIX="cli-native-archives" readonly ASPIRE_CLI_ARTIFACT_NAME_PREFIX="aspire-cli" readonly EXTENSION_ARTIFACT_NAME="aspire-extension" +readonly WINGET_MANIFEST_ARTIFACT_NAME="winget-manifests-prerelease" +readonly HOMEBREW_CASK_ARTIFACT_NAME="homebrew-cask-prerelease" # Repository: Allow override via ASPIRE_REPO env var (owner/name). Default: microsoft/aspire readonly REPO="${ASPIRE_REPO:-microsoft/aspire}" @@ -85,9 +87,11 @@ USAGE: NuGet hive: /hives/pr-/packages (or run-) -m, --install-mode MODE How to install the CLI: 'archive' (default) installs from cli-native-archives- artifact, 'tool' installs the Aspire.Cli - dotnet tool from the PR's RID-specific NuGet artifact. - --force Tool mode only: run dotnet tool update instead of install to move an - existing Aspire.Cli tool to the exact PR package version (allows downgrades). + dotnet tool from the PR's RID-specific NuGet artifact, 'winget' + installs from the generated WinGet manifest artifact, and + 'homebrew' installs from the generated Homebrew cask artifact. + --force Tool mode: update an existing Aspire.Cli tool to the exact PR package + version. WinGet mode: allow replacing an existing Microsoft.Aspire install. --os OS Override OS detection (win, linux, linux-musl, osx) --arch ARCH Override architecture detection (x64, arm64) --hive-only For installs from archives only: only install NuGet packages to the hive, skip CLI download @@ -113,6 +117,8 @@ EXAMPLES: ./get-aspire-cli-pr.sh 1234 --use-insiders ./get-aspire-cli-pr.sh 1234 -m tool ./get-aspire-cli-pr.sh 1234 --install-mode tool --force + ./get-aspire-cli-pr.sh 1234 --install-mode homebrew + ./get-aspire-cli-pr.sh 1234 --install-mode winget ./get-aspire-cli-pr.sh --local-dir /path/to/artifacts --install-mode tool ./get-aspire-cli-pr.sh 1234 --skip-path ./get-aspire-cli-pr.sh 1234 --dry-run @@ -122,6 +128,8 @@ EXAMPLES: REQUIREMENTS: - GitHub CLI (gh) must be installed and authenticated (not needed with --local-dir) - In tool mode (--install-mode tool), the .NET SDK 'dotnet' command must be available in PATH + - In Homebrew mode (--install-mode homebrew), Homebrew must be available on macOS + - In WinGet mode (--install-mode winget), PowerShell and WinGet must be available on Windows - Permissions to download artifacts from the target repository - VS Code extension installation requires VS Code CLI (code) to be available in PATH @@ -221,11 +229,11 @@ parse_args() { exit 1 fi case "$2" in - archive|tool) + archive|tool|winget|homebrew) INSTALL_MODE="$2" ;; *) - say_error "Invalid value for --install-mode: '$2'. Allowed: archive, tool" + say_error "Invalid value for --install-mode: '$2'. Allowed: archive, tool, winget, homebrew" say_info "Use --help for usage information." exit 1 ;; @@ -1054,6 +1062,221 @@ download_aspire_cli() { return 0 } +is_installer_mode() { + [[ "$INSTALL_MODE" == "winget" || "$INSTALL_MODE" == "homebrew" ]] +} + +script_manages_cli_path() { + [[ "$INSTALL_MODE" == "archive" || ("$INSTALL_MODE" == "tool" && "$INSTALL_PREFIX_EXPLICIT" == true) ]] +} + +get_installer_artifact_name() { + case "$INSTALL_MODE" in + winget) + printf '%s' "$WINGET_MANIFEST_ARTIFACT_NAME" + ;; + homebrew) + printf '%s' "$HOMEBREW_CASK_ARTIFACT_NAME" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use an installer artifact." + return 1 + ;; + esac +} + +get_installer_archive_rids() { + case "$INSTALL_MODE" in + winget) + printf '%s\n' "win-x64" "win-arm64" + ;; + homebrew) + printf '%s\n' "osx-arm64" "osx-x64" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use native installer archives." + return 1 + ;; + esac +} + +download_artifact_by_name() { + local workflow_run_id="$1" + local artifact_name="$2" + local download_dir="$3" + local download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$artifact_name" -D "$download_dir") + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download $artifact_name with: ${download_command[*]}" + return 0 + fi + + say_info "Downloading artifact - $artifact_name ..." + say_verbose "Downloading with: ${download_command[*]}" + + if ! "${download_command[@]}"; then + say_verbose "gh run download command failed. Command: ${download_command[*]}" + say_error "Failed to download artifact '$artifact_name' from run: $workflow_run_id . If the workflow is still running then the artifact named '$artifact_name' may not be available yet. Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + return 1 + fi + + return 0 +} + +download_installer_artifacts() { + local workflow_run_id="$1" + local temp_dir="$2" + local installer_artifact_name + installer_artifact_name="$(get_installer_artifact_name)" + + INSTALLER_ARTIFACT_DIR="$temp_dir/installer-$INSTALL_MODE" + INSTALLER_ARCHIVE_ROOT="$temp_dir/installer-native-archives" + + if ! download_artifact_by_name "$workflow_run_id" "$installer_artifact_name" "$INSTALLER_ARTIFACT_DIR"; then + return 1 + fi + + local rid + while IFS= read -r rid; do + if [[ -z "$rid" ]]; then + continue + fi + + if ! download_artifact_by_name "$workflow_run_id" "$CLI_ARCHIVE_ARTIFACT_NAME_PREFIX-$rid" "$INSTALLER_ARCHIVE_ROOT"; then + return 1 + fi + done < <(get_installer_archive_rids) + + say_verbose "Downloaded installer artifact to: $INSTALLER_ARTIFACT_DIR" + say_verbose "Downloaded native installer archives to: $INSTALLER_ARCHIVE_ROOT" + return 0 +} + +find_installer_dogfood_script() { + local artifact_dir="$1" + local script_name + local companion_pattern + + case "$INSTALL_MODE" in + winget) + script_name="dogfood.ps1" + companion_pattern="*.installer.yaml" + ;; + homebrew) + script_name="dogfood.sh" + companion_pattern="aspire.rb" + ;; + *) + say_error "Install mode '$INSTALL_MODE' does not use a dogfood script." + return 1 + ;; + esac + + local -a matches=() + local f + while IFS= read -r -d '' f; do + if find "$(dirname "$f")" -maxdepth 1 -type f -name "$companion_pattern" | grep -q .; then + matches+=("$f") + fi + done < <(find "$artifact_dir" -type f -name "$script_name" -print0 | sort -z) + + if [[ "${#matches[@]}" -eq 0 ]]; then + say_error "Could not find $script_name co-located with $companion_pattern under: $artifact_dir" + return 1 + fi + + if [[ "${#matches[@]}" -gt 1 ]]; then + say_error "Found multiple $script_name files co-located with $companion_pattern under $artifact_dir:" + printf ' %s\n' "${matches[@]}" >&2 + return 1 + fi + + printf '%s' "${matches[0]}" + return 0 +} + +install_with_installer_artifact() { + local artifact_dir="$1" + local archive_root="$2" + local dogfood_script + + if [[ "$DRY_RUN" == true ]]; then + case "$INSTALL_MODE" in + winget) + dogfood_script="$artifact_dir/dogfood.ps1" + ;; + homebrew) + dogfood_script="$artifact_dir/dogfood.sh" + ;; + esac + else + if ! dogfood_script="$(find_installer_dogfood_script "$artifact_dir")"; then + return 1 + fi + fi + + case "$INSTALL_MODE" in + winget) + local winget_command=(pwsh -NoProfile -ExecutionPolicy Bypass -File "$dogfood_script" -ArchiveRoot "$archive_root") + if [[ "$FORCE" == true ]]; then + winget_command+=(-Force) + fi + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install Aspire CLI with WinGet artifact: ${winget_command[*]}" + return 0 + fi + + "${winget_command[@]}" + ;; + homebrew) + local homebrew_command=(bash "$dogfood_script" --archive-root "$archive_root") + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install Aspire CLI with Homebrew artifact: ${homebrew_command[*]}" + return 0 + fi + + "${homebrew_command[@]}" + ;; + *) + say_error "Unsupported installer mode: $INSTALL_MODE" + return 1 + ;; + esac +} + +validate_installer_mode_environment() { + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + + case "$INSTALL_MODE" in + winget) + if [[ "$HOST_OS" != "win" ]]; then + say_error "--install-mode winget can only be executed on Windows. Use --dry-run to preview downloads from another OS." + return 1 + fi + if ! command -v pwsh >/dev/null 2>&1; then + say_error "--install-mode winget requires PowerShell (pwsh) to run the WinGet dogfood installer." + return 1 + fi + if ! command -v winget >/dev/null 2>&1; then + say_error "--install-mode winget requires WinGet to install the generated manifest artifact." + return 1 + fi + ;; + homebrew) + if [[ "$HOST_OS" != "osx" ]]; then + say_error "--install-mode homebrew can only be executed on macOS. Use --dry-run to preview downloads from another OS." + return 1 + fi + if ! command -v brew >/dev/null 2>&1; then + say_error "--install-mode homebrew requires Homebrew (brew) to install the generated cask artifact." + return 1 + fi + ;; + esac +} + # Function to check if VS Code CLI is available check_vscode_cli_dependency() { local vscode_cmd="code" @@ -1321,11 +1544,15 @@ install_from_local_dir() { fi say_verbose "Computed RID: $rid" - # Find CLI archive in local directory, or auto-detect raw build output. + # Find CLI archive in local directory, use installer artifact, or auto-detect raw build output. if [[ "$HIVE_ONLY" == true ]]; then say_info "Skipping CLI installation due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI archive lookup in local directory (install mode: tool)" + elif is_installer_mode; then + if ! install_with_installer_artifact "$local_dir" "$local_dir"; then + return 1 + fi else local -a cli_files=() while IFS= read -r -d '' f; do @@ -1469,8 +1696,12 @@ download_and_install_from_pr() { say_info "Skipping CLI download due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI native archive download (install mode: tool)" + elif is_installer_mode; then + if ! download_installer_artifacts "$workflow_run_id" "$temp_dir"; then + return 1 + fi else - if ! cli_archive_path=$(download_aspire_cli "$workflow_run_id" "$rid" "$temp_dir"); then + if ! cli_archive_path=$(download_aspire_cli "$workflow_run_id" "$rid" "$temp_dir"); then return 1 fi fi @@ -1511,6 +1742,10 @@ download_and_install_from_pr() { say_info "Skipping CLI installation due to --hive-only flag" elif [[ "$INSTALL_MODE" == "tool" ]]; then say_verbose "Skipping CLI archive installation (install mode: tool)" + elif is_installer_mode; then + if ! install_with_installer_artifact "$INSTALLER_ARTIFACT_DIR" "$INSTALLER_ARCHIVE_ROOT"; then + return 1 + fi else if ! install_aspire_cli "$cli_archive_path" "$cli_install_dir"; then return 1 @@ -1581,15 +1816,15 @@ main() { fi fi - if [[ "$INSTALL_MODE" == "tool" && "$HIVE_ONLY" == true ]]; then - say_error "--hive-only cannot be combined with --install-mode tool: --hive-only skips the CLI install, but --install-mode tool installs Aspire.Cli as a .NET tool." - say_info "Drop one of the two flags. Both archive and tool modes populate the hive." + if [[ "$HIVE_ONLY" == true && "$INSTALL_MODE" != "archive" ]]; then + say_error "--hive-only cannot be combined with --install-mode $INSTALL_MODE: --hive-only skips the CLI install, but this install mode installs Aspire CLI through a package or tool manager." + say_info "Drop one of the two flags. All install modes populate the hive." exit 1 fi - if [[ "$INSTALL_MODE" != "tool" && "$FORCE" == true ]]; then - say_error "--force can only be combined with --install-mode tool: archive mode installs from downloaded binaries and does not use dotnet tool update." - say_info "Use --install-mode tool with --force, or drop --force." + if [[ "$INSTALL_MODE" != "tool" && "$INSTALL_MODE" != "winget" && "$FORCE" == true ]]; then + say_error "--force can only be combined with --install-mode tool or --install-mode winget." + say_info "Use --install-mode tool/winget with --force, or drop --force." exit 1 fi @@ -1603,6 +1838,10 @@ main() { fi fi + if is_installer_mode && ! validate_installer_mode_environment; then + exit 1 + fi + # Check gh dependency (not needed for --local-dir mode) if [[ -z "$LOCAL_DIR" ]]; then check_gh_dependency @@ -1652,14 +1891,14 @@ main() { fi fi - # Add to shell profile for persistent PATH. Default tool installs use 'dotnet tool install -g', - # which owns any global-tools PATH guidance; explicit tool-path installs use cli_install_dir. + # Add to shell profile for persistent PATH. Package-manager modes and default tool installs own + # their own PATH guidance; explicit tool-path installs use cli_install_dir. # PR installs deliberately skip the persistent profile write: a PR build is a per-session # dogfood activation. Touching ~/.zshrc / ~/.bashrc would silently demote a developer's # daily/stable install on every new terminal until they hunt down the stale `export PATH=` # line. The activation hint printed below shows how to opt in manually. if [[ "$HIVE_ONLY" != true ]]; then - if [[ "$INSTALL_MODE" != "tool" || "$INSTALL_PREFIX_EXPLICIT" == true ]]; then + if script_manages_cli_path; then if [[ "$SKIP_PATH" == true ]]; then say_info "Skipping PATH configuration due to --skip-path flag" else @@ -1689,7 +1928,7 @@ main() { # Print PATH activation hint for PR installs. # Goes to stdout (not stderr) so it's visible in normal install output and tests can grep it. # Printed in success path (after install completes) and also under --dry-run. - if [[ "$HIVE_ONLY" != true && -n "$PR_NUMBER" && ("$INSTALL_MODE" == "archive" || "$INSTALL_PREFIX_EXPLICIT" == true) ]]; then + if [[ "$HIVE_ONLY" != true && -n "$PR_NUMBER" ]] && script_manages_cli_path; then local profile_path_unexpanded="$INSTALL_PATH_UNEXPANDED" echo "Add to your shell profile: export PATH=\"$profile_path_unexpanded:\$PATH\"" fi diff --git a/eng/scripts/smoke-installed-cli.ps1 b/eng/scripts/smoke-installed-cli.ps1 new file mode 100644 index 00000000000..0ad304531a6 --- /dev/null +++ b/eng/scripts/smoke-installed-cli.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS + Smoke-tests the already-installed aspire CLI. + +.DESCRIPTION + Scaffolds an aspire-starter project and runs its restore, against whatever + 'aspire' is first on PATH. Assumes the CLI has already been installed (via + WinGet manifest, dotnet-tool, Homebrew cask, archive script, etc.). + + Catches regressions that only show up once the installed bits actually + launch — broken launcher resolution, missing layout assets, packaging-time + PATH issues, etc. +#> + +[CmdletBinding()] +param( + [string]$WorkDir, + [string]$ProjectName = 'SmokeApp', + [string]$LogLevel = 'trace' +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +if ([string]::IsNullOrWhiteSpace($WorkDir)) { + $WorkDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +} +New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null + +# Always scaffold into a fresh subdirectory created under $WorkDir. This +# deliberately avoids ever Remove-Item -Recurse -Force'ing a caller-provided +# path: even if -WorkDir points at a sensitive directory, the worst case is a +# new empty aspire-cli-smoke.XXXXXXXX subdirectory being created underneath. +# CI tears down $env:RUNNER_TEMP between jobs; local users can clean up whenever. +$scaffoldDir = Join-Path $WorkDir ("aspire-cli-smoke." + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $scaffoldDir | Out-Null +Write-Host "Scaffolding into: $scaffoldDir" + +aspire --version + +Push-Location $scaffoldDir +try { + aspire --log-level $LogLevel new aspire-starter --name $ProjectName --output . --non-interactive --nologo --suppress-agent-init + aspire --log-level $LogLevel restore --non-interactive --nologo +} +finally { + Pop-Location +} diff --git a/eng/scripts/smoke-installed-cli.sh b/eng/scripts/smoke-installed-cli.sh new file mode 100755 index 00000000000..66fe7685033 --- /dev/null +++ b/eng/scripts/smoke-installed-cli.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Smoke-tests the already-installed `aspire` CLI by scaffolding a starter +# project and running its restore. Assumes `aspire` is on PATH. +# +# Used by CI after a real installer run (Homebrew cask / WinGet manifest / +# dotnet-tool / archive script) to catch regressions that only show up once the +# installed bits actually launch — broken launcher resolution, missing layout +# assets, packaging-time PATH issues, etc. +set -euo pipefail + +usage() { + cat <&2; usage >&2; exit 1 ;; + esac +done + +PARENT_DIR="${WORK_DIR:-${RUNNER_TEMP:-/tmp}}" +mkdir -p "$PARENT_DIR" + +# Always scaffold into a fresh subdirectory created with mktemp under +# $PARENT_DIR. This deliberately avoids ever rm -rf'ing a caller-provided +# path: even if --work-dir points at a sensitive directory, the worst case is +# a new empty aspire-cli-smoke.XXXXXX subdirectory being created underneath. +# CI tears down RUNNER_TEMP between jobs; local users can clean up whenever. +scaffold_dir="$(mktemp -d "$PARENT_DIR/aspire-cli-smoke.XXXXXX")" +echo "Scaffolding into: $scaffold_dir" + +aspire --version +cd "$scaffold_dir" + +aspire --log-level "$LOG_LEVEL" new aspire-starter --name "$PROJECT_NAME" --output . --non-interactive --nologo --suppress-agent-init +aspire --log-level "$LOG_LEVEL" restore --non-interactive --nologo diff --git a/eng/winget/README.md b/eng/winget/README.md index 26395fdaddb..fbaa3534ab2 100644 --- a/eng/winget/README.md +++ b/eng/winget/README.md @@ -12,10 +12,12 @@ winget install Microsoft.Aspire # stable ## Contents -| Directory / File | Description | -|--------------------------|----------------------------------------------------------------------------------| -| `microsoft.aspire/` | Manifest templates for stable releases | -| `generate-manifests.ps1` | Downloads installers, computes SHA256 hashes, generates manifests from templates | +| Directory / File | Description | +|--------------------------------|----------------------------------------------------------------------------------| +| `microsoft.aspire/` | Manifest templates for stable releases | +| `generate-manifests.ps1` | Downloads installers, computes SHA256 hashes, generates manifests from templates | +| `prepare-manifest-artifact.ps1` | Prepares CI artifacts by generating, validating, and adding dogfood helpers | +| `dogfood.ps1` | Installs generated manifests locally, optionally using downloaded native archives | Each manifest set contains three YAML files following the [WinGet manifest schema v1.10](https://learn.microsoft.com/windows/package-manager/package/manifest): @@ -46,9 +48,19 @@ Where arch is `x64` or `arm64`. ## CI Pipeline -| Pipeline | Prepares | Publishes | -|---------------------------------------|------------------------------------------------|-----------------------| -| `azure-pipelines.yml` (prepare stage) | Stable manifests (artifacts only) | — | -| `release-publish-nuget.yml` (release) | — | Stable manifests only | +| Pipeline | Prepares | Publishes | +|---------------------------------------|-------------------------------------------------|-----------------------| +| `.github/workflows/tests.yml` | Prerelease manifests (artifacts only) | — | +| `azure-pipelines.yml` (prepare stage) | Stable or prerelease manifests (artifacts only) | — | +| `release-publish-nuget.yml` (release) | — | Stable manifests only | Publishing submits a PR to `microsoft/winget-pkgs` using `wingetcreate submit`. + +To dogfood a GitHub Actions artifact locally, download the `winget-manifests-prerelease` +artifact and the `cli-native-archives-win-*` artifacts into the same parent directory, then run: + +```powershell +.\dogfood.ps1 -ArchiveRoot .. +``` + +If `Microsoft.Aspire` is already installed through WinGet, uninstall it first or pass `-Force` to allow the local dogfood manifest to replace it. diff --git a/eng/winget/dogfood.ps1 b/eng/winget/dogfood.ps1 index 2d6e42862d8..2495901240b 100644 --- a/eng/winget/dogfood.ps1 +++ b/eng/winget/dogfood.ps1 @@ -10,9 +10,16 @@ Path to the directory containing the WinGet manifest YAML files. Defaults to auto-detecting the manifest directory relative to this script. +.PARAMETER ArchiveRoot + Root directory containing downloaded aspire-cli-win-* archive artifacts. When present, the + local manifest is rewritten to install from those archive files instead of ci.dot.net URLs. + .PARAMETER Uninstall Uninstall a previously dogfooded Aspire CLI. +.PARAMETER Force + Allow replacing an existing Microsoft.Aspire WinGet installation. + .EXAMPLE .\dogfood.ps1 # Auto-detects manifests in the script directory and installs @@ -21,6 +28,10 @@ .\dogfood.ps1 -ManifestPath .\manifests\m\Microsoft\Aspire\9.2.0 # Install from a specific manifest directory +.EXAMPLE + .\dogfood.ps1 -ArchiveRoot ..\native-archives + # Install using downloaded native archive artifacts + .EXAMPLE .\dogfood.ps1 -Uninstall # Uninstall the dogfooded Aspire CLI @@ -31,13 +42,445 @@ param( [Parameter(Position = 0)] [string]$ManifestPath, - [switch]$Uninstall + [string]$ArchiveRoot, + + [switch]$Uninstall, + + [switch]$Force ) $ErrorActionPreference = 'Stop' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +function Get-InstallerManifestPath { + param([string]$Path) + + $installerManifests = @(Get-ChildItem -Path $Path -File -Filter "*.installer.yaml") + if ($installerManifests.Count -ne 1) { + Write-Error "Expected exactly one *.installer.yaml manifest under $Path, but found $($installerManifests.Count)." + exit 1 + } + + return $installerManifests[0].FullName +} + +function Get-ManifestVersion { + param([string]$ManifestPath) + + foreach ($line in Get-Content -Path $ManifestPath) { + if ($line -match '^\s*PackageVersion:\s*"?([^"]+)"?\s*$') { + return $Matches[1] + } + } + + Write-Error "Could not read PackageVersion from $ManifestPath." + exit 1 +} + +function Find-ArchiveIfPresent { + param( + [string]$Root, + [string]$ArchiveName + ) + + if (-not (Test-Path $Root)) { + return $null + } + + return Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName +} + +function Find-Archive { + param( + [string]$Root, + [string]$ArchiveName + ) + + $matchedItems = @(Get-ChildItem -Path $Root -File -Recurse -Filter $ArchiveName -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($matchedItems.Count -eq 0) { + Write-Error "Could not find $ArchiveName under $Root." + exit 1 + } + + if ($matchedItems.Count -gt 1) { + $matchList = $matchedItems | ForEach-Object { " $($_.FullName)" } + Write-Error "Found multiple $ArchiveName archives under ${Root}:`n$($matchList -join "`n")" + exit 1 + } + + return $matchedItems[0].FullName +} + +function Get-DefaultArchiveRoot { + param([string]$Version) + + foreach ($candidate in @($ScriptDir, (Split-Path -Parent $ScriptDir))) { + if ((Find-ArchiveIfPresent -Root $candidate -ArchiveName "aspire-cli-win-x64-$Version.zip") -and + (Find-ArchiveIfPresent -Root $candidate -ArchiveName "aspire-cli-win-arm64-$Version.zip")) { + return (Resolve-Path $candidate).Path + } + } + + return $null +} + +function Start-LocalArchiveServer { + param( + # Map of . Only these names are + # serveable; anything else returns 404. + [hashtable]$FileMap + ) + + # WinGet downloads InstallerUrl payloads via WinINet's InternetOpenUrl(), which + # only supports http/https/ftp — not file://. So we serve the local archives over + # a loopback HTTP listener instead of rewriting InstallerUrl to file:///, which + # used to fail at install time with: + # "InternetOpenUrl() failed. 0x8007007b : The filename, directory name, or + # volume label syntax is incorrect." + # + # HttpListener on a loopback prefix does NOT require admin / netsh urlacl + # registration for the current user — that restriction only applies to non-loopback + # bindings. See: + # https://learn.microsoft.com/dotnet/api/system.net.httplistener + # + # Get-FreeLoopbackPort picks a port by reserving and immediately releasing a + # TcpListener, so between that release and HttpListener.Start() below the + # kernel could hand the port to another process on a busy CI runner. Retry + # on HttpListenerException with a fresh port; only fail after several misses. + $listener = $null + $prefix = $null + for ($attempt = 1; $attempt -le 5; $attempt++) { + $port = (Get-FreeLoopbackPort) + $prefix = "http://127.0.0.1:$port/" + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add($prefix) + try { + $listener.Start() + break + } catch [System.Net.HttpListenerException] { + $listener.Close() + $listener = $null + if ($attempt -eq 5) { + throw + } + } + } + + # Use a runspace (not Start-Job/Start-ThreadJob) so we don't pull in the Jobs or + # ThreadJob modules, which aren't guaranteed to be available on every winget host. + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $ps = [powershell]::Create() + $ps.Runspace = $runspace + [void]$ps.AddScript({ + param($Listener, $FileMap) + while ($Listener.IsListening) { + try { + $context = $Listener.GetContext() + } catch { + break + } + + try { + $requestedName = [System.IO.Path]::GetFileName([System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath)) + if ($FileMap.ContainsKey($requestedName)) { + $filePath = $FileMap[$requestedName] + $bytes = [System.IO.File]::ReadAllBytes($filePath) + $context.Response.ContentType = 'application/octet-stream' + $context.Response.ContentLength64 = $bytes.LongLength + $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } else { + $context.Response.StatusCode = 404 + } + } catch { + try { $context.Response.StatusCode = 500 } catch { } + } finally { + try { $context.Response.Close() } catch { } + } + } + }).AddArgument($listener).AddArgument($FileMap) + + $asyncResult = $ps.BeginInvoke() + + return [pscustomobject]@{ + Listener = $listener + BaseUri = $prefix.TrimEnd('/') + PowerShell = $ps + Runspace = $runspace + AsyncResult = $asyncResult + } +} + +function Stop-LocalArchiveServer { + param($Server) + + if (-not $Server) { return } + + try { + if ($Server.Listener -and $Server.Listener.IsListening) { + $Server.Listener.Stop() + } + if ($Server.Listener) { + $Server.Listener.Close() + } + } catch { } + + try { + if ($Server.PowerShell -and $Server.AsyncResult) { + [void]$Server.PowerShell.EndInvoke($Server.AsyncResult) + } + } catch { } + + try { if ($Server.PowerShell) { $Server.PowerShell.Dispose() } } catch { } + try { if ($Server.Runspace) { $Server.Runspace.Dispose() } } catch { } +} + +function Get-FreeLoopbackPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Show-LatestWinGetDiagLog { + param([string]$Reason = '') + + # Real winget writes per-invocation diag logs to + # %LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir + # When `winget install --manifest` exits non-zero, the diag log is almost always + # the only place that says *why* (the console output is often truncated or empty, + # and `winget` exit codes like 0x8A150001 are generic). Print the newest log to + # the host console (Write-Host, matching the rest of this script) so dogfood + # failures are diagnosable without separately hunting it down. In CI runs the + # host output is captured into the job log, so this lands in the same place as + # the surrounding install output. + # + # Tests set ASPIRE_TEST_MODE=true and run a mock winget that doesn't produce diag + # logs, so skip this in test mode to avoid emitting unrelated stale logs. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + return + } + + $diagDir = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir' + if (-not (Test-Path -LiteralPath $diagDir)) { + Write-Host "(no winget diag dir at $diagDir)" + return + } + + $log = Get-ChildItem -LiteralPath $diagDir -Filter 'WinGet-*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $log) { + Write-Host "(no WinGet-*.log files in $diagDir)" + return + } + + Write-Host "" + Write-Host "=== winget diag log ($Reason) ===" + Write-Host " $($log.FullName)" + Write-Host "" + Get-Content -LiteralPath $log.FullName | ForEach-Object { Write-Host " $_" } + Write-Host "=== end winget diag log ===" + Write-Host "" +} + +function Find-AspireBinaryOnPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$ExpectedVersion + ) + + # Walks the freshly-refreshed PATH (machine+user from the registry, which + # picks up the dir winget added during portable install) for every aspire.exe + # and runs it. Returns the FIRST one whose --version output matches + # $ExpectedVersion. If none match, returns the first aspire.exe found so the + # caller can warn about PATH shadowing without losing the diagnostic. Returns + # $null if no aspire.exe is on PATH at all. + # + # This serves two callers: + # + # - Post-install fallback: some Windows environments (corp-managed machines + # hitting winget bug https://github.com/microsoft/winget-cli/issues/6230) + # cause `winget install --manifest` to return -1978335231 (0x8A150001) + # even when the install completed end-to-end. A successful version match + # here means the binary is really deployed despite winget's exit code. + # Don't fall back to `winget list` for this — winget's installed-package + # store keeps stale entries from prior partial installs even after a + # rollback, so it returns false positives. + # + # - Post-install verification: an older aspire.exe earlier on PATH (e.g. + # from a previous get-aspire-cli install) would shadow the winget-installed + # binary if we just used Get-Command. Walking PATH for a version match + # finds the freshly-installed binary even when it isn't first. + # + # Returns: $null OR [pscustomobject]@{ + # Path = full path to aspire.exe (or .cmd in test mode) + # Version = trimmed --version output + # ExitCode = aspire --version exit code + # ExpectedVersionMatched= $true iff Version contains $ExpectedVersion + # } + # + # In test mode the mock aspire.cmd's --version output ("mock aspire version") + # does not match any real PackageVersion. Fall back to Get-Command so the + # test-mode verification assertions still fire on the mock. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + $cmd = Get-Command aspire -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + $output = & $cmd.Source --version 2>&1 + $exitCode = $LASTEXITCODE + $versionString = ($output | Out-String).Trim() + return [pscustomobject]@{ + Path = $cmd.Source + Version = $versionString + ExitCode = $exitCode + ExpectedVersionMatched = $false + } + } + + $newPath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path', 'User') + $firstFound = $null + foreach ($dir in ($newPath -split ';' | Where-Object { $_ })) { + $candidate = Join-Path $dir 'aspire.exe' + if (-not (Test-Path -LiteralPath $candidate)) { continue } + try { + $output = & $candidate --version 2>&1 + $exitCode = $LASTEXITCODE + } catch { + continue + } + $versionString = ($output | Out-String).Trim() + $info = [pscustomobject]@{ + Path = $candidate + Version = $versionString + ExitCode = $exitCode + ExpectedVersionMatched = ($versionString -match [regex]::Escape($ExpectedVersion)) + } + if ($info.ExpectedVersionMatched) { + return $info + } + if (-not $firstFound) { + $firstFound = $info + } + } + return $firstFound +} + +function Test-WinGetVersionForMotwFix { + [CmdletBinding()] + param() + + # winget-cli bug https://github.com/microsoft/winget-cli/issues/6230: portable + # installs exit -1978335231 (0x8A150001) at the post-install IAttachmentExecute + # Mark-of-the-Web step on some machines, even when the binary is deployed end-to-end. + # Fixed in v1.29.140-preview by https://github.com/microsoft/winget-cli/pull/6127; + # last broken stable release is v1.28.240 (2026-04-17). + # + # The failure is environmental (interaction with AV / IOfficeAntiVirus) and not every + # < 1.29.140 install actually trips it, so this is a soft warning, not a hard gate. + # Find-AspireBinaryOnPath in the post-install fallback already recovers from the + # case where winget exits non-zero but the binary IS deployed. + if ($env:ASPIRE_TEST_MODE -eq 'true') { + return + } + + try { + $versionOutput = (winget --version 2>&1) | Out-String + } catch { + return + } + $trimmed = $versionOutput.Trim() + # winget --version output: "v1.29.170-preview" or "v1.28.240" + if ($trimmed -notmatch '^v(\d+\.\d+\.\d+)') { + return + } + $parsed = $null + if (-not [Version]::TryParse($Matches[1], [ref]$parsed)) { + return + } + if ($parsed -lt [Version]'1.29.140') { + Write-Warning @" +Local winget is $trimmed. Versions older than v1.29.140-preview can fail portable +installs with exit code -1978335231 (0x8A150001) at the post-install Mark-of-the-Web +step even when the binary is fully deployed. See: + https://github.com/microsoft/winget-cli/issues/6230 + +If the install below fails with that exit code, upgrade winget to v1.29.140-preview +or newer. Pre-release builds are available at: + https://github.com/microsoft/winget-cli/releases +"@ + } +} + +function Resolve-ArchiveFileMap { + param( + [string]$ResolvedArchiveRoot, + [string]$Version + ) + + # The PR-channel CI artifact extracts under /Debug/Shipping/, not + # the root of the artifact directory. Find-Archive scans recursively to locate + # the actual on-disk path. This returns: + # filename → absolute path (e.g. "aspire-cli-win-x64-13.4.0-pr.X.gSHA.zip" + # → "C:\...\Debug\Shipping\aspire-cli-win-x64-13.4.0-pr.X.gSHA.zip") + $archives = @{} + foreach ($arch in @("x64", "arm64")) { + $archiveName = "aspire-cli-win-$arch-$Version.zip" + $archives[$archiveName] = Find-Archive -Root $ResolvedArchiveRoot -ArchiveName $archiveName + } + return $archives +} + +function Set-LocalInstallerSources { + param( + [string]$InstallerManifestPath, + [hashtable]$ArchiveFileMap, + [string]$BaseUri + ) + + # winget install hashes the downloaded payload and compares it with the recorded + # InstallerSha256, so we have to refresh the hash whenever we redirect InstallerUrl + # at a local archive. PR-channel manifests in particular ship with the placeholder + # ("0" * 64) baked in by generate-manifests.ps1's -SkipUrlValidation path, and that + # hash never matches a real build. + $sha256ByArchitecture = @{} + $urlByArchitecture = @{} + foreach ($name in $ArchiveFileMap.Keys) { + $archivePath = $ArchiveFileMap[$name] + $arch = if ($name -match 'aspire-cli-win-(x64|arm64)-') { $Matches[1] } else { continue } + $sha256ByArchitecture[$arch] = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToUpperInvariant() + $urlByArchitecture[$arch] = "$BaseUri/$name" + } + + $currentArchitecture = $null + $updatedLines = foreach ($line in Get-Content -Path $InstallerManifestPath) { + if ($line -match '^\s*-\s*Architecture:\s*(\S+)\s*$') { + $currentArchitecture = $Matches[1] + $line + continue + } + + if ($line -match '^(\s*)InstallerUrl:\s*' -and $currentArchitecture -and $urlByArchitecture.ContainsKey($currentArchitecture)) { + "$($Matches[1])InstallerUrl: $($urlByArchitecture[$currentArchitecture])" + continue + } + + if ($line -match '^(\s*)InstallerSha256:\s*' -and $currentArchitecture -and $sha256ByArchitecture.ContainsKey($currentArchitecture)) { + "$($Matches[1])InstallerSha256: $($sha256ByArchitecture[$currentArchitecture])" + continue + } + + $line + } + + Set-Content -Path $InstallerManifestPath -Value $updatedLines +} + if ($Uninstall) { Write-Host "Uninstalling dogfooded Aspire CLI..." Write-Host "" @@ -65,19 +508,23 @@ if ($Uninstall) { # Auto-detect manifest path if not specified if (-not $ManifestPath) { - # Look for versioned manifest directories under the script directory. - # Convention: manifests/m/Microsoft/Aspire/{Version}/ - $candidates = Get-ChildItem -Path $ScriptDir -Directory -Recurse -Depth 6 | - Where-Object { - Test-Path (Join-Path $_.FullName "*.installer.yaml") - } | - Select-Object -First 1 - - if ($candidates) { - $ManifestPath = $candidates.FullName + if (Get-ChildItem -Path $ScriptDir -File -Filter "*.installer.yaml" | Select-Object -First 1) { + $ManifestPath = $ScriptDir } else { - Write-Error "No manifest directory found under $ScriptDir. Specify -ManifestPath explicitly." - exit 1 + # Look for versioned manifest directories under the script directory. + # Convention: manifests/m/Microsoft/Aspire/{Version}/ + $candidates = Get-ChildItem -Path $ScriptDir -Directory -Recurse -Depth 6 | + Where-Object { + Test-Path (Join-Path $_.FullName "*.installer.yaml") + } | + Select-Object -First 1 + + if ($candidates) { + $ManifestPath = $candidates.FullName + } else { + Write-Error "No manifest directory found under $ScriptDir. Specify -ManifestPath explicitly." + exit 1 + } } } @@ -102,56 +549,183 @@ foreach ($f in $manifestFiles) { } Write-Host "" -# Enable local manifest files -Write-Host "Enabling local manifest files in winget settings..." -winget settings --enable LocalManifestFiles -if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to enable local manifests. You may need to run this as Administrator." -} +Test-WinGetVersionForMotwFix -# Validate -Write-Host "" -Write-Host "Validating manifests..." -winget validate --manifest $ManifestPath -if ($LASTEXITCODE -ne 0) { - Write-Error "Manifest validation failed. Fix the manifests and try again." - exit $LASTEXITCODE +if (-not $Force) { + $existingInstall = winget list --id Microsoft.Aspire --accept-source-agreements 2>&1 + if ($LASTEXITCODE -eq 0 -and $existingInstall -match "Microsoft\.Aspire") { + Write-Error "Microsoft.Aspire is already installed. Uninstall it first, or rerun with -Force to replace it with the dogfood manifest." + exit 1 + } } -Write-Host "Validation passed." -# Install -Write-Host "" -Write-Host "Installing Aspire CLI from local manifest..." -winget install --manifest $ManifestPath --accept-package-agreements --accept-source-agreements -if ($LASTEXITCODE -ne 0) { - Write-Error "Installation failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE +# Stage the .yaml manifest files into a clean temp directory before calling winget. +# `winget validate --manifest ` and `winget install --manifest ` treat every +# file in the directory as a multi-file manifest. The CI artifact ships dogfood.ps1 +# alongside the yaml files, so pointing winget at the artifact directly fails with +# "The manifest does not contain a valid root. File: dogfood.ps1". Staging also keeps +# Set-LocalInstallerSources's rewrites out of the user's artifact so reruns are idempotent. +$stagedManifestDir = Join-Path ([System.IO.Path]::GetTempPath()) ("aspire-winget-manifest-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stagedManifestDir -Force | Out-Null + +$archiveServer = $null +try { + foreach ($f in $manifestFiles) { + Copy-Item -Path $f.FullName -Destination (Join-Path $stagedManifestDir $f.Name) -Force + } + + $installerManifestPath = Get-InstallerManifestPath -Path $stagedManifestDir + $version = Get-ManifestVersion -ManifestPath $installerManifestPath + + if (-not $ArchiveRoot) { + $ArchiveRoot = Get-DefaultArchiveRoot -Version $version + } + + if ($ArchiveRoot) { + $ArchiveRoot = (Resolve-Path $ArchiveRoot).Path + Write-Host "Using local native archive artifacts from: $ArchiveRoot" + } else { + Write-Host "No local native archive artifacts found; installing with URLs from the manifests." + } + Write-Host "" + + # Enable local manifest files + Write-Host "Enabling local manifest files in winget settings..." + winget settings --enable LocalManifestFiles + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to enable local manifests. You may need to run this as Administrator." + } + + # Validate the manifest BEFORE we rewrite InstallerUrl/InstallerSha256. winget's + # schema requires InstallerUrl to match ^https?:// (so any rewritten URL — even an + # http://127.0.0.1/... one — passes validation, but file:// does not). Validating + # the pristine yaml first catches manifest authoring problems before we mutate it. + Write-Host "" + Write-Host "Validating manifests..." + winget validate --manifest $stagedManifestDir + if ($LASTEXITCODE -ne 0) { + Write-Error "Manifest validation failed. Fix the manifests and try again." + exit $LASTEXITCODE + } + Write-Host "Validation passed." + + if ($ArchiveRoot) { + # winget's downloader is WinINet (InternetOpenUrl), which only supports + # http/https/ftp/gopher — not file://. Serve the local archives over a loopback + # HTTP listener and rewrite InstallerUrl to point at it. + # + # The PR-channel artifact is laid out as /Debug/Shipping/*.zip, + # so we resolve archive paths via Find-Archive (recursive) and serve the same + # map from the listener — otherwise the listener would 404 on the manifest's + # rewritten URL because the file isn't at the root of $ArchiveRoot. + $archiveFileMap = Resolve-ArchiveFileMap -ResolvedArchiveRoot $ArchiveRoot -Version $version + Write-Host "" + Write-Host "Starting local archive server..." + $archiveServer = Start-LocalArchiveServer -FileMap $archiveFileMap + Write-Host " Serving $($archiveFileMap.Count) archive(s) at $($archiveServer.BaseUri)" + foreach ($name in $archiveFileMap.Keys) { + Write-Host " $name -> $($archiveFileMap[$name])" + } + Set-LocalInstallerSources -InstallerManifestPath $installerManifestPath -ArchiveFileMap $archiveFileMap -BaseUri $archiveServer.BaseUri + } + + # Install + Write-Host "" + Write-Host "Installing Aspire CLI from local manifest..." + $installArgs = @("install", "--manifest", $stagedManifestDir, "--accept-package-agreements", "--accept-source-agreements") + if ($Force) { + $installArgs += "--force" + } + + winget @installArgs + $wingetExitCode = $LASTEXITCODE + + if ($wingetExitCode -eq 0) { + Write-Host "" + Write-Host "winget install succeeded." + } + else { + $installInfo = Find-AspireBinaryOnPath -ExpectedVersion $version + if ($installInfo -and $installInfo.ExpectedVersionMatched) { + # winget exited non-zero but an aspire.exe on PATH reports the expected + # version. See Find-AspireBinaryOnPath for context — this surfaces on + # machines hitting winget bug + # https://github.com/microsoft/winget-cli/issues/6230 (the post-install + # Mark-of-the-Web step in winget's DownloadFlow aborts even though the + # install completed). Fixed in v1.29.140-preview+. + Write-Host "" + Write-Warning @" +winget reported failure (exit code $wingetExitCode) but aspire $version is installed at: + $($installInfo.Path) +This is winget-cli bug https://github.com/microsoft/winget-cli/issues/6230 (post-install +Mark-of-the-Web step fails on some machines, fixed in winget v1.29.140-preview+). The CLI +itself is deployed. + +To avoid this on future installs, upgrade winget. Pre-release builds are at: + https://github.com/microsoft/winget-cli/releases + +Verify with: + aspire --version +(You may need to open a new shell to pick up the updated PATH.) +"@ + } + else { + Show-LatestWinGetDiagLog -Reason "winget exit code $wingetExitCode" + Write-Error "Installation failed with exit code $wingetExitCode" + exit $wingetExitCode + } + } +} +finally { + Stop-LocalArchiveServer -Server $archiveServer + Remove-Item -Path $stagedManifestDir -Recurse -Force -ErrorAction SilentlyContinue } -# Refresh PATH -$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +# Refresh PATH so the verification subprocess can see machine/user changes that +# winget made during install (the parent process's environment block is a snapshot +# from before install). Tests set ASPIRE_TEST_MODE=true so the mock winget bin +# already on PATH isn't replaced by the machine/user PATH from the registry. +if ($env:ASPIRE_TEST_MODE -ne 'true') { + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} -# Verify in a new process to pick up PATH changes +# Verify by walking PATH for the just-installed binary (matched by version), so +# an older aspire.exe earlier on PATH can't masquerade as the freshly-installed +# one. See Find-AspireBinaryOnPath for the matching strategy. Write-Host "" Write-Host "Verifying installation..." -$verifyResult = pwsh -NoProfile -Command ' - $cmd = Get-Command aspire -ErrorAction SilentlyContinue - if (-not $cmd) { Write-Error "aspire not found in PATH"; exit 1 } - Write-Host " Path: $($cmd.Source)" - $v = & aspire --version 2>&1 - if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } - Write-Host " Version: $v" -' 2>&1 - -if ($LASTEXITCODE -eq 0) { - Write-Host $verifyResult +$verifyInfo = Find-AspireBinaryOnPath -ExpectedVersion $version + +if (-not $verifyInfo) { Write-Host "" - Write-Host "Installed successfully!" -} else { - Write-Host $verifyResult + Write-Error "Failed to verify Aspire CLI installation: aspire not found in PATH." + exit 1 +} + +Write-Host " Path: $($verifyInfo.Path)" +Write-Host " Version: $($verifyInfo.Version)" + +if ($verifyInfo.ExitCode -ne 0) { Write-Host "" - Write-Warning "aspire command not found in PATH. You may need to restart your shell." + Write-Error "Failed to verify Aspire CLI installation: 'aspire --version' exited with code $($verifyInfo.ExitCode)." + exit $verifyInfo.ExitCode } +if ($env:ASPIRE_TEST_MODE -ne 'true' -and -not $verifyInfo.ExpectedVersionMatched) { + # Treat shadowing as a hard failure rather than a warning: the script's whole + # purpose is to validate that the freshly-built manifest is the one users will + # run. Reporting "Installed successfully!" while an older aspire.exe is + # masquerading on PATH would silently green-light broken dogfood runs in CI. + Write-Error @" +Reported version does not match the just-installed manifest version ($version). +An older aspire.exe at the path above is shadowing the winget-installed binary on PATH. +Either reorder PATH so the winget-installed location wins, or uninstall the older copy. +"@ + exit 1 +} + +Write-Host "" +Write-Host "Installed successfully!" + Write-Host "" Write-Host "To uninstall: .\dogfood.ps1 -Uninstall" diff --git a/eng/winget/prepare-manifest-artifact.ps1 b/eng/winget/prepare-manifest-artifact.ps1 new file mode 100644 index 00000000000..45ad3df00e3 --- /dev/null +++ b/eng/winget/prepare-manifest-artifact.ps1 @@ -0,0 +1,227 @@ +<# +.SYNOPSIS + Prepares the WinGet manifest artifact for Aspire CLI builds. + +.DESCRIPTION + Generates WinGet manifests, optionally validates/tests them, and adds the + dogfood helper script. This script intentionally does not publish artifacts; + each CI system owns its upload task. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$Version, + + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "prerelease")] + [string]$Channel, + + [Parameter(Mandatory = $false)] + [string]$ArtifactVersion, + + [Parameter(Mandatory = $false)] + [string]$ArchiveRoot, + + [Parameter(Mandatory = $false)] + [string]$TemplateDir, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $false)] + [ValidateSet("Full", "Offline", "GenerateOnly")] + [string]$ValidationMode = "Full" +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +if ([string]::IsNullOrWhiteSpace($TemplateDir)) { + $TemplateDir = Join-Path $ScriptDir "microsoft.aspire" +} + +function Get-ArchiveVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ArchiveRoot, + + [Parameter(Mandatory = $true)] + [string]$Rid + ) + + if (-not (Test-Path $ArchiveRoot)) { + Write-Error "Archive root directory not found: $ArchiveRoot" + exit 1 + } + + $prefix = "aspire-cli-$Rid-" + $suffix = ".zip" + $matchedItems = @(Get-ChildItem -Path $ArchiveRoot -File -Recurse -Filter "$prefix*$suffix" | Sort-Object FullName) + + if ($matchedItems.Count -eq 0) { + Write-Error "Could not find archive '$prefix*$suffix' under '$ArchiveRoot' to infer the Aspire CLI version." + exit 1 + } + + if ($matchedItems.Count -gt 1) { + $matchList = $matchedItems | ForEach-Object { " $($_.FullName)" } + Write-Error "Found multiple archives matching '$prefix*$suffix' under '$ArchiveRoot':`n$($matchList -join "`n")" + exit 1 + } + + $fileName = $matchedItems[0].Name + return $fileName.Substring($prefix.Length, $fileName.Length - $prefix.Length - $suffix.Length) +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + if ([string]::IsNullOrWhiteSpace($ArchiveRoot)) { + Write-Error "Version is required when ArchiveRoot is not specified." + exit 1 + } + + $Version = Get-ArchiveVersion -ArchiveRoot $ArchiveRoot -Rid "win-x64" +} + +if ([string]::IsNullOrWhiteSpace($ArtifactVersion)) { + $ArtifactVersion = $Version +} + +$isPrereleaseInStablePackage = $Channel -ne "stable" + +Write-Host "Preparing WinGet manifests" +Write-Host " Version: $Version" +Write-Host " Channel: $Channel" +Write-Host " Artifact version: $ArtifactVersion" +Write-Host " Template dir: $TemplateDir" +Write-Host " Output path: $OutputPath" +Write-Host " Validation mode: $ValidationMode" + +$generateArgs = @{ + Version = $Version + ArtifactVersion = $ArtifactVersion + TemplateDir = $TemplateDir + OutputPath = $OutputPath +} + +if (-not [string]::IsNullOrWhiteSpace($ArchiveRoot)) { + $generateArgs.ArchiveRoot = $ArchiveRoot +} + +if ($ValidationMode -ne "Full") { + $generateArgs.SkipUrlValidation = $true +} + +if ($isPrereleaseInStablePackage) { + $generateArgs.IsPrereleaseInStablePackage = $true +} + +& (Join-Path $ScriptDir "generate-manifests.ps1") @generateArgs + +if ($LASTEXITCODE -ne 0) { + Write-Error "generate-manifests.ps1 failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "Manifest files generated:" +Get-ChildItem -Path $OutputPath -Recurse | Format-Table FullName + +$versionedManifestPath = Get-ChildItem -Path $OutputPath -Directory -Recurse | + Where-Object { $_.Name -eq $Version } | + Select-Object -First 1 -ExpandProperty FullName + +if (-not $versionedManifestPath) { + $versionedManifestPath = $OutputPath +} + +Write-Host "Versioned manifest path: $versionedManifestPath" + +if ($ValidationMode -ne "GenerateOnly") { + $winget = Get-Command winget -ErrorAction SilentlyContinue + + if ($winget) { + Write-Host "Enabling local manifest files in winget settings..." + winget settings --enable LocalManifestFiles + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to enable local manifests. This may require admin privileges." + } + + Write-Host "Running winget validate..." + winget validate --manifest $versionedManifestPath + if ($LASTEXITCODE -ne 0) { + Write-Error "winget validate failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + + Write-Host "winget validate passed" + } elseif ($ValidationMode -eq "Full") { + Write-Error "winget is required for Full validation mode, but it was not found in PATH." + exit 1 + } else { + Write-Warning "winget was not found in PATH; skipping offline manifest validation." + } +} + +if ($ValidationMode -eq "Full") { + Write-Host "Testing WinGet manifest install/uninstall at: $versionedManifestPath" + + Write-Host "Verifying aspire is not already installed..." + if (Get-Command aspire -ErrorAction SilentlyContinue) { + Write-Error "aspire command is already available before install - test environment is not clean" + exit 1 + } + Write-Host " Confirmed: aspire is not in PATH" + + Write-Host "Installing Aspire.Cli from local manifest..." + winget install --manifest $versionedManifestPath --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + Write-Error "winget install failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + Write-Host "Install succeeded" + + Write-Host "Refreshing PATH environment variable..." + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + + $failed = $false + Write-Host "Verifying aspire CLI is in PATH (new process)..." + try { + $aspireInfo = pwsh -NoProfile -Command ' + $cmd = Get-Command aspire -ErrorAction SilentlyContinue + if (-not $cmd) { Write-Error "aspire not found in PATH"; exit 1 } + Write-Host " Path: $($cmd.Source)" + $v = & aspire --version 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Error "aspire --version failed: $v"; exit $LASTEXITCODE } + Write-Host " Version: $v" + ' + if ($LASTEXITCODE -ne 0) { + throw "Child process exited with code $LASTEXITCODE" + } + Write-Host "aspire CLI verified" + } catch { + Write-Host "##[error]Failed to verify aspire CLI: $_" + $failed = $true + } + + Write-Host "Uninstalling Aspire.Cli..." + winget uninstall --manifest $versionedManifestPath --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + if ($failed) { + Write-Warning "winget uninstall also failed with exit code $LASTEXITCODE (ignoring since verification already failed)" + } else { + Write-Error "winget uninstall failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Write-Host "Uninstall succeeded" + } + + if ($failed) { + exit 1 + } +} + +Copy-Item (Join-Path $ScriptDir "dogfood.ps1") (Join-Path $OutputPath "dogfood.ps1") + +Write-Host "WinGet manifest artifact prepared at: $OutputPath" diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index e8dd5c1188b..30a76600576 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Utils; using Aspire.Shared; using Microsoft.Extensions.Logging; @@ -42,6 +43,12 @@ public LayoutDiscovery(ILogger logger) _logger = logger; } + /// + /// Overrides for relative-layout discovery. + /// Used in tests to simulate the CLI executable living at an arbitrary path. + /// + internal string? ProcessPathOverride { get; init; } + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) { // 1. Try environment variable for layout path @@ -138,18 +145,36 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryDiscoverRelativeLayout() { - // Get CLI executable location - var cliPath = Environment.ProcessPath; + var cliPath = ProcessPathOverride ?? Environment.ProcessPath; if (string.IsNullOrEmpty(cliPath)) { _logger.LogDebug("TryDiscoverRelativeLayout: ProcessPath is null or empty"); return null; } + var resolvedCliPath = CliPathHelper.ResolveSymlinkOrOriginalPath(cliPath, _logger); + if (!string.Equals(resolvedCliPath, cliPath, StringComparison.Ordinal)) + { + _logger.LogDebug("TryDiscoverRelativeLayout: Resolved CLI path {RawPath} -> {ResolvedPath}", cliPath, resolvedCliPath); + + var resolvedLayout = TryDiscoverRelativeLayout(resolvedCliPath); + if (resolvedLayout is not null) + { + return resolvedLayout; + } + + _logger.LogDebug("TryDiscoverRelativeLayout: No layout found relative to resolved CLI path; trying raw path {Path}.", cliPath); + } + + return TryDiscoverRelativeLayout(cliPath); + } + + private LayoutConfiguration? TryDiscoverRelativeLayout(string cliPath) + { var cliDir = Path.GetDirectoryName(cliPath); if (string.IsNullOrEmpty(cliDir)) { - _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from ProcessPath"); + _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from process path {Path}", cliPath); return null; } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs new file mode 100644 index 00000000000..4741ada81bf --- /dev/null +++ b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptInstallerModeTests.cs @@ -0,0 +1,923 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using Aspire.TestUtilities; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; + +namespace Aspire.Acquisition.Tests.Scripts; + +/// +/// Tests for package-manager installer modes on get-aspire-cli-pr.{sh,ps1}. +/// +public class PRScriptInstallerModeTests(ITestOutputHelper testOutput) +{ + private readonly ITestOutputHelper _testOutput = testOutput; + + private async Task CreateBashCommandWithMockGhAsync(TestEnvironment env) + { + var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput); + var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + return cmd; + } + + private async Task CreatePsCommandWithMockGhAsync(TestEnvironment env) + { + var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput); + var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + return cmd; + } + + private static async Task CreateHomebrewInstallerArtifactAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "aspire.rb"), "cask \"aspire\" do\n version \"13.3.0\"\nend\n"); + await File.WriteAllTextAsync(Path.Combine(root, "dogfood.sh"), "#!/usr/bin/env bash\nexit 0\n"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc"); + return root; + } + + private static async Task CreateWinGetInstallerArtifactAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "Microsoft.Aspire.installer.yaml"), "PackageIdentifier: Microsoft.Aspire\nPackageVersion: 13.3.0\nInstallers: []\n"); + await File.WriteAllTextAsync(Path.Combine(root, "dogfood.ps1"), "exit 0\n"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc"); + await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc"); + return root; + } + + // Builds the realistic PR-channel layout that prepare-manifest-artifact.ps1 produces: + // an installer.yaml with two Installers entries (https:// URLs and a placeholder + // SHA256 of all zeros) co-located with dogfood.ps1, plus fake aspire-cli-win-* + // archives. The archives are nested under /Debug/Shipping/ to mirror + // what `gh run download cli-native-archives-` produces — that artifact is + // uploaded with `path: artifacts/packages/**/aspire-cli*` in + // .github/workflows/build-cli-native-archives.yml, so the `**/` glob preserves the + // Debug/Shipping/ directory structure. dogfood.ps1's HTTP server is required to + // serve files regardless of how deep they are under -ArchiveRoot; a flat fixture + // hid that requirement and let a real-world 404 ship. + private static async Task<(string ManifestDir, string ArchiveRoot)> CreateWinGetPrChannelArtifactAsync(string root, string version = "13.3.0") + { + var manifestDir = Path.Combine(root, "installer-winget"); + var archiveRoot = Path.Combine(root, "installer-native-archives"); + // Archives live under Debug/Shipping/ when `gh run download` extracts the + // cli-native-archives- artifact — see comment above. + var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping"); + Directory.CreateDirectory(manifestDir); + Directory.CreateDirectory(archiveDir); + + var placeholder = new string('0', 64); + var installerYaml = $$""" + # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json + PackageIdentifier: Microsoft.Aspire + PackageVersion: "{{version}}" + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: aspire.exe + PortableCommandAlias: aspire + Installers: + - Architecture: x64 + InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-x64-{{version}}.zip + InstallerSha256: {{placeholder}} + - Architecture: arm64 + InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-arm64-{{version}}.zip + InstallerSha256: {{placeholder}} + ManifestType: installer + ManifestVersion: 1.10.0 + """; + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml"), installerYaml); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nManifestType: version\nManifestVersion: 1.10.0\n"); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.locale.en-US.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nPackageLocale: en-US\nManifestType: defaultLocale\nManifestVersion: 1.10.0\n"); + await File.WriteAllTextAsync(Path.Combine(manifestDir, "dogfood.ps1"), "exit 0\n"); + + // Distinct fake bytes per RID so SHA256 differences flow into the mock install check. + // Real zips with a stub aspire.exe at the root, mirroring the contract real winget + // expects (NestedInstallerFiles[].RelativeFilePath must exist after extraction). + // The mock winget extracts these and checks the contract — see CreateMockWinGetBinAsync. + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-x64-{version}.zip"), $"stub-x64-{version}"); + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-arm64-{version}.zip"), $"stub-arm64-{version}"); + + return (manifestDir, archiveRoot); + } + + private static async Task CreateMockHomebrewBinAsync(TestEnvironment env, int aspireExitCode) + { + var mockBinDir = Path.Combine(env.TempDirectory, "mock-homebrew-bin"); + var brewRepository = Path.Combine(env.TempDirectory, "brew-repository"); + var brewPrefix = Path.Combine(env.TempDirectory, "brew-prefix"); + var brewLog = Path.Combine(env.TempDirectory, "brew.log"); + + Directory.CreateDirectory(mockBinDir); + Directory.CreateDirectory(brewRepository); + Directory.CreateDirectory(brewPrefix); + + var brewPath = Path.Combine(mockBinDir, "brew"); + await File.WriteAllTextAsync(brewPath, $$""" + #!/usr/bin/env bash + set -euo pipefail + echo "$*" >> "{{brewLog}}" + + create_aspire() { + cat > "{{mockBinDir}}/aspire" <<'ASPIRE' + #!/usr/bin/env bash + echo "mock aspire failure" + exit {{aspireExitCode}} + ASPIRE + chmod +x "{{mockBinDir}}/aspire" + } + + case "${1:-}" in + --repository) + echo "{{brewRepository}}" + exit 0 + ;; + --prefix) + echo "{{brewPrefix}}" + exit 0 + ;; + list) + exit 0 + ;; + tap-info) + exit 1 + ;; + tap-new) + tap="${@: -1}" + org="${tap%%/*}" + repo="${tap##*/}" + mkdir -p "{{brewRepository}}/Library/Taps/$org/homebrew-$repo" + exit 0 + ;; + style|audit|info) + exit 0 + ;; + install) + create_aspire + exit 0 + ;; + uninstall) + rm -f "{{mockBinDir}}/aspire" + exit 0 + ;; + untap) + exit 0 + ;; + esac + + echo "unexpected brew command: $*" >&2 + exit 1 + """); + FileHelper.MakeExecutable(brewPath); + + var curlPath = Path.Combine(mockBinDir, "curl"); + await File.WriteAllTextAsync(curlPath, """ + #!/usr/bin/env bash + printf '404' + exit 0 + """); + FileHelper.MakeExecutable(curlPath); + + return mockBinDir; + } + + // Mock winget for Windows. Mirrors enough of real winget's behaviour that + // dogfood.ps1 regressions surface here: + // * `validate --manifest ` and `install --manifest ` scan every file + // in and reject non-yaml entries. + // * `validate` enforces the schema rule that InstallerUrl matches ^https?://. + // * `install` actually downloads each InstallerUrl over the wire (via + // Invoke-WebRequest) and verifies the SHA256 of the bytes it received against + // InstallerSha256. This catches both schemes real winget can't fetch (real + // winget uses WinINet's InternetOpenUrl which doesn't support file://) and + // stale InstallerSha256 entries — both bugs we have already shipped in PRs. + // * `install` honors the manifest-vs-archive contract: for InstallerType: zip + // it extracts the downloaded zip and requires every NestedInstallerFiles[] + // .RelativeFilePath to exist after extraction. Real winget fails at this step + // with an opaque error (observed 0x8A150001 with no diag-log line beyond + // "Started applying motw"); catching it here makes the failure attributable. + private static async Task CreateMockWinGetBinAsync(TestEnvironment env, int aspireExitCode) + { + if (!OperatingSystem.IsWindows()) + { + throw new InvalidOperationException("Mock winget is Windows-only (winget itself is Windows-only)."); + } + + var mockBinDir = Path.Combine(env.TempDirectory, "mock-winget-bin"); + var wingetLog = Path.Combine(env.TempDirectory, "winget.log"); + + Directory.CreateDirectory(mockBinDir); + + // PowerShell implementation of the mock. Kept in a separate file so it can be + // edited as PowerShell rather than escaped through a .cmd shim. + var implPath = Path.Combine(mockBinDir, "winget-impl.ps1"); + await File.WriteAllTextAsync(implPath, $$""" + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Args) + $ErrorActionPreference = 'Stop' + $cmd = if ($Args.Count -ge 1) { $Args[0] } else { '' } + $rest = if ($Args.Count -ge 2) { $Args[1..($Args.Count - 1)] } else { @() } + Add-Content -LiteralPath '{{wingetLog.Replace("\\", "\\\\")}}' -Value ($Args -join ' ') + + switch ($cmd) { + 'list' { exit 1 } + 'settings' { exit 0 } + 'uninstall' { exit 0 } + { $_ -in 'validate','install' } { } + default { + Write-Error "Mock winget: unexpected command: $cmd" + exit 1 + } + } + + $manifestDir = $null + for ($i = 0; $i -lt $rest.Count - 1; $i++) { + if ($rest[$i] -eq '--manifest') { $manifestDir = $rest[$i + 1]; break } + } + if (-not $manifestDir -or -not (Test-Path -LiteralPath $manifestDir)) { + Write-Error "Mock winget: --manifest required" + exit 1 + } + + # Real winget treats every file in the manifest dir as a manifest and rejects + # non-yaml entries (e.g. "The manifest does not contain a valid root. + # File: dogfood.ps1"). Mirror that so manifest-staging regressions in + # dogfood.ps1 are caught here. + foreach ($f in Get-ChildItem -LiteralPath $manifestDir -File) { + if ($f.Extension -notin '.yaml', '.yml') { + Write-Error "Mock winget: non-yaml file in manifest dir: $($f.Name)" + exit 1 + } + } + + $installerYaml = Get-ChildItem -LiteralPath $manifestDir -File -Filter '*.installer.yaml' | Select-Object -First 1 + if (-not $installerYaml) { exit 0 } + + $entries = @() + $current = $null + $nestedFiles = @() + foreach ($line in Get-Content -LiteralPath $installerYaml.FullName) { + if ($line -match '^\s*InstallerUrl:\s*(\S+)\s*$') { + if ($current) { $entries += [pscustomobject]$current } + $current = @{ Url = $Matches[1]; Sha = $null } + } elseif ($line -match '^\s*InstallerSha256:\s*(\S+)\s*$' -and $current) { + $current.Sha = $Matches[1].ToUpperInvariant() + } elseif ($line -match '^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$') { + # NestedInstallerFiles is shared across all Installers in the manifest, + # not per-entry. Collect them once and apply to every downloaded zip. + $nestedFiles += $Matches[1] + } + } + if ($current) { $entries += [pscustomobject]$current } + + foreach ($e in $entries) { + # Schema rule: InstallerUrl must match ^https?://. Real winget enforces + # this in `validate`; in `install`, WinINet's InternetOpenUrl() rejects + # any other scheme (file://, ftp://, etc) with HRESULT 0x8007007b. Mock + # both layers so PR-script regressions can't sneak past tests. + if ($e.Url -notmatch '^(?i)https?://') { + Write-Error "Mock winget ${cmd}: InstallerUrl '$($e.Url)' does not match ^https?://" + exit 1 + } + } + + if ($cmd -eq 'install') { + foreach ($e in $entries) { + $tmp = New-TemporaryFile + $renamedZip = $null + $extractDir = $null + try { + try { + Invoke-WebRequest -Uri $e.Url -OutFile $tmp.FullName -UseBasicParsing -TimeoutSec 30 | Out-Null + } catch { + Write-Error "Mock winget install: failed to download $($e.Url): $_" + exit 1 + } + $actual = (Get-FileHash -LiteralPath $tmp.FullName -Algorithm SHA256).Hash.ToUpperInvariant() + if ($e.Sha -and $actual -ne $e.Sha) { + Write-Error "Mock winget install: InstallerSha256 mismatch for $($e.Url) (expected $($e.Sha), got $actual)" + exit 1 + } + + # Manifest-vs-archive contract: for InstallerType: zip with + # NestedInstallerType: portable, real winget extracts the zip + # and requires every NestedInstallerFiles[].RelativeFilePath to + # exist after extraction. If it doesn't, real winget fails at + # install time with an opaque error (we observed 0x8A150001 + # with no diag-log line beyond "Started applying motw"). Catch + # the contract violation here so the failure is attributable. + if ($nestedFiles.Count -gt 0) { + $renamedZip = "$($tmp.FullName).zip" + Move-Item -LiteralPath $tmp.FullName -Destination $renamedZip + $extractDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + try { + Expand-Archive -LiteralPath $renamedZip -DestinationPath $extractDir -Force + } catch { + Write-Error "Mock winget install: failed to extract $($e.Url): $_" + exit 1 + } + foreach ($rel in $nestedFiles) { + # RelativeFilePath uses '/' as separator in YAML even on Windows. + $relNative = $rel -replace '/', [System.IO.Path]::DirectorySeparatorChar + $expected = Join-Path $extractDir $relNative + if (-not (Test-Path -LiteralPath $expected)) { + Write-Error "Mock winget install: NestedInstallerFiles entry '$rel' is missing from extracted archive $($e.Url)" + exit 1 + } + } + } + } finally { + if ($extractDir -and (Test-Path -LiteralPath $extractDir)) { + Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue + } + if ($renamedZip -and (Test-Path -LiteralPath $renamedZip)) { + Remove-Item -LiteralPath $renamedZip -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $tmp.FullName) { + Remove-Item -LiteralPath $tmp.FullName -ErrorAction SilentlyContinue + } + } + } + } + + exit 0 + """); + + // .cmd shim so the mock is invokable as 'winget' from PATH (PowerShell honors + // PATHEXT and resolves 'winget' to winget.cmd before scanning system paths). + var wingetCmd = Path.Combine(mockBinDir, "winget.cmd"); + await File.WriteAllTextAsync(wingetCmd, """ + @echo off + pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0winget-impl.ps1" %* + """); + + var aspireCmd = Path.Combine(mockBinDir, "aspire.cmd"); + await File.WriteAllTextAsync(aspireCmd, $$""" + @echo off + echo mock aspire {{(aspireExitCode == 0 ? "version" : "failure")}} + exit /b {{aspireExitCode}} + """); + + return mockBinDir; + } + + private static async Task CreateFakeHomebrewArchivesAsync(string root) + { + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-arm64-13.3.0.tar.gz"), "fake arm64 archive"); + await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-x64-13.3.0.tar.gz"), "fake x64 archive"); + } + + private static async Task CreateFakeWinGetArchivesAsync(string root) + { + var archiveDir = Path.Combine(root, "Debug", "Shipping"); + Directory.CreateDirectory(archiveDir); + // Real zips with a stub aspire.exe at the root so prepare-manifest-artifact.ps1 + // sees a valid archive layout and downstream contract tests (extraction + + // NestedInstallerFiles lookup) work against the same fixture. + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip"), "stub-x64-13.3.0"); + await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip"), "stub-arm64-13.3.0"); + } + + private static async Task GetSha256HexAsync(string path) + { + var bytes = await File.ReadAllBytesAsync(path); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + + // Writes a real .zip containing a stub `aspire.exe` at the root, matching the + // top-level layout of the real aspire-cli-win-*.zip artifacts. The bytes are + // tiny stubs — winget and our tests only care about the extraction layout + // (NestedInstallerFiles[].RelativeFilePath = aspire.exe), not signed binary + // content. The `aspireExeContent` argument is hashed into the zip so callers + // can produce zips with distinct SHA256 hashes per RID. + private static async Task WriteRealAspireWinGetZipAsync(string path, string aspireExeContent) + { + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Directory.CreateDirectory(parent); + } + if (File.Exists(path)) + { + File.Delete(path); + } + + await using var fs = File.Create(path); + using var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create); + var entry = archive.CreateEntry("aspire.exe", System.IO.Compression.CompressionLevel.NoCompression); + await using var s = entry.Open(); + await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes(aspireExeContent)); + } + + // Parses NestedInstallerFiles[].RelativeFilePath entries from an installer manifest + // YAML. Uses a regex instead of a YAML parser to avoid pulling in YamlDotNet just + // for the test project (we already do regex parsing of InstallerUrl/InstallerSha256 + // elsewhere in this file and in the mock winget script). + private static IReadOnlyList ParseRelativeFilePaths(string installerYaml) + { + var paths = new List(); + foreach (var rawLine in installerYaml.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + var m = System.Text.RegularExpressions.Regex.Match(line, @"^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$"); + if (m.Success) + { + paths.Add(m.Groups[1].Value); + } + } + return paths; + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_Help_DescribesInstallerModes() + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + + var result = await cmd.ExecuteAsync("--help"); + + result.EnsureSuccessful(); + Assert.Contains("winget", result.Output); + Assert.Contains("homebrew", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_WinGetMode_PrDryRun_DownloadsManifestAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "winget", + "--force", + "--dry-run", + "--skip-extension", + "--verbose"); + + result.EnsureSuccessful(); + Assert.Contains("winget-manifests-prerelease", result.Output); + Assert.Contains("cli-native-archives-win-x64", result.Output); + Assert.Contains("cli-native-archives-win-arm64", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("-Force", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewMode_PrDryRun_DownloadsCaskAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "homebrew", + "--dry-run", + "--skip-extension", + "--verbose"); + + result.EnsureSuccessful(); + Assert.Contains("homebrew-cask-prerelease", result.Output); + Assert.Contains("cli-native-archives-osx-arm64", result.Output); + Assert.Contains("cli-native-archives-osx-x64", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewMode_LocalDir_DryRun_UsesDogfoodArtifact() + { + using var env = new TestEnvironment(); + var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact")); + using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput); + + var result = await cmd.ExecuteAsync( + "--local-dir", localDir, + "--install-mode", "homebrew", + "--dry-run", + "--skip-path"); + + result.EnsureSuccessful(); + Assert.Contains("dogfood.sh", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("Would copy nugets", result.Output); + Assert.DoesNotContain("Would install CLI archive", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_InstallerMode_RejectsHiveOnly() + { + using var env = new TestEnvironment(); + using var cmd = await CreateBashCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "12345", + "--install-mode", "homebrew", + "--hive-only", + "--dry-run", + "--skip-extension"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("--hive-only cannot be combined with --install-mode homebrew", result.Output); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_HomebrewDogfood_FailsWhenVersionCheckFails() + { + using var env = new TestEnvironment(); + var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact")); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/homebrew/dogfood.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync(Path.Combine(localDir, "aspire.rb")); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("aspire --version failed after install", result.Output); + } + + [Fact] + [RequiresTools(["ruby"])] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_PrepareHomebrewCask_FailedVerification_UninstallsCask() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeHomebrewArchivesAsync(archiveRoot); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync( + "--version", "13.3.0", + "--artifact-version", "13.3.0", + "--channel", "stable", + "--archive-root", archiveRoot, + "--output-dir", Path.Combine(env.TempDirectory, "homebrew-output")); + + Assert.NotEqual(0, result.ExitCode); + var brewLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "brew.log")); + Assert.Contains("uninstall --cask local/aspire-test/aspire", brewLog); + } + + [Fact] + [RequiresTools(["ruby"])] + [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")] + public async Task Bash_PrepareHomebrewCask_Offline_GeneratesCaskWithArchiveHashesAndDogfood() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeHomebrewArchivesAsync(archiveRoot); + var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 0); + var outputDir = Path.Combine(env.TempDirectory, "homebrew-output"); + using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin"); + + var result = await cmd.ExecuteAsync( + "--version", "13.3.0", + "--artifact-version", "13.3.0-pr.1234.abc", + "--channel", "prerelease", + "--archive-root", archiveRoot, + "--output-dir", outputDir, + "--validation-mode", "Offline"); + + result.EnsureSuccessful(); + + var cask = await File.ReadAllTextAsync(Path.Combine(outputDir, "aspire.rb")); + Assert.Contains("version \"13.3.0\"", cask); + Assert.Contains("https://ci.dot.net/public/aspire/13.3.0-pr.1234.abc/aspire-cli-osx-#{arch}-#{version}.tar.gz", cask); + Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-arm64-13.3.0.tar.gz"))).ToLowerInvariant(), cask); + Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-x64-13.3.0.tar.gz"))).ToLowerInvariant(), cask); + Assert.DoesNotContain("${", cask); + Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.sh"))); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_WinGetMode_WhatIf_DownloadsManifestAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "WinGet", + "-Force", + "-WhatIf", + "-SkipExtension", + "-Verbose"); + + result.EnsureSuccessful(); + Assert.Contains("winget-manifests-prerelease", result.Output); + Assert.Contains("cli-native-archives-win-x64", result.Output); + Assert.Contains("cli-native-archives-win-arm64", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("-Force", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("Route sidecar", result.Output); + Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_HomebrewMode_WhatIf_DownloadsCaskAndNativeArchives() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "Homebrew", + "-WhatIf", + "-SkipExtension", + "-Verbose"); + + result.EnsureSuccessful(); + Assert.Contains("homebrew-cask-prerelease", result.Output); + Assert.Contains("cli-native-archives-osx-arm64", result.Output); + Assert.Contains("cli-native-archives-osx-x64", result.Output); + Assert.Contains("--archive-root", result.Output); + Assert.Contains("built-nugets", result.Output); + Assert.DoesNotContain("Add to your shell profile", result.Output); + Assert.DoesNotContain("Route sidecar", result.Output); + Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_WinGetMode_LocalDir_WhatIf_UsesDogfoodArtifact() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-LocalDir", localDir, + "-InstallMode", "WinGet", + "-WhatIf", + "-SkipPath"); + + result.EnsureSuccessful(); + Assert.Contains("dogfood.ps1", result.Output); + Assert.Contains("-ArchiveRoot", result.Output); + Assert.Contains("Copying built nugets", result.Output); + Assert.DoesNotContain("Installing Aspire CLI to", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_InstallerMode_RejectsHiveOnly() + { + using var env = new TestEnvironment(); + using var cmd = await CreatePsCommandWithMockGhAsync(env); + + var result = await cmd.ExecuteAsync( + "-PRNumber", "12345", + "-InstallMode", "Homebrew", + "-HiveOnly", + "-WhatIf", + "-SkipExtension"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("-HiveOnly cannot be combined with -InstallMode Homebrew", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_PrepareWinGetManifest_GenerateOnly_GeneratesManifestsWithArchiveHashesAndDogfood() + { + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + await CreateFakeWinGetArchivesAsync(archiveRoot); + var outputDir = Path.Combine(env.TempDirectory, "winget-output"); + using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-Channel", "prerelease", + "-ArchiveRoot", archiveRoot, + "-OutputPath", outputDir, + "-ValidationMode", "GenerateOnly"); + + result.EnsureSuccessful(); + + var installerManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml")); + Assert.Contains("PackageVersion: \"13.3.0\"", installerManifest); + Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-x64-13.3.0.zip", installerManifest); + Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-arm64-13.3.0.zip", installerManifest); + Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip")), installerManifest); + Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-arm64-13.3.0.zip")), installerManifest); + Assert.DoesNotContain(new string('0', 64), installerManifest); + Assert.DoesNotContain("${", installerManifest); + + var localeManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.locale.en-US.yaml")); + Assert.Contains("For testing builds only. Prerelease package in stable manifest.", localeManifest); + Assert.True(File.Exists(Path.Combine(outputDir, "Microsoft.Aspire.yaml"))); + Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.ps1"))); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_Force_PassesForceToWingetInstall() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", localDir, "-Force"); + + result.EnsureSuccessful(); + var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log")); + Assert.Contains("install --manifest", wingetLog); + Assert.Contains("--force", wingetLog); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_FailsWhenVersionCheckFails() + { + using var env = new TestEnvironment(); + var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact")); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 42); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", localDir); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Failed to verify Aspire CLI installation", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_ValidatesPristineAndInstallsRewrittenManifest() + { + // This mirrors get-aspire-cli-pr.ps1 -InstallMode WinGet's invocation of + // dogfood.ps1 -ArchiveRoot. End-to-end behaviours that have to hold: + // 1. ``winget validate`` runs against the pristine https:// URLs (the schema + // rejects anything that's not ^https?://). + // 2. ``Set-LocalInstallerSources`` rewrites InstallerUrl to point at the + // loopback HTTP listener (not file:// — winget's WinINet-based downloader + // rejects file:// with HRESULT 0x8007007b) and refreshes InstallerSha256 + // (PR-channel manifests ship with a placeholder of all zeros). + // 3. The mock then actually downloads the URL via Invoke-WebRequest and + // hashes the bytes, which exercises both points 1 and 2 end-to-end. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + result.EnsureSuccessful(); + + var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log")); + Assert.Contains("validate --manifest", wingetLog); + Assert.Contains("install --manifest", wingetLog); + + // The pristine manifest in the artifact directory must be untouched (re-runnable). + var originalInstaller = await File.ReadAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml")); + Assert.Contains("https://ci.dot.net/", originalInstaller); + Assert.Contains(new string('0', 64), originalInstaller); + Assert.DoesNotContain("file://", originalInstaller); + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenArchiveBytesChange() + { + // Guard against silent regressions: if Set-LocalInstallerSources ever stops + // refreshing InstallerSha256, the mock winget install will download the + // archive over the loopback listener and detect the hash mismatch. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + + // Tamper the archive *after* the manifest's placeholder hash was written. The + // refreshed hash must reflect the new bytes for ``winget install`` to succeed. + // We replace with a valid zip (still extractable, still satisfies the + // NestedInstallerFiles contract) but with different stub contents — so only + // the hash refresh is being exercised, not the extraction layout check. + var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip"); + await WriteRealAspireWinGetZipAsync(x64Archive, "post-generate-mutated-x64-bytes"); + + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + result.EnsureSuccessful(); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents() + { + // Manifest-vs-archive contract: real winget extracts InstallerUrl's zip at + // install time and looks for every NestedInstallerFiles[].RelativeFilePath in + // the extracted contents. If the prepare-manifest-artifact.ps1 template ever + // drifts away from the actual archive layout — wrong RelativeFilePath, wrong + // CWD, missing file — real winget fails at install time with an opaque error + // (we observed 0x8A150001 with no diag-log line beyond "Started applying motw"). + // + // This test runs the real prepare script against real zips and checks the + // contract holds end-to-end without needing winget itself, so it can run on + // Linux CI as a fast deterministic gate. + using var env = new TestEnvironment(); + var archiveRoot = Path.Combine(env.TempDirectory, "archives"); + var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping"); + Directory.CreateDirectory(archiveDir); + var x64Zip = Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip"); + var arm64Zip = Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip"); + await WriteRealAspireWinGetZipAsync(x64Zip, "stub-x64-13.3.0"); + await WriteRealAspireWinGetZipAsync(arm64Zip, "stub-arm64-13.3.0"); + + var outputDir = Path.Combine(env.TempDirectory, "winget-output"); + using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput); + + var result = await cmd.ExecuteAsync( + "-Channel", "prerelease", + "-ArchiveRoot", archiveRoot, + "-OutputPath", outputDir, + "-ValidationMode", "GenerateOnly"); + + result.EnsureSuccessful(); + + var installerYaml = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml")); + var relativeFilePaths = ParseRelativeFilePaths(installerYaml); + Assert.NotEmpty(relativeFilePaths); + + foreach (var archive in new[] { x64Zip, arm64Zip }) + { + var extractDir = Path.Combine(env.TempDirectory, $"extract-{Path.GetFileNameWithoutExtension(archive)}"); + System.IO.Compression.ZipFile.ExtractToDirectory(archive, extractDir); + foreach (var rel in relativeFilePaths) + { + var expected = Path.Combine(extractDir, rel.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(expected), + $"NestedInstallerFiles entry '{rel}' from {Path.GetFileName(archive)} not found at {expected}. Manifest disagrees with archive contents."); + } + } + } + + [Fact] + [RequiresTools(["pwsh"])] + [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")] + public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenNestedInstallerFileMissingFromArchive() + { + // Companion to PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents: + // exercises the same contract from the install side. The manifest declares + // NestedInstallerFiles: [RelativeFilePath: aspire.exe] but the served zip is + // rebuilt with only a sentinel file, no aspire.exe. Real winget would fail at + // install time with an opaque error (after the InstallerSha256 verify step); + // the mock catches the same contract violation deterministically with an + // attributable error message. + using var env = new TestEnvironment(); + var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory); + + var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip"); + File.Delete(x64Archive); + await using (var fs = File.Create(x64Archive)) + using (var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("not-aspire.exe", System.IO.Compression.CompressionLevel.NoCompression); + await using var s = entry.Open(); + await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes("sentinel")); + } + + var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0); + using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput); + cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}"); + + var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("NestedInstallerFiles entry 'aspire.exe' is missing", result.Output); + } +} diff --git a/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs index ab636a43025..45dff134e58 100644 --- a/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs +++ b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs @@ -42,10 +42,29 @@ private static PeerProbeResult.Ok AssertProbeOk(PeerProbeResult result) return Assert.IsType(result); } + // Construct a probe with a much wider timeout than production's 5s default. + // + // These positive-path tests assert how the probe interprets a successful + // peer's output, not the timeout behavior — but the FakePeerScript helper + // on Windows shells out to cmd.exe (and powershell.exe in the stderr + // variant), which under heavy CI load (saturated CPU, slow disk) can take + // several seconds just to start. With the production 5s timeout we + // intermittently see the probe synthesize + // `Failed: "Peer probe timed out after 5.0s."` before the fake peer even + // produces stdout, even though the peer would complete instantly given a + // bit more wallclock. + // + // The timeout path itself is covered by ProbeAsync_PeerHangs_TimesOutAndReturnsFailed + // and ProbeAsync_CallerCancels_KillsSpawnedProcess, so widening the + // budget here removes the CI flake without losing coverage of the 5s + // production behavior. + private PeerInstallProbe CreateProbeWithGenerousTimeout() + => new(TimeSpan.FromSeconds(30), ProbeLogger); + [Fact] public async Task ProbeAsync_BinaryNotFound_ReturnsFailed() { - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); using var workspace = TemporaryWorkspace.Create(outputHelper); var missing = Path.Combine(workspace.WorkspaceRoot.FullName, "does-not-exist"); @@ -66,7 +85,7 @@ public async Task ProbeAsync_InvokesPeerWithDoctorSelfFormatJson() // default when `--format` is omitted. using var fakePeer = FakePeerScript.BuildArgvRecorder(outputHelper); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); AssertProbeOk(result); @@ -99,7 +118,7 @@ public async Task ProbeAsync_PeerEmitsValidJsonArray_ReturnsOk() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -125,7 +144,7 @@ public async Task ProbeAsync_PeerOmitsPathStatus_DefaultsToNotOnPath() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -149,7 +168,7 @@ public async Task ProbeAsync_PeerEmitsInvalidPathStatus_DefaultsToNotOnPath() """, exitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -238,7 +257,7 @@ public async Task ProbeAsync_PeerExitsNonZero_FallsBackToVersionAndReturnsPartia versionStdout: "13.4.0-pr.16817.g790d6fa3\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -261,7 +280,7 @@ public async Task ProbeAsync_BothInfoAndVersionFail_ReturnsFailed() versionStdout: string.Empty, versionExitCode: 1); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var failed = Assert.IsType(result); @@ -281,7 +300,7 @@ public async Task ProbeAsync_PeerEmitsEmptyArray_FallsBackToVersion() versionStdout: string.Empty, versionExitCode: 1); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); Assert.IsType(result); @@ -300,7 +319,7 @@ public async Task ProbeAsync_PeerEmitsInvalidJson_FallsBackToVersion() versionStdout: "9.0.0\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -328,7 +347,7 @@ public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackTo versionStdout: "9.0.0\n", versionExitCode: 0); - var probe = new PeerInstallProbe(ProbeLogger); + var probe = CreateProbeWithGenerousTimeout(); var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); var ok = AssertProbeOk(result); @@ -336,7 +355,7 @@ public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackTo } [Fact] - public async Task ProbeAsync_PeerHangs_TimesOutAndKills() + public async Task ProbeAsync_PeerHangs_TimesOutAndReturnsFailed() { // Sleep significantly longer than the probe timeout we configure so // the timeout path is the one that completes the await. @@ -351,8 +370,14 @@ public async Task ProbeAsync_PeerHangs_TimesOutAndKills() var failed = Assert.IsType(result); Assert.Contains("timed out", failed.Reason, StringComparison.OrdinalIgnoreCase); - Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), - $"Expected probe to return within a few seconds after timing out; took {sw.Elapsed}."); + // The probe is configured with a 300ms timeout; the outer budget here + // is a sanity bound against a probe that ignores its configured + // timeout entirely (the bug class this test catches). Windows CI under + // saturated CPU / slow disk has been observed to take ~5s just for the + // fake-peer cmd.exe spawn + kill round-trip, so the budget needs to be + // well above 5s to avoid noise without losing the bound. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), + $"Expected probe to return within 15s after its 300ms timeout; took {sw.Elapsed}."); } [Fact] @@ -389,7 +414,7 @@ public async Task ProbeAsync_CallerCancels_KillsSpawnedProcess() // Windows shells out to powershell.exe to emit raw stderr bytes — can // take several seconds just to start. These tests are about how the // probe formats a Failed result from real peer stderr/exit semantics, - // not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndKills + // not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndReturnsFailed // for that). A wider budget here removes the CI flake without changing // what's being tested. var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger); diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs index 849b616f2a5..ca29889cfd5 100644 --- a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs +++ b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs @@ -16,6 +16,68 @@ namespace Aspire.Cli.Tests.LayoutTests; /// public class LayoutDiscoveryReparsePointTests(ITestOutputHelper outputHelper) { + [Fact] + public void DiscoverLayout_ResolvesProcessPathSymlinkBeforeRelativeDiscovery() + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), + "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var realLayoutRoot = Path.Combine( + workspace.WorkspaceRoot.FullName, + "WinGet", + "Packages", + "Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe"); + CreateValidBundleLayout(realLayoutRoot); + + var realBinary = Path.Combine(realLayoutRoot, "aspire"); + File.WriteAllText(realBinary, "stub"); + + var linksDir = Path.Combine(workspace.WorkspaceRoot.FullName, "WinGet", "Links"); + Directory.CreateDirectory(linksDir); + var linkPath = Path.Combine(linksDir, "aspire"); + File.CreateSymbolicLink(linkPath, realBinary); + + var discovery = new LayoutDiscovery(NullLogger.Instance) + { + ProcessPathOverride = linkPath + }; + + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(realLayoutRoot, layout!.LayoutPath); + } + + [Fact] + public void DiscoverLayout_FallsBackToRawProcessPathWhenResolvedPathHasNoLayout() + { + Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), + "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var rawLayoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "custom-layout"); + CreateValidBundleLayout(rawLayoutRoot); + + var realBinaryDir = Path.Combine(workspace.WorkspaceRoot.FullName, "real-binary"); + Directory.CreateDirectory(realBinaryDir); + var realBinary = Path.Combine(realBinaryDir, "aspire"); + File.WriteAllText(realBinary, "stub"); + + var linkPath = Path.Combine(rawLayoutRoot, "aspire"); + File.CreateSymbolicLink(linkPath, realBinary); + + var discovery = new LayoutDiscovery(NullLogger.Instance) + { + ProcessPathOverride = linkPath + }; + + var layout = discovery.DiscoverLayout(); + + Assert.NotNull(layout); + Assert.Equal(rawLayoutRoot, layout!.LayoutPath); + } + [Fact] public void DiscoverLayout_ResolvesThroughReparsePoints() { @@ -54,4 +116,16 @@ public void DiscoverLayout_ResolvesThroughReparsePoints() ReparsePoint.RemoveIfExists(bundleLink); } } + + private static void CreateValidBundleLayout(string layoutRoot) + { + var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName); + var managedDir = Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName); + var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName); + Directory.CreateDirectory(managedDir); + Directory.CreateDirectory(dcpDir); + File.WriteAllText( + Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + "stub"); + } }