From 7d7d78db0e72b5549f88e5b4f46c8b36bc9e8a48 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:06:42 -0700 Subject: [PATCH 1/8] fix(pack): analyzer DLL path on clean machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mur pack-local` was failing on a freshly-cloned machine with: NuGet.Build.Tasks.Pack.targets(222,5): error Could not find a part of the path '...\src\Reactor.Analyzers\bin\Debug\netstandard2.0\' Root cause: Reactor.csproj packs the analyzer + localization-generator DLLs into the framework nupkg via `` paths that hardcode `bin\$(Configuration)\netstandard2.0\` — no `$(Platform)` segment. The two projects are also ``s, so when `dotnet pack` is invoked with `-p:Platform=x64` (or ARM64) the inherited Platform cascades to the transitive analyzer build and the output actually lands at `bin\x64\Debug\netstandard2.0\`. Pack then looks at the platform-less path, doesn't find the file, and fails. The bug was masked on dev machines by any prior AnyCPU build (VS solution, bare `dotnet build src/Reactor.Analyzers`, etc.) that left a stale DLL at the platform-less path. Fix: set `AppendPlatformToOutputPath=false` on both analyzer csprojs. These are netstandard2.0 IL with no platform-specific code, so output should always land at `bin\$(Configuration)\netstandard2.0\` regardless of what Platform the parent pack invocation inherited. Reproduced + verified locally with a wiped bin/obj tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Reactor.Analyzers/Reactor.Analyzers.csproj | 11 +++++++++++ .../Reactor.Localization.Generator.csproj | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src/Reactor.Analyzers/Reactor.Analyzers.csproj b/src/Reactor.Analyzers/Reactor.Analyzers.csproj index 430cdda9a..cb773f75f 100644 --- a/src/Reactor.Analyzers/Reactor.Analyzers.csproj +++ b/src/Reactor.Analyzers/Reactor.Analyzers.csproj @@ -12,6 +12,17 @@ false + + + false diff --git a/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj b/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj index 49dba1d93..63a7b0ed7 100644 --- a/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj +++ b/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj @@ -9,6 +9,12 @@ true Microsoft.UI.Reactor.Localization.Generator Reactor.Localization.Generator + + + false From 34a6041dc633706b754f7e5f5fb993b3af17b625 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:07:20 -0700 Subject: [PATCH 2/8] feat(bootstrap): winget auto-install for .NET 10 + optional WinAppSDK prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces clean-machine friction. Instead of failing with a doc link when a prerequisite is missing, bootstrap.ps1 now drives a `winget install` itself (hard-failing if winget itself isn't on PATH). - New `Install-WithWinget` helper: single point for winget calls. Runs with `--silent --disable-interactivity --accept-source-agreements --accept-package-agreements`, treats "already installed" exit -1978335189 as success, and refreshes `$env:Path` from Machine + User registry strings so freshly-installed binaries resolve in this same shell (Path edits made by winget don't otherwise propagate to a running process). - .NET 10 SDK: new `Test-DotnetSdk10` probe. When the SDK is missing or pre-10, dump the installed-SDK list (for debugging) and call `Install-WithWinget Microsoft.DotNet.SDK.10`. Re-probe after install and fail with a "open a new shell" hint if it still isn't visible. - Windows App SDK: tri-state `-InstallWinAppSdk` parameter. Unspecified → prompt the dev (default no) since the framework defaults to WindowsAppSDKSelfContained=true and the runtime is only needed for framework-dependent deployment. `-InstallWinAppSdk` → force install non-interactively (use this from CI / fresh-dev-box automation). `-InstallWinAppSdk:$false` → skip the prompt silently. The prompt-by-default for WinAppSDK preserves the existing self-contained happy path (no surprise 50MB install) while making framework-dependent deployment one keystroke away. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap.ps1 | 132 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 7031bca3d..0a3840ca2 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -22,19 +22,39 @@ .PARAMETER Configuration Build configuration for the CLI nupkg. Default: Release. +.PARAMETER InstallWinAppSdk + Tri-state Windows App Runtime 2.0 install. When unspecified (default), + prompt interactively (default no). Pass -InstallWinAppSdk to force-install + non-interactively (useful for CI / one-shot dev-box setup); pass + -InstallWinAppSdk:$false to skip the prompt silently. The framework + defaults to self-contained, so the runtime is only required for + framework-dependent deployment. + .EXAMPLE ./bootstrap.ps1 - Full bootstrap. + Full bootstrap (prompts before installing WindowsAppRuntime). .EXAMPLE ./bootstrap.ps1 -SkipPlugin Skip the Claude plugin step. + +.EXAMPLE + ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin + Non-interactive: install everything (incl. WindowsAppRuntime) and + skip the agent plugin. Suitable for CI / fresh-dev-box automation. #> [CmdletBinding()] param( [switch]$SkipPlugin, [switch]$SkipMurInstall, - [string]$Configuration = 'Release' + [string]$Configuration = 'Release', + # Windows App SDK runtime install: tri-state. When unspecified, prompt + # interactively (default no) since the framework defaults to + # WindowsAppSDKSelfContained=true and the machine runtime is only needed + # for framework-dependent deployment. Pass -InstallWinAppSdk to force- + # install non-interactively; pass -InstallWinAppSdk:$false to skip the + # prompt and continue. + [Nullable[bool]]$InstallWinAppSdk = $null ) $ErrorActionPreference = 'Stop' @@ -56,33 +76,107 @@ function Fail($msg) { exit 1 } +# Install a winget package and refresh $env:Path so the freshly-installed tool +# is resolvable in this same shell. Hard-fails if winget itself is missing — +# that's an OS-level prerequisite this script doesn't try to repair. +function Install-WithWinget { + param( + [Parameter(Mandatory)][string]$Id, + [string]$Reason = $Id + ) + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Fail "Need to install '$Reason' but winget is not on PATH. Install App Installer from the Microsoft Store, then re-run ./bootstrap.ps1." + } + Write-Host " Installing $Reason via winget ($Id)..." -ForegroundColor Yellow + & winget install --id $Id --accept-source-agreements --accept-package-agreements --silent --disable-interactivity + if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne -1978335189) { + # -1978335189 = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE (already installed / up-to-date) + Fail "winget install $Id failed (exit $LASTEXITCODE). Install $Reason manually and re-run ./bootstrap.ps1." + } + # winget edits the Machine + User PATH but the current process keeps its + # original. Rebuild $env:Path from the registry so subsequent commands in + # this script can find the freshly-installed binaries. + $env:Path = ( + [Environment]::GetEnvironmentVariable('Path', 'Machine'), + [Environment]::GetEnvironmentVariable('Path', 'User') + ) -join ';' +} + # --------------------------------------------------------------------------- # 1. Pre-flight # --------------------------------------------------------------------------- Write-Step 'Pre-flight checks' -$dotnetCmd = Get-Command dotnet -ErrorAction SilentlyContinue -if (-not $dotnetCmd) { - winget install Microsoft.DotNet.SDK.10 - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") -} -$sdkOutput = & dotnet --list-sdks -$has10OrLater = $false -foreach ($line in $sdkOutput) { - if ($line -match '^(\d+)\.') { - if ([int]$Matches[1] -ge 10) { $has10OrLater = $true; break } +function Test-DotnetSdk10 { + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { return $false } + foreach ($line in (& dotnet --list-sdks)) { + if ($line -match '^(\d+)\.' -and [int]$Matches[1] -ge 10) { return $true } } + return $false } -if (-not $has10OrLater) { - Fail @" -.NET 10+ SDK not detected — Reactor requires 10 or later. -Installed SDKs: -$($sdkOutput -join "`n") -Install the latest .NET SDK from https://dotnet.microsoft.com/download and re-run ./bootstrap.ps1. -"@ + +if (-not (Test-DotnetSdk10)) { + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + Write-Host " [info] dotnet not found on PATH." -ForegroundColor Yellow + } else { + Write-Host " [info] dotnet present but no .NET 10+ SDK detected. Installed:" -ForegroundColor Yellow + & dotnet --list-sdks | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } + Install-WithWinget -Id 'Microsoft.DotNet.SDK.10' -Reason '.NET 10 SDK' + if (-not (Test-DotnetSdk10)) { + Fail '.NET 10 SDK install reported success but `dotnet --list-sdks` still does not show a 10.x entry. Open a new shell and re-run ./bootstrap.ps1.' + } } Write-Ok ".NET SDK present" +# Windows App SDK runtime — optional but recommended. +# +# The framework defaults to WindowsAppSDKSelfContained=true (see +# Directory.Build.props), so builds and scaffolded apps work *without* the +# machine-wide runtime — every app's bin/ output ships its own copy of WAS +# native binaries from NuGet restore. +# +# But many devs prefer framework-dependent deployment: smaller per-app +# output, faster incremental builds, and the runtime installed once on the +# machine. For that path the user needs the WindowsAppRuntime 2.0 install +# matching our WindowsAppSDKVersion=2.0.1. +# +# So we prompt by default. `-InstallWinAppSdk` to force-install, +# `-InstallWinAppSdk:$false` to skip the prompt non-interactively. + +function Test-WindowsAppRuntime20 { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return $true } # nothing we can check without winget + & winget list --id Microsoft.WindowsAppRuntime.2.0 --exact 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) +} + +if (-not (Test-WindowsAppRuntime20)) { + $shouldInstall = $false + if ($null -ne $InstallWinAppSdk) { + $shouldInstall = [bool]$InstallWinAppSdk + if (-not $shouldInstall) { + Write-Host ' [skip] Windows App Runtime 2.0 not installed (skipped per -InstallWinAppSdk:$false).' -ForegroundColor Yellow + } + } else { + Write-Host '' + Write-Host ' Windows App Runtime 2.0 is not installed on this machine.' -ForegroundColor Yellow + Write-Host ' Reactor builds default to WindowsAppSDKSelfContained=true, so this is optional —' + Write-Host ' your apps will work either way. Installing it enables framework-dependent' + Write-Host ' deployment (smaller per-app output, faster builds) when you override' + Write-Host ' WindowsAppSDKSelfContained=false in a consuming project.' + $answer = Read-Host ' Install Windows App Runtime 2.0 via winget now? [y/N]' + $shouldInstall = ($answer -match '^[Yy]') + if (-not $shouldInstall) { + Write-Host " Skipped. Re-run later with: winget install Microsoft.WindowsAppRuntime.2.0" -ForegroundColor Cyan + } + } + if ($shouldInstall) { + Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0' + } +} else { + Write-Ok 'Windows App Runtime 2.0 installed' +} + # --------------------------------------------------------------------------- # 2. Pack `mur` as a global-tool nupkg # --------------------------------------------------------------------------- From 831d65ce1e75541dec5afc32fd359bb625de4a52 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:07:57 -0700 Subject: [PATCH 3/8] =?UTF-8?q?ci:=20bootstrap.yml=20=E2=80=94=20exercise?= =?UTF-8?q?=20bootstrap.ps1=20end-to-end=20on=20a=20clean=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Windows-only workflow that would have caught the clean-machine pack-local regression fixed in a863f879. Runs on PRs that touch bootstrap.ps1, the CLI source, the templates project, or Directory.Build.props. Pipeline (single job, ~25 min): 1. Pre-flight diagnostic dump of dotnet/winget/WindowsAppRuntime state so failures are debuggable from the logs alone. 2. `./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin` non-interactively. Exercises both winget install paths when the runner image is bare. 3. From a fresh-shell step, `mur --version` resolves on PATH (proves the global-tool install propagated to User PATH). 4. `mur doctor` exits 0 with all checks PASS (plugin shows `info` when skipped, not FAIL). 5. Verify `local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg`, `Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg`, and any `Microsoft.UI.Reactor.Cli.*.nupkg` are produced. 6. `dotnet new list reactorapp` finds the template. 7. `dotnet new reactorapp -n TestApp` + restore + build -c Release. 8. `mur upgrade --skip-plugin` succeeds against the bootstrapped tree. 9. Re-run bootstrap.ps1 to validate idempotence (winget already- installed exit code handled, dotnet tool update is a no-op, dotnet new uninstall+install handles the template-engine cache). Deliberately omits actions/setup-dotnet so bootstrap.ps1 itself is responsible for the SDK install. If windows-latest already ships .NET 10, the install branch silently no-ops and the rest of the pipeline still runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/bootstrap.yml | 174 ++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 .github/workflows/bootstrap.yml diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml new file mode 100644 index 000000000..c343109a1 --- /dev/null +++ b/.github/workflows/bootstrap.yml @@ -0,0 +1,174 @@ +name: Bootstrap + +# Exercises bootstrap.ps1 end-to-end on a fresh windows-latest runner to +# validate the source-checkout developer flow (`git clone` → `bootstrap.ps1` +# → `dotnet new reactorapp`). The script is invoked non-interactively via +# `-InstallWinAppSdk -SkipPlugin`, which also exercises the winget install +# path for the Windows App Runtime when the runner image doesn't already +# have it. + +on: + pull_request: + paths: + - 'bootstrap.ps1' + - 'src/Reactor.Cli/**' + - 'tools/Templates/**' + - 'Directory.Build.props' + - '.github/workflows/bootstrap.yml' + push: + branches: [main] + paths: + - 'bootstrap.ps1' + - 'src/Reactor.Cli/**' + - 'tools/Templates/**' + - 'Directory.Build.props' + - '.github/workflows/bootstrap.yml' + workflow_dispatch: + +# Cancel in-progress runs against the same ref when a new push arrives. +concurrency: + group: bootstrap-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + bootstrap: + name: bootstrap.ps1 (windows-latest) + runs-on: windows-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # NOTE: deliberately *not* running actions/setup-dotnet here. We want + # bootstrap.ps1 to detect the SDK situation on the runner and, if + # necessary, winget-install .NET 10 itself. If the runner image + # already has 10.x the install branch is a silent no-op and we + # still exercise the rest of the pipeline. + + - name: Pre-flight — show toolchain baseline + shell: pwsh + run: | + Write-Host "=== dotnet --list-sdks ===" + if (Get-Command dotnet -ErrorAction SilentlyContinue) { + dotnet --list-sdks + } else { + Write-Host "(dotnet not on PATH)" + } + Write-Host "" + Write-Host "=== winget --version ===" + winget --version + Write-Host "" + Write-Host "=== Windows App Runtime 2.0 (winget list) ===" + winget list --id Microsoft.WindowsAppRuntime.2.0 --exact 2>$null + Write-Host "exit=$LASTEXITCODE" + + - name: Run bootstrap.ps1 (non-interactive) + shell: pwsh + run: | + ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin + if ($LASTEXITCODE -ne 0) { throw "bootstrap.ps1 exited $LASTEXITCODE" } + + - name: Verify mur resolves from a fresh shell + shell: pwsh + run: | + # A new step gets a fresh process with its own $env:Path snapshot. + # `dotnet tool install -g` updates the *User* PATH, which fresh + # processes pick up at start. If GH Actions doesn't propagate that + # (varies by image), prepend defensively. + $toolsDir = Join-Path $env:USERPROFILE '.dotnet\tools' + if (-not (($env:Path -split ';') -contains $toolsDir)) { + $env:Path = "$toolsDir;$env:Path" + } + $cmd = Get-Command mur -ErrorAction SilentlyContinue + if (-not $cmd) { throw "mur is not resolvable on PATH after bootstrap" } + Write-Host "mur resolved at: $($cmd.Source)" + mur --version + if ($LASTEXITCODE -ne 0) { throw "mur --version exited $LASTEXITCODE" } + + - name: Run mur doctor (must report all checks PASS / info-only) + shell: pwsh + run: | + $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" + mur doctor + if ($LASTEXITCODE -ne 0) { throw "mur doctor failed (exit $LASTEXITCODE)" } + + - name: Verify local-nupkgs/ produced + shell: pwsh + run: | + $expected = @( + 'local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg', + 'local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg', + 'local-nupkgs/Microsoft.UI.Reactor.Cli.1.0.0.nupkg' + ) + $missing = @() + foreach ($p in $expected) { + if (Test-Path $p) { + $size = (Get-Item $p).Length + Write-Host " [ok] $p ($size bytes)" + } else { + Write-Host " [missing] $p" -ForegroundColor Red + # Microsoft.UI.Reactor.Cli is versioned from MinVer/etc and + # may not match the literal "1.0.0" filename. Don't fail on + # it specifically — just on the two 0.0.0-local nupkgs. + if ($p -notlike '*Microsoft.UI.Reactor.Cli*') { $missing += $p } + } + } + if ($missing.Count -gt 0) { throw "Missing nupkgs: $($missing -join ', ')" } + # Sanity: at least one *.Cli.*.nupkg under local-nupkgs/. + $cliPkgs = @(Get-ChildItem local-nupkgs -Filter 'Microsoft.UI.Reactor.Cli.*.nupkg') + if ($cliPkgs.Count -eq 0) { throw "No Microsoft.UI.Reactor.Cli.*.nupkg found in local-nupkgs/" } + Write-Host " [ok] CLI tool nupkg: $($cliPkgs[0].Name)" + + - name: Verify dotnet new reactorapp template registered + shell: pwsh + run: | + $listing = dotnet new list reactorapp 2>&1 + $rc = $LASTEXITCODE + Write-Host $listing + if ($rc -ne 0) { throw "dotnet new list reactorapp exited $rc" } + if ($listing -notmatch 'reactorapp') { + throw "reactorapp template not found in `dotnet new list` output" + } + + - name: Scaffold a TestApp and restore against the local feed + shell: pwsh + run: | + # Inside the repo so the root nuget.config (which maps local-nupkgs/) + # is picked up by NuGet's parent-dir walk. + New-Item -ItemType Directory -Path TestProjects | Out-Null + Push-Location TestProjects + try { + dotnet new reactorapp -n TestApp + if ($LASTEXITCODE -ne 0) { throw "dotnet new reactorapp exited $LASTEXITCODE" } + if (-not (Test-Path 'TestApp/TestApp.csproj')) { throw "TestApp/TestApp.csproj not produced" } + + dotnet restore TestApp/TestApp.csproj --nologo -v:m + if ($LASTEXITCODE -ne 0) { throw "dotnet restore for TestApp exited $LASTEXITCODE" } + } finally { + Pop-Location + } + + - name: Build TestApp (default WindowsAppSDKSelfContained=true) + shell: pwsh + run: | + dotnet build TestProjects/TestApp/TestApp.csproj ` + -c Release ` + --nologo -v:m + if ($LASTEXITCODE -ne 0) { throw "TestApp build exited $LASTEXITCODE" } + + - name: Verify mur upgrade is idempotent + shell: pwsh + run: | + # `mur upgrade` should succeed against an already-bootstrapped tree: + # re-pack, re-install template (uninstall-first), refresh plugin. + $env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path" + mur upgrade --skip-plugin + if ($LASTEXITCODE -ne 0) { throw "mur upgrade exited $LASTEXITCODE" } + + - name: Re-run bootstrap.ps1 (idempotence check) + shell: pwsh + run: | + # Re-running on the same machine should be a clean no-op for any + # already-correct piece (winget detects already-installed runtimes, + # dotnet tool update is a no-op when up to date, etc.). + ./bootstrap.ps1 -InstallWinAppSdk -SkipPlugin + if ($LASTEXITCODE -ne 0) { throw "bootstrap.ps1 re-run exited $LASTEXITCODE" } From e5cf89245a61519dbc1f84008f207223a894d48a Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:32:46 -0700 Subject: [PATCH 4/8] fix(bootstrap): --accept-source-agreements for fresh runners The first bootstrap.yml run failed in 23s, at the pre-flight diagnostic: The `msstore` source requires that you view the following agreements Do you agree to all the source agreements terms? [Y] Yes [N] No: An unexpected error occurred while executing the command: 0x8a150042 : Error reading input in prompt exit=-1978335166 `winget list` and `winget install` both gate on msstore source acceptance the first time winget runs against an account/runner. On non-interactive shells (GH Actions, CI in general) the prompt fails with exit -1978335166, which pwsh's "propagate $LASTEXITCODE on the final command" behavior then surfaces as the step exit. Two places fixed: - bootstrap.ps1 `Test-WindowsAppRuntime20`: add `--accept-source-agreements` to the existence probe, and reset $LASTEXITCODE on the way out so a "not installed" status doesn't leak past the probe into surrounding script logic. - .github/workflows/bootstrap.yml pre-flight diagnostic step: same --accept-source-agreements flag on the `winget list`, plus a `$global:LASTEXITCODE = 0` at the end of the step so the diagnostic dump can't fail the workflow before bootstrap.ps1 even starts. `Install-WithWinget` already passes --accept-source-agreements on the install command, so that path was correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/bootstrap.yml | 10 +++++++++- bootstrap.ps1 | 10 ++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index c343109a1..51726556a 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -47,6 +47,10 @@ jobs: - name: Pre-flight — show toolchain baseline shell: pwsh run: | + # Diagnostic-only step. Reset $LASTEXITCODE on exit so a non-zero + # winget status (which is common — "not installed" returns + # -1978335166) doesn't fail the workflow before bootstrap.ps1 even + # starts. Write-Host "=== dotnet --list-sdks ===" if (Get-Command dotnet -ErrorAction SilentlyContinue) { dotnet --list-sdks @@ -58,8 +62,12 @@ jobs: winget --version Write-Host "" Write-Host "=== Windows App Runtime 2.0 (winget list) ===" - winget list --id Microsoft.WindowsAppRuntime.2.0 --exact 2>$null + # --accept-source-agreements: a fresh runner hasn't accepted the + # msstore source terms. Without it, winget prompts and fails on a + # non-interactive shell. + winget list --id Microsoft.WindowsAppRuntime.2.0 --exact --accept-source-agreements 2>&1 Write-Host "exit=$LASTEXITCODE" + $global:LASTEXITCODE = 0 - name: Run bootstrap.ps1 (non-interactive) shell: pwsh diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 0a3840ca2..7c6ace490 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -146,8 +146,14 @@ Write-Ok ".NET SDK present" function Test-WindowsAppRuntime20 { if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return $true } # nothing we can check without winget - & winget list --id Microsoft.WindowsAppRuntime.2.0 --exact 2>$null | Out-Null - return ($LASTEXITCODE -eq 0) + # --accept-source-agreements is needed even for `list` on a winget that + # hasn't been used before (e.g. a fresh CI runner). Without it, winget + # prompts for msstore terms and fails on a non-interactive shell with + # exit -1978335166. + & winget list --id Microsoft.WindowsAppRuntime.2.0 --exact --accept-source-agreements 2>$null | Out-Null + $rc = $LASTEXITCODE + $global:LASTEXITCODE = 0 # don't let winget's status leak out of the probe + return ($rc -eq 0) } if (-not (Test-WindowsAppRuntime20)) { From bc86a9b39ff285464f51c9a3f5848b9386fd5999 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:39:45 -0700 Subject: [PATCH 5/8] fix(bootstrap): -InstallWinAppSdk as [switch] not [Nullable[bool]] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[Nullable[bool]]$Foo = $null` doesn't get PowerShell's switch-style parameter binding — passing a bare `-InstallWinAppSdk` errors with "Missing an argument for parameter 'InstallWinAppSdk'." which is what the bootstrap.yml run hit. Tri-state via nullable bool would have required every caller (including the .EXAMPLE block in the doc header) to write `-InstallWinAppSdk:$true`, which is a footgun. Refactor to two mutually-exclusive [switch] parameters: -InstallWinAppSdk force install non-interactively (CI / automation) -NoWinAppSdk skip the prompt silently (neither) prompt the user (default no) Mutual exclusion checked at the top of the script (early exit with a clear error before any side effects). The "default no, prompt" behavior is preserved for interactive dev runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap.ps1 | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 7c6ace490..7861e2c37 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -23,12 +23,15 @@ Build configuration for the CLI nupkg. Default: Release. .PARAMETER InstallWinAppSdk - Tri-state Windows App Runtime 2.0 install. When unspecified (default), - prompt interactively (default no). Pass -InstallWinAppSdk to force-install - non-interactively (useful for CI / one-shot dev-box setup); pass - -InstallWinAppSdk:$false to skip the prompt silently. The framework - defaults to self-contained, so the runtime is only required for - framework-dependent deployment. + Install the Windows App Runtime 2.0 via winget without prompting. + Useful for CI / one-shot dev-box automation. Mutually exclusive with + -NoWinAppSdk. The framework defaults to self-contained, so the + runtime is only required for framework-dependent deployment. + +.PARAMETER NoWinAppSdk + Skip the Windows App Runtime 2.0 prompt silently. Useful for + non-interactive scripts that explicitly don't want the runtime + installed. Mutually exclusive with -InstallWinAppSdk. .EXAMPLE ./bootstrap.ps1 @@ -48,15 +51,16 @@ param( [switch]$SkipPlugin, [switch]$SkipMurInstall, [string]$Configuration = 'Release', - # Windows App SDK runtime install: tri-state. When unspecified, prompt - # interactively (default no) since the framework defaults to - # WindowsAppSDKSelfContained=true and the machine runtime is only needed - # for framework-dependent deployment. Pass -InstallWinAppSdk to force- - # install non-interactively; pass -InstallWinAppSdk:$false to skip the - # prompt and continue. - [Nullable[bool]]$InstallWinAppSdk = $null + [switch]$InstallWinAppSdk, + [switch]$NoWinAppSdk ) +if ($InstallWinAppSdk -and $NoWinAppSdk) { + Write-Host '' + Write-Host "ERROR: -InstallWinAppSdk and -NoWinAppSdk are mutually exclusive." -ForegroundColor Red + exit 1 +} + $ErrorActionPreference = 'Stop' $repoRoot = $PSScriptRoot Set-Location $repoRoot @@ -157,12 +161,10 @@ function Test-WindowsAppRuntime20 { } if (-not (Test-WindowsAppRuntime20)) { - $shouldInstall = $false - if ($null -ne $InstallWinAppSdk) { - $shouldInstall = [bool]$InstallWinAppSdk - if (-not $shouldInstall) { - Write-Host ' [skip] Windows App Runtime 2.0 not installed (skipped per -InstallWinAppSdk:$false).' -ForegroundColor Yellow - } + if ($InstallWinAppSdk) { + Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0' + } elseif ($NoWinAppSdk) { + Write-Host ' [skip] Windows App Runtime 2.0 not installed (skipped per -NoWinAppSdk).' -ForegroundColor Yellow } else { Write-Host '' Write-Host ' Windows App Runtime 2.0 is not installed on this machine.' -ForegroundColor Yellow @@ -171,14 +173,12 @@ if (-not (Test-WindowsAppRuntime20)) { Write-Host ' deployment (smaller per-app output, faster builds) when you override' Write-Host ' WindowsAppSDKSelfContained=false in a consuming project.' $answer = Read-Host ' Install Windows App Runtime 2.0 via winget now? [y/N]' - $shouldInstall = ($answer -match '^[Yy]') - if (-not $shouldInstall) { + if ($answer -match '^[Yy]') { + Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0' + } else { Write-Host " Skipped. Re-run later with: winget install Microsoft.WindowsAppRuntime.2.0" -ForegroundColor Cyan } } - if ($shouldInstall) { - Install-WithWinget -Id 'Microsoft.WindowsAppRuntime.2.0' -Reason 'Windows App Runtime 2.0' - } } else { Write-Ok 'Windows App Runtime 2.0 installed' } From a55433df6905f01eee508ec32033a8439c2a72e3 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:49:10 -0700 Subject: [PATCH 6/8] fix(ci): array-vs-string -notmatch in template-registration check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dotnet new list reactorapp` writes a multi-line table; pwsh stores the output as a [string[]]. PowerShell's -match/-notmatch on an array returns the matching/non-matching elements rather than a single bool — so `$listing -notmatch 'reactorapp'` returned the header + separator lines (which don't contain "reactorapp"), evaluating truthy and throwing the "template not found" error even though the template WAS registered. Join the array first so the comparison is a single substring check. (Caught by run 3 of bootstrap.ps1 (windows-latest): bootstrap.ps1 itself + mur doctor + nupkg verification all PASS, just the workflow's own validation logic was bugged.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/bootstrap.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 51726556a..bd303d972 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -133,7 +133,13 @@ jobs: $rc = $LASTEXITCODE Write-Host $listing if ($rc -ne 0) { throw "dotnet new list reactorapp exited $rc" } - if ($listing -notmatch 'reactorapp') { + # `dotnet new list` writes a multi-line table; PowerShell stores the + # output as a [string[]]. -match / -notmatch against an array filter + # element-wise, so `-notmatch 'reactorapp'` returns the non-matching + # lines (header, separator) which evaluates truthy even when the + # template is present. Join + match to do a whole-output substring + # check instead. + if (($listing -join "`n") -notmatch 'reactorapp') { throw "reactorapp template not found in `dotnet new list` output" } From dee53ef01cc7d1ae340d08faf67cd2063393161e Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 09:59:38 -0700 Subject: [PATCH 7/8] fix(ci): pass -p:Platform=$hostArch to TestApp build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolded apps inherit Directory.Build.props: true The SelfContained target rejects AnyCPU (it needs a concrete arch to embed the right runtime), so `dotnet build TestApp` with no `-p:Platform` fails on a fresh clone: Microsoft.WindowsAppSDK.SelfContained.targets(74,9): error : WindowsAppSDKSelfContained requires a supported Windows architecture. Detect $env:PROCESSOR_ARCHITECTURE and pass it through. This is the same wart the user flagged earlier ("we don't want to REQUIRE devs to do self contained") — addressing that properly means changing the default in Directory.Build.props or in the template, which is bigger than this PR's scope. For now, unblock CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/bootstrap.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index bd303d972..effd06403 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -164,8 +164,14 @@ jobs: - name: Build TestApp (default WindowsAppSDKSelfContained=true) shell: pwsh run: | + # WindowsAppSDKSelfContained=true (inherited from + # Directory.Build.props) requires a concrete arch to embed the + # runtime under — AnyCPU is rejected by the SelfContained + # target. Pass the host arch explicitly. + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' } dotnet build TestProjects/TestApp/TestApp.csproj ` -c Release ` + "-p:Platform=$arch" ` --nologo -v:m if ($LASTEXITCODE -ne 0) { throw "TestApp build exited $LASTEXITCODE" } From 2b6c1ebaf087ba8e560b382de77a91a1e5b0f79b Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Wed, 27 May 2026 10:20:12 -0700 Subject: [PATCH 8/8] fix(pack): make Reactor.csproj find analyzer DLL at platform-qualified path Backs out the AppendPlatformToOutputPath=false approach (which broke unit tests with CS2012 file-lock contention on Reactor.Localization.Generator/obj/Debug/netstandard2.0/...) in favor of teaching Reactor.csproj's globs about the inherited- Platform layout. The cause of the original clean-machine pack failure is unchanged: when mur pack-local runs `dotnet pack -p:Platform=x64` (or ARM64), the inherited Platform cascades to the transitive Reactor.Analyzers and Reactor.Localization.Generator builds, which then land at bin\\\netstandard2.0\. But Reactor.csproj's pack glob hardcoded bin\\netstandard2.0\ (no Platform segment), so pack couldn't find the file on a freshly-cloned machine. Pinning AppendPlatformToOutputPath=false on the analyzers fixed pack-local but removed bin/obj segregation across Platform values, which let two concurrent transitive compiles (e.g. dotnet test + ProjectReference deduplication race) collide on the same obj\Debug\netstandard2.0\ output path. CI Unit Tests caught this with: CSC : error CS2012: Cannot open '...obj\Debug\netstandard2.0\ Reactor.Localization.Generator.dll' for writing -- The process cannot access the file ... because it is being used by another process. New approach: Reactor.csproj computes $(_ReactorAnalyzerBinDir) = bin\\netstandard2.0 when Platform is empty or AnyCPU, OR = bin\\\netstandard2.0 when Platform is x64 / ARM64. Pack now finds the analyzers in either layout. The analyzer csprojs go back to their stock SDK behavior, preserving per-Platform obj/bin segregation that prevents the build-graph race. Verified locally: - mur pack-local from a wiped bin/obj tree succeeds on ARM64 host (analyzers land in bin\ARM64\Debug\, Reactor.csproj finds them). - Plain dotnet build src/Reactor (no -p:Platform) would land analyzers at bin\Debug\, also found by the conditional binding. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Reactor.Analyzers/Reactor.Analyzers.csproj | 11 ----------- .../Reactor.Localization.Generator.csproj | 6 ------ src/Reactor/Reactor.csproj | 18 ++++++++++++++++-- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/Reactor.Analyzers/Reactor.Analyzers.csproj b/src/Reactor.Analyzers/Reactor.Analyzers.csproj index cb773f75f..430cdda9a 100644 --- a/src/Reactor.Analyzers/Reactor.Analyzers.csproj +++ b/src/Reactor.Analyzers/Reactor.Analyzers.csproj @@ -12,17 +12,6 @@ false - - - false diff --git a/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj b/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj index 63a7b0ed7..49dba1d93 100644 --- a/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj +++ b/src/Reactor.Localization.Generator/Reactor.Localization.Generator.csproj @@ -9,12 +9,6 @@ true Microsoft.UI.Reactor.Localization.Generator Reactor.Localization.Generator - - - false diff --git a/src/Reactor/Reactor.csproj b/src/Reactor/Reactor.csproj index 1636578b1..ab0b5ce60 100644 --- a/src/Reactor/Reactor.csproj +++ b/src/Reactor/Reactor.csproj @@ -102,10 +102,24 @@ against the framework itself), so their build outputs must already exist before pack — verified by the AssertAnalyzerDllsExist target below. --> + + + <_ReactorAnalyzerBinDir Condition="'$(Platform)' == '' or '$(Platform)' == 'AnyCPU'">bin\$(Configuration)\netstandard2.0 + <_ReactorAnalyzerBinDir Condition="'$(_ReactorAnalyzerBinDir)' == ''">bin\$(Platform)\$(Configuration)\netstandard2.0 + + - -