Skip to content
Merged
174 changes: 174 additions & 0 deletions .github/workflows/bootstrap.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
codemonkeychris marked this conversation as resolved.

- 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" }
132 changes: 113 additions & 19 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)) {
Comment thread
codemonkeychris marked this conversation as resolved.
$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
# ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions src/Reactor.Analyzers/Reactor.Analyzers.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
<!-- Bundled into Microsoft.UI.Reactor.nupkg — does not ship as its own
package. See docs/specs/022-packaging-and-distribution.md §5. -->
<IsPackable>false</IsPackable>

<!-- Analyzers are platform-independent netstandard2.0 IL. Without this,
when Reactor.csproj is packed with `-p:Platform=x64` (or ARM64) the
inherited Platform cascades into this transitive build and the
output lands at bin\x64\Debug\netstandard2.0\ — but Reactor.csproj's
<None Include="...\bin\$(Configuration)\netstandard2.0\..."> for
packing the analyzer DLL has no $(Platform) segment and breaks pack
on a clean machine (no prior AnyCPU build to fall back to).
AppendPlatformToOutputPath=false pins us to bin\$(Configuration)\
regardless of inherited Platform. -->
<AppendPlatformToOutputPath>false</AppendPlatformToOutputPath>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
<IsRoslynComponent>true</IsRoslynComponent>
<RootNamespace>Microsoft.UI.Reactor.Localization.Generator</RootNamespace>
<AssemblyName>Reactor.Localization.Generator</AssemblyName>

<!-- See Reactor.Analyzers.csproj for the full rationale: Reactor.csproj's
<None Include="...\bin\$(Configuration)\netstandard2.0\..."> pack
glob has no $(Platform) segment, so this transitive source-generator
build must not append the inherited Platform to its OutputPath. -->
<AppendPlatformToOutputPath>false</AppendPlatformToOutputPath>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading