From 61e908494608b75e40f0d5c30cce65f59fb935b4 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 5 Feb 2026 17:01:57 -0600 Subject: [PATCH 01/19] Fix font assets not copied on first build for Android/Tizen For Android and Tizen, ProcessMauiFonts used AfterTargets (a weak scheduling hint) which could be skipped during concurrent builds (e.g. VS design-time build racing with regular build). Changed to BeforeTargets to create a hard dependency, matching the pattern already used by iOS, Windows, and WPF platforms. Fixes #23268 --- .../Microsoft.Maui.Resizetizer.After.targets | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index feb26d3d5946..ad4cae1ae65a 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -171,10 +171,14 @@ ResizetizeCollectItems; - - $(ProcessMauiFontsAfterTargets); - ResizetizeCollectItems; - + + + $(ProcessMauiFontsBeforeTargets); + _ComputeAndroidResourcePaths; + @@ -226,10 +230,12 @@ ResizetizeCollectItems; - - $(ProcessMauiFontsAfterTargets); - ResizetizeCollectItems; - + + + $(ProcessMauiFontsBeforeTargets); + PrepareResources; + From 80c1a86c624efa68f9454a15a850071c7ee66243 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 5 Feb 2026 19:07:18 -0600 Subject: [PATCH 02/19] Split ProcessMauiFonts into separate item collection target The ProcessMauiFonts target has Inputs/Outputs incremental checks, which means its entire body is skipped when the stamp file is up-to-date. This includes the platform-specific item registrations (AndroidAsset, BundleResource, etc.), causing fonts to be missing from the app when the target is skipped during concurrent or cached builds (e.g., VS design-time builds running alongside regular builds). Fix: Extract all platform item registrations into a new _CollectMauiFontItems target that has no Inputs/Outputs, ensuring items are always registered regardless of incremental build state. The new target uses DependsOnTargets=ProcessMauiFonts to ensure font files are copied first, and uses predictive path mapping from @(MauiFont) instead of wildcard globbing to avoid collecting stale intermediate files. This follows the same pattern used by ResizetizeImages in the same file. Fixes #23268 --- .../Microsoft.Maui.Resizetizer.After.targets | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index ad4cae1ae65a..11a72a45b4f9 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -171,14 +171,10 @@ ResizetizeCollectItems; - - - $(ProcessMauiFontsBeforeTargets); - _ComputeAndroidResourcePaths; - + + $(ProcessMauiFontsAfterTargets); + ResizetizeCollectItems; + @@ -230,12 +226,10 @@ ResizetizeCollectItems; - - - $(ProcessMauiFontsBeforeTargets); - PrepareResources; - + + $(ProcessMauiFontsAfterTargets); + ResizetizeCollectItems; + @@ -536,8 +530,37 @@ DestinationFolder="$(_MauiIntermediateFonts)" SkipUnchangedFiles="true" /> + + + + + + + - <_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> + + + + + + + + + + <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> @@ -553,13 +576,6 @@ - - - <_MauiFontPListFiles Include="$(_MauiIntermediateFonts)MauiInfo.plist" Condition="Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> @@ -601,12 +617,8 @@ - - - - + - From d56ef5de2497fe3e117bd8558613330875e73b99 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 5 Feb 2026 19:47:44 -0600 Subject: [PATCH 03/19] Fix iOS/MacCatalyst: reference _CollectMauiFontItems in CollectAppManifestsDependsOn The iOS scheduling uses CollectAppManifestsDependsOn to pull in ProcessMauiFonts. With the split target, this must reference _CollectMauiFontItems instead, which transitively depends on ProcessMauiFonts via DependsOnTargets. --- .../buildTransitive/Microsoft.Maui.Resizetizer.After.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 11a72a45b4f9..ff292e6abf23 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -151,7 +151,7 @@ - ProcessMauiFonts; + _CollectMauiFontItems; ProcessMauiSplashScreens; $(CollectAppManifestsDependsOn) From c20f5929ab2973a5dbb5440d0490645893973105 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 5 Feb 2026 20:11:59 -0600 Subject: [PATCH 04/19] Add Resizetizer MSBuild targets instructions file --- .../instructions/resizetizer.instructions.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 .github/instructions/resizetizer.instructions.md diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md new file mode 100644 index 000000000000..eec6e484f583 --- /dev/null +++ b/.github/instructions/resizetizer.instructions.md @@ -0,0 +1,239 @@ +--- +applyTo: + - "src/SingleProject/Resizetizer/**" + - ".buildtasks/Microsoft.Maui.Resizetizer.After.targets" +--- + +# Resizetizer MSBuild Targets Guidelines + +Guidance for working with .NET MAUI's Resizetizer build system, which processes images, fonts, splash screens, and assets at build time. + +## Architecture Overview + +The Resizetizer is an MSBuild-integrated pipeline that processes `MauiImage`, `MauiFont`, `MauiSplashScreen`, and `MauiAsset` items into platform-specific resources during the build. All core logic lives in a single file: + +**`src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets`** + +### File Loading Order + +| File | Purpose | +|------|---------| +| `Microsoft.Maui.Resizetizer.props` | Early property defaults (currently empty) | +| `Microsoft.Maui.Resizetizer.Before.targets` | Pre-SDK target hooks (currently empty) | +| `Microsoft.Maui.Resizetizer.After.targets` | **All logic** — targets, item registration, platform dispatch | + +`After.targets` is imported via `AfterMicrosoftNETSdkTargets`, ensuring it runs after the .NET SDK targets are loaded. + +### Local Testing with `.buildtasks/` + +The `.buildtasks/` directory at the repo root contains a local copy of the Resizetizer targets used by Sandbox and sample builds. It is **NOT git-tracked**. When testing MSBuild target changes locally: + +1. Edit the source file in `src/SingleProject/Resizetizer/src/nuget/buildTransitive/` +2. Copy it to `.buildtasks/Microsoft.Maui.Resizetizer.After.targets` +3. Build the Sandbox or sample project to test + +⚠️ Always remember to update both files. The `.buildtasks/` copy is what actually runs during local Sandbox builds. + +## Key Properties + +### Intermediate Output Paths + +```xml +<_ResizetizerIntermediateOutputPath>$(IntermediateOutputPath) +<_ResizetizerIntermediateOutputRoot>$(_ResizetizerIntermediateOutputPath)resizetizer\ +<_MauiIntermediateImages>...\resizetizer\r\ +<_MauiIntermediateFonts>...\resizetizer\f\ +<_MauiIntermediateSplashScreen>...\resizetizer\sp\ +``` + +**⚠️ CRITICAL**: `_ResizetizerIntermediateOutputPath` defaults to `$(IntermediateOutputPath)`, which **differs between outer and inner builds** in multi-targeting scenarios: +- Outer build: `obj/Release/net10.0-android/` +- Inner build (arm64): `obj/Release/net10.0-android/android-arm64/` + +This means stamp files, input tracking files, and intermediate outputs are at **different paths** in outer vs inner builds. + +### Stamp Files (Incremental Build Tracking) + +| Stamp File | Tracks | +|------------|--------| +| `mauifont.stamp` | Font processing (ProcessMauiFonts) | +| `mauiimage.stamp` | Image resizing (ResizetizeImages) | +| `mauisplash.stamp` | Splash screen processing | +| `mauimanifest.stamp` | Platform manifest generation | + +Each has a companion `.inputs` file containing serialized metadata for change detection. + +## Target Pipeline + +### Main Targets (Execution Order) + +``` +ResizetizeCollectItems ← Collects items from project + references + ├── ProcessMauiAssets ← Copies MauiAsset items + ├── ProcessMauiSplashScreens ← Generates splash resources + ├── ProcessMauiFonts ← Copies font files (incremental) + │ └── _CollectMauiFontItems ← Registers platform items (ALWAYS runs) + └── ResizetizeImages ← Resizes images (incremental) +``` + +### Platform-Specific Scheduling + +| Platform | ResizetizeCollectItems | ProcessMauiFonts | ResizetizeImages | +|----------|----------------------|-----------------|-----------------| +| **iOS** | `CollectAppManifestsDependsOn` | `CollectAppManifestsDependsOn` | `BeforeTargets=CompileImageAssets` | +| **Android** | `BeforeTargets=_ComputeAndroidResourcePaths` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | +| **Windows** | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | +| **WPF** | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | +| **Tizen** | `BeforeTargets=PrepareResources` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | + +## ⚠️ Critical Pattern: Inputs/Outputs and Item Registration + +### The Problem + +MSBuild's `Inputs`/`Outputs` incremental check **completely skips the target body** when outputs are up-to-date. This means any `` inside the target body that registers platform items (e.g., `AndroidAsset`, `BundleResource`) will **NOT be evaluated** on incremental builds. + +### The Solution: Split Target Pattern + +**ALWAYS separate file-processing work from item registration into two targets:** + +1. **Processing target** (with `Inputs`/`Outputs`): Does the actual work (copy, resize, generate) +2. **Collection target** (NO `Inputs`/`Outputs`): Registers platform-specific items — always runs + +```xml + + + + + + + + + + + + +``` + +### Item Collection Best Practices + +**✅ DO**: Use predictive path mapping from source items: +```xml +<_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> +``` + +**❌ DON'T**: Use wildcard globs on intermediate directories — they can pick up stale files from deleted sources: +```xml + +<_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> +``` + +**Exception**: `ResizetizeImages` uses wildcard globs (`$(_MauiIntermediateImages)**\*`) because image resizing produces multiple output files per input (different sizes/densities). It compensates by explicitly deleting orphaned files. + +## Platform Item Registration + +### How Each Platform Receives Assets + +| Platform | Font Item | Image Item | Metadata | +|----------|-----------|------------|----------| +| **iOS** | `BundleResource` | `BundleResource` or `ImageAsset` | `LogicalName`, `TargetPath` | +| **Android** | `AndroidAsset` | `LibraryResourceDirectories` | `Link` | +| **Windows** | `ContentWithTargetPath` | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | +| **WPF** | `Resource` | `Resource` | `LogicalName`, `Link` | +| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | + +### iOS-Specific: Info.plist Font Registration + +iOS requires fonts to be declared in Info.plist via `UIAppFonts`. The `CreatePartialInfoPlistTask` generates a `MauiInfo.plist` fragment, which is then added to `PartialAppManifest` for merging. + +**Important**: The plist generation (`CreatePartialInfoPlistTask`) is inside `ProcessMauiFonts` (the incremental target), while the `PartialAppManifest` registration is in `_CollectMauiFontItems` (always runs). This is correct because: +- The plist only needs regeneration when fonts change (handled by Inputs/Outputs) +- The plist FILE registration must happen every build (handled by always-run target using `Exists()` check) + +## ResizetizeCollectItems + +This target is the starting point for the pipeline. It: + +1. Calls `GetMauiItems` on the project itself (if `ResizetizerIncludeSelfProject='True'`) +2. Calls `GetMauiItems` on all `@(ProjectReference)` projects (parallel MSBuild calls) +3. Aggregates `MauiImage`, `MauiIcon`, `MauiFont`, `MauiAsset`, `MauiSplashScreen` from all sources +4. Serializes item metadata to `.inputs` files for incremental change detection +5. Computes hashes for splash screen filename stability + +## MSBuild Scheduling Semantics + +### Understanding AfterTargets / BeforeTargets / DependsOnTargets + +**All three are hard requirements.** None of them are "hints" or "suggestions." + +| Mechanism | Meaning | When to Use | +|-----------|---------|-------------| +| `DependsOnTargets="X"` | "When I run, run X first (if it hasn't run)" | Hard dependency chain | +| `AfterTargets="X"` | "After X runs, run me" | Scheduling — ensures ordering | +| `BeforeTargets="X"` | "Before X runs, run me" | Scheduling — ensures ordering | + +**Key difference**: `DependsOnTargets` is **pull-based** (only runs if the depending target runs). `AfterTargets`/`BeforeTargets` are **push-based** (registers the target to run whenever the referenced target runs). + +**⚠️ CRITICAL**: `DependsOnTargets` alone does NOT trigger a target. Something must invoke the target first (via `AfterTargets`, `BeforeTargets`, or another target's `DependsOnTargets`). + +### Common Pitfall: Items in Inputs/Outputs Targets + +**Never put platform item registrations inside a target that has `Inputs`/`Outputs`.** When MSBuild determines the target is up-to-date, the ENTIRE body is skipped — including `` elements that register items needed by downstream targets. + +This is the root cause of issue #23268. + +## Platform Detection Properties + +| Property | Detects | +|----------|---------| +| `_ResizetizerIsAndroidApp` | Android application (`AndroidApplication='True'`) | +| `_ResizetizerIsiOSApp` | iOS/MacCatalyst application (includes both) | +| `_ResizetizerIsWindowsAppSdk` | Windows App SDK (WinUI) | +| `_ResizetizerIsWPFApp` | WPF application | +| `_ResizetizerIsTizenApp` | Tizen application | +| `_ResizetizerIsCompatibleApp` | Any of the above | + +## Testing MSBuild Target Changes + +### Build Verification + +```bash +# 1. Copy updated targets to .buildtasks/ +cp src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets \ + .buildtasks/Microsoft.Maui.Resizetizer.After.targets + +# 2. Clean build test +rm -rf artifacts/obj/Maui.Controls.Sample.Sandbox/Release/net10.0-android/ +dotnet build src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj \ + -f:net10.0-android -c:Release --no-restore + +# 3. Verify fonts exist +find artifacts/obj/Maui.Controls.Sample.Sandbox/Release/net10.0-android/ -path "*/assets/*.ttf" | wc -l + +# 4. Incremental build test (no changes) +dotnet build ... (same command) +# Verify fonts still present + +# 5. Diagnostic build to verify target execution +dotnet build ... -v:diag 2>&1 | grep -E "ProcessMauiFonts|_CollectMauiFontItems|Skipping" +``` + +### Key Things to Verify + +- **Clean build**: All assets appear in output +- **Incremental build**: Processing targets SKIP, collection targets RUN, assets still present +- **No unnecessary downstream work**: Platform asset targets (e.g., `_GenerateAndroidAssetsDir`) should skip when fonts haven't changed +- **Modified input**: Touching a font source file should cause `ProcessMauiFonts` to re-run + +## Common Mistakes + +| Mistake | Impact | Correct Approach | +|---------|--------|-----------------| +| Put item registration inside `Inputs`/`Outputs` target | Items lost on incremental build | Use split target pattern | +| Use wildcard glob for intermediate collection | Picks up stale files | Use predictive path mapping from source items | +| Forget to copy changes to `.buildtasks/` | Local testing uses old code | Always copy after editing source | +| Assume `DependsOnTargets` triggers execution | Target never runs | Add `AfterTargets` or `BeforeTargets` trigger | +| Mix up `AfterTargets` vs `DependsOnTargets` | Both are hard requirements, but serve different purposes | `DependsOnTargets` = pull, `AfterTargets` = push | From 87aa7d1093fd605d3433c094b1e5eb7564b593fb Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 5 Feb 2026 21:00:04 -0600 Subject: [PATCH 05/19] Update resizetizer instructions with output inference correction MSBuild output inference evaluates ItemGroups even in skipped targets. The real danger is wildcard globs that depend on task side-effects. --- .../instructions/resizetizer.instructions.md | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index eec6e484f583..7383022c5a3c 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -90,7 +90,10 @@ ResizetizeCollectItems ← Collects items from project + references ### The Problem -MSBuild's `Inputs`/`Outputs` incremental check **completely skips the target body** when outputs are up-to-date. This means any `` inside the target body that registers platform items (e.g., `AndroidAsset`, `BundleResource`) will **NOT be evaluated** on incremental builds. +MSBuild's `Inputs`/`Outputs` incremental check skips **tasks** when outputs are up-to-date, but **still evaluates ItemGroups and PropertyGroups** via [output inference](https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds#output-inference). However, this is dangerous when ItemGroups depend on side-effects of skipped tasks: + +- Wildcard globs (`$(_MauiIntermediateFonts)*`) depend on files created by `Copy` tasks — if the intermediate directory is missing (partial clean, concurrent builds), the glob evaluates to nothing +- Tasks like `CreatePartialInfoPlistTask` are genuinely skipped — their output files won't exist if they haven't run ### The Solution: Split Target Pattern @@ -179,9 +182,22 @@ This target is the starting point for the pipeline. It: **⚠️ CRITICAL**: `DependsOnTargets` alone does NOT trigger a target. Something must invoke the target first (via `AfterTargets`, `BeforeTargets`, or another target's `DependsOnTargets`). -### Common Pitfall: Items in Inputs/Outputs Targets +### Common Pitfall: Items Dependent on Task Side-Effects + +**Never use wildcard globs that depend on files created by tasks in the same target.** During output inference (when the target is skipped), tasks don't run but ItemGroups ARE evaluated — globs will find nothing if the intermediate files don't exist yet. -**Never put platform item registrations inside a target that has `Inputs`/`Outputs`.** When MSBuild determines the target is up-to-date, the ENTIRE body is skipped — including `` elements that register items needed by downstream targets. +```xml + + + + <_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> + + + + + <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + +``` This is the root cause of issue #23268. @@ -232,8 +248,9 @@ dotnet build ... -v:diag 2>&1 | grep -E "ProcessMauiFonts|_CollectMauiFontItems| | Mistake | Impact | Correct Approach | |---------|--------|-----------------| -| Put item registration inside `Inputs`/`Outputs` target | Items lost on incremental build | Use split target pattern | -| Use wildcard glob for intermediate collection | Picks up stale files | Use predictive path mapping from source items | +| Use wildcard glob dependent on task output | Glob finds nothing if task was skipped (output inference) | Use predictive path mapping from source items | +| Put task-dependent logic in same target as work | During output inference, tasks are skipped but ItemGroups evaluate | Use split target pattern | | Forget to copy changes to `.buildtasks/` | Local testing uses old code | Always copy after editing source | | Assume `DependsOnTargets` triggers execution | Target never runs | Add `AfterTargets` or `BeforeTargets` trigger | | Mix up `AfterTargets` vs `DependsOnTargets` | Both are hard requirements, but serve different purposes | `DependsOnTargets` = pull, `AfterTargets` = push | +| Assume target body is fully skipped by Inputs/Outputs | ItemGroups ARE evaluated via output inference; only tasks are skipped | Be aware of output inference; don't rely on it for task-dependent items | From d3c39adda8d2b5c34250646e776e4d4c68f8e70f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 6 Feb 2026 09:09:05 -0600 Subject: [PATCH 06/19] Fix inaccuracies in resizetizer instructions found by multi-model review Corrections verified by 6 agents (Sonnet 4.5, Gemini 3 Pro, Codex 5.2, Opus 4.5, GPT-5.1, Sonnet 4): 1. iOS scheduling table: ResizetizeCollectItems uses CollectBundleResourcesDependsOn/CompileImageAssetsDependsOn. ResizetizeImages uses AfterTargets=ResizetizeCollectItems. ProcessMauiFonts now via _CollectMauiFontItems. 2. mauimanifest.stamp has no companion .inputs file. 3. Split platform items into separate font/image tables with correct metadata (Android images use StampFile, not Link). --- .../instructions/resizetizer.instructions.md | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index 7383022c5a3c..9ad7dd021f91 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -61,7 +61,7 @@ This means stamp files, input tracking files, and intermediate outputs are at ** | `mauisplash.stamp` | Splash screen processing | | `mauimanifest.stamp` | Platform manifest generation | -Each has a companion `.inputs` file containing serialized metadata for change detection. +Each stamp file (except `mauimanifest.stamp`) has a companion `.inputs` file containing serialized metadata for change detection. ## Target Pipeline @@ -78,9 +78,9 @@ ResizetizeCollectItems ← Collects items from project + references ### Platform-Specific Scheduling -| Platform | ResizetizeCollectItems | ProcessMauiFonts | ResizetizeImages | +| Platform | ResizetizeCollectItems | ProcessMauiFonts / _CollectMauiFontItems | ResizetizeImages | |----------|----------------------|-----------------|-----------------| -| **iOS** | `CollectAppManifestsDependsOn` | `CollectAppManifestsDependsOn` | `BeforeTargets=CompileImageAssets` | +| **iOS** | `CollectBundleResourcesDependsOn`, `CompileImageAssetsDependsOn` | `_CollectMauiFontItems` via `CollectAppManifestsDependsOn` | `AfterTargets=ResizetizeCollectItems` | | **Android** | `BeforeTargets=_ComputeAndroidResourcePaths` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | | **Windows** | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | | **WPF** | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | @@ -140,13 +140,25 @@ MSBuild's `Inputs`/`Outputs` incremental check skips **tasks** when outputs are ### How Each Platform Receives Assets -| Platform | Font Item | Image Item | Metadata | -|----------|-----------|------------|----------| -| **iOS** | `BundleResource` | `BundleResource` or `ImageAsset` | `LogicalName`, `TargetPath` | -| **Android** | `AndroidAsset` | `LibraryResourceDirectories` | `Link` | -| **Windows** | `ContentWithTargetPath` | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | -| **WPF** | `Resource` | `Resource` | `LogicalName`, `Link` | -| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | +**Font Items** (from `_CollectMauiFontItems`): + +| Platform | Item Type | Metadata | +|----------|-----------|----------| +| **iOS** | `BundleResource` | `LogicalName`, `TargetPath` | +| **Android** | `AndroidAsset` | `Link` | +| **Windows** | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | +| **WPF** | `Resource` | `LogicalName`, `Link` | +| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | + +**Image Items** (from `ResizetizeImages`): + +| Platform | Item Type | Metadata | +|----------|-----------|----------| +| **iOS** | `BundleResource` or `ImageAsset` | `LogicalName`, `TargetPath` (+ `Link` for ImageAsset) | +| **Android** | `LibraryResourceDirectories` | `StampFile` | +| **Windows** | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | +| **WPF** | `Resource` | `LogicalName`, `Link` | +| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | ### iOS-Specific: Info.plist Font Registration From 5f91b31637e4ac00b2c74ab371ddf9004d07f5a6 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 6 Feb 2026 09:24:54 -0600 Subject: [PATCH 07/19] Fix misleading XML comment: predictive mapping, not wildcard --- .../Microsoft.Maui.Resizetizer.After.targets | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index ff292e6abf23..ac7d0b35497a 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -549,9 +549,9 @@ + Uses predictive path mapping from source @(MauiFont) items to handle both cases: + - When ProcessMauiFonts runs: items point to newly copied fonts + - When ProcessMauiFonts skips: items point to existing fonts from intermediate folder --> Date: Fri, 6 Feb 2026 10:12:28 -0600 Subject: [PATCH 08/19] Fix instructions: ResizetizeCollectItems scheduling and ProcessMauiAssets description --- .github/instructions/resizetizer.instructions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index 9ad7dd021f91..3aecd8b07d52 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -69,7 +69,7 @@ Each stamp file (except `mauimanifest.stamp`) has a companion `.inputs` file con ``` ResizetizeCollectItems ← Collects items from project + references - ├── ProcessMauiAssets ← Copies MauiAsset items + ├── ProcessMauiAssets ← Computes asset paths and registers platform items ├── ProcessMauiSplashScreens ← Generates splash resources ├── ProcessMauiFonts ← Copies font files (incremental) │ └── _CollectMauiFontItems ← Registers platform items (ALWAYS runs) @@ -82,9 +82,9 @@ ResizetizeCollectItems ← Collects items from project + references |----------|----------------------|-----------------|-----------------| | **iOS** | `CollectBundleResourcesDependsOn`, `CompileImageAssetsDependsOn` | `_CollectMauiFontItems` via `CollectAppManifestsDependsOn` | `AfterTargets=ResizetizeCollectItems` | | **Android** | `BeforeTargets=_ComputeAndroidResourcePaths` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | -| **Windows** | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | -| **WPF** | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | -| **Tizen** | `BeforeTargets=PrepareResources` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | +| **Windows** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | +| **WPF** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | +| **Tizen** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | ## ⚠️ Critical Pattern: Inputs/Outputs and Item Registration From 283f2329fcd84225d5d07a567a9ed677c39cd410 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:54:48 +0200 Subject: [PATCH 09/19] De-duplicate _MauiFontCopied to preserve filesystem glob dedup semantics The predictive transform @(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)') emits one item per source MauiFont, so two fonts sharing a filename (e.g. one from the project and one from a ProjectReference) flatten to the same intermediate path and produced duplicate _MauiFontCopied items. This registered duplicate AndroidAsset/BundleResource/ ContentWithTargetPath entries (iOS can error on duplicate bundle resources). The previous wildcard glob over the intermediate folder was implicitly de-duplicated by the filesystem. Restore that behavior with RemoveDuplicates before the platform-specific item registrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Maui.Resizetizer.After.targets | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index ac7d0b35497a..3f16945b9ae8 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -563,6 +563,20 @@ <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + <_MauiFontCopied Remove="@(_MauiFontCopied)" /> + <_MauiFontCopied Include="@(_MauiFontCopiedUnique)" /> + + From fc13e8eb363bbc825ab97102b4aaf86531bf2fa4 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:22:34 +0200 Subject: [PATCH 10/19] Delete stale MauiInfo.plist when the last MauiFont is removed CreatePartialInfoPlistTask is skipped when @(MauiFont) is empty, but _CollectMauiFontItems still registers a previously-generated MauiInfo.plist via its Exists() check, keeping stale UIAppFonts entries on iOS/MacCatalyst. Delete the plist in ProcessMauiFonts when there are no fonts so font registration is correctly torn down. Removing the last font rewrites the (now empty) mauifont.inputs, which makes ProcessMauiFonts out-of-date and re-run once to clear it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Maui.Resizetizer.After.targets | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 3f16945b9ae8..79b2e06ec62c 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -537,6 +537,15 @@ PlistName="MauiInfo.plist" CustomFonts="@(MauiFont)" /> + + + From b8a82bbbbf02f630801ba0fba7f4fca6a2ddca98 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:22:43 +0200 Subject: [PATCH 11/19] Add first-build Android font-copy regression test for #23268 Guards against the first-build font regression: clean-builds the maui template for net10.0-android in Release (where the bug reproduced), asserts OpenSans-Regular.ttf lands in the Android assets folder on the FIRST build, then incrementally rebuilds and asserts ProcessMauiFonts is skipped (up-to-date) while the always-run _CollectMauiFontItems still executes and the asset remains registered. Target execution is read from the build's binlog via MSBuild.StructuredLogger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResizetizerTests.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index fefbe048b1ed..1a94707d5304 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Logging.StructuredLogger; + namespace Microsoft.Maui.IntegrationTests; [Trait("Category", "Build")] @@ -99,4 +102,95 @@ public void CollectsAssets(string id, string libid, bool unpackaged) Assert.True(File.Exists(Path.Combine(appDir, $"obj\\Debug\\{DotNetCurrent}-windows10.0.19041.0\\win-x64\\resizetizer\\r\\the_image.scale-100.png")), "Windows was missing the image file."); } + + // Regression test for https://github.com/dotnet/maui/issues/23268: custom font assets were not + // copied into the Android assets folder on the *first* (clean) Release build — they only showed + // up after a second build. The fix makes _CollectMauiFontItems always run and map font paths + // predictively from @(MauiFont) instead of relying on a filesystem glob that was empty during + // first-build output inference. Release is required: the bug only reproduced in Release. + [Theory] + [InlineData("Release")] + public void FontsAreCopiedToAndroidAssetsOnFirstBuild(string config) + { + SetTestIdentifier(config); + + var projectDir = TestDirectory; + var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); + + // The default maui template registers , which includes + // OpenSans-Regular.ttf, so building it exercises the font pipeline without extra assets. + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, output: _output), + "Unable to create template maui. Check test output for errors."); + + var framework = $"{DotNetCurrent}-android"; + var androidObjDir = Path.Combine(projectDir, "obj", config, framework); + const string fontFileName = "OpenSans-Regular.ttf"; + + // First (clean) build for Android only. + var firstBinlog = Path.Combine(projectDir, "first.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, framework: framework, properties: BuildProps, binlogPath: firstBinlog, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build (first build). Check test output/attachments for errors."); + + Assert.True(FontExistsInAndroidAssets(androidObjDir, fontFileName), + $"Font '{fontFileName}' was not copied into the Android assets folder under '{androidObjDir}' on the first build (regression #23268)."); + + // Second (incremental) build. + var secondBinlog = Path.Combine(projectDir, "second.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, framework: framework, properties: BuildProps, binlogPath: secondBinlog, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build (incremental build). Check test output/attachments for errors."); + + // ProcessMauiFonts is incremental and should be skipped (up-to-date) on the second build, + // while the always-run _CollectMauiFontItems must still execute and re-register the items. + Assert.True(WasTargetSkipped(secondBinlog, "ProcessMauiFonts"), + "ProcessMauiFonts should have been skipped (up-to-date) on the incremental build."); + Assert.True(WasTargetExecuted(secondBinlog, "_CollectMauiFontItems"), + "_CollectMauiFontItems should run on every build, even when ProcessMauiFonts is skipped."); + Assert.True(FontExistsInAndroidAssets(androidObjDir, fontFileName), + $"Font '{fontFileName}' is missing from the Android assets folder after an incremental build."); + } + + static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) + { + if (!Directory.Exists(androidObjDir)) + return false; + + // The intermediate copy lives under resizetizer\f\; only the file staged under an "assets" + // folder proves the font was actually registered as an AndroidAsset and packaged. + return Directory.EnumerateFiles(androidObjDir, fontFileName, SearchOption.AllDirectories) + .Any(path => path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(segment => string.Equals(segment, "assets", StringComparison.OrdinalIgnoreCase))); + } + + static bool WasTargetSkipped(string binlogPath, string targetName) + => GetTargetStatus(binlogPath, targetName).skipped; + + static bool WasTargetExecuted(string binlogPath, string targetName) + { + var (started, skipped) = GetTargetStatus(binlogPath, targetName); + // An up-to-date incremental target emits BOTH a TargetStarted and a TargetSkipped event, so + // "executed" means it started and was not skipped. + return started && !skipped; + } + + static (bool started, bool skipped) GetTargetStatus(string binlogPath, string targetName) + { + bool started = false; + bool skipped = false; + if (File.Exists(binlogPath)) + { + foreach (var record in new BinLogReader().ReadRecords(binlogPath)) + { + switch (record.Args) + { + case TargetStartedEventArgs s when string.Equals(s.TargetName, targetName, StringComparison.Ordinal): + started = true; + break; + case TargetSkippedEventArgs sk when string.Equals(sk.TargetName, targetName, StringComparison.Ordinal): + skipped = true; + break; + } + } + } + return (started, skipped); + } } From 3e178db56c2f478665001fd63f613e52f2336213 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:20:17 +0200 Subject: [PATCH 12/19] Consolidate #35962: track font/splash generated outputs in manifests Ports the outputs-manifest incremental invalidation from #35962 (now closed) into this PR so deleting generated font/splash intermediates re-triggers ProcessMauiFonts/ProcessMauiSplashScreens (fixes #33092), complementing the first-build registration fix for #23268. - Replace mauifont.stamp/mauisplash.stamp with mauifont.outputs/mauisplash.outputs manifests, read back via _ReadMauiFontOutputs/_ReadMauiSplashOutputs and fed into each target's Outputs, so a missing generated file invalidates only that target. - Drop the font/splash stamp files entirely (per jonathanpeppers review on #35962). - Add ProcessMauiSplashScreensDependsOnTargets. - De-duplicate fonts by intermediate filename before CreatePartialInfoPlistTask to avoid duplicate UIAppFonts entries (addresses open review suggestion on #33919). - Port the all-platform BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing regression test (macOS-gated; adds iOS/MacCatalyst coverage). - Update resizetizer.instructions.md for the outputs-manifest mechanism. Co-authored-by: Gerald Versluis <939291+jfversluis@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../instructions/resizetizer.instructions.md | 53 ++++++-- .../Microsoft.Maui.Resizetizer.After.targets | 88 ++++++++++--- .../ResizetizerTests.cs | 118 ++++++++++++++++++ 3 files changed, 232 insertions(+), 27 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index 3aecd8b07d52..f178fcbe34f8 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -50,18 +50,28 @@ The `.buildtasks/` directory at the repo root contains a local copy of the Resiz - Outer build: `obj/Release/net10.0-android/` - Inner build (arm64): `obj/Release/net10.0-android/android-arm64/` -This means stamp files, input tracking files, and intermediate outputs are at **different paths** in outer vs inner builds. +This means stamp/manifest files, input tracking files, and intermediate outputs are at **different paths** in outer vs inner builds. -### Stamp Files (Incremental Build Tracking) +### Incremental Build Tracking (Stamp & Output-Manifest Files) -| Stamp File | Tracks | -|------------|--------| -| `mauifont.stamp` | Font processing (ProcessMauiFonts) | -| `mauiimage.stamp` | Image resizing (ResizetizeImages) | -| `mauisplash.stamp` | Splash screen processing | -| `mauimanifest.stamp` | Platform manifest generation | +Two mechanisms drive the `Inputs`/`Outputs` up-to-date checks: -Each stamp file (except `mauimanifest.stamp`) has a companion `.inputs` file containing serialized metadata for change detection. +| File | Tracks | Mechanism | +|------|--------|-----------| +| `mauifont.outputs` | Font processing (`ProcessMauiFonts`) | Output manifest | +| `mauisplash.outputs` | Splash screen processing (`ProcessMauiSplashScreens`) | Output manifest | +| `mauiimage.stamp` + `mauiimage.outputs` | Image resizing (`ResizetizeImages`) | Stamp + output manifest | +| `mauimanifest.stamp` | Platform manifest generation | Stamp | + +Each processing target (except `mauimanifest.stamp`) has a companion `.inputs` file containing serialized metadata for change detection. + +**⚠️ Prefer output manifests over bare stamp files.** A bare stamp (`` + `Outputs="$(stamp)"`) only records *when* a target last ran — its timestamp can stay newer than a generated output that was later deleted (e.g. a partial `obj` clean or concurrent build), so MSBuild wrongly treats the target as up-to-date and the package ships without the missing font/splash (regression #33092). Instead: + +1. Write the list of generated files to a `*.outputs` manifest with `WriteLinesToFile`. +2. Read it back **before** the up-to-date check via a `_Read*Outputs` target wired through `DependsOnTargets`. +3. Include those files in the target `Outputs`, e.g. `Outputs="$(_MauiFontOutputsFile);@(_MauiFontOutputs)"`. + +Now if any generated file disappears, MSBuild sees a missing output and re-runs *only* that target. `ProcessMauiFonts` / `ProcessMauiSplashScreens` use this pattern; `ResizetizeImages` keeps its legacy stamp alongside its own `mauiimage.outputs`. ## Target Pipeline @@ -103,15 +113,30 @@ MSBuild's `Inputs`/`Outputs` incremental check skips **tasks** when outputs are 2. **Collection target** (NO `Inputs`/`Outputs`): Registers platform-specific items — always runs ```xml - + - + + <_MauiFontOutput Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + + + + + - + @@ -167,6 +192,8 @@ iOS requires fonts to be declared in Info.plist via `UIAppFonts`. The `CreatePar **Important**: The plist generation (`CreatePartialInfoPlistTask`) is inside `ProcessMauiFonts` (the incremental target), while the `PartialAppManifest` registration is in `_CollectMauiFontItems` (always runs). This is correct because: - The plist only needs regeneration when fonts change (handled by Inputs/Outputs) - The plist FILE registration must happen every build (handled by always-run target using `Exists()` check) +- The generated `MauiInfo.plist` is added to `mauifont.outputs`, so deleting it also re-triggers `ProcessMauiFonts` +- Fonts are de-duplicated by intermediate filename before `CreatePartialInfoPlistTask` so colliding names (e.g. a project and a `ProjectReference` both shipping `OpenSans.ttf`) don't emit duplicate `UIAppFonts` entries ## ResizetizeCollectItems diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 79b2e06ec62c..9a0ad8d80c13 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -75,9 +75,9 @@ <_ResizetizerOutputsFile>$(_ResizetizerIntermediateOutputPath)mauiimage.outputs <_ResizetizerStampFile>$(_ResizetizerIntermediateOutputPath)mauiimage.stamp <_MauiFontInputsFile>$(_ResizetizerIntermediateOutputPath)mauifont.inputs - <_MauiFontStampFile>$(_ResizetizerIntermediateOutputPath)mauifont.stamp + <_MauiFontOutputsFile>$(_ResizetizerIntermediateOutputPath)mauifont.outputs <_MauiSplashInputsFile>$(_ResizetizerIntermediateOutputPath)mauisplash.inputs - <_MauiSplashStampFile>$(_ResizetizerIntermediateOutputPath)mauisplash.stamp + <_MauiSplashOutputsFile>$(_ResizetizerIntermediateOutputPath)mauisplash.outputs <_MauiManifestStampFile>$(_ResizetizerIntermediateOutputPath)mauimanifest.stamp <_ResizetizerIntermediateOutputRoot>$(_ResizetizerIntermediateOutputPath)resizetizer\ @@ -120,11 +120,16 @@ ProcessMauiSplashScreens; _ReadResizetizeImagesOutputs; + + $(ProcessMauiSplashScreensDependsOnTargets); + _ReadMauiSplashOutputs; + $(ProcessMauiFontsDependsOnTargets); ResizetizeCollectItems; ProcessMauiAssets; ProcessMauiSplashScreens; + _ReadMauiFontOutputs; @@ -399,7 +404,8 @@ + Outputs="$(_MauiSplashOutputsFile);@(_MauiSplashOutputs)" + DependsOnTargets="$(ProcessMauiSplashScreensDependsOnTargets)"> <_MauiHasSplashScreens>false @@ -505,21 +511,37 @@ - + - + + + - + + + + + + + @@ -530,12 +552,27 @@ DestinationFolder="$(_MauiIntermediateFonts)" SkipUnchangedFiles="true" /> - + + + <_MauiFontOutput Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + + CustomFonts="@(_MauiFontOutputUnique)" /> - + + + <_MauiFontOutputUnique Include="$(_MauiIntermediateFonts)MauiInfo.plist" + Condition="'$(_ResizetizerIsiOSApp)' == 'True' And Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> + + + + + + - + - + + @@ -646,6 +700,12 @@ + + + + + + diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index 1a94707d5304..cf6fe60dc14b 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -149,6 +149,58 @@ public void FontsAreCopiedToAndroidAssetsOnFirstBuild(string config) $"Font '{fontFileName}' is missing from the Android assets folder after an incremental build."); } + // Regression test for https://github.com/dotnet/maui/issues/33092 (consolidated from #35962): + // after a successful build, deleting the generated font/splash intermediate outputs must + // re-trigger ProcessMauiFonts / ProcessMauiSplashScreens on the next build. This is guaranteed by + // tracking the generated files in mauifont.outputs / mauisplash.outputs manifests that feed the + // targets' Outputs (see _ReadMauiFontOutputs / _ReadMauiSplashOutputs), replacing the old stamp + // files whose timestamps could stay newer than the deleted outputs. A final no-op build asserts + // both targets are skipped when nothing changed. + [Fact] + public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() + { + SetTestIdentifier("MissingResizetizerOutputs"); + + // Builds every TFM of the default maui template (Android + iOS + MacCatalyst), which only + // fully builds on macOS, so this is gated accordingly. The Android first-build path is + // additionally covered on Windows by FontsAreCopiedToAndroidAssetsOnFirstBuild. + if (!TestEnvironment.IsMacOS) + return; + + var projectDir = TestDirectory; + var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); + const string config = "Debug"; + + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, output: _output), + $"Unable to create template maui. Check test output for errors."); + + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build. Check test output/attachments for errors."); + + var intermediateOutputRoots = GetResizetizerOutputRoots(projectDir, config); + AssertBuiltTargetPlatforms(intermediateOutputRoots); + AssertIntermediateOutputsExist(intermediateOutputRoots); + + foreach (var intermediateOutputRoot in intermediateOutputRoots) + { + DeleteDirectory(Path.Combine(intermediateOutputRoot, "resizetizer", "f")); + DeleteDirectory(Path.Combine(intermediateOutputRoot, "resizetizer", "sp")); + } + + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to rebuild. Check test output/attachments for errors."); + + AssertIntermediateOutputsExist(intermediateOutputRoots); + + var noOpBinlogPath = Path.Combine(projectDir, "no-op.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, binlogPath: noOpBinlogPath, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to no-op rebuild. Check test output/attachments for errors."); + + AssertTargetSkipped(noOpBinlogPath, "ProcessMauiFonts", intermediateOutputRoots.Count); + AssertTargetSkipped(noOpBinlogPath, "ProcessMauiSplashScreens", intermediateOutputRoots.Count); + AssertIntermediateOutputsExist(intermediateOutputRoots); + } + static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) { if (!Directory.Exists(androidObjDir)) @@ -193,4 +245,70 @@ static bool WasTargetExecuted(string binlogPath, string targetName) } return (started, skipped); } + + static IReadOnlyList GetResizetizerOutputRoots(string projectDir, string config) + { + var intermediateOutputPath = Path.Combine(projectDir, "obj", config); + var outputRoots = Directory + .GetFiles(intermediateOutputPath, "mauifont.outputs", SearchOption.AllDirectories) + .Select(Path.GetDirectoryName) + .Where(root => root is not null && File.Exists(Path.Combine(root, "mauisplash.outputs"))) + .Cast() + .OrderBy(root => root, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.NotEmpty(outputRoots); + return outputRoots; + } + + static void AssertBuiltTargetPlatforms(IReadOnlyList intermediateOutputRoots) + { + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-android")); + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-ios")); + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-maccatalyst")); + + if (TestEnvironment.IsWindows) + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-windows")); + } + + static bool ContainsTargetFramework(string path, string targetFramework) => + path.Contains(targetFramework, StringComparison.OrdinalIgnoreCase); + + static void AssertIntermediateOutputsExist(IReadOnlyList intermediateOutputRoots) + { + foreach (var intermediateOutputRoot in intermediateOutputRoots) + { + var fontsDir = Path.Combine(intermediateOutputRoot, "resizetizer", "f"); + Assert.True(File.Exists(Path.Combine(fontsDir, "OpenSans-Regular.ttf")), + $"Missing OpenSans-Regular.ttf in {fontsDir}."); + Assert.True(File.Exists(Path.Combine(fontsDir, "OpenSans-Semibold.ttf")), + $"Missing OpenSans-Semibold.ttf in {fontsDir}."); + + if (!ContainsTargetFramework(intermediateOutputRoot, $"{DotNetCurrent}-maccatalyst")) + { + var splashDir = Path.Combine(intermediateOutputRoot, "resizetizer", "sp"); + Assert.True( + Directory.Exists(splashDir) && Directory.EnumerateFiles(splashDir, "*", SearchOption.AllDirectories).Any(), + $"Missing generated splash screen files in {splashDir}."); + } + } + } + + static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + + static void AssertTargetSkipped(string binlogPath, string targetName, int minimumSkipCount) + { + var skipCount = new BinLogReader() + .ReadRecords(binlogPath) + .Count(record => record.Args is BuildMessageEventArgs { Message: string message } && + message.Contains($"Skipping target \"{targetName}\"", StringComparison.Ordinal) && + message.Contains("because all output files are up-to-date", StringComparison.OrdinalIgnoreCase)); + + Assert.True(skipCount >= minimumSkipCount, + $"Expected target '{targetName}' to be skipped at least {minimumSkipCount} times, but found {skipCount}. See binlog: {binlogPath}"); + } } From f2d6a2e63a368c43754ab80a53ec908e4f3fd0e7 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:38:01 +0200 Subject: [PATCH 13/19] Address review feedback: robust target-status test helpers + instructions accuracy Test helpers (ResizetizerTests.cs): - GetTargetStatus now counts TargetStarted/TargetSkipped events instead of tracking booleans. A skipped up-to-date invocation emits BOTH a TargetStarted and a TargetSkipped event, so in multi-RID / outer+inner builds the old boolean logic gave a false-positive WasTargetSkipped (any skip => true) and a false-negative WasTargetExecuted (started && !skipped). Now: executed = started > skipped; skipped-only = skipped > 0 && started == skipped. Verified empirically against a two-instance (exec+skip) binlog. - AssertTargetSkipped counts TargetSkippedEventArgs instead of matching the localized/implementation-specific "Skipping target ... up-to-date" message, and asserts the binlog exists first. - Remove the unreachable `if (IsWindows)` branch in AssertBuiltTargetPlatforms (only ever called from the macOS-gated test). Instructions (resizetizer.instructions.md): - Fix "skips tasks" -> "skips targets" (Inputs/Outputs skips the whole target). - Add guidance to prefer DependsOnTargets via an overridable property. - Reword the "Items Dependent on Task Side-Effects" pitfall to accurately scope when a same-target glob is actually dangerous (deleted-outputs-with-stale-stamp #33092, and registration visibility/ordering #23268) instead of implying the normal incremental-skip case is broken. - Link .NET for Android's MSBuild Best Practices guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../instructions/resizetizer.instructions.md | 32 +++++++++--- .../ResizetizerTests.cs | 49 +++++++++++-------- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index f178fcbe34f8..da19c6c21d15 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -8,6 +8,8 @@ applyTo: Guidance for working with .NET MAUI's Resizetizer build system, which processes images, fonts, splash screens, and assets at build time. +> **See also:** .NET for Android's [MSBuild Best Practices](https://github.com/dotnet/android/blob/main/Documentation/guides/MSBuildBestPractices.md) is the canonical guide for MSBuild target authoring. Its *Incremental Builds*, *Stamp Files*, *FileWrites and IncrementalClean*, *When to not use Inputs and Outputs?*, and *Should I use BeforeTargets or AfterTargets?* sections directly inform the patterns documented below. + ## Architecture Overview The Resizetizer is an MSBuild-integrated pipeline that processes `MauiImage`, `MauiFont`, `MauiSplashScreen`, and `MauiAsset` items into platform-specific resources during the build. All core logic lives in a single file: @@ -100,7 +102,7 @@ ResizetizeCollectItems ← Collects items from project + references ### The Problem -MSBuild's `Inputs`/`Outputs` incremental check skips **tasks** when outputs are up-to-date, but **still evaluates ItemGroups and PropertyGroups** via [output inference](https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds#output-inference). However, this is dangerous when ItemGroups depend on side-effects of skipped tasks: +MSBuild's `Inputs`/`Outputs` incremental check skips **targets** when outputs are up-to-date, but **still evaluates ItemGroups and PropertyGroups** via [output inference](https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds#output-inference). However, this is dangerous when ItemGroups depend on side-effects of skipped tasks: - Wildcard globs (`$(_MauiIntermediateFonts)*`) depend on files created by `Copy` tasks — if the intermediate directory is missing (partial clean, concurrent builds), the glob evaluates to nothing - Tasks like `CreatePartialInfoPlistTask` are genuinely skipped — their output files won't exist if they haven't run @@ -221,25 +223,43 @@ This target is the starting point for the pipeline. It: **⚠️ CRITICAL**: `DependsOnTargets` alone does NOT trigger a target. Something must invoke the target first (via `AfterTargets`, `BeforeTargets`, or another target's `DependsOnTargets`). +**✅ Prefer `DependsOnTargets` via an overridable property where possible.** Express ordering as `DependsOnTargets="$(_MyTargetDependsOn)"` and define the list as a property so consumers can extend the chain without editing the target. This is how the Resizetizer wires its read-manifest targets, e.g.: + +```xml + + + $(ProcessMauiFontsDependsOnTargets); + _ReadMauiFontOutputs; + + + +``` + +Reserve `AfterTargets`/`BeforeTargets` for one-off push-based hooks into SDK targets you don't own (e.g. `AssignTargetPaths`, `_ComputeAndroidResourcePaths`). See [Should I use BeforeTargets or AfterTargets?](https://github.com/dotnet/android/blob/main/Documentation/guides/MSBuildBestPractices.md#should-i-use-beforetargets-or-aftertargets). + ### Common Pitfall: Items Dependent on Task Side-Effects -**Never use wildcard globs that depend on files created by tasks in the same target.** During output inference (when the target is skipped), tasks don't run but ItemGroups ARE evaluated — globs will find nothing if the intermediate files don't exist yet. +A wildcard glob over an intermediate directory populated by a task in the **same** target is usually fine on a normal incremental build: when the target is skipped as up-to-date, the files from the previous run are still on disk, so the glob still finds them. It becomes a problem in two specific cases: + +1. **The generated files are deleted out-of-band while the target still skips** — e.g. a partial `obj` clean, a concurrent/parallel build racing on the same intermediate folder, or a tool deleting intermediate files. If the up-to-date check is driven by a bare stamp whose timestamp stays newer than the (now missing) outputs, MSBuild skips the target, the glob finds nothing, and the item is silently dropped. **Fix:** track the real generated files in an output manifest (see [Incremental Build Tracking](#incremental-build-tracking-stamp--output-manifest-files)) so a missing output re-runs the target (issue #33092). +2. **A consumer needs the registered items even when the processing target is skipped or runs later in the schedule** — item registration that lives inside the incremental processing target is not guaranteed to be visible to the target that packages it. **Fix:** register platform items in a companion always-run collection target (issue #23268). + +Predictive mapping from the source items is also preferred over a filesystem glob because it never picks up stale files left over from deleted sources: ```xml - + <_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> - + <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> ``` -This is the root cause of issue #23268. - ## Platform Detection Properties | Property | Detects | diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index cf6fe60dc14b..ef84b1ba6777 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -214,20 +214,27 @@ static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) } static bool WasTargetSkipped(string binlogPath, string targetName) - => GetTargetStatus(binlogPath, targetName).skipped; + { + var (started, skipped) = GetTargetStatus(binlogPath, targetName); + // A skipped (up-to-date) invocation emits BOTH a TargetStarted and a TargetSkipped event, + // while an executed invocation emits only TargetStarted. In multi-RID / outer+inner builds a + // target can be invoked several times, so the target counts as "skipped" only when every + // invocation was skipped: at least one skip and no net executions (started == skipped). + return skipped > 0 && started == skipped; + } static bool WasTargetExecuted(string binlogPath, string targetName) { var (started, skipped) = GetTargetStatus(binlogPath, targetName); - // An up-to-date incremental target emits BOTH a TargetStarted and a TargetSkipped event, so - // "executed" means it started and was not skipped. - return started && !skipped; + // Each skipped invocation consumes one TargetStarted, so the number of real executions is + // (started - skipped). The target executed if at least one invocation actually ran. + return started > skipped; } - static (bool started, bool skipped) GetTargetStatus(string binlogPath, string targetName) + static (int started, int skipped) GetTargetStatus(string binlogPath, string targetName) { - bool started = false; - bool skipped = false; + int started = 0; + int skipped = 0; if (File.Exists(binlogPath)) { foreach (var record in new BinLogReader().ReadRecords(binlogPath)) @@ -235,10 +242,10 @@ static bool WasTargetExecuted(string binlogPath, string targetName) switch (record.Args) { case TargetStartedEventArgs s when string.Equals(s.TargetName, targetName, StringComparison.Ordinal): - started = true; + started++; break; case TargetSkippedEventArgs sk when string.Equals(sk.TargetName, targetName, StringComparison.Ordinal): - skipped = true; + skipped++; break; } } @@ -263,12 +270,13 @@ static IReadOnlyList GetResizetizerOutputRoots(string projectDir, string static void AssertBuiltTargetPlatforms(IReadOnlyList intermediateOutputRoots) { + // This is only reached from BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing, + // which is gated to macOS (it builds the Apple TFMs), so only the Apple + Android roots are + // asserted here. Windows is covered separately by FontsAreCopiedToAndroidAssetsOnFirstBuild's + // sibling Windows lanes and CollectsAssets, and is never built by this macOS-only test. Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-android")); Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-ios")); Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-maccatalyst")); - - if (TestEnvironment.IsWindows) - Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-windows")); } static bool ContainsTargetFramework(string path, string targetFramework) => @@ -302,13 +310,14 @@ static void DeleteDirectory(string path) static void AssertTargetSkipped(string binlogPath, string targetName, int minimumSkipCount) { - var skipCount = new BinLogReader() - .ReadRecords(binlogPath) - .Count(record => record.Args is BuildMessageEventArgs { Message: string message } && - message.Contains($"Skipping target \"{targetName}\"", StringComparison.Ordinal) && - message.Contains("because all output files are up-to-date", StringComparison.OrdinalIgnoreCase)); - - Assert.True(skipCount >= minimumSkipCount, - $"Expected target '{targetName}' to be skipped at least {minimumSkipCount} times, but found {skipCount}. See binlog: {binlogPath}"); + Assert.True(File.Exists(binlogPath), $"Binlog not found: {binlogPath}"); + + // Count TargetSkippedEventArgs directly instead of matching localized/implementation-specific + // log message text ("Skipping target ... because all output files are up-to-date"), which is + // brittle across MSBuild versions and non-English locales. + var (_, skipped) = GetTargetStatus(binlogPath, targetName); + + Assert.True(skipped >= minimumSkipCount, + $"Expected target '{targetName}' to be skipped at least {minimumSkipCount} times, but found {skipped}. See binlog: {binlogPath}"); } } From dd6cea8f10cc028e253fe3b2c85bb78975296452 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:06:32 +0200 Subject: [PATCH 14/19] Use deterministic asset paths in Resizetizer font/splash tests Address @jonathanpeppers review feedback: replace recursive directory scans with Path.Combine + File.Exists using the deterministic staged paths. - FontExistsInAndroidAssets: a registered AndroidAsset is staged into $(IntermediateOutputPath)assets/ = obj/{config}/{tfm}/assets/ (no RID segment for a default build). Confirmed with a Release net10.0-android build (font landed at obj/Release/net10.0-android/assets/OpenSans-Regular.ttf). - AssertIntermediateOutputsExist splash check: assert a deterministic per-platform marker instead of enumerating the whole sp/ folder: * Android: resizetizer/sp/drawable/maui_splash_image.xml * iOS: resizetizer/sp/MauiSplash.storyboard Confirmed with Release net10.0-android and net10.0-ios builds (iOS runs in the RID inner path, already captured by the test's output roots). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResizetizerTests.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index ef84b1ba6777..add476053d8b 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -203,14 +203,11 @@ public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) { - if (!Directory.Exists(androidObjDir)) - return false; - - // The intermediate copy lives under resizetizer\f\; only the file staged under an "assets" - // folder proves the font was actually registered as an AndroidAsset and packaged. - return Directory.EnumerateFiles(androidObjDir, fontFileName, SearchOption.AllDirectories) - .Any(path => path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - .Any(segment => string.Equals(segment, "assets", StringComparison.OrdinalIgnoreCase))); + // A registered AndroidAsset is staged by .NET for Android into $(IntermediateOutputPath)assets/, + // i.e. obj/{config}/{tfm}/assets/ — a deterministic path (no RID segment for a default build). + // The intermediate resizetizer copy under resizetizer/f/ is NOT proof of registration; only the + // staged copy under assets/ is. Confirmed empirically with a Release net*-android build. + return File.Exists(Path.Combine(androidObjDir, "assets", fontFileName)); } static bool WasTargetSkipped(string binlogPath, string targetName) @@ -295,9 +292,15 @@ static void AssertIntermediateOutputsExist(IReadOnlyList intermediateOut if (!ContainsTargetFramework(intermediateOutputRoot, $"{DotNetCurrent}-maccatalyst")) { var splashDir = Path.Combine(intermediateOutputRoot, "resizetizer", "sp"); - Assert.True( - Directory.Exists(splashDir) && Directory.EnumerateFiles(splashDir, "*", SearchOption.AllDirectories).Any(), - $"Missing generated splash screen files in {splashDir}."); + // Assert a deterministic generated splash marker per platform instead of scanning the + // whole directory (confirmed with Release builds): + // - Android: resizetizer/sp/drawable/maui_splash_image.xml + // - iOS: resizetizer/sp/MauiSplash.storyboard + var splashMarker = ContainsTargetFramework(intermediateOutputRoot, $"{DotNetCurrent}-android") + ? Path.Combine(splashDir, "drawable", "maui_splash_image.xml") + : Path.Combine(splashDir, "MauiSplash.storyboard"); + Assert.True(File.Exists(splashMarker), + $"Missing generated splash marker '{splashMarker}'."); } } } From 274ac882a6fad3a6cdfb0e6f5b579ed1c8758ebc Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:54:53 +0200 Subject: [PATCH 15/19] Fix Resizetizer instructions: correct File Loading Order table The table omitted Microsoft.Maui.Resizetizer.targets and misattributed the SDK-registration logic. Corrected from the actual files: - Microsoft.Maui.Resizetizer.props -> empty () - Microsoft.Maui.Resizetizer.targets -> sets UsingMicrosoftMauiResizetizerSdk, imports Before.targets, appends After.targets to AfterMicrosoftNETSdkTargets - Microsoft.Maui.Resizetizer.Before.targets -> empty () - Microsoft.Maui.Resizetizer.After.targets -> all logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/instructions/resizetizer.instructions.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index da19c6c21d15..8f6823e3da96 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -18,13 +18,16 @@ The Resizetizer is an MSBuild-integrated pipeline that processes `MauiImage`, `M ### File Loading Order -| File | Purpose | -|------|---------| -| `Microsoft.Maui.Resizetizer.props` | Early property defaults (currently empty) | -| `Microsoft.Maui.Resizetizer.Before.targets` | Pre-SDK target hooks (currently empty) | -| `Microsoft.Maui.Resizetizer.After.targets` | **All logic** — targets, item registration, platform dispatch | +These files are auto-imported by the NuGet package (`.props` / `.targets`), which then pulls in the named hook files: -`After.targets` is imported via `AfterMicrosoftNETSdkTargets`, ensuring it runs after the .NET SDK targets are loaded. +| File | Loaded | Purpose | +|------|--------|---------| +| `Microsoft.Maui.Resizetizer.props` | auto (package) | Early property defaults — currently empty (``) | +| `Microsoft.Maui.Resizetizer.targets` | auto (package) | **SDK registration**: sets `UsingMicrosoftMauiResizetizerSdk`, imports `Before.targets`, and appends `After.targets` to `AfterMicrosoftNETSdkTargets` | +| `Microsoft.Maui.Resizetizer.Before.targets` | imported by `.targets` | Pre-SDK target hooks — currently empty (``) | +| `Microsoft.Maui.Resizetizer.After.targets` | via `AfterMicrosoftNETSdkTargets` | **All logic** — targets, item registration, platform dispatch | + +`Microsoft.Maui.Resizetizer.targets` appends `After.targets` to `AfterMicrosoftNETSdkTargets`, ensuring the logic runs after the .NET SDK targets are loaded. ### Local Testing with `.buildtasks/` From c1b41328102821aff4bb023970cd0c3b975d0a8d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:48:40 +0200 Subject: [PATCH 16/19] Fix regression: match up-to-date target skips by SkipReason, not raw counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My earlier "robust target-status" change (started == skipped) broke FontsAreCopiedToAndroidAssetsOnFirstBuild on the real Release Android build (CI builds started failing the moment it landed). Root cause: when a target is requested through multiple edges — ProcessMauiFonts is pulled in by its own AfterTargets, by _CollectMauiFontItems' DependsOnTargets, and by _ComputeAndroidResourcePaths — an up-to-date target emits ONE TargetStarted + ONE OutputsUpToDate skip + one PreviouslyBuiltSuccessfully skip PER extra edge. So started(1) != skipped(2), and "started == skipped" wrongly reported the target as not-skipped. Fix: key off TargetSkippedEventArgs.SkipReason (available in the test's MSBuild.StructuredLogger) and count only OutputsUpToDate skips: - WasTargetSkipped = OutputsUpToDate skips > 0 - WasTargetExecuted = started > 0 && OutputsUpToDate skips == 0 - AssertTargetSkipped counts OutputsUpToDate skips This is more correct than the original "any skip" check too: a genuine re-run has zero OutputsUpToDate skips, so it is no longer masked (the concern that motivated the original change). Validated empirically: - Synthetic multi-edge up-to-date target: started=1, OutputsUpToDate=1, PreviouslyBuilt=2 -> WasSkipped=true, WasExecuted=false. - Real Release net10.0-android incremental binlog with these targets: ProcessMauiFonts started=1/OutputsUpToDate=1 -> skipped=true; _CollectMauiFontItems started=1/OutputsUpToDate=0 -> executed=true. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResizetizerTests.cs | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index add476053d8b..657d53686185 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -211,27 +211,31 @@ static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) } static bool WasTargetSkipped(string binlogPath, string targetName) - { - var (started, skipped) = GetTargetStatus(binlogPath, targetName); - // A skipped (up-to-date) invocation emits BOTH a TargetStarted and a TargetSkipped event, - // while an executed invocation emits only TargetStarted. In multi-RID / outer+inner builds a - // target can be invoked several times, so the target counts as "skipped" only when every - // invocation was skipped: at least one skip and no net executions (started == skipped). - return skipped > 0 && started == skipped; - } + // "Skipped" here means the target did no work because its outputs were up-to-date. We count + // only OutputsUpToDate skips (see GetTargetStatus) so a genuine re-run is never masked. + => GetTargetStatus(binlogPath, targetName).upToDateSkips > 0; static bool WasTargetExecuted(string binlogPath, string targetName) { - var (started, skipped) = GetTargetStatus(binlogPath, targetName); - // Each skipped invocation consumes one TargetStarted, so the number of real executions is - // (started - skipped). The target executed if at least one invocation actually ran. - return started > skipped; + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + // The target actually ran its tasks: it started at least once and was never skipped as + // up-to-date. An always-run target (no Inputs/Outputs) that is requested via several edges + // still emits PreviouslyBuiltSuccessfully skips for the extra edges — those must NOT be + // treated as "not executed", which is why we key off up-to-date skips only. + return started > 0 && upToDateSkips == 0; } - static (int started, int skipped) GetTargetStatus(string binlogPath, string targetName) + // Returns the number of TargetStarted events and the number of *up-to-date* skips + // (TargetSkipReason.OutputsUpToDate) for the target. Counting only OutputsUpToDate — rather than + // every TargetSkipped — is essential: when a target is requested through multiple edges (its own + // AfterTargets plus other targets' DependsOnTargets, e.g. ProcessMauiFonts pulled in by + // _CollectMauiFontItems and _ComputeAndroidResourcePaths), MSBuild emits a single OutputsUpToDate + // skip plus one PreviouslyBuiltSuccessfully skip per extra edge. A naive "any skip" or + // "started == skipped" check therefore misclassifies real multi-target builds. + static (int started, int upToDateSkips) GetTargetStatus(string binlogPath, string targetName) { int started = 0; - int skipped = 0; + int upToDateSkips = 0; if (File.Exists(binlogPath)) { foreach (var record in new BinLogReader().ReadRecords(binlogPath)) @@ -241,13 +245,14 @@ static bool WasTargetExecuted(string binlogPath, string targetName) case TargetStartedEventArgs s when string.Equals(s.TargetName, targetName, StringComparison.Ordinal): started++; break; - case TargetSkippedEventArgs sk when string.Equals(sk.TargetName, targetName, StringComparison.Ordinal): - skipped++; + case TargetSkippedEventArgs sk when string.Equals(sk.TargetName, targetName, StringComparison.Ordinal) + && sk.SkipReason == TargetSkipReason.OutputsUpToDate: + upToDateSkips++; break; } } } - return (started, skipped); + return (started, upToDateSkips); } static IReadOnlyList GetResizetizerOutputRoots(string projectDir, string config) @@ -315,12 +320,12 @@ static void AssertTargetSkipped(string binlogPath, string targetName, int minimu { Assert.True(File.Exists(binlogPath), $"Binlog not found: {binlogPath}"); - // Count TargetSkippedEventArgs directly instead of matching localized/implementation-specific - // log message text ("Skipping target ... because all output files are up-to-date"), which is - // brittle across MSBuild versions and non-English locales. - var (_, skipped) = GetTargetStatus(binlogPath, targetName); + // Count only up-to-date (OutputsUpToDate) skips — one per platform/project instance on a no-op + // build — via TargetSkippedEventArgs.SkipReason, instead of matching localized log text or + // counting the PreviouslyBuiltSuccessfully skips emitted for extra request edges. + var (_, upToDateSkips) = GetTargetStatus(binlogPath, targetName); - Assert.True(skipped >= minimumSkipCount, - $"Expected target '{targetName}' to be skipped at least {minimumSkipCount} times, but found {skipped}. See binlog: {binlogPath}"); + Assert.True(upToDateSkips >= minimumSkipCount, + $"Expected target '{targetName}' to be skipped as up-to-date at least {minimumSkipCount} times, but found {upToDateSkips}. See binlog: {binlogPath}"); } } From 619e68d1babbae8de4104570833d76453405e359 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:00:52 +0200 Subject: [PATCH 17/19] Refine target-status helpers per review; add macOS skip rationale Addresses Copilot review feedback on the SkipReason-based helpers: - WasTargetSkipped: require started == upToDateSkips (not just > 0) so a mixed binlog where one instance executed while another was up-to-date is not a false positive. Equivalent to the previous check for the single-TFM Android test, but correct for multi-instance callers. - WasTargetExecuted: use the invariant executions = started - upToDateSkips, so an always-run target requested via several edges (extra PreviouslyBuiltSuccessfully skips, no OutputsUpToDate) is still correctly classified as executed. - Add a `// Skip:` rationale on the macOS-only early return for consistency with the other platform-gated tests. Validated against the recorded real Android incremental binlog: ProcessMauiFonts started=1/upToDate=1 -> skipped; _CollectMauiFontItems started=1/upToDate=0 -> executed. Compiles 0/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResizetizerTests.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index 657d53686185..5b3bfd82ebc1 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -165,7 +165,7 @@ public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() // fully builds on macOS, so this is gated accordingly. The Android first-build path is // additionally covered on Windows by FontsAreCopiedToAndroidAssetsOnFirstBuild. if (!TestEnvironment.IsMacOS) - return; + return; // Skip: building the Apple TFMs (iOS/MacCatalyst) is only supported on macOS. var projectDir = TestDirectory; var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); @@ -211,18 +211,24 @@ static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) } static bool WasTargetSkipped(string binlogPath, string targetName) - // "Skipped" here means the target did no work because its outputs were up-to-date. We count - // only OutputsUpToDate skips (see GetTargetStatus) so a genuine re-run is never masked. - => GetTargetStatus(binlogPath, targetName).upToDateSkips > 0; + { + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + // Each project instance that reaches the target emits exactly one TargetStarted, followed by + // either an OutputsUpToDate skip (up-to-date) or real task execution (no OutputsUpToDate skip). + // PreviouslyBuiltSuccessfully skips from extra request edges add no TargetStarted, so the + // target is "skipped" only when it ran at least once and *every* started instance was + // up-to-date. Requiring started == upToDateSkips (rather than upToDateSkips > 0) avoids a false + // positive if some instance actually executed while another was up-to-date. + return started > 0 && started == upToDateSkips; + } static bool WasTargetExecuted(string binlogPath, string targetName) { var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); - // The target actually ran its tasks: it started at least once and was never skipped as - // up-to-date. An always-run target (no Inputs/Outputs) that is requested via several edges - // still emits PreviouslyBuiltSuccessfully skips for the extra edges — those must NOT be - // treated as "not executed", which is why we key off up-to-date skips only. - return started > 0 && upToDateSkips == 0; + // Executions = started instances that did NOT end in an OutputsUpToDate skip. An always-run + // target (no Inputs/Outputs) requested via several edges still only adds + // PreviouslyBuiltSuccessfully skips (no OutputsUpToDate), so it correctly counts as executed. + return started - upToDateSkips > 0; } // Returns the number of TargetStarted events and the number of *up-to-date* skips From e7ca7fcfdf2f9bbf1b5b5bf69f1f8e8c276174a6 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:00:08 +0200 Subject: [PATCH 18/19] Resizetizer: stop over-touching generated splash/font assets (align with ResizetizeImages) Per review (jonathanpeppers), ProcessMauiSplashScreens/ProcessMauiFonts touched ALL generated assets (@(_MauiSplashAssets) / @(_MauiFontOutputUnique)) at the end of each run. On Android those splash assets live under $(_MauiIntermediateSplashScreen) and feed LibraryResourceDirectories, so bumping their timestamps needlessly re-triggered aapt2 on unchanged resources whenever the target ran. The obj-scoped manifest ($(_Maui*OutputsFile)) is already touched AlwaysCreate and serves as the single up-to-date stamp, and @(_Maui*Outputs) in each target's Outputs still forces regeneration if a generated file disappears. This mirrors the sibling ResizetizeImages target, which touches only $(_ResizetizerStampFile) and never the generated images. Drop the broad asset touches; keep the stamp + manifest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Maui.Resizetizer.After.targets | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 9a0ad8d80c13..eb67cc49067c 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -513,8 +513,12 @@ + and the @(_MauiSplashOutputs) entries in this target's Outputs). The manifest is + touched (AlwaysCreate) so it acts as the single up-to-date stamp — mirroring the + ResizetizeImages target, which touches only $(_ResizetizerStampFile) and never the + generated files themselves. We deliberately do NOT touch @(_MauiSplashAssets): on + Android those are $(_MauiIntermediateSplashScreen)**\* and feed LibraryResourceDirectories, + so bumping their timestamps would needlessly re-trigger aapt2 on unchanged resources. --> - @@ -586,7 +589,10 @@ + The manifest is touched (AlwaysCreate) so it acts as the single up-to-date stamp, mirroring + ResizetizeImages. We deliberately do NOT touch @(_MauiFontOutputUnique): touching the + generated files would needlessly invalidate downstream consumers (e.g. Android aapt2 / + iOS asset processing) even when the font content is unchanged. --> <_MauiFontOutputUnique Include="$(_MauiIntermediateFonts)MauiInfo.plist" Condition="'$(_ResizetizerIsiOSApp)' == 'True' And Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> @@ -600,7 +606,6 @@ WriteOnlyWhenDifferent="true" /> - From 569d2a0e2de4e9102e0cac691b86d6a5b22c3e57 Mon Sep 17 00:00:00 2001 From: PureWeen <223556219+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:53:35 -0500 Subject: [PATCH 19/19] Resizetizer: preserve manifest-based incremental recovery Use the outputs manifest as the sole incremental output and invalidate it before freshness evaluation when a recorded output is missing. This restores no-op skipping without re-stamping generated assets, and adds focused Apple plist recovery coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b638daa1-ac96-4462-a8e3-57a7493f041f --- .../instructions/resizetizer.instructions.md | 22 +++++++--- .../Microsoft.Maui.Resizetizer.After.targets | 35 ++++++++------- .../ResizetizerTests.cs | 44 ++++++++++++++++++- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md index 8f6823e3da96..80a272e9e272 100644 --- a/.github/instructions/resizetizer.instructions.md +++ b/.github/instructions/resizetizer.instructions.md @@ -74,9 +74,15 @@ Each processing target (except `mauimanifest.stamp`) has a companion `.inputs` f 1. Write the list of generated files to a `*.outputs` manifest with `WriteLinesToFile`. 2. Read it back **before** the up-to-date check via a `_Read*Outputs` target wired through `DependsOnTargets`. -3. Include those files in the target `Outputs`, e.g. `Outputs="$(_MauiFontOutputsFile);@(_MauiFontOutputs)"`. +3. Before the incremental check, detect missing files from the manifest and delete the + manifest when any are absent. +4. Use the manifest as the target's sole `Outputs`, e.g. `Outputs="$(_MauiFontOutputsFile)"`. -Now if any generated file disappears, MSBuild sees a missing output and re-runs *only* that target. `ProcessMauiFonts` / `ProcessMauiSplashScreens` use this pattern; `ResizetizeImages` keeps its legacy stamp alongside its own `mauiimage.outputs`. +Now if any generated file disappears, the read target invalidates the manifest, MSBuild sees +the missing output, and re-runs *only* that target. The target does not re-stamp unchanged +generated assets, so downstream consumers such as Android aapt2 avoid unnecessary work. +`ProcessMauiFonts` / `ProcessMauiSplashScreens` use this pattern; `ResizetizeImages` keeps +its legacy stamp alongside its own `mauiimage.outputs`. ## Target Pipeline @@ -122,7 +128,7 @@ MSBuild's `Inputs`/`Outputs` incremental check skips **targets** when outputs ar in a manifest (read back by _ReadMauiFontOutputs) so a deleted output re-triggers it. --> @@ -131,14 +137,20 @@ MSBuild's `Inputs`/`Outputs` incremental check skips **targets** when outputs ar - + - + + + <_MauiMissingFontOutput Include="@(_MauiFontOutputs)" + Condition="!Exists('%(_MauiFontOutputs.Identity)')" /> + + diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index d38d502b937b..74cd94653edd 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -404,7 +404,7 @@ @@ -511,14 +511,10 @@ - + + + <_MauiMissingSplashOutput Include="@(_MauiSplashOutputs)" Condition="!Exists('%(_MauiSplashOutputs.Identity)')" /> + + @@ -586,13 +586,10 @@ Condition="'$(_ResizetizerIsiOSApp)' == 'True' And '@(MauiFont)' == '' And Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" Files="$(_MauiIntermediateFonts)MauiInfo.plist" /> - + <_MauiFontOutputUnique Include="$(_MauiIntermediateFonts)MauiInfo.plist" Condition="'$(_ResizetizerIsiOSApp)' == 'True' And Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> @@ -709,6 +706,10 @@ + + <_MauiMissingFontOutput Include="@(_MauiFontOutputs)" Condition="!Exists('%(_MauiFontOutputs.Identity)')" /> + + diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index 0fb2f3c86e9b..38938afee33c 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -154,8 +154,9 @@ public void FontsAreCopiedToAndroidAssetsOnFirstBuild(string config) // re-trigger ProcessMauiFonts / ProcessMauiSplashScreens on the next build. This is guaranteed by // tracking the generated files in mauifont.outputs / mauisplash.outputs manifests that feed the // targets' Outputs (see _ReadMauiFontOutputs / _ReadMauiSplashOutputs), replacing the old stamp - // files whose timestamps could stay newer than the deleted outputs. A final no-op build asserts - // both targets are skipped when nothing changed. + // files whose timestamps could stay newer than the deleted outputs. It first deletes only the + // Apple MauiInfo.plist to verify that its separate manifest entry re-triggers font processing. + // A final no-op build asserts both targets are skipped when nothing changed. [Fact] public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() { @@ -181,6 +182,30 @@ public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() AssertBuiltTargetPlatforms(intermediateOutputRoots); AssertIntermediateOutputsExist(intermediateOutputRoots); + var appleOutputRoots = intermediateOutputRoots + .Where(IsAppleTargetFramework) + .ToArray(); + Assert.NotEmpty(appleOutputRoots); + + foreach (var appleOutputRoot in appleOutputRoots) + { + var plistPath = Path.Combine(appleOutputRoot, "resizetizer", "f", "MauiInfo.plist"); + Assert.True(File.Exists(plistPath), $"Missing generated font plist '{plistPath}'."); + File.Delete(plistPath); + } + + var plistRecoveryBinlogPath = Path.Combine(projectDir, "plist-recovery.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, binlogPath: plistRecoveryBinlogPath, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to rebuild missing font plists. Check test output/attachments for errors."); + + AssertTargetExecuted(plistRecoveryBinlogPath, "ProcessMauiFonts", appleOutputRoots.Length); + + foreach (var appleOutputRoot in appleOutputRoots) + { + var plistPath = Path.Combine(appleOutputRoot, "resizetizer", "f", "MauiInfo.plist"); + Assert.True(File.Exists(plistPath), $"Missing regenerated font plist '{plistPath}'."); + } + foreach (var intermediateOutputRoot in intermediateOutputRoots) { DeleteDirectory(Path.Combine(intermediateOutputRoot, "resizetizer", "f")); @@ -290,6 +315,10 @@ static void AssertBuiltTargetPlatforms(IReadOnlyList intermediateOutputR static bool ContainsTargetFramework(string path, string targetFramework) => path.Contains(targetFramework, StringComparison.OrdinalIgnoreCase); + static bool IsAppleTargetFramework(string path) => + ContainsTargetFramework(path, $"{DotNetCurrent}-ios") || + ContainsTargetFramework(path, $"{DotNetCurrent}-maccatalyst"); + static void AssertIntermediateOutputsExist(IReadOnlyList intermediateOutputRoots) { foreach (var intermediateOutputRoot in intermediateOutputRoots) @@ -334,6 +363,17 @@ static void AssertTargetSkipped(string binlogPath, string targetName, int minimu Assert.True(upToDateSkips >= minimumSkipCount, $"Expected target '{targetName}' to be skipped as up-to-date at least {minimumSkipCount} times, but found {upToDateSkips}. See binlog: {binlogPath}"); } + + static void AssertTargetExecuted(string binlogPath, string targetName, int minimumExecutionCount) + { + Assert.True(File.Exists(binlogPath), $"Binlog not found: {binlogPath}"); + + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + var executions = started - upToDateSkips; + + Assert.True(executions >= minimumExecutionCount, + $"Expected target '{targetName}' to execute at least {minimumExecutionCount} times, but found {executions}. See binlog: {binlogPath}"); + } [Theory] [InlineData("maui", "mauilib", true)] [InlineData("maui", "mauilib", false)]