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