Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3f6fa9f
Build winapp run projects with Platform instead of a global RID
nmetulev Oct 8, 2026
41c11f6
Merge remote-tracking branch 'origin/main' into nmetulev-fluentstore-…
nmetulev Oct 8, 2026
fd9b319
Keep the RID for projects that need it even when references remove it
nmetulev Oct 8, 2026
b0883aa
Merge remote-tracking branch 'origin/main' into nmetulev-fluentstore-…
nmetulev Oct 8, 2026
4e1984e
Keep the RID-free build for dynamic platform resolution graphs
nmetulev Oct 8, 2026
ebd75ee
Keep the RID for cross-architecture builds with project-referenced an…
nmetulev Oct 9, 2026
4caa28a
Merge remote-tracking branch 'origin/main' into nmetulev-fluentstore-…
nmetulev Oct 9, 2026
e622801
Catch only file and XML errors when walking project references
nmetulev Oct 9, 2026
ac9e879
Merge branch 'main' into nmetulev-fluentstore-winapp-run-debug
nmetulev Oct 9, 2026
5cc4e8a
Decide Platform-only builds from the evaluated reference graph
nmetulev Oct 9, 2026
06c292f
Fix project-arch CI: x86 runtime, R20 host, hang diagnostics
nmetulev Oct 9, 2026
8d086b6
Address code-quality findings in architecture graph test helper
nmetulev Oct 9, 2026
8e5ac81
project-arch: dump winapp when a run times out
nmetulev Oct 9, 2026
59c1fa0
Merge branch 'main' into nmetulev-fluentstore-winapp-run-debug
nmetulev Oct 9, 2026
19fd807
project-arch: add property-path generator and solution bundle fixtures
nmetulev Oct 9, 2026
97f9f66
Merge remote-tracking branch 'origin/main' into nmetulev-fluentstore-…
nmetulev Oct 9, 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
139 changes: 137 additions & 2 deletions .github/workflows/build-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,143 @@ jobs:
if-no-files-found: warn
overwrite: true

# Decides whether a PR needs the architecture matrix: it builds and launches 45 real apps, so docs-only
# and unrelated PRs skip it. Pushes to main and manual runs always run it.
project-arch-changes:
runs-on: ubuntu-latest
outputs:
run: ${{ steps.check.outputs.run }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2

- name: Check for project-mode changes
id: check
shell: pwsh
env:
IS_PR: ${{ github.event_name == 'pull_request' }}
run: |
if ($env:IS_PR -ne 'true') {
"run=true" >> $env:GITHUB_OUTPUT
return
}
# The PR checkout is a merge commit; its first parent is the base branch.
$changed = @(git diff --name-only HEAD^1 HEAD)
if ($LASTEXITCODE -ne 0) { throw 'Could not list the files this PR changes.' }
$patterns = @(
'^src/winapp-CLI/WinApp\.Cli/Services/(ProjectRunService|ProjectDetectionService|DotNetService|PackageRegistrationService|AppLauncherService|MsixService)'
'^src/winapp-CLI/WinApp\.Cli/Commands/RunCommand'
'^src/winapp-CLI/WinApp\.Cli/Models/ProjectRun'
'^src/winapp-CLI/WinApp\.Cli/Helpers/(RunArchHelper|MsBuildPropertyReader)'
'^tests/project-arch/'
'^\.github/workflows/build-package\.yml$'
'^global\.json$'
)
$relevant = @($changed | Where-Object { $file = $_; @($patterns | Where-Object { $file -match $_ }).Count -gt 0 })
Write-Host "Project-mode changes: $(if ($relevant) { $relevant -join ', ' } else { 'none' })"
"run=$(($relevant.Count -gt 0).ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT

# Generated .NET projects covering how `winapp run` conveys the target architecture to MSBuild.
# Fixtures run one at a time per shard; see tests/project-arch/README.md.
project-arch:
Comment thread
nmetulev marked this conversation as resolved.
needs: [build-artifacts, project-arch-changes]
if: needs.project-arch-changes.outputs.run == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: project-arch (${{ matrix.shard }}/4)
runs-on: windows-latest
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Enable Windows Developer Mode
run: |
reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /v "AllowDevelopmentWithoutDevLicense" /d 1 /f

# Some fixtures target net8.0 and must launch on its runtime.
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
8.0.x
10.0.x

- name: Download CLI binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-binaries
path: artifacts/cli

# The -x86 fixtures launch framework-dependent x86 apps. setup-dotnet sets DOTNET_ROOT to the x64 install,
# which an x86 app host would also use, so point DOTNET_ROOT_X86 at x86 runtimes (installed if missing).
- name: Prepare x86 .NET runtimes
run: |
$dir = Join-Path ${env:ProgramFiles(x86)} 'dotnet'
$installer = Join-Path $env:RUNNER_TEMP 'dotnet-install.ps1'
foreach ($runtime in @(@{ Name = 'dotnet'; Folder = 'Microsoft.NETCore.App' }, @{ Name = 'windowsdesktop'; Folder = 'Microsoft.WindowsDesktop.App' })) {
foreach ($channel in '8.0', '10.0') {
$present = @(Get-ChildItem (Join-Path $dir "shared\$($runtime.Folder)") -Directory -Filter "$channel.*" -ErrorAction SilentlyContinue)
$names = if ($present) { $present.Name -join ', ' } else { 'none' }
Write-Host "x86 $($runtime.Folder) $channel present: $names"
if ($present.Count -eq 0) {
if (-not (Test-Path $installer)) { Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile $installer }
$install = @{ Channel = $channel; Runtime = $runtime.Name; Architecture = 'x86'; InstallDir = $dir }
& $installer @install
}
}
}
"DOTNET_ROOT_X86=$dir" >> $env:GITHUB_ENV

- name: Run architecture matrix
env:
SHARD: ${{ matrix.shard }}
run: |
$pester = Get-Module -Name Pester -ListAvailable | Where-Object { $_.Version.Major -ge 5 } | Sort-Object Version -Descending | Select-Object -First 1
if (-not $pester) { throw 'Pester 5 is required.' }
Import-Module $pester -Force
$container = New-PesterContainer -Path tests/project-arch/ProjectArch.Tests.ps1 -Data @{
WinappPath = 'artifacts/cli/win-x64'
Architecture = 'x64'
Shard = "$env:SHARD/4"
DumpDirectory = 'artifacts/TestResults/project-arch-dumps'
}
$config = New-PesterConfiguration
$config.Run.Container = $container
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = "artifacts/TestResults/project-arch-$env:SHARD.xml"
$config.TestResult.OutputFormat = 'JUnitXml'
Invoke-Pester -Configuration $config

- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: project-arch-results-${{ matrix.shard }}
path: artifacts/TestResults/project-arch-${{ matrix.shard }}.xml
if-no-files-found: error
overwrite: true

- name: Upload hang dumps
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: project-arch-dumps-${{ matrix.shard }}
path: artifacts/TestResults/project-arch-dumps
if-no-files-found: ignore
overwrite: true
retention-days: 7

# Retain the original required check, but only turn it green after every lane.
build-and-package:
if: always()
needs: [build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics]
needs: [build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics, project-arch-changes, project-arch]
runs-on: ubuntu-latest
steps:
- name: Require all build and validation jobs
Expand All @@ -358,7 +491,9 @@ jobs:
run: |
$results = $env:NEEDS_JSON | ConvertFrom-Json -AsHashtable
foreach ($job in $results.Keys) {
$expected = if ($job -eq 'samples' -and $env:IS_PR -ne 'true') { 'skipped' } else { 'success' }
$expected = if ($job -eq 'samples' -and $env:IS_PR -ne 'true') { 'skipped' }
elseif ($job -eq 'project-arch' -and $results['project-arch-changes'].outputs.run -ne 'true') { 'skipped' }
else { 'success' }
if ($results[$job].result -ne $expected) {
throw "$job did not meet the required outcome: $($results[$job].result) (expected $expected)"
}
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,17 @@ matrix with `npm-package` and `nuget-packages` from the same run. Manual sample
runs still build their own packages and support selecting one sample. Sample
results are uploaded as JUnit XML via Pester's `TestResult` configuration.

The `project-arch` job runs `tests\project-arch\ProjectArch.Tests.ps1` in four
shards with the same-run x64 CLI. It generates .NET projects covering how
`winapp run` conveys the target architecture (Platform vs. RuntimeIdentifier,
reference graphs, packaging, Windows App SDK versions) and launches each app.
It runs on every push to main and manual build, and on PRs whose changes match
the path list in the `project-arch-changes` job; `build-and-package` accepts it
as skipped only when that job says it isn't needed. Add a fixture there when
changing project-mode architecture handling, and extend the path list when that
handling moves to new files; see `tests\project-arch\README.md` to run it
locally.

Keep `build-and-package` and `test-samples-result` as top-level aggregate check
names for branch protection. They must fail on failures, cancellations, and
unexpected skips, not just test failures. Metrics join package artifacts with
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/dotnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ winapp run .\bin\Debug\net10.0-windows10.0.26100.0

Project mode supports both **packaged** and **unpackaged** WinUI apps — it detects which from the project's `WindowsPackageType` and installs the matching-architecture Windows App Runtime automatically. To force an unpackaged run of a packaged project, add `-p WindowsPackageType=None`.

**Multi-project apps** (an app referencing class libraries) build correctly: winapp keeps `AnyCPU`/`netstandard2.0` references on their compatible platform instead of forcing the app's architecture across the graph. RID-only remains the default; when the effective configuration requires a self-contained profile (for example, a trimmed Release build), winapp selects the matching profile without changing referenced libraries' platforms.
**Multi-project apps** (an app referencing class libraries) build correctly: winapp builds with the matching MSBuild `Platform`, as Visual Studio does, so referenced libraries, including `AnyCPU`/`netstandard2.0` ones and WinUI libraries that enable MSIX tooling, are built once and packaged once. A `win-$(Platform)` publish profile in your app (for example, for a trimmed Release build) applies automatically.

The `dotnet build` output streams live, with the exact invocation printed first. Add `--verbose` for winapp's own build decision traces. Requires .NET SDK 8.0.100 or newer. See [`winapp run` in the usage reference](../usage.md#project-mode-net-sdk-projects) for the full option list.

Expand Down
4 changes: 2 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -892,8 +892,8 @@ For apps that use package identity without a generated MSIX layout, include `Pac
**Project-mode options** (ignored in folder mode unless noted):

- `-c, --configuration <name>` - Build configuration. Default: `Debug`. *(Also honored in single-file mode.)*
- `--arch <x64|arm64|x86>` - Target architecture. Default: the current process architecture. Determines the build RID and Windows App Runtime architecture, and selects a matching platform-dependent publish profile when required by the effective build. *(Also honored in single-file mode.)* For a project, an architecture this machine can't run (such as arm64 on an x64 PC) is rejected before building unless `--no-launch` is set.
- `-r, --runtime <rid>` - Target .NET runtime identifier (e.g. `win-x64`). Project mode uses only the RID's architecture, always builds the canonical `win-<arch>`, and rejects non-Windows RIDs (e.g. `linux-x64`). Its architecture overrides `--arch` and can select the required publish profile. *(Also honored in single-file mode, where it overrides a `#:property RuntimeIdentifier` declared by the file.)*
- `--arch <x64|arm64|x86>` - Target architecture. Default: the current process architecture. Builds with the matching MSBuild `Platform` (for example `-p:Platform=ARM64`), as Visual Studio does, so a `win-$(Platform)` publish profile in your project applies automatically. If the app or any project it references sets its own `RuntimeIdentifier` or enables `EnableDynamicPlatformResolution`, or a build for another architecture than the .NET SDK's references an analyzer or source-generator project, winapp builds for `win-<arch>` instead. Also selects the Windows App Runtime architecture. *(Also honored in single-file mode.)* For a project, an architecture this machine can't run (such as arm64 on an x64 PC) is rejected before building unless `--no-launch` is set.
- `-r, --runtime <rid>` - Target .NET runtime identifier (e.g. `win-x64`). Project mode uses only the RID's architecture, exactly like `--arch`, and rejects non-Windows RIDs (e.g. `linux-x64`). Its architecture overrides `--arch`. *(Also honored in single-file mode, where it overrides a `#:property RuntimeIdentifier` declared by the file.)*
- `-f, --framework <tfm>` - Target framework moniker for multi-targeted projects (e.g. `net10.0-windows10.0.26100.0`). *(Rejected in single-file mode — use `#:property TargetFramework=...`.)*
- `--project <name-or-path>` - When the input is a solution (`.sln`/`.slnx`) or a directory with multiple runnable app projects, selects which project to launch (by project name or path). *(Rejected in single-file mode — a `.cs` file-based app is itself the project.)*
- `--no-build` - Skip building and run the existing build output (still evaluates output properties). *(Also honored in single-file mode.)*
Expand Down
2 changes: 1 addition & 1 deletion plugins/winapp/skills/winapp-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ winapp run . --aot
winapp run . --aot -c Release
```

Project mode supports both **packaged** and **unpackaged** WinUI apps, detected from the project's effective `WindowsPackageType` (`MSIX` ⇒ loose-layout register + AUMID launch; `None` ⇒ launch the built `.exe`), and installs the matching-architecture Windows App Runtime before launching. RID-only remains the default; when the effective configuration requires a self-contained profile, winapp selects the architecture-matching profile without forcing that platform onto referenced `AnyCPU` libraries. Requires .NET SDK 8.0.100+.
Project mode supports both **packaged** and **unpackaged** WinUI apps, detected from the project's effective `WindowsPackageType` (`MSIX` ⇒ loose-layout register + AUMID launch; `None` ⇒ launch the built `.exe`), and installs the matching-architecture Windows App Runtime before launching. `--arch` builds with the matching MSBuild `Platform`, as Visual Studio does, so multi-project graphs and `win-$(Platform)` publish profiles work without extra properties; if the app or any project it references sets its own `RuntimeIdentifier` or enables `EnableDynamicPlatformResolution`, or a cross-architecture build references an analyzer or source-generator project, winapp builds for `win-<arch>` instead. Requires .NET SDK 8.0.100+.

- **Build inputs:** `-c/--configuration`, `--arch`, `-r/--runtime`, `-f/--framework`, `--no-build`, `--no-restore`, `--aot`, `-p/--property` (repeat for multiple properties; use `%3B` or `%2C` for a literal semicolon or comma in a value). `--aot` supports x64/ARM64 projects, requires .NET SDK 8.0.300+ and effective `PublishAot=true`, and cannot use `--no-build` or `--manifest`; configure the project manifest before publishing.
- **Packaged-only options:** `--manifest`, `--no-launch`, `--with-alias`, `--clean`, `--unregister-on-exit`, `--output-appx-directory`, `--executable`, `--unique-identity` — rejected for unpackaged apps.
Expand Down
23 changes: 15 additions & 8 deletions samples/winui-app/test.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Describe 'winui-app sample' {
$script:platform = if ($script:rid -eq 'win-arm64') { 'ARM64' } else { 'x64' }
$script:profileName = "debug-$($script:platform).pubxml"

# Exercise the real .NET SDK boundary behind architecture-profile inference. Keep this
# Exercise the real .NET SDK boundary behind architecture-profile selection. Keep this
# test-only configuration out of the sample itself so its normal workflow stays lightweight.
$projectPath = Join-Path $script:tempDir 'winui-app.csproj'
$project = [System.Xml.Linq.XDocument]::Load($projectPath)
Expand Down Expand Up @@ -125,7 +125,7 @@ Describe 'winui-app sample' {
"@ | Set-Content -Path (Join-Path $profileDir $script:profileName)

# A same-named library profile would be imported by a plain global PublishProfile property.
# winapp scopes the inferred profile to the root app, so this file must remain inactive.
# Only the app selects a profile, so this file must remain inactive.
$libraryProfileDir = Join-Path $libraryDir 'Properties\PublishProfiles'
New-Item -ItemType Directory -Path $libraryProfileDir -Force | Out-Null
Copy-Item -Path (Join-Path $profileDir $script:profileName) -Destination $libraryProfileDir
Expand All @@ -150,16 +150,21 @@ Describe 'winui-app sample' {
# --no-launch builds the loose layout and registers a debug identity
# without launching the app (no GUI, deterministic in CI).
$output = Invoke-WinappCommand -Arguments 'run . --no-launch'
"$output" | Should -Match ([regex]::Escape("-p:PublishProfile=$($script:profileName)"))
# winapp conveys the architecture with Platform, so the project's own debug-$(Platform)
# profile applies; RID-only would fail with NETSDK1102 (proven above).
"$output" | Should -Match ([regex]::Escape("-p:Platform=$($script:platform)"))
"$output" | Should -Not -Match '(^|\s)-r\s+win-'
# Scoped to the profile argument rather than the whole console output: winapp prints its
# version banner, which carries the branch name, so a bare 'release-' match fails on any
# branch whose name happens to contain it.
"$output" | Should -Not -Match ([regex]::Escape('PublishProfile=release-'))
Join-Path $script:tempDir "bin\$($script:platform)\Debug\net10.0-windows10.0.26100.0\$($script:rid)\winui-app.exe" |
Should -Exist -Because 'the self-contained profile places the output under its RuntimeIdentifier'
"$output" | Should -Match 'Registering packaged application'
"$output" | Should -Match 'registered'
}

It 'Finds the inferred-profile output with --no-build' -Skip:$script:skip {
It 'Finds the profile output with --no-build' -Skip:$script:skip {
Invoke-WinappCommand -Arguments 'run . --no-build --no-launch'
}

Expand Down Expand Up @@ -218,7 +223,12 @@ Describe 'winui-app sample' {
$stagedExe = Join-Path $layoutDir $executable
$stagedExe | Should -Exist

$recipes = @(Get-ChildItem (Join-Path $script:tempDir 'bin') -Recurse -Filter '*.build.appxrecipe')
# Earlier steps in this folder leave their own build output (bin\<Platform>\...), so locate the
# Native AOT build through its publish folder: the recipe sits in that publish folder's TargetDir.
$published = @(Get-ChildItem (Join-Path $script:tempDir 'bin') -Recurse -Filter $executable |
Where-Object { $_.Directory.Name -eq 'publish' })
$published.Count | Should -Be 1
$recipes = @(Get-ChildItem $published[0].Directory.Parent.FullName -Filter '*.build.appxrecipe')
$recipes.Count | Should -Be 1
$recipe = [System.Xml.Linq.XDocument]::Load($recipes[0].FullName)
$entries = @($recipe.Descendants() | Where-Object {
Expand All @@ -228,9 +238,6 @@ Describe 'winui-app sample' {
$entries.Count | Should -Be 1
$nativeExe = [System.IO.Path]::GetFullPath($entries[0].Attribute('Include').Value, $recipes[0].DirectoryName)
$nativeExe | Should -Exist
$published = @(Get-ChildItem (Join-Path $script:tempDir 'bin') -Recurse -Filter $executable |
Where-Object { $_.Directory.Name -eq 'publish' })
$published.Count | Should -Be 1
(Get-FileHash $stagedExe).Hash | Should -Be (Get-FileHash $nativeExe).Hash
(Get-FileHash $stagedExe).Hash | Should -Be (Get-FileHash $published[0].FullName).Hash

Expand Down
3 changes: 2 additions & 1 deletion samples/winui-solution/App.Core/App.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"PRI file not found" failure when a consumer forces a global -p:Platform=<arch> together with
-p:EnableDynamicPlatformResolution=true: EDPR negotiates this library's compile down to AnyCPU
(bin\Debug\...) while the forced Platform keeps the app's PRI-outputs lookup at bin\<arch>\...
Conveying arch via -r <rid> only (winapp project mode) keeps both sides consistent.
Without EDPR a global Platform reaches this library too, so both sides stay consistent; winapp
project mode conveys the arch with -r <rid> instead for projects that enable EDPR.
-->
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
Expand Down
Loading
Loading