Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
42db8e9
fix(winget): harden local manifest dogfood installs
radical May 20, 2026
dac0025
fix(homebrew): harden local cask dogfood installs
radical May 20, 2026
56d6f21
ci(installer-artifacts): generate WinGet and Homebrew artifacts
radical May 20, 2026
c9c2e4c
feat(acquisition): add PR installer dogfood modes
radical May 20, 2026
45052ba
test(acquisition): cover installer artifact dogfood flows
radical May 20, 2026
1912371
ci(installer-artifacts): upload CLI logs from smoke tests
radical May 20, 2026
c90bb0e
fix(cli): resolve launcher links during layout discovery
radical May 20, 2026
e49b2f0
fix(installer): validate Homebrew casks in PR artifact prep
radical May 20, 2026
3280694
fix(installer): keep Homebrew dogfood compatible with hosted macOS
radical May 20, 2026
e8fce4f
ci(release): guard WinGet/Homebrew submit on production branches
radical May 20, 2026
95b8713
Add empty zap stanza to Homebrew cask template
radical May 20, 2026
2d90625
ci(installers): share production-branch guard via _IsProductionBranch
radical May 20, 2026
3763a14
ci(installers): extract installed-CLI smoke test to shared scripts
radical May 20, 2026
cdab0f8
ci(homebrew): authenticate the upstream-cask existence probe
radical May 21, 2026
eb507fd
ci(installers): gate URL/summary validation on production branches
radical May 21, 2026
8eb08aa
docs(dogfood): drop unsupported x86 arch from PR-dogfood reference
radical May 21, 2026
548668b
fix(installer): drop destructive rm -rf from smoke helpers; clarify c…
radical May 21, 2026
a08bf7e
docs(installer): fix dogfood.ps1 diag-log stream comment
radical May 21, 2026
496fc65
Merge remote-tracking branch 'origin/main' into radical/installer-man…
radical May 21, 2026
c621e1d
test(cli/acquisition): widen PeerInstallProbeTests timeouts and renam…
radical May 21, 2026
eba8cfc
fix(installer): address installer review feedback
radical May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/workflows/prepare-installer-artifacts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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
env:
# Authenticate the upstream-cask existence check inside
# validate-cask-artifact.sh; unauthenticated requests hit the 60/hour
# shared rate limit and return 403.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
56 changes: 48 additions & 8 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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' ||
Expand All @@ -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
47 changes: 43 additions & 4 deletions docs/dogfooding-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<PR_NUMBER>/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-<PR_NUMBER>/packages`. The difference is only how the CLI binary itself is acquired.

## Prerequisites

Expand All @@ -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
Expand Down Expand Up @@ -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-<PR_NUMBER>/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-<PR_NUMBER>/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`.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions eng/homebrew/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion eng/homebrew/aspire.rb.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading