diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml new file mode 100644 index 000000000..effd06403 --- /dev/null +++ b/.github/workflows/bootstrap.yml @@ -0,0 +1,194 @@ +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: | + # 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 + } 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) ===" + # --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 + 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" } + # `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" + } + + - 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: | + # 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" } + + - 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" } diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 7031bca3d..7861e2c37 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -22,21 +22,45 @@ .PARAMETER Configuration Build configuration for the CLI nupkg. Default: Release. +.PARAMETER InstallWinAppSdk + 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 - 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', + [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 @@ -56,33 +80,109 @@ 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 + # --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)) { + 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 + 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]' + 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 + } + } +} else { + Write-Ok 'Windows App Runtime 2.0 installed' +} + # --------------------------------------------------------------------------- # 2. Pack `mur` as a global-tool nupkg # --------------------------------------------------------------------------- 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 + + - -