diff --git a/.github/scripts/template-size-tracking/build-and-publish.ps1 b/.github/scripts/template-size-tracking/build-and-publish.ps1
new file mode 100644
index 000000000000..3e919e5c8ae6
--- /dev/null
+++ b/.github/scripts/template-size-tracking/build-and-publish.ps1
@@ -0,0 +1,149 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Builds and publishes a .NET MAUI template project for size measurement.
+
+.PARAMETER ProjectPath
+ Path to the project directory.
+
+.PARAMETER Platform
+ Target platform (android, ios, maccatalyst, windows-packaged, windows-unpackaged).
+
+.PARAMETER Framework
+ Target framework (e.g., "net10.0-android").
+
+.PARAMETER Rid
+ Runtime identifier (e.g., "android-arm64", "ios-arm64", "win-x64").
+
+.PARAMETER IsAot
+ Whether to build with Native AOT ("True" or "False").
+#>
+
+param(
+ [Parameter(Mandatory)][string]$ProjectPath,
+ [Parameter(Mandatory)][string]$Platform,
+ [Parameter(Mandatory)][string]$Framework,
+ [Parameter(Mandatory)][string]$Rid,
+ [Parameter(Mandatory)][string]$IsAot
+)
+
+$ErrorActionPreference = "Stop"
+
+$isAotBool = $IsAot -eq "True"
+$startTime = Get-Date
+
+$projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1
+if (-not $projectFile) {
+ Write-Error "Could not find project file in $ProjectPath"
+ exit 1
+}
+
+Write-Host "Found project: $($projectFile.FullName)"
+
+switch ($Platform) {
+ "android" {
+ if ($isAotBool) {
+ Write-Host "Building Android AAB with Native AOT..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ -p:PublishAot=true `
+ -p:AndroidPackageFormat=aab -p:AndroidKeyStore=false `
+ -o publish /bl:build.binlog
+ } else {
+ Write-Host "Building Android AAB..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release `
+ -p:AndroidPackageFormat=aab -p:AndroidKeyStore=false `
+ -o publish /bl:build.binlog
+ }
+ }
+
+ "ios" {
+ # iOS publish requires device RID + signing. Use ad-hoc signing
+ # (CodesignKey=-) with BuildIpa=false to skip IPA creation.
+ Write-Host "Building iOS with ad-hoc signing..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ -p:_RequireCodeSigning=false `
+ -p:EnableCodeSigning=false `
+ '-p:CodesignKey=-' `
+ -p:BuildIpa=false `
+ -p:ValidateXcodeVersion=false `
+ /bl:build.binlog
+
+ # With BuildIpa=false, the .app stays in bin/ (not copied to -o path).
+ # Find and copy it to publish/ for consistent measurement.
+ $binPath = Join-Path $ProjectPath "bin/Release/$Framework/$Rid"
+ Write-Host "Looking for .app in: $binPath"
+ $appBundle = Get-ChildItem -Path $binPath -Filter "*.app" -Directory -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($appBundle) {
+ New-Item -ItemType Directory -Path "publish" -Force | Out-Null
+ Copy-Item -Path $appBundle.FullName -Destination "publish/" -Recurse
+ Write-Host "Copied $($appBundle.Name) to publish/"
+ } else {
+ Write-Host "Warning: No .app bundle found in $binPath, listing contents:"
+ Get-ChildItem -Path $binPath -Recurse -ErrorAction SilentlyContinue |
+ Select-Object -First 20 | ForEach-Object { Write-Host " $_" }
+ }
+ }
+
+ "maccatalyst" {
+ if ($isAotBool) {
+ Write-Host "Building MacCatalyst with Native AOT..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ -p:PublishAot=true `
+ -p:_RequireCodeSigning=false -p:ValidateXcodeVersion=false `
+ -o publish /bl:build.binlog
+ } else {
+ Write-Host "Building MacCatalyst (unsigned)..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ -p:_RequireCodeSigning=false -p:ValidateXcodeVersion=false `
+ -o publish /bl:build.binlog
+ }
+ }
+
+ "windows-packaged" {
+ if ($isAotBool) {
+ Write-Host "Building Windows MSIX with Native AOT..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ -p:PublishAot=true `
+ -p:WindowsPackageType=MSIX `
+ -p:GenerateAppxPackageOnBuild=true -p:AppxPackageSigningEnabled=false `
+ -o publish /bl:build.binlog
+ } else {
+ Write-Host "Building Windows MSIX..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release `
+ -p:WindowsPackageType=MSIX `
+ -p:GenerateAppxPackageOnBuild=true -p:AppxPackageSigningEnabled=false `
+ -o publish /bl:build.binlog
+ }
+ }
+
+ "windows-unpackaged" {
+ if ($isAotBool) {
+ Write-Host "Building Windows unpackaged with Native AOT..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release -r $Rid `
+ --self-contained true -p:PublishAot=true `
+ -p:WindowsPackageType=None `
+ -o publish /bl:build.binlog
+ } else {
+ # Framework-dependent publish without explicit RID.
+ # Specifying -r win-x64 forces self-contained which needs
+ # Mono.win-x64 runtime package (not available for .NET 10 GA).
+ Write-Host "Building Windows unpackaged (framework-dependent)..."
+ dotnet publish $projectFile.FullName `
+ -f $Framework -c Release `
+ -p:WindowsPackageType=None `
+ -o publish /bl:build.binlog
+ }
+ }
+}
+
+$buildTime = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2)
+Write-Host "Build completed in $buildTime seconds"
+echo "BUILD_TIME=$buildTime" >> $env:GITHUB_ENV
diff --git a/.github/scripts/template-size-tracking/compare-and-alert.ps1 b/.github/scripts/template-size-tracking/compare-and-alert.ps1
new file mode 100644
index 000000000000..9e18739a9453
--- /dev/null
+++ b/.github/scripts/template-size-tracking/compare-and-alert.ps1
@@ -0,0 +1,281 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Compares current metrics with previous runs and generates alerts.
+
+.PARAMETER CurrentMetricsPath
+ Path to current metrics directory.
+
+.PARAMETER PreviousMetricsPath
+ Path to previous metrics directory.
+
+.PARAMETER HistoricalMetricsPath
+ Path to historical metrics directory with dated subdirectories.
+
+.PARAMETER AlertThreshold
+ Percentage threshold for alerts (default: 10).
+
+.PARAMETER FailureThreshold
+ Percentage threshold for critical failures (default: 20).
+#>
+
+param(
+ [Parameter(Mandatory=$true)]
+ [string]$CurrentMetricsPath,
+
+ [Parameter(Mandatory=$true)]
+ [string]$PreviousMetricsPath,
+
+ [Parameter(Mandatory=$false)]
+ [string]$HistoricalMetricsPath = "metrics-history",
+
+ [Parameter(Mandatory=$false)]
+ [decimal]$AlertThreshold = 10,
+
+ [Parameter(Mandatory=$false)]
+ [decimal]$FailureThreshold = 20
+)
+
+$ErrorActionPreference = "Stop"
+
+Write-Host "=== Comparing Metrics ===" -ForegroundColor Cyan
+Write-Host "Alert Threshold: $AlertThreshold%"
+Write-Host "Failure Threshold: $FailureThreshold%"
+
+# Load current metrics
+$currentMetrics = @()
+$currentFiles = Get-ChildItem -Path $CurrentMetricsPath -Filter "*.json" -Recurse
+
+foreach ($file in $currentFiles) {
+ $content = Get-Content $file.FullName -Raw | ConvertFrom-Json
+ $currentMetrics += $content
+}
+
+Write-Host "Loaded $($currentMetrics.Count) current metrics"
+
+# Load previous metrics
+$previousMetrics = @()
+if (Test-Path $PreviousMetricsPath) {
+ $previousFiles = Get-ChildItem -Path $PreviousMetricsPath -Filter "*.json" -Recurse
+
+ foreach ($file in $previousFiles) {
+ try {
+ $content = Get-Content $file.FullName -Raw | ConvertFrom-Json
+ $previousMetrics += $content
+ } catch {
+ Write-Warning "Could not parse previous metric file: $($file.Name)"
+ }
+ }
+
+ Write-Host "Loaded $($previousMetrics.Count) previous metrics"
+} else {
+ Write-Host "No previous metrics found - this appears to be the first run"
+}
+
+# Load historical metrics
+$historicalMetrics = @{}
+$daysToLoad = @(1, 2, 3, 4, 5, 7, 30)
+
+foreach ($daysAgo in $daysToLoad) {
+ $targetDate = (Get-Date).AddDays(-$daysAgo).ToString("yyyy-MM-dd")
+ $historicalPath = Join-Path $HistoricalMetricsPath $targetDate
+
+ if (Test-Path $historicalPath) {
+ $historicalFiles = Get-ChildItem -Path $historicalPath -Filter "*.json" -Recurse
+ $metrics = @()
+
+ foreach ($file in $historicalFiles) {
+ try {
+ $content = Get-Content $file.FullName -Raw | ConvertFrom-Json
+ $metrics += $content
+ } catch {
+ Write-Warning "Could not parse historical metric file: $($file.Name)"
+ }
+ }
+
+ $historicalMetrics[$daysAgo] = $metrics
+ Write-Host "Loaded $($metrics.Count) metrics from $daysAgo day(s) ago ($targetDate)"
+ } else {
+ Write-Host "No historical metrics found for $daysAgo day(s) ago ($targetDate)"
+ $historicalMetrics[$daysAgo] = @()
+ }
+}
+
+# Compare metrics
+$comparisons = @()
+$alerts = @()
+$criticalAlerts = @()
+
+foreach ($current in $currentMetrics) {
+ $currentKey = if ($current.PSObject.Properties["description"]) { $current.description } else { $current.platform }
+
+ # Find matching previous metric
+ $previous = $previousMetrics | Where-Object {
+ $previousKey = if ($_.PSObject.Properties["description"]) { $_.description } else { $_.platform }
+ $_.dotnetVersion -eq $current.dotnetVersion -and
+ $_.template -eq $current.template -and
+ $previousKey -eq $currentKey
+ } | Select-Object -First 1
+
+ $comparison = @{
+ dotnetVersion = $current.dotnetVersion
+ template = $current.template
+ platform = $currentKey
+ currentSize = $current.packageSize
+ currentCompressedSize = $current.compressedSize
+ currentBuildTime = $current.buildTimeSeconds
+ previousSize = if ($previous) { $previous.packageSize } else { 0 }
+ previousBuildTime = if ($previous) { $previous.buildTimeSeconds } else { 0 }
+ sizeChange = 0
+ sizeChangePercent = 0
+ buildTimeChange = 0
+ buildTimeChangePercent = 0
+ status = "new"
+ historical = @{}
+ }
+
+ # Historical data
+ foreach ($daysAgo in $daysToLoad) {
+ $historical = $historicalMetrics[$daysAgo] | Where-Object {
+ $historicalKey = if ($_.PSObject.Properties["description"]) { $_.description } else { $_.platform }
+ $_.dotnetVersion -eq $current.dotnetVersion -and
+ $_.template -eq $current.template -and
+ $historicalKey -eq $currentKey
+ } | Select-Object -First 1
+
+ if ($historical) {
+ $compressedSize = if ($historical.PSObject.Properties["compressedSize"]) { $historical.compressedSize } else { 0 }
+ $percentChange = 0
+
+ if ($compressedSize -gt 0 -and $current.compressedSize -gt 0) {
+ $percentChange = [math]::Round((($current.compressedSize - $compressedSize) / $compressedSize) * 100, 2)
+ }
+
+ $comparison.historical["days_$daysAgo"] = @{
+ compressedSize = $compressedSize
+ percentChange = $percentChange
+ }
+ } else {
+ $comparison.historical["days_$daysAgo"] = @{
+ compressedSize = 0
+ percentChange = 0
+ }
+ }
+ }
+
+ if ($previous) {
+ if ($previous.packageSize -gt 0) {
+ $comparison.sizeChange = $current.packageSize - $previous.packageSize
+ $comparison.sizeChangePercent = [math]::Round(($comparison.sizeChange / $previous.packageSize) * 100, 2)
+ }
+
+ if ($previous.buildTimeSeconds -gt 0) {
+ $comparison.buildTimeChange = $current.buildTimeSeconds - $previous.buildTimeSeconds
+ $comparison.buildTimeChangePercent = [math]::Round(($comparison.buildTimeChange / $previous.buildTimeSeconds) * 100, 2)
+ }
+
+ if ($comparison.sizeChangePercent -ge $FailureThreshold) {
+ $comparison.status = "critical"
+ $criticalAlerts += $comparison
+ } elseif ($comparison.sizeChangePercent -ge $AlertThreshold) {
+ $comparison.status = "alert"
+ $alerts += $comparison
+ } elseif ($comparison.sizeChangePercent -gt 0) {
+ $comparison.status = "increased"
+ } elseif ($comparison.sizeChangePercent -lt 0) {
+ $comparison.status = "decreased"
+ } else {
+ $comparison.status = "unchanged"
+ }
+ }
+
+ $comparisons += $comparison
+}
+
+# Save comparison results
+$comparisons | ConvertTo-Json -Depth 10 | Out-File -FilePath "comparison.json" -Encoding UTF8
+
+# Generate alert report if needed
+if ($alerts.Count -gt 0 -or $criticalAlerts.Count -gt 0) {
+ Write-Host "`nβ οΈ ALERTS DETECTED!" -ForegroundColor Red
+
+ $alertReport = @"
+# β οΈ .NET MAUI Template Size Alert - $(Get-Date -Format "yyyy-MM-dd")
+
+Significant size increases detected in .NET MAUI templates.
+
+"@
+
+ if ($criticalAlerts.Count -gt 0) {
+ $alertReport += @"
+
+## π΄ Critical Increases (>$FailureThreshold%)
+
+| Template | Platform | .NET | Previous | Current | Change |
+|----------|----------|------|----------|---------|--------|
+
+"@
+
+ foreach ($alert in $criticalAlerts) {
+ $prevMB = [math]::Round($alert.previousSize / 1MB, 2)
+ $currMB = [math]::Round($alert.currentSize / 1MB, 2)
+ $change = "+$($alert.sizeChangePercent)%"
+ $alertReport += "| $($alert.template) | $($alert.platform) | $($alert.dotnetVersion) | $prevMB MB | $currMB MB | $change π΄ |`n"
+ }
+ }
+
+ if ($alerts.Count -gt 0) {
+ $alertReport += @"
+
+## π‘ Notable Increases (>$AlertThreshold%)
+
+| Template | Platform | .NET | Previous | Current | Change |
+|----------|----------|------|----------|---------|--------|
+
+"@
+
+ foreach ($alert in $alerts) {
+ $prevMB = [math]::Round($alert.previousSize / 1MB, 2)
+ $currMB = [math]::Round($alert.currentSize / 1MB, 2)
+ $change = "+$($alert.sizeChangePercent)%"
+ $alertReport += "| $($alert.template) | $($alert.platform) | $($alert.dotnetVersion) | $prevMB MB | $currMB MB | $change π‘ |`n"
+ }
+ }
+
+ $alertReport += @"
+
+## Details
+
+- **Workflow Run**: [View Details]($($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID))
+- **Alert Threshold**: $AlertThreshold%
+- **Failure Threshold**: $FailureThreshold%
+
+---
+*This issue was automatically generated by the daily template size tracking workflow.*
+"@
+
+ $alertReport | Out-File -FilePath "alert-report.md" -Encoding UTF8
+
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "alert=true"
+
+ if ($criticalAlerts.Count -gt 0) {
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "critical=true"
+ Write-Host "Critical alerts: $($criticalAlerts.Count)" -ForegroundColor Red
+ } else {
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "critical=false"
+ }
+} else {
+ Write-Host "`nβ No significant size increases detected" -ForegroundColor Green
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "alert=false"
+ Add-Content -Path $env:GITHUB_OUTPUT -Value "critical=false"
+}
+
+# Summary
+Write-Host "`n=== Comparison Summary ===" -ForegroundColor Cyan
+Write-Host "Total comparisons: $($comparisons.Count)"
+Write-Host "New entries: $(($comparisons | Where-Object { $_.status -eq 'new' }).Count)"
+Write-Host "Decreased: $(($comparisons | Where-Object { $_.status -eq 'decreased' }).Count)"
+Write-Host "Unchanged: $(($comparisons | Where-Object { $_.status -eq 'unchanged' }).Count)"
+Write-Host "Increased: $(($comparisons | Where-Object { $_.status -eq 'increased' }).Count)"
+Write-Host "Alerts: $($alerts.Count)" -ForegroundColor Yellow
+Write-Host "Critical: $($criticalAlerts.Count)" -ForegroundColor Red
diff --git a/.github/scripts/template-size-tracking/create-project.ps1 b/.github/scripts/template-size-tracking/create-project.ps1
new file mode 100644
index 000000000000..6c77a147ab35
--- /dev/null
+++ b/.github/scripts/template-size-tracking/create-project.ps1
@@ -0,0 +1,91 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Creates a .NET MAUI template project configured for size measurement.
+
+.PARAMETER Template
+ Template to use (maui, maui-blazor, maui-sample).
+ "maui-sample" is a synthetic name that maps to "dotnet new maui --sample-content".
+
+.PARAMETER DotNetVersion
+ .NET version (e.g., "9.0", "10.0").
+
+.PARAMETER Description
+ Platform description (e.g., "android", "ios", "windows-packaged-aot").
+
+.PARAMETER Framework
+ Target framework (e.g., "net10.0-android").
+#>
+
+param(
+ [Parameter(Mandatory)][string]$Template,
+ [Parameter(Mandatory)][string]$DotNetVersion,
+ [Parameter(Mandatory)][string]$Description,
+ [Parameter(Mandatory)][string]$Framework
+)
+
+$ErrorActionPreference = "Stop"
+
+# Project name: hyphens are fine for MSIX identity ([-.A-Za-z0-9]).
+# Remove hyphens from template name to keep it short.
+$shortTemplate = $Template -replace '-', ''
+$projectName = "Ma-$shortTemplate-$Description"
+
+# Create project in $HOME to avoid inheriting repo's Directory.Build.props
+# (which imports Arcade SDK and causes build failures)
+$buildRoot = Join-Path $HOME "template-builds"
+New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null
+$projectDir = Join-Path $buildRoot $projectName
+
+# Place NuGet.config in the PARENT directory (not project dir) to avoid
+# macOS build system bundling it into .app packages
+$nugetConfig = @"
+
+
+
+
+
+
+"@
+$nugetConfig | Out-File -FilePath (Join-Path $buildRoot "NuGet.config") -Encoding UTF8
+
+# Map synthetic template names to actual dotnet new commands.
+# "maui-sample" β "dotnet new maui --sample-content"
+$dotnetNewTemplate = $Template
+$extraArgs = @()
+if ($Template -eq 'maui-sample') {
+ $dotnetNewTemplate = 'maui'
+ $extraArgs = @('--sample-content')
+}
+
+Write-Host "Creating project: dotnet new $dotnetNewTemplate -o $projectDir --framework net$DotNetVersion $($extraArgs -join ' ')"
+dotnet new $dotnetNewTemplate -o $projectDir --framework "net$DotNetVersion" @extraArgs
+
+# Pin SDK version to match the target .NET version.
+# Runners may have multiple SDKs installed; without pinning, the highest
+# SDK resolves workload manifests that conflict with older TFMs.
+$sdkVersion = dotnet --list-sdks `
+ | Where-Object { $_ -match "^$DotNetVersion" } `
+ | ForEach-Object { ($_ -split ' ')[0] } `
+ | Select-Object -Last 1
+
+if ($sdkVersion) {
+ Write-Host "Pinning SDK to $sdkVersion via global.json"
+ @{ sdk = @{ version = $sdkVersion; rollForward = "latestPatch" } } `
+ | ConvertTo-Json `
+ | Out-File -FilePath (Join-Path $projectDir "global.json") -Encoding UTF8
+}
+
+# Restrict TargetFrameworks to only the platform we're building for.
+# MAUI templates target all platforms (android, ios, maccatalyst, windows).
+# Building on Ubuntu for Android would fail trying to resolve iOS workloads.
+$csproj = Get-ChildItem -Path $projectDir -Filter "*.csproj" -Recurse | Select-Object -First 1
+if ($csproj) {
+ $content = Get-Content $csproj.FullName -Raw
+ $content = $content -replace '[^<]+', "$Framework"
+ Set-Content -Path $csproj.FullName -Value $content
+ Write-Host "Restricted TargetFrameworks to: $Framework"
+}
+
+echo "PROJECT_PATH=$projectDir" >> $env:GITHUB_ENV
+echo "PROJECT_NAME=$projectName" >> $env:GITHUB_ENV
diff --git a/.github/scripts/template-size-tracking/generate-summary.ps1 b/.github/scripts/template-size-tracking/generate-summary.ps1
new file mode 100644
index 000000000000..d6e69c493104
--- /dev/null
+++ b/.github/scripts/template-size-tracking/generate-summary.ps1
@@ -0,0 +1,225 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Generates a GitHub Actions summary report for .NET MAUI template size tracking.
+
+.PARAMETER MetricsPath
+ Path to current metrics directory.
+
+.PARAMETER ComparisonPath
+ Path to comparison JSON file.
+#>
+
+param(
+ [Parameter(Mandatory=$true)]
+ [string]$MetricsPath,
+
+ [Parameter(Mandatory=$false)]
+ [string]$ComparisonPath = "comparison.json"
+)
+
+$ErrorActionPreference = "Stop"
+
+Write-Host "=== Generating Summary Report ===" -ForegroundColor Cyan
+
+# Load comparisons
+$comparisons = @()
+if (Test-Path $ComparisonPath) {
+ $comparisons = Get-Content $ComparisonPath -Raw | ConvertFrom-Json
+}
+
+# Load current metrics
+$currentMetrics = @()
+$currentFiles = Get-ChildItem -Path $MetricsPath -Filter "*.json" -Recurse
+
+foreach ($file in $currentFiles) {
+ $content = Get-Content $file.FullName -Raw | ConvertFrom-Json
+ $currentMetrics += $content
+}
+
+# Group by .NET version
+$groupedByDotNet = $currentMetrics | Group-Object -Property dotnetVersion
+
+$totalSize = ($currentMetrics | Measure-Object -Property packageSize -Sum).Sum
+$avgBuildTime = ($currentMetrics | Measure-Object -Property buildTimeSeconds -Average).Average
+
+$summary = @"
+# π .NET MAUI Template Size Tracking - $(Get-Date -Format "yyyy-MM-dd")
+
+## Overall Statistics
+
+- **Total Configurations**: $($currentMetrics.Count)
+- **Total Package Size**: $([math]::Round($totalSize / 1GB, 2)) GB
+- **Average Build Time**: $([math]::Round($avgBuildTime, 2)) seconds
+- **MAUI Templates Version**: $(if ($currentMetrics.Count -gt 0) { $currentMetrics[0].mauiVersion } else { 'unknown' })
+
+"@
+
+foreach ($dotnetGroup in $groupedByDotNet) {
+ $dotnetVersion = $dotnetGroup.Name
+ $summary += @"
+
+## .NET $dotnetVersion
+
+| Template | Platform | Size | Compressed | Files | Assemblies | Build Time | Change | 1 day | 2 days | 3 days | 4 days | 5 days | 1 week | 1 month |
+|----------|----------|------|------------|-------|------------|------------|--------|-------|--------|--------|--------|--------|--------|---------|
+
+"@
+
+ foreach ($metric in $dotnetGroup.Group | Sort-Object template, platform) {
+ $metricKey = if ($metric.PSObject.Properties["description"]) { $metric.description } else { $metric.platform }
+
+ $comparison = $comparisons | Where-Object {
+ $_.dotnetVersion -eq $metric.dotnetVersion -and
+ $_.template -eq $metric.template -and
+ $_.platform -eq $metricKey
+ } | Select-Object -First 1
+
+ $sizeMB = [math]::Round($metric.packageSize / 1MB, 1)
+ $compressedMB = [math]::Round($metric.compressedSize / 1MB, 1)
+ if ($metric.PSObject.Properties["buildTimeFormatted"] -and $metric.buildTimeFormatted) {
+ $buildTime = $metric.buildTimeFormatted
+ } else {
+ $buildTime = "$([math]::Round($metric.buildTimeSeconds / 60, 1))m"
+ }
+
+ $changeIndicator = ""
+ if ($comparison -and $comparison.status -ne "new") {
+ $changePercent = [math]::Round($comparison.sizeChangePercent, 1)
+
+ if ($changePercent -gt 1) {
+ $changeIndicator = "+$changePercent% π΄"
+ } elseif ($changePercent -gt 0) {
+ $changeIndicator = "+$changePercent% π "
+ } elseif ($changePercent -lt -1) {
+ $changeIndicator = "$changePercent% π’"
+ } elseif ($changePercent -lt 0) {
+ $changeIndicator = "$changePercent% π "
+ } else {
+ $changeIndicator = "β"
+ }
+ } elseif ($comparison -and $comparison.status -eq "new") {
+ $changeIndicator = "NEW β¨"
+ } else {
+ $changeIndicator = "β"
+ }
+
+ # Historical columns
+ $historicalColumns = @()
+ $daysToShow = @(1, 2, 3, 4, 5, 7, 30)
+
+ foreach ($daysAgo in $daysToShow) {
+ $histData = $null
+ $keyName = "days_$daysAgo"
+
+ if ($comparison -and $comparison.PSObject.Properties["historical"] -and $comparison.historical.PSObject.Properties[$keyName]) {
+ $histData = $comparison.historical.$keyName
+ }
+
+ if ($histData -and $histData.compressedSize -gt 0) {
+ $histSizeMB = [math]::Round($histData.compressedSize / 1MB, 1)
+ $histPercent = [math]::Round($histData.percentChange, 1)
+
+ $percentStr = if ($histPercent -gt 1) {
+ "+$histPercent% π΄"
+ } elseif ($histPercent -gt 0) {
+ "+$histPercent% π "
+ } elseif ($histPercent -lt -1) {
+ "$histPercent% π’"
+ } elseif ($histPercent -lt 0) {
+ "$histPercent% π "
+ } else {
+ "0%"
+ }
+
+ $historicalColumns += "$histSizeMB MB ($percentStr)"
+ } else {
+ $historicalColumns += "β"
+ }
+ }
+
+ $summary += "| $($metric.template) | $metricKey | $sizeMB MB | $compressedMB MB | $($metric.fileCount) | $($metric.assemblyCount) | $buildTime | $changeIndicator | $($historicalColumns -join ' | ') |`n"
+ }
+}
+
+# Trend analysis
+if ($comparisons.Count -gt 0) {
+ $increased = ($comparisons | Where-Object { $_.sizeChangePercent -gt 0 }).Count
+ $decreased = ($comparisons | Where-Object { $_.sizeChangePercent -lt 0 }).Count
+ $unchanged = ($comparisons | Where-Object { $_.sizeChangePercent -eq 0 }).Count
+ $new = ($comparisons | Where-Object { $_.status -eq "new" }).Count
+
+ $summary += @"
+
+## Trend Analysis
+
+- π Increased: $increased
+- π Decreased: $decreased
+- β‘οΈ Unchanged: $unchanged
+- β¨ New: $new
+
+"@
+
+ $topIncreases = $comparisons |
+ Where-Object { $_.sizeChangePercent -gt 0 } |
+ Sort-Object -Property sizeChangePercent -Descending |
+ Select-Object -First 5
+
+ if ($topIncreases.Count -gt 0) {
+ $summary += @"
+
+### Top Size Increases
+
+| Template | Platform | .NET | Change |
+|----------|----------|------|--------|
+
+"@
+
+ foreach ($increase in $topIncreases) {
+ $summary += "| $($increase.template) | $($increase.platform) | $($increase.dotnetVersion) | +$($increase.sizeChangePercent)% |`n"
+ }
+ }
+
+ $topDecreases = $comparisons |
+ Where-Object { $_.sizeChangePercent -lt 0 } |
+ Sort-Object -Property sizeChangePercent |
+ Select-Object -First 5
+
+ if ($topDecreases.Count -gt 0) {
+ $summary += @"
+
+### Top Size Decreases
+
+| Template | Platform | .NET | Change |
+|----------|----------|------|--------|
+
+"@
+
+ foreach ($decrease in $topDecreases) {
+ $summary += "| $($decrease.template) | $($decrease.platform) | $($decrease.dotnetVersion) | $($decrease.sizeChangePercent)% |`n"
+ }
+ }
+}
+
+$summary += @"
+
+## Links
+
+- π [Workflow Run]($($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID))
+- π [Repository]($($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY))
+
+---
+*Generated at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") UTC*
+"@
+
+# Write to GitHub Actions summary
+$summaryFile = $env:GITHUB_STEP_SUMMARY
+if ($summaryFile) {
+ $summary | Out-File -FilePath $summaryFile -Encoding UTF8 -Append
+ Write-Host "Summary written to GitHub Actions" -ForegroundColor Green
+} else {
+ Write-Host $summary
+}
+
+$summary | Out-File -FilePath "summary-report.md" -Encoding UTF8
+Write-Host "Summary saved to summary-report.md" -ForegroundColor Green
diff --git a/.github/scripts/template-size-tracking/measure-package-size.ps1 b/.github/scripts/template-size-tracking/measure-package-size.ps1
new file mode 100644
index 000000000000..13ba73ae734b
--- /dev/null
+++ b/.github/scripts/template-size-tracking/measure-package-size.ps1
@@ -0,0 +1,339 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Measures package size and collects metrics for .NET MAUI templates.
+
+.PARAMETER PublishPath
+ Path to the publish output directory.
+
+.PARAMETER Platform
+ Target platform (android, ios, maccatalyst, windows-packaged, windows-unpackaged).
+
+.PARAMETER Description
+ Full description including platform and build type (e.g., windows-packaged-aot).
+
+.PARAMETER OS
+ Runner operating system (ubuntu-latest, macos-latest, windows-latest).
+
+.PARAMETER IsAot
+ Whether this is a Native AOT build.
+
+.PARAMETER Template
+ Template type (maui, maui-blazor).
+
+.PARAMETER DotNetVersion
+ .NET version used for the build.
+
+.PARAMETER MauiVersion
+ .NET MAUI Templates version used.
+
+.PARAMETER BuildTime
+ Build time in seconds.
+
+.PARAMETER OutputPath
+ Path where the metrics JSON file will be saved.
+#>
+
+param(
+ [Parameter(Mandatory=$true)]
+ [string]$PublishPath,
+
+ [Parameter(Mandatory=$true)]
+ [string]$Platform,
+
+ [Parameter(Mandatory=$true)]
+ [string]$Description,
+
+ [Parameter(Mandatory=$true)]
+ [string]$OS,
+
+ [Parameter(Mandatory=$true)]
+ [ValidateSet('True', 'False', 'true', 'false')]
+ [string]$IsAot,
+
+ [Parameter(Mandatory=$true)]
+ [string]$Template,
+
+ [Parameter(Mandatory=$true)]
+ [string]$DotNetVersion,
+
+ [Parameter(Mandatory=$true)]
+ [string]$MauiVersion,
+
+ [Parameter(Mandatory=$true)]
+ [decimal]$BuildTime,
+
+ [Parameter(Mandatory=$true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = "Stop"
+
+Write-Host "=== Measuring Package Size ===" -ForegroundColor Cyan
+Write-Host "Platform: $Platform"
+Write-Host "Description: $Description"
+Write-Host "OS: $OS"
+Write-Host "Is AOT: $IsAot"
+Write-Host "Template: $Template"
+Write-Host "Publish Path: $PublishPath"
+
+# Initialize metrics object
+$metrics = @{
+ timestamp = (Get-Date).ToUniversalTime().ToString("o")
+ template = $Template
+ platform = $Platform
+ description = $Description
+ os = $OS
+ dotnetVersion = $DotNetVersion
+ mauiVersion = $MauiVersion
+ buildTimeSeconds = [math]::Round($BuildTime, 2)
+ isAot = [System.Convert]::ToBoolean($IsAot)
+}
+
+function Get-DirectorySize {
+ param([string]$Path)
+ if (-not (Test-Path $Path)) { return 0 }
+ $size = (Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue |
+ Measure-Object -Property Length -Sum).Sum
+ if ($null -eq $size) { return 0 }
+ return $size
+}
+
+function Get-FileCount {
+ param([string]$Path, [string]$Filter = "*")
+ if (-not (Test-Path $Path)) { return 0 }
+ return (Get-ChildItem -Path $Path -Filter $Filter -Recurse -File -ErrorAction SilentlyContinue |
+ Measure-Object).Count
+}
+
+function Get-LargestFile {
+ param([string]$Path)
+ if (-not (Test-Path $Path)) { return $null }
+ $largest = Get-ChildItem -Path $Path -Recurse -File -ErrorAction SilentlyContinue |
+ Sort-Object Length -Descending |
+ Select-Object -First 1
+ if ($largest) {
+ return @{ name = $largest.Name; size = $largest.Length }
+ }
+ return $null
+}
+
+switch ($Platform) {
+ "android" {
+ Write-Host "Measuring Android AAB package..." -ForegroundColor Yellow
+
+ $aabFile = Get-ChildItem -Path $PublishPath -Filter "*.aab" -Recurse | Select-Object -First 1
+
+ if ($aabFile) {
+ $metrics.packageSize = $aabFile.Length
+ $metrics.packagePath = $aabFile.Name
+ Write-Host "AAB Size: $([math]::Round($aabFile.Length / 1MB, 2)) MB"
+ } else {
+ # Fall back to APK
+ $apkFile = Get-ChildItem -Path $PublishPath -Filter "*-Signed.apk" -Recurse | Select-Object -First 1
+ if (-not $apkFile) {
+ $apkFile = Get-ChildItem -Path $PublishPath -Filter "*.apk" -Recurse | Select-Object -First 1
+ }
+ if ($apkFile) {
+ $metrics.packageSize = $apkFile.Length
+ $metrics.packagePath = $apkFile.Name
+ Write-Host "APK Size: $([math]::Round($apkFile.Length / 1MB, 2)) MB"
+ } else {
+ Write-Warning "No AAB or APK file found"
+ $metrics.packageSize = 0
+ }
+ }
+
+ $metrics.totalPublishSize = Get-DirectorySize -Path $PublishPath
+ $metrics.fileCount = Get-FileCount -Path $PublishPath
+ $metrics.assemblyCount = Get-FileCount -Path $PublishPath -Filter "*.dll"
+ }
+
+ "ios" {
+ Write-Host "Measuring iOS package..." -ForegroundColor Yellow
+
+ # Look for .ipa first, then .app bundle
+ $ipaFile = Get-ChildItem -Path $PublishPath -Filter "*.ipa" -Recurse | Select-Object -First 1
+
+ if ($ipaFile) {
+ $metrics.packageSize = $ipaFile.Length
+ $metrics.packagePath = $ipaFile.Name
+ Write-Host "IPA Size: $([math]::Round($ipaFile.Length / 1MB, 2)) MB"
+ } else {
+ $appBundle = Get-ChildItem -Path $PublishPath -Filter "*.app" -Recurse -Directory | Select-Object -First 1
+ if ($appBundle) {
+ $metrics.packageSize = Get-DirectorySize -Path $appBundle.FullName
+ $metrics.packagePath = $appBundle.Name
+ Write-Host "App Bundle Size: $([math]::Round($metrics.packageSize / 1MB, 2)) MB"
+ } else {
+ Write-Warning "No IPA or .app bundle found, measuring publish directory"
+ $metrics.packageSize = Get-DirectorySize -Path $PublishPath
+ }
+ }
+
+ $metrics.totalPublishSize = Get-DirectorySize -Path $PublishPath
+ $metrics.fileCount = Get-FileCount -Path $PublishPath
+ $metrics.assemblyCount = Get-FileCount -Path $PublishPath -Filter "*.dll"
+ }
+
+ "maccatalyst" {
+ Write-Host "Measuring MacCatalyst package..." -ForegroundColor Yellow
+
+ # Look for .pkg first, then .app bundle
+ $pkgFile = Get-ChildItem -Path $PublishPath -Filter "*.pkg" -Recurse | Select-Object -First 1
+
+ if ($pkgFile) {
+ $metrics.packageSize = $pkgFile.Length
+ $metrics.packagePath = $pkgFile.Name
+ Write-Host "PKG Size: $([math]::Round($pkgFile.Length / 1MB, 2)) MB"
+ } else {
+ $appBundle = Get-ChildItem -Path $PublishPath -Filter "*.app" -Recurse -Directory | Select-Object -First 1
+ if ($appBundle) {
+ $metrics.packageSize = Get-DirectorySize -Path $appBundle.FullName
+ $metrics.packagePath = $appBundle.Name
+ Write-Host "App Bundle Size: $([math]::Round($metrics.packageSize / 1MB, 2)) MB"
+ } else {
+ Write-Warning "No PKG or .app bundle found, measuring publish directory"
+ $metrics.packageSize = Get-DirectorySize -Path $PublishPath
+ }
+ }
+
+ $metrics.totalPublishSize = Get-DirectorySize -Path $PublishPath
+ $metrics.fileCount = Get-FileCount -Path $PublishPath
+ $metrics.assemblyCount = Get-FileCount -Path $PublishPath -Filter "*.dll"
+ }
+
+ "windows-packaged" {
+ Write-Host "Measuring Windows MSIX package..." -ForegroundColor Yellow
+
+ $msixFile = Get-ChildItem -Path $PublishPath -Filter "*.msix" -Recurse | Select-Object -First 1
+
+ if ($msixFile) {
+ $metrics.packageSize = $msixFile.Length
+ $metrics.packagePath = $msixFile.Name
+ Write-Host "MSIX Size: $([math]::Round($msixFile.Length / 1MB, 2)) MB"
+ } else {
+ # Fall back to msixbundle or appx
+ $bundleFile = Get-ChildItem -Path $PublishPath -Filter "*.msixbundle" -Recurse | Select-Object -First 1
+ if ($bundleFile) {
+ $metrics.packageSize = $bundleFile.Length
+ $metrics.packagePath = $bundleFile.Name
+ Write-Host "MSIX Bundle Size: $([math]::Round($bundleFile.Length / 1MB, 2)) MB"
+ } else {
+ Write-Warning "No MSIX file found, measuring publish directory"
+ $metrics.packageSize = Get-DirectorySize -Path $PublishPath
+ }
+ }
+
+ $metrics.totalPublishSize = Get-DirectorySize -Path $PublishPath
+ $metrics.fileCount = Get-FileCount -Path $PublishPath
+ $metrics.assemblyCount = Get-FileCount -Path $PublishPath -Filter "*.dll"
+ }
+
+ "windows-unpackaged" {
+ Write-Host "Measuring Windows unpackaged publish folder..." -ForegroundColor Yellow
+
+ $metrics.packageSize = Get-DirectorySize -Path $PublishPath
+ $metrics.packagePath = "publish"
+ Write-Host "Publish Folder Size: $([math]::Round($metrics.packageSize / 1MB, 2)) MB"
+
+ $metrics.totalPublishSize = $metrics.packageSize
+ $metrics.fileCount = Get-FileCount -Path $PublishPath
+
+ if ($metrics.isAot) {
+ $mainExe = Get-ChildItem -Path $PublishPath -Filter "*.exe" -File |
+ Where-Object { $_.Length -gt 1MB } |
+ Sort-Object Length -Descending |
+ Select-Object -First 1
+ if ($mainExe) {
+ $metrics.assemblyCount = 1
+ $metrics.mainExecutableSize = $mainExe.Length
+ $metrics.mainExecutableName = $mainExe.Name
+ } else {
+ $metrics.assemblyCount = 0
+ }
+ } else {
+ $metrics.assemblyCount = (Get-ChildItem -Path $PublishPath -Filter "*.dll" -Recurse -File | Measure-Object).Count
+ $mainExe = Get-ChildItem -Path $PublishPath -Filter "*.exe" -File |
+ Where-Object { $_.Length -gt 1MB } |
+ Sort-Object Length -Descending |
+ Select-Object -First 1
+ if ($mainExe) {
+ $metrics.mainExecutableSize = $mainExe.Length
+ $metrics.mainExecutableName = $mainExe.Name
+ }
+ }
+ }
+}
+
+# Common metrics
+$metrics.totalAssemblySize = (Get-ChildItem -Path $PublishPath -Filter "*.dll" -Recurse -ErrorAction SilentlyContinue |
+ Measure-Object -Property Length -Sum).Sum
+
+$largestFile = Get-LargestFile -Path $PublishPath
+if ($largestFile) {
+ $metrics.largestFile = $largestFile
+}
+
+# Compressed size
+if ($Platform -eq "android" -or $Platform -eq "windows-packaged") {
+ # AAB and MSIX are already compressed archives
+ $metrics.compressedSize = $metrics.packageSize
+ Write-Host "Compressed Size (same as package): $([math]::Round($metrics.compressedSize / 1MB, 2)) MB"
+} elseif ($Platform -eq "ios" -or $Platform -eq "maccatalyst") {
+ # .app bundles are directories, not compressed archives β measure actual compressed size
+ $tempZip = Join-Path ([System.IO.Path]::GetTempPath()) "package.zip"
+ if (Test-Path $tempZip) { Remove-Item $tempZip -Force }
+ Write-Host "Creating compressed archive for .app bundle measurement..." -ForegroundColor Yellow
+ try {
+ Compress-Archive -Path "$PublishPath/*" -DestinationPath $tempZip -CompressionLevel Optimal -Force
+ $metrics.compressedSize = (Get-Item $tempZip).Length
+ Write-Host "Compressed Size: $([math]::Round($metrics.compressedSize / 1MB, 2)) MB"
+ Remove-Item $tempZip -Force
+ } catch {
+ Write-Warning "Could not create compressed archive: $_"
+ $metrics.compressedSize = 0
+ }
+} else {
+ $tempZip = Join-Path ([System.IO.Path]::GetTempPath()) "package.zip"
+ if (Test-Path $tempZip) { Remove-Item $tempZip -Force }
+ Write-Host "Creating compressed archive for measurement..." -ForegroundColor Yellow
+ try {
+ Compress-Archive -Path "$PublishPath/*" -DestinationPath $tempZip -CompressionLevel Optimal -Force
+ $metrics.compressedSize = (Get-Item $tempZip).Length
+ Write-Host "Compressed Size: $([math]::Round($metrics.compressedSize / 1MB, 2)) MB"
+ Remove-Item $tempZip -Force
+ } catch {
+ Write-Warning "Could not create compressed archive: $_"
+ $metrics.compressedSize = 0
+ }
+}
+
+if ($metrics.compressedSize -gt 0 -and $metrics.packageSize -gt 0) {
+ $metrics.compressionRatio = [math]::Round(($metrics.compressedSize / $metrics.packageSize) * 100, 2)
+}
+
+# SDK version
+try {
+ $sdkVersion = dotnet --version
+ $metrics.dotnetSdkVersion = $sdkVersion.Trim()
+} catch {
+ $metrics.dotnetSdkVersion = "unknown"
+}
+
+# Build time formatting
+$ts = [System.TimeSpan]::FromSeconds($metrics.buildTimeSeconds)
+$metrics.buildTimeFormatted = $ts.ToString("hh\:mm\:ss")
+
+# Summary
+Write-Host "`n=== Metrics Summary ===" -ForegroundColor Green
+Write-Host "Package Size: $([math]::Round($metrics.packageSize / 1MB, 2)) MB"
+Write-Host "Compressed Size: $([math]::Round($metrics.compressedSize / 1MB, 2)) MB"
+Write-Host "File Count: $($metrics.fileCount)"
+Write-Host "Assembly Count: $($metrics.assemblyCount)"
+Write-Host "Build Time: $($metrics.buildTimeFormatted)"
+
+$metricsJson = $metrics | ConvertTo-Json -Depth 10
+$metricsJson | Out-File -FilePath $OutputPath -Encoding UTF8
+
+Write-Host "`nMetrics saved to: $OutputPath" -ForegroundColor Green
diff --git a/.github/scripts/template-size-tracking/prepare-historical-data.ps1 b/.github/scripts/template-size-tracking/prepare-historical-data.ps1
new file mode 100644
index 000000000000..920eb6018ab3
--- /dev/null
+++ b/.github/scripts/template-size-tracking/prepare-historical-data.ps1
@@ -0,0 +1,65 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Prepares historical metrics data for comparison and trend analysis.
+
+.PARAMETER MetricsPath
+ Path to current metrics directory.
+
+.PARAMETER HistoryDir
+ Path to the historical metrics directory (restored from cache).
+
+.PARAMETER RetentionDays
+ Number of days of history to retain.
+#>
+
+param(
+ [string]$MetricsPath = "metrics",
+ [string]$HistoryDir = "metrics-history",
+ [int]$RetentionDays = 35
+)
+
+$ErrorActionPreference = "Stop"
+
+$today = (Get-Date).ToString("yyyy-MM-dd")
+
+if (-not (Test-Path $HistoryDir)) {
+ New-Item -ItemType Directory -Force -Path $HistoryDir | Out-Null
+ Write-Host "Created new metrics history directory"
+}
+
+# Copy current metrics into today's history slot
+$todayDir = Join-Path $HistoryDir $today
+New-Item -ItemType Directory -Force -Path $todayDir | Out-Null
+
+$metricsFiles = Get-ChildItem -Path $MetricsPath -Filter "*.json" -Recurse
+foreach ($file in $metricsFiles) {
+ Copy-Item $file.FullName -Destination $todayDir -Force
+}
+Write-Host "Saved $($metricsFiles.Count) metrics to $todayDir"
+
+# Find yesterday's metrics for comparison
+$yesterday = (Get-Date).AddDays(-1).ToString("yyyy-MM-dd")
+$yesterdayDir = Join-Path $HistoryDir $yesterday
+
+if (Test-Path $yesterdayDir) {
+ Write-Host "Found previous metrics from $yesterday"
+ Copy-Item $yesterdayDir -Destination "previous-metrics" -Recurse
+} else {
+ Write-Host "No previous metrics found for $yesterday"
+ New-Item -ItemType Directory -Force -Path "previous-metrics" | Out-Null
+}
+
+# Prune history older than retention period
+$cutoffDate = (Get-Date).AddDays(-$RetentionDays).ToString("yyyy-MM-dd")
+$historyDirs = Get-ChildItem -Path $HistoryDir -Directory | Sort-Object Name
+
+foreach ($dir in $historyDirs) {
+ if ($dir.Name -lt $cutoffDate) {
+ Write-Host "Pruning old metrics: $($dir.Name)"
+ Remove-Item $dir.FullName -Recurse -Force
+ }
+}
+
+$remaining = (Get-ChildItem -Path $HistoryDir -Directory).Count
+Write-Host "History contains $remaining day(s) of metrics"
diff --git a/.github/scripts/template-size-tracking/prepare-matrix.ps1 b/.github/scripts/template-size-tracking/prepare-matrix.ps1
new file mode 100644
index 000000000000..41e9733b6849
--- /dev/null
+++ b/.github/scripts/template-size-tracking/prepare-matrix.ps1
@@ -0,0 +1,145 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Generates the GitHub Actions build matrix for template size tracking.
+
+.PARAMETER DotNetVersions
+ Comma-separated .NET versions to test (e.g., "9.0,10.0").
+
+.PARAMETER Templates
+ Comma-separated templates to test (e.g., "maui,maui-blazor").
+#>
+
+param(
+ [string]$DotNetVersions = "9.0,10.0",
+ [string]$Templates = "maui,maui-blazor"
+)
+
+$ErrorActionPreference = "Stop"
+
+# Use different variable names to avoid PowerShell's case-insensitive
+# variable collision with the [string]-typed parameters above.
+[array]$versions = $DotNetVersions.Split(',') | ForEach-Object { $_.Trim() }
+[array]$templateList = $Templates.Split(',') | ForEach-Object { $_.Trim() }
+
+$matrix = @{
+ include = @()
+}
+
+foreach ($dotnet in $versions) {
+ foreach ($template in $templateList) {
+ # Android
+ $matrix.include += @{
+ description = 'android'
+ dotnet = $dotnet
+ template = $template
+ platform = 'android'
+ os = 'ubuntu-latest'
+ framework = "net$dotnet-android"
+ rid = 'android-arm64'
+ aot = $false
+ }
+
+ # Android Native AOT (.NET 10+ only)
+ if ($dotnet -ne "9.0") {
+ $matrix.include += @{
+ description = 'android-aot'
+ dotnet = $dotnet
+ template = $template
+ platform = 'android'
+ os = 'ubuntu-latest'
+ framework = "net$dotnet-android"
+ rid = 'android-arm64'
+ aot = $true
+ }
+ }
+
+ # iOS
+ $matrix.include += @{
+ description = 'ios'
+ dotnet = $dotnet
+ template = $template
+ platform = 'ios'
+ os = 'macos-latest'
+ framework = "net$dotnet-ios"
+ rid = 'ios-arm64'
+ aot = $false
+ }
+
+ # MacCatalyst
+ $matrix.include += @{
+ description = 'maccatalyst'
+ dotnet = $dotnet
+ template = $template
+ platform = 'maccatalyst'
+ os = 'macos-latest'
+ framework = "net$dotnet-maccatalyst"
+ rid = 'maccatalyst-arm64'
+ aot = $false
+ }
+
+ # MacCatalyst Native AOT
+ $matrix.include += @{
+ description = 'maccatalyst-aot'
+ dotnet = $dotnet
+ template = $template
+ platform = 'maccatalyst'
+ os = 'macos-latest'
+ framework = "net$dotnet-maccatalyst"
+ rid = 'maccatalyst-arm64'
+ aot = $true
+ }
+
+ # Windows Packaged (MSIX)
+ $matrix.include += @{
+ description = 'windows-packaged'
+ dotnet = $dotnet
+ template = $template
+ platform = 'windows-packaged'
+ os = 'windows-latest'
+ framework = "net$dotnet-windows10.0.19041.0"
+ rid = 'win-x64'
+ aot = $false
+ }
+
+ # Windows Packaged (MSIX) Native AOT
+ $matrix.include += @{
+ description = 'windows-packaged-aot'
+ dotnet = $dotnet
+ template = $template
+ platform = 'windows-packaged'
+ os = 'windows-latest'
+ framework = "net$dotnet-windows10.0.19041.0"
+ rid = 'win-x64'
+ aot = $true
+ }
+
+ # Windows Unpackaged
+ $matrix.include += @{
+ description = 'windows-unpackaged'
+ dotnet = $dotnet
+ template = $template
+ platform = 'windows-unpackaged'
+ os = 'windows-latest'
+ framework = "net$dotnet-windows10.0.19041.0"
+ rid = 'win-x64'
+ aot = $false
+ }
+
+ # Windows Unpackaged Native AOT
+ $matrix.include += @{
+ description = 'windows-unpackaged-aot'
+ dotnet = $dotnet
+ template = $template
+ platform = 'windows-unpackaged'
+ os = 'windows-latest'
+ framework = "net$dotnet-windows10.0.19041.0"
+ rid = 'win-x64'
+ aot = $true
+ }
+ }
+}
+
+$matrixJson = $matrix | ConvertTo-Json -Compress -Depth 10
+Write-Host "Matrix: $matrixJson"
+echo "matrix=$matrixJson" >> $env:GITHUB_OUTPUT
diff --git a/.github/workflows/daily-template-size-tracking.yml b/.github/workflows/daily-template-size-tracking.yml
new file mode 100644
index 000000000000..564ce1376bc3
--- /dev/null
+++ b/.github/workflows/daily-template-size-tracking.yml
@@ -0,0 +1,236 @@
+name: Daily Template Size Tracking
+
+on:
+ schedule:
+ # Daily at midnight ET (5 AM UTC)
+ - cron: '0 5 * * *'
+ pull_request:
+ branches:
+ - main
+ paths:
+ - '.github/workflows/daily-template-size-tracking.yml'
+ - '.github/scripts/template-size-tracking/**'
+ workflow_dispatch:
+ inputs:
+ dotnet_versions:
+ description: 'Comma-separated .NET versions to test (e.g., 9.0,10.0)'
+ required: false
+ default: '9.0,10.0'
+ type: string
+ templates:
+ description: 'Comma-separated templates to test (maui,maui-blazor,maui-sample)'
+ required: false
+ default: 'maui,maui-blazor,maui-sample'
+ type: string
+ alert_threshold:
+ description: 'Alert threshold percentage for size increases'
+ required: false
+ default: '10'
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ ALERT_THRESHOLD: ${{ inputs.alert_threshold || '10' }}
+ FAILURE_THRESHOLD: '20'
+
+jobs:
+ prepare-matrix:
+ runs-on: ubuntu-latest
+ outputs:
+ matrix: ${{ steps.set-matrix.outputs.matrix }}
+ maui-version: ${{ steps.detect-version.outputs.maui-version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Detect MAUI Version
+ id: detect-version
+ shell: pwsh
+ run: |
+ $response = Invoke-RestMethod -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.maui.templates.net10/index.json" -ErrorAction Stop
+ $stable = $response.versions | Where-Object { $_ -notmatch '-' }
+ $version = if ($stable.Count -gt 0) { $stable[-1] } else { $response.versions[-1] }
+ Write-Host "MAUI Templates version: $version"
+ echo "maui-version=$version" >> $env:GITHUB_OUTPUT
+
+ - name: Prepare Build Matrix
+ id: set-matrix
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/prepare-matrix.ps1" `
+ -DotNetVersions "${{ inputs.dotnet_versions || '9.0,10.0' }}" `
+ -Templates "${{ inputs.templates || 'maui,maui-blazor,maui-sample' }}"
+
+ build-and-measure:
+ needs: prepare-matrix
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET ${{ matrix.dotnet }}
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '${{ matrix.dotnet }}.x'
+
+ - name: Setup Xcode
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ LATEST_XCODE=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
+ if [ -n "$LATEST_XCODE" ]; then
+ echo "Selecting Xcode: $LATEST_XCODE"
+ sudo xcode-select -s "$LATEST_XCODE/Contents/Developer"
+ fi
+ xcodebuild -version
+
+ - name: Setup Java
+ if: matrix.platform == 'android'
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'microsoft'
+ java-version: '17'
+
+ - name: Install Workloads (Linux)
+ if: runner.os == 'Linux'
+ run: dotnet workload install maui-android
+
+ - name: Install Workloads (macOS/Windows)
+ if: runner.os != 'Linux'
+ run: dotnet workload install maui
+
+ - name: Create Project
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/create-project.ps1" `
+ -Template "${{ matrix.template }}" `
+ -DotNetVersion "${{ matrix.dotnet }}" `
+ -Description "${{ matrix.description }}" `
+ -Framework "${{ matrix.framework }}"
+
+ - name: Build and Publish
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/build-and-publish.ps1" `
+ -ProjectPath $env:PROJECT_PATH `
+ -Platform "${{ matrix.platform }}" `
+ -Framework "${{ matrix.framework }}" `
+ -Rid "${{ matrix.rid }}" `
+ -IsAot "${{ matrix.aot }}"
+
+ - name: Upload Build Binlog
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: binlog-${{ matrix.dotnet }}-${{ matrix.template }}-${{ matrix.description }}
+ path: build.binlog
+ retention-days: 14
+
+ - name: Measure Package Size
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/measure-package-size.ps1" `
+ -PublishPath "publish" `
+ -Platform "${{ matrix.platform }}" `
+ -Description "${{ matrix.description }}" `
+ -OS "${{ matrix.os }}" `
+ -IsAot "${{ matrix.aot }}" `
+ -Template "${{ matrix.template }}" `
+ -DotNetVersion "${{ matrix.dotnet }}" `
+ -MauiVersion "${{ needs.prepare-matrix.outputs.maui-version }}" `
+ -BuildTime $env:BUILD_TIME `
+ -OutputPath "metrics.json"
+
+ - name: Upload Metrics
+ uses: actions/upload-artifact@v4
+ with:
+ name: metrics-${{ matrix.dotnet }}-${{ matrix.template }}-${{ matrix.description }}
+ path: metrics.json
+ retention-days: 31
+
+ analyze-and-report:
+ needs: [prepare-matrix, build-and-measure]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download All Metrics
+ uses: actions/download-artifact@v4
+ with:
+ pattern: metrics-*
+ path: metrics
+
+ - name: Restore Historical Metrics Cache
+ uses: actions/cache/restore@v4
+ with:
+ path: metrics-history
+ # Each run saves with a unique key (GitHub Actions cache is immutable).
+ # Restore uses prefix matching to find the most recent entry.
+ key: template-size-metrics-${{ github.run_id }}
+ restore-keys: |
+ template-size-metrics-
+
+ - name: Prepare Historical Data
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/prepare-historical-data.ps1" `
+ -MetricsPath "metrics" `
+ -HistoryDir "metrics-history"
+
+ - name: Compare and Generate Report
+ id: compare
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/compare-and-alert.ps1" `
+ -CurrentMetricsPath "metrics" `
+ -PreviousMetricsPath "previous-metrics" `
+ -HistoricalMetricsPath "metrics-history" `
+ -AlertThreshold $env:ALERT_THRESHOLD `
+ -FailureThreshold $env:FAILURE_THRESHOLD
+
+ - name: Generate Summary
+ shell: pwsh
+ run: |
+ & "${{ github.workspace }}/.github/scripts/template-size-tracking/generate-summary.ps1" `
+ -MetricsPath "metrics" `
+ -ComparisonPath "comparison.json"
+
+ - name: Save Historical Metrics Cache
+ uses: actions/cache/save@v4
+ with:
+ path: metrics-history
+ key: template-size-metrics-${{ github.run_id }}
+
+ - name: Create Issue on Alert
+ if: steps.compare.outputs.alert == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const alertContent = fs.readFileSync('alert-report.md', 'utf8');
+
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `β οΈ Template Size Alert - ${new Date().toISOString().split('T')[0]}`,
+ body: alertContent,
+ labels: ['size-alert', 'automated']
+ });
+
+ - name: Fail on Critical Size Increase
+ if: steps.compare.outputs.critical == 'true'
+ run: |
+ echo "::error::Critical size increase detected (>${{ env.FAILURE_THRESHOLD }}%)"
+ exit 1
diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs
index 279b965728dc..2917b6060ed7 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs
@@ -489,7 +489,7 @@ void UpdatePosition(int position)
void SetCurrentItem(int carouselPosition)
{
- if (ItemsViewAdapter?.ItemsSource?.Count == 0)
+ if (ItemsViewAdapter?.ItemsSource?.Count == 0 || carouselPosition < 0)
return;
var item = ItemsViewAdapter.ItemsSource.GetItem(carouselPosition);
diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs
index 6fa39583b485..567bb5a30ba9 100644
--- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs
+++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs
@@ -45,6 +45,10 @@ public static void MapPeekAreaInsets(CarouselViewHandler handler, CarouselView c
public static void MapPosition(CarouselViewHandler handler, CarouselView carouselView)
{
+ if (carouselView.Position < 0)
+ {
+ return;
+ }
(handler.PlatformView as IMauiCarouselRecyclerView).UpdateFromPosition();
}
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue31680.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue31680.cs
new file mode 100644
index 000000000000..b7c8f1264df5
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue31680.cs
@@ -0,0 +1,170 @@
+ο»Ώusing System.Collections.ObjectModel;
+using Microsoft.Maui.Controls.Shapes;
+
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 31680, "System.IndexOutOfRangeException when scrolling CollectionView with image CarouselView", PlatformAffected.Android)]
+
+public class Issue31680 : ContentPage
+{
+ ObservableCollection
_items = new();
+
+ public ObservableCollection Items
+ {
+ get => _items;
+ set
+ {
+ _items = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Issue31680()
+ {
+ BindingContext = this;
+
+ var collectionView = new CollectionView
+ {
+ VerticalOptions = LayoutOptions.Fill,
+ HorizontalOptions = LayoutOptions.Fill,
+ AutomationId = "MainCollectionView"
+ };
+
+ collectionView.SetBinding(ItemsView.ItemsSourceProperty, nameof(Items));
+
+ collectionView.ItemTemplate = new DataTemplate(() =>
+ {
+ var border = new Border
+ {
+ StrokeShape = new RoundRectangle { CornerRadius = 8 },
+ Padding = 8,
+ BackgroundColor = Colors.White,
+ Margin = new Thickness(4)
+ };
+
+ var carouselView = new CarouselView
+ {
+ HeightRequest = 200,
+ VerticalOptions = LayoutOptions.Fill,
+ };
+ carouselView.SetBinding(ItemsView.ItemsSourceProperty, "ImageUrls");
+
+ carouselView.ItemTemplate = new DataTemplate(() =>
+ {
+ var image = new Image
+ {
+ Aspect = Aspect.AspectFill
+ };
+ image.SetBinding(Image.SourceProperty, ".");
+ return image;
+ });
+
+ var titleLabel = new Label
+ {
+ FontAttributes = FontAttributes.Bold,
+ FontSize = 16
+ };
+ titleLabel.SetBinding(Label.TextProperty, "Title");
+
+ var addressLabel = new Label
+ {
+ FontSize = 12
+ };
+ addressLabel.SetBinding(Label.TextProperty, "Address");
+
+ var priceLabel = new Label
+ {
+ HorizontalOptions = LayoutOptions.End
+ };
+ priceLabel.SetBinding(Label.TextProperty, new Binding("Price", stringFormat: "β¬{0:N0}"));
+
+ var favoriteButton = new Button { Text = "Favorite" };
+ var openButton = new Button { Text = "Open" };
+
+ var buttonLayout = new HorizontalStackLayout
+ {
+ Spacing = 10,
+ Children = { favoriteButton, openButton }
+ };
+
+ var stack = new VerticalStackLayout
+ {
+ Spacing = 6,
+ Children = { carouselView, titleLabel, addressLabel, priceLabel, buttonLayout }
+ };
+
+ border.Content = stack;
+
+ return border;
+ });
+
+ collectionView.Footer = new Label {Text = "Footer", AutomationId="Footer"};
+
+ var label = new Label
+ {
+ Text = "CollectionView with CarouselView Items",
+ FontSize = 20,
+ AutomationId="TitleLabel",
+ FontAttributes = FontAttributes.Bold,
+ HorizontalOptions = LayoutOptions.Center,
+ Margin = new Thickness(0, 10, 0, 10)
+ };
+
+ Grid.SetRow(label, 0);
+ Grid.SetRow(collectionView, 1);
+
+ Content = new Grid
+ {
+ RowDefinitions =
+ {
+ new RowDefinition { Height = GridLength.Auto },
+ new RowDefinition { Height = GridLength.Star }
+ },
+ Children =
+ {
+ label,
+ collectionView
+ }
+ };
+
+ LoadData();
+ }
+
+ void LoadData()
+ {
+ var random = new Random();
+ var items = new ObservableCollection();
+
+ for (int i = 1; i <= 10; i++)
+ {
+ var imageUrls = new List();
+ int imageCount = random.Next(3, 6);
+
+ for (int j = 0; j < imageCount; j++)
+ {
+ imageUrls.Add($"https://picsum.photos/id/{random.Next(1, 1084)}/400/300");
+ }
+
+ items.Add(new Listing
+ {
+ Id = Guid.NewGuid().ToString(),
+ Title = $"Beautiful Property {i}",
+ Address = $"Street Address {i}, City {i}",
+ Price = random.Next(100000, 1000000),
+ ImageUrls = imageUrls
+ });
+ }
+
+ Items = items;
+ }
+}
+
+public class Listing
+{
+ public string Id { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public string Address { get; set; } = string.Empty;
+ public decimal Price { get; set; }
+ public List ImageUrls { get; set; } = new();
+}
+
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31680.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31680.cs
new file mode 100644
index 000000000000..a7ccc726ed2c
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31680.cs
@@ -0,0 +1,29 @@
+ο»Ώ#if TEST_FAILS_ON_WINDOWS // Scrolling the CollectionView using App.ScrollDown is not working on Windows platform.
+ // Also, the issue itself is not reproducible on Windows.
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+public class Issue31680 : _IssuesUITest
+{
+ public Issue31680(TestDevice testDevice) : base(testDevice) { }
+
+ public override string Issue => "System.IndexOutOfRangeException when scrolling CollectionView with image CarouselView";
+
+ [Test]
+ [Category(UITestCategories.CarouselView)]
+ public void CollectionViewInsideCarouselViewShouldNotThrowIndexOutOfRangeException()
+ {
+ App.WaitForElement("TitleLabel");
+
+ // Note: This issue occurs only during manual scrolling. The test may fail randomly without the fix,
+ // but the issue can be consistently reproduced manually with each scroll.
+ App.ScrollDown("MainCollectionView", ScrollStrategy.Gesture, 0.99, swipeSpeed: 50, true);
+ App.ScrollDown("MainCollectionView", ScrollStrategy.Gesture, 0.99, swipeSpeed: 50, true);
+ App.ScrollTo("Footer");
+ App.WaitForElement("Footer");
+ }
+}
+#endif
\ No newline at end of file