From 62536f5037a87243c8d3b9712ba20ddc435b8164 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 10:41:06 +0200 Subject: [PATCH 01/30] Add template app distribution workflow Adds a manually triggered workflow that packs local MAUI templates, generates variant apps, builds Android/iOS packages, and publishes through fastlane for store testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 173 +++++++ .../Install-AppleSigningAssets.ps1 | 102 ++++ .../New-TemplateApp.ps1 | 116 +++++ .../Pack-Templates.ps1 | 52 ++ .../Prepare-Matrix.ps1 | 159 ++++++ .../Resolve-DotNetSdk.ps1 | 41 ++ .../Resolve-SourceRef.ps1 | 95 ++++ .../fastlane/Fastfile | 79 +++ .../fastlane/Gemfile | 3 + .../workflows/template-app-distribution.yml | 474 ++++++++++++++++++ 10 files changed, 1294 insertions(+) create mode 100644 .github/scripts/template-app-distribution/Build-TemplateApp.ps1 create mode 100644 .github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 create mode 100644 .github/scripts/template-app-distribution/New-TemplateApp.ps1 create mode 100644 .github/scripts/template-app-distribution/Pack-Templates.ps1 create mode 100644 .github/scripts/template-app-distribution/Prepare-Matrix.ps1 create mode 100644 .github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1 create mode 100644 .github/scripts/template-app-distribution/Resolve-SourceRef.ps1 create mode 100644 .github/scripts/template-app-distribution/fastlane/Fastfile create mode 100644 .github/scripts/template-app-distribution/fastlane/Gemfile create mode 100644 .github/workflows/template-app-distribution.yml diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 new file mode 100644 index 000000000000..be5582efaaf2 --- /dev/null +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -0,0 +1,173 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$ProjectPath, + + [Parameter(Mandatory)] + [ValidateSet("android", "ios")] + [string]$Platform, + + [Parameter(Mandatory)] + [string]$TargetFramework, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$AppDisplayVersion, + + [Parameter(Mandatory)] + [string]$AppBuildNumber, + + [string]$Configuration = "Release", + + [switch]$Publish +) + +$ErrorActionPreference = "Stop" + +function Assert-EnvironmentValue([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required environment variable '$Name' is not set." + } + + return $value +} + +function Write-Base64File([string]$Base64Value, [string]$Path) { + $bytes = [Convert]::FromBase64String($Base64Value) + [System.IO.File]::WriteAllBytes($Path, $bytes) +} + +function Get-NewestBuildOutput([string]$Root, [string]$Filter, [switch]$Directory) { + $itemType = if ($Directory) { "Directory" } else { "File" } + return Get-ChildItem -Path $Root -Filter $Filter -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -eq [bool]$Directory -and $_.FullName -notmatch "[\\/](obj)[\\/]" } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 +} + +$projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 +if (-not $projectFile) { + throw "No project file was found in '$ProjectPath'." +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +$binlogPath = Join-Path $OutputPath "build.binlog" + +switch ($Platform) { + "android" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:AndroidPackageFormat=aab", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-o", $OutputPath, + "/bl:$binlogPath" + ) + + if ($Publish) { + $keystorePath = $env:ANDROID_KEYSTORE_PATH + if ([string]::IsNullOrWhiteSpace($keystorePath)) { + $keystoreBase64 = Assert-EnvironmentValue "ANDROID_KEYSTORE_BASE64" + $keystorePath = Join-Path $env:RUNNER_TEMP "template-app-distribution.keystore" + Write-Base64File $keystoreBase64 $keystorePath + } + + $env:ANDROID_SIGNING_STORE_PASS = Assert-EnvironmentValue "ANDROID_KEYSTORE_PASSWORD" + $env:ANDROID_SIGNING_KEY_PASS = if ([string]::IsNullOrWhiteSpace($env:ANDROID_KEY_PASSWORD)) { + $env:ANDROID_SIGNING_STORE_PASS + } else { + $env:ANDROID_KEY_PASSWORD + } + + $keyAlias = Assert-EnvironmentValue "ANDROID_KEY_ALIAS" + + $arguments += @( + "-p:AndroidKeyStore=true", + "-p:AndroidSigningKeyStore=$keystorePath", + "-p:AndroidSigningKeyAlias=$keyAlias", + "-p:AndroidSigningStorePass=env:ANDROID_SIGNING_STORE_PASS", + "-p:AndroidSigningKeyPass=env:ANDROID_SIGNING_KEY_PASS" + ) + } else { + $arguments += "-p:AndroidKeyStore=false" + } + + Write-Host "Building Android package for $($projectFile.FullName)" + & dotnet @arguments + + $package = Get-NewestBuildOutput $ProjectPath "*.aab" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.aab" + } + } + + "ios" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "/bl:$binlogPath" + ) + + if ($Publish) { + $codesignKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" + $codesignProvision = Assert-EnvironmentValue "IOS_CODESIGN_PROVISION" + $arguments += @( + "-p:BuildIpa=true", + "-p:ArchiveOnBuild=true", + "-p:CodesignKey=$codesignKey", + "-p:CodesignProvision=$codesignProvision", + "-o", $OutputPath + ) + } else { + $arguments += @( + "-p:_RequireCodeSigning=false", + "-p:EnableCodeSigning=false", + "-p:CodesignKey=-", + "-p:BuildIpa=false" + ) + } + + Write-Host "Building iOS package for $($projectFile.FullName)" + & dotnet @arguments + + if ($Publish) { + $package = Get-NewestBuildOutput $ProjectPath "*.ipa" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.ipa" + } + } else { + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Compress-Archive -Path $appBundle.FullName -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } + } + } +} + +if (-not $package) { + throw "Build completed but no package artifact was found for platform '$Platform'." +} + +Write-Host "Package artifact: $($package.FullName)" +Write-Host "Build binlog: $binlogPath" + +if ($env:GITHUB_OUTPUT) { + "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT + "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 new file mode 100644 index 000000000000..a4c0edd26a37 --- /dev/null +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -0,0 +1,102 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$Variant +) + +$ErrorActionPreference = "Stop" + +function Assert-EnvironmentValue([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required environment variable '$Name' is not set." + } + + return $value +} + +function Get-SecretText([string]$Value) { + $trimmed = $Value.Trim() + if ($trimmed.StartsWith("{") -or $trimmed.StartsWith("-----BEGIN")) { + return $Value + } + + try { + return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($trimmed)) + } catch { + return $Value + } +} + +function Get-VariantProvisioningProfile([string]$VariantName) { + if (-not [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILES_JSON)) { + $profiles = Get-SecretText $env:IOS_PROVISIONING_PROFILES_JSON | ConvertFrom-Json + $property = $profiles.PSObject.Properties | Where-Object { $_.Name -eq $VariantName } | Select-Object -First 1 + if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) { + return [string]$property.Value + } + } + + if (-not [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILE_BASE64)) { + return $env:IOS_PROVISIONING_PROFILE_BASE64 + } + + throw "No iOS provisioning profile was provided for variant '$VariantName'. Set IOS_PROVISIONING_PROFILES_JSON or IOS_PROVISIONING_PROFILE_BASE64." +} + +function Write-Base64File([string]$Base64Value, [string]$Path) { + $bytes = [Convert]::FromBase64String($Base64Value.Trim()) + [System.IO.File]::WriteAllBytes($Path, $bytes) +} + +if (-not $IsMacOS) { + throw "Apple signing assets can only be installed on macOS runners." +} + +$tempDirectory = Join-Path $env:RUNNER_TEMP "template-app-apple-signing" +New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null + +$certificatePath = Join-Path $tempDirectory "certificate.p12" +$profilePath = Join-Path $tempDirectory "$Variant.mobileprovision" +$profilePlistPath = Join-Path $tempDirectory "$Variant.plist" +$keychainPath = Join-Path $tempDirectory "template-app-distribution.keychain-db" + +Write-Base64File (Assert-EnvironmentValue "IOS_CERTIFICATE_BASE64") $certificatePath +Write-Base64File (Get-VariantProvisioningProfile $Variant) $profilePath + +$certificatePassword = Assert-EnvironmentValue "IOS_CERTIFICATE_PASSWORD" +$keychainPassword = Assert-EnvironmentValue "IOS_KEYCHAIN_PASSWORD" + +& security create-keychain -p $keychainPassword $keychainPath +& security set-keychain-settings -lut 21600 $keychainPath +& security unlock-keychain -p $keychainPassword $keychainPath + +$existingKeychains = & security list-keychains -d user | ForEach-Object { $_.Trim().Trim('"') } +& security list-keychains -d user -s $keychainPath @existingKeychains +& security import $certificatePath -k $keychainPath -P $certificatePassword -T /usr/bin/codesign -T /usr/bin/security +& security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychainPath + +& security cms -D -i $profilePath | Out-File -FilePath $profilePlistPath -Encoding utf8 +$profileUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $profilePlistPath).Trim() +$profileName = (& /usr/libexec/PlistBuddy -c "Print :Name" $profilePlistPath).Trim() + +if ([string]::IsNullOrWhiteSpace($profileUuid) -or [string]::IsNullOrWhiteSpace($profileName)) { + throw "Could not read UUID and Name from provisioning profile '$profilePath'." +} + +$profilesDirectory = Join-Path $HOME "Library/MobileDevice/Provisioning Profiles" +New-Item -ItemType Directory -Path $profilesDirectory -Force | Out-Null +Copy-Item -Path $profilePath -Destination (Join-Path $profilesDirectory "$profileUuid.mobileprovision") -Force + +Write-Host "Installed provisioning profile '$profileName' ($profileUuid)" + +if ($env:GITHUB_ENV) { + "IOS_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV + "IOS_KEYCHAIN_PATH=$keychainPath" >> $env:GITHUB_ENV +} + +if ($env:GITHUB_OUTPUT) { + "codesign_provision=$profileName" >> $env:GITHUB_OUTPUT + "keychain_path=$keychainPath" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 new file mode 100644 index 000000000000..1c1687430e88 --- /dev/null +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -0,0 +1,116 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$TemplatePackagePath, + + [Parameter(Mandatory)] + [string]$BuildRoot, + + [Parameter(Mandatory)] + [string]$Variant, + + [Parameter(Mandatory)] + [string]$ProjectName, + + [Parameter(Mandatory)] + [string]$Template, + + [Parameter(Mandatory)] + [string]$TemplateArgsJson, + + [Parameter(Mandatory)] + [string]$DotNetTfm, + + [Parameter(Mandatory)] + [string]$TargetFramework, + + [Parameter(Mandatory)] + [string]$ApplicationId, + + [Parameter(Mandatory)] + [string]$DisplayName, + + [Parameter(Mandatory)] + [string]$DotNetSdk, + + [Parameter(Mandatory)] + [string]$AppDisplayVersion, + + [Parameter(Mandatory)] + [string]$AppBuildNumber +) + +$ErrorActionPreference = "Stop" + +function ConvertTo-XmlEscaped([string]$Value) { + return [System.Security.SecurityElement]::Escape($Value) +} + +if (-not (Test-Path $TemplatePackagePath)) { + throw "Template package was not found at '$TemplatePackagePath'." +} + +$projectRoot = Join-Path $BuildRoot $Variant +$projectDir = Join-Path $projectRoot $ProjectName +$dotnetHome = Join-Path $projectRoot ".dotnet" +$nugetPackages = Join-Path $projectRoot ".nuget" + +Remove-Item -Path $projectRoot -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $projectRoot -Force | Out-Null +New-Item -ItemType Directory -Path $dotnetHome -Force | Out-Null +New-Item -ItemType Directory -Path $nugetPackages -Force | Out-Null + +$env:DOTNET_CLI_HOME = $dotnetHome +$env:NUGET_PACKAGES = $nugetPackages + +$nugetConfig = @" + + + + + + + + +"@ +$nugetConfig | Out-File -FilePath (Join-Path $projectRoot "NuGet.config") -Encoding utf8 + +Write-Host "Installing template package $TemplatePackagePath" +dotnet new install $TemplatePackagePath + +$templateArgs = @() +if (-not [string]::IsNullOrWhiteSpace($TemplateArgsJson)) { + $templateArgs = @(ConvertFrom-Json $TemplateArgsJson | ForEach-Object { [string]$_ }) +} + +$dotnetNewArgs = @("new", $Template, "-n", $ProjectName, "-o", $projectDir, "--framework", $DotNetTfm) + $templateArgs +Write-Host "Creating project: dotnet $($dotnetNewArgs -join ' ')" +& dotnet @dotnetNewArgs + +$projectFile = Get-ChildItem -Path $projectDir -Filter "*.csproj" -Recurse | Select-Object -First 1 +if (-not $projectFile) { + throw "No project file was created in '$projectDir'." +} + +$content = Get-Content $projectFile.FullName -Raw +$content = $content -replace "[^<]+", "$TargetFramework" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $DisplayName)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $ApplicationId)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppDisplayVersion)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppBuildNumber)" +Set-Content -Path $projectFile.FullName -Value $content -Encoding utf8 + +@{ + sdk = @{ + version = $DotNetSdk + rollForward = "latestPatch" + } +} | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $projectDir "global.json") -Encoding utf8 + +Write-Host "Generated project: $($projectFile.FullName)" + +if ($env:GITHUB_OUTPUT) { + "project_path=$projectDir" >> $env:GITHUB_OUTPUT + "project_file=$($projectFile.FullName)" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Pack-Templates.ps1 b/.github/scripts/template-app-distribution/Pack-Templates.ps1 new file mode 100644 index 000000000000..20bc53cf849e --- /dev/null +++ b/.github/scripts/template-app-distribution/Pack-Templates.ps1 @@ -0,0 +1,52 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$DotNetCliHome, + + [Parameter(Mandatory)] + [string]$NuGetPackages +) + +$ErrorActionPreference = "Stop" + +$templatesProject = Join-Path $RepositoryPath "src/Templates/src/Microsoft.Maui.Templates.csproj" +if (-not (Test-Path $templatesProject)) { + throw "Template project was not found at '$templatesProject'." +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +New-Item -ItemType Directory -Path $DotNetCliHome -Force | Out-Null +New-Item -ItemType Directory -Path $NuGetPackages -Force | Out-Null + +$env:DOTNET_CLI_HOME = $DotNetCliHome +$env:NUGET_PACKAGES = $NuGetPackages + +Write-Host "Building MAUI templates from $templatesProject" +dotnet build -t:Rebuild $templatesProject -p:PackageVersion=$PackageVersion -p:GenerateCgManifest=false + +Write-Host "Packing MAUI templates with PackageVersion=$PackageVersion" +dotnet pack $templatesProject -p:PackageVersion=$PackageVersion -p:GenerateCgManifest=false -o $OutputPath + +$package = Get-ChildItem -Path $OutputPath -Filter "*.nupkg" -Recurse | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + +if (-not $package) { + throw "No template package was produced in '$OutputPath'." +} + +Write-Host "Template package: $($package.FullName)" + +if ($env:GITHUB_OUTPUT) { + "template_package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 new file mode 100644 index 000000000000..b5e8fb3e7a54 --- /dev/null +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$Variants, + + [Parameter(Mandatory)] + [string]$Platforms, + + [Parameter(Mandatory)] + [string]$DotNetTfm +) + +$ErrorActionPreference = "Stop" + +function Split-InputList([string]$Value) { + return @($Value.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_.Trim().ToLowerInvariant() }) +} + +function Get-EnvironmentOrDefault([string]$Name, [string]$DefaultValue) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + return $DefaultValue + } + + return $value +} + +function ConvertTo-StringArray($Value) { + if ($null -eq $Value) { + return @() + } + + if ($Value -is [array]) { + return @($Value | ForEach-Object { [string]$_ }) + } + + return @([string]$Value) +} + +function Merge-VariantDefinition($Definitions, [string]$Name, $Definition) { + if (-not $Definitions.Contains($Name)) { + $Definitions[$Name] = [ordered]@{} + } + + foreach ($property in $Definition.PSObject.Properties) { + $Definitions[$Name][$property.Name] = $property.Value + } +} + +$variantDefinitions = [ordered]@{ + blank = [ordered]@{ + displayName = "MAUI Template" + projectName = "MauiTemplateBlank" + template = "maui" + templateArgs = @() + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" "com.microsoft.maui.template.blank" + iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" "com.microsoft.maui.template.blank" + } + sample = [ordered]@{ + displayName = "MAUI Template Sample" + projectName = "MauiTemplateSample" + template = "maui" + templateArgs = @("--sample-content") + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" "com.microsoft.maui.template.sample" + iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" "com.microsoft.maui.template.sample" + } +} + +if (-not [string]::IsNullOrWhiteSpace($env:TEMPLATE_APP_VARIANTS_JSON)) { + $customDefinitions = $env:TEMPLATE_APP_VARIANTS_JSON | ConvertFrom-Json + foreach ($property in $customDefinitions.PSObject.Properties) { + Merge-VariantDefinition $variantDefinitions $property.Name.ToLowerInvariant() $property.Value + } +} + +$platformDefinitions = [ordered]@{ + android = [ordered]@{ + runner = "ubuntu-latest" + workload = "maui-android" + targetFramework = "$DotNetTfm-android" + runtimeIdentifier = "android-arm64" + } + ios = [ordered]@{ + runner = "macos-latest" + workload = "maui-ios" + targetFramework = "$DotNetTfm-ios" + runtimeIdentifier = "ios-arm64" + } +} + +$selectedVariants = Split-InputList $Variants +if ($selectedVariants.Count -eq 0 -or $selectedVariants -contains "all") { + $selectedVariants = @($variantDefinitions.Keys) +} + +$selectedPlatforms = Split-InputList $Platforms +if ($selectedPlatforms.Count -eq 0 -or $selectedPlatforms -contains "all") { + $selectedPlatforms = @($platformDefinitions.Keys) +} + +$matrix = [ordered]@{ + include = @() +} + +foreach ($variantName in $selectedVariants) { + if (-not $variantDefinitions.Contains($variantName)) { + throw "Unknown template app variant '$variantName'. Known variants: $($variantDefinitions.Keys -join ', ')" + } + + $variant = $variantDefinitions[$variantName] + + foreach ($platformName in $selectedPlatforms) { + if (-not $platformDefinitions.Contains($platformName)) { + throw "Unknown platform '$platformName'. Known platforms: $($platformDefinitions.Keys -join ', ')" + } + + $platform = $platformDefinitions[$platformName] + $applicationId = if ($platformName -eq "ios") { $variant.iosBundleId } else { $variant.androidApplicationId } + + if ([string]::IsNullOrWhiteSpace($applicationId)) { + throw "Variant '$variantName' does not define an application identifier for '$platformName'." + } + + $templateArgs = @(ConvertTo-StringArray $variant.templateArgs) + $templateArgsJson = if ($templateArgs.Count -eq 0) { + "[]" + } else { + ConvertTo-Json -InputObject $templateArgs -Compress + } + + $matrix.include += [ordered]@{ + variant = $variantName + platform = $platformName + runner = $platform.runner + workload = $platform.workload + targetFramework = $platform.targetFramework + runtimeIdentifier = $platform.runtimeIdentifier + displayName = [string]$variant.displayName + projectName = [string]$variant.projectName + template = [string]$variant.template + templateArgsJson = $templateArgsJson + applicationId = [string]$applicationId + androidApplicationId = [string]$variant.androidApplicationId + iosBundleId = [string]$variant.iosBundleId + } + } +} + +if ($matrix.include.Count -eq 0) { + throw "The selected variants/platforms produced an empty build matrix." +} + +$matrixJson = $matrix | ConvertTo-Json -Compress -Depth 10 +Write-Host "Matrix: $matrixJson" + +if ($env:GITHUB_OUTPUT) { + "matrix=$matrixJson" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1 b/.github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1 new file mode 100644 index 000000000000..81dfe4bed54d --- /dev/null +++ b/.github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1 @@ -0,0 +1,41 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + + [Parameter(Mandatory)] + [string]$DotNetSdk +) + +$ErrorActionPreference = "Stop" + +if ($DotNetSdk -eq "global-json") { + $globalJsonPath = Join-Path $RepositoryPath "global.json" + if (-not (Test-Path $globalJsonPath)) { + throw "Cannot resolve dotnet SDK from global.json because '$globalJsonPath' does not exist." + } + + $globalJson = Get-Content $globalJsonPath -Raw | ConvertFrom-Json + if ($globalJson.tools -and $globalJson.tools.dotnet) { + $DotNetSdk = [string]$globalJson.tools.dotnet + } elseif ($globalJson.sdk -and $globalJson.sdk.version) { + $DotNetSdk = [string]$globalJson.sdk.version + } else { + throw "global.json does not contain tools.dotnet or sdk.version." + } +} + +if ($DotNetSdk -notmatch "^(\d+)\.(\d+)") { + throw "Unable to derive a target framework from dotnet SDK version '$DotNetSdk'." +} + +$dotNetTfm = "net$($Matches[1]).$($Matches[2])" + +Write-Host "Resolved .NET SDK: $DotNetSdk" +Write-Host "Resolved .NET TFM: $dotNetTfm" + +if ($env:GITHUB_OUTPUT) { + "dotnet_sdk=$DotNetSdk" >> $env:GITHUB_OUTPUT + "dotnet_tfm=$dotNetTfm" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 new file mode 100644 index 000000000000..2d04f65404cc --- /dev/null +++ b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 @@ -0,0 +1,95 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + + [Parameter(Mandatory)] + [string]$SourceRef, + + [Parameter(Mandatory)] + [string]$WorkflowRef, + + [Parameter(Mandatory)] + [string]$DefaultBranch, + + [Parameter(Mandatory)] + [bool]$Publish +) + +$ErrorActionPreference = "Stop" + +function Invoke-Git([string[]]$Arguments, [switch]$IgnoreExitCode) { + $output = & git @Arguments 2>&1 + if (-not $IgnoreExitCode -and $LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed: $output" + } + + return $output +} + +function Test-GitSuccess([string[]]$Arguments) { + & git @Arguments *> $null + return $LASTEXITCODE -eq 0 +} + +Push-Location $RepositoryPath +try { + $sourceSha = (Invoke-Git -Arguments @("rev-parse", "HEAD")).Trim() + $normalizedSourceRef = $SourceRef.Trim() + $trustedBranches = @( + Invoke-Git -Arguments @("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin") | + Where-Object { $_ -eq "origin/$DefaultBranch" -or $_ -match "^origin/net\d+\.0$" } + ) + + $isTrusted = $false + $trustedReason = "" + + $sourceBranchName = $normalizedSourceRef -replace "^refs/heads/", "" -replace "^origin/", "" + if ($sourceBranchName -eq $DefaultBranch -or $sourceBranchName -match "^net\d+\.0$") { + $isTrusted = $true + $trustedReason = "trusted branch '$sourceBranchName'" + } + + if (-not $isTrusted -and ($normalizedSourceRef -match "^refs/tags/.+" -or (Test-GitSuccess -Arguments @("rev-parse", "--verify", "refs/tags/$normalizedSourceRef")))) { + $tagName = $normalizedSourceRef -replace "^refs/tags/", "" + $tagSha = (Invoke-Git -Arguments @("rev-list", "-n", "1", "refs/tags/$tagName")).Trim() + if ($tagSha -eq $sourceSha) { + $isTrusted = $true + $trustedReason = "tag '$tagName'" + } + } + + if (-not $isTrusted) { + foreach ($branch in $trustedBranches) { + if (Test-GitSuccess -Arguments @("merge-base", "--is-ancestor", $sourceSha, $branch)) { + $isTrusted = $true + $trustedReason = "commit reachable from '$branch'" + break + } + } + } + + if ($Publish) { + $expectedWorkflowRef = "refs/heads/$DefaultBranch" + if ($WorkflowRef -ne $expectedWorkflowRef) { + throw "Publishing must be run from workflow ref '$expectedWorkflowRef'. Current workflow ref is '$WorkflowRef'." + } + + if (-not $isTrusted) { + throw "Publishing requires a trusted source_ref. '$SourceRef' resolved to '$sourceSha', which is not a trusted branch/tag or reachable from a trusted branch. Rerun with publish=false for a dry run." + } + } + + Write-Host "Source ref '$SourceRef' resolved to $sourceSha" + Write-Host "Trusted for publishing: $isTrusted $trustedReason" + + if ($env:GITHUB_OUTPUT) { + "source_sha=$sourceSha" >> $env:GITHUB_OUTPUT + "trusted=$($isTrusted.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "trusted_reason=$trustedReason" >> $env:GITHUB_OUTPUT + } +} +finally { + Pop-Location +} diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile new file mode 100644 index 000000000000..e8761089befc --- /dev/null +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -0,0 +1,79 @@ +require "fileutils" +require "tmpdir" + +def required_option(options, key) + value = options[key].to_s + UI.user_error!("Missing required option: #{key}") if value.empty? + value +end + +def optional_option(options, key) + value = options[key].to_s + value.empty? ? nil : value +end + +def release_notes + value = ENV["TEMPLATE_APP_RELEASE_NOTES"].to_s + value.empty? ? nil : value +end + +default_platform(:ios) + +platform :android do + desc "Upload a generated MAUI template app bundle to a Google Play testing track" + lane :template_app_play do |options| + changelog = release_notes + metadata_path = nil + + unless changelog.nil? + metadata_path = File.join(Dir.mktmpdir("template-app-play-metadata"), "android") + changelog_dir = File.join(metadata_path, "en-US", "changelogs") + FileUtils.mkdir_p(changelog_dir) + File.write(File.join(changelog_dir, "default.txt"), changelog) + end + + upload_to_play_store( + package_name: required_option(options, :package_name), + aab: required_option(options, :aab), + track: required_option(options, :track), + json_key: required_option(options, :json_key), + release_status: "completed", + version_name: optional_option(options, :version_name), + metadata_path: metadata_path, + skip_upload_metadata: true, + skip_upload_changelogs: changelog.nil?, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end +end + +platform :ios do + desc "Upload a generated MAUI template IPA to TestFlight" + lane :template_app_testflight do |options| + groups = optional_option(options, :groups).to_s.split(",").map(&:strip).reject(&:empty?) + changelog = release_notes + api_key = app_store_connect_api_key( + key_id: required_option(options, :api_key_id), + issuer_id: required_option(options, :issuer_id), + key_filepath: required_option(options, :api_private_key_path) + ) + + upload_options = { + ipa: required_option(options, :ipa), + app_identifier: required_option(options, :app_identifier), + api_key: api_key, + uses_non_exempt_encryption: false, + skip_waiting_for_build_processing: groups.empty? && changelog.nil? + } + + upload_options[:changelog] = changelog unless changelog.nil? + + unless groups.empty? + upload_options[:groups] = groups + upload_options[:distribute_external] = true + end + + upload_to_testflight(upload_options) + end +end diff --git a/.github/scripts/template-app-distribution/fastlane/Gemfile b/.github/scripts/template-app-distribution/fastlane/Gemfile new file mode 100644 index 000000000000..7a118b49be75 --- /dev/null +++ b/.github/scripts/template-app-distribution/fastlane/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml new file mode 100644 index 000000000000..557429837646 --- /dev/null +++ b/.github/workflows/template-app-distribution.yml @@ -0,0 +1,474 @@ +# Required protected environment: template-app-distribution +# +# Required secrets for publishing: +# - TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 +# - TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD +# - TEMPLATE_APP_ANDROID_KEY_ALIAS +# - TEMPLATE_APP_ANDROID_KEY_PASSWORD +# - TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON +# - TEMPLATE_APP_IOS_CERTIFICATE_BASE64 +# - TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD +# - TEMPLATE_APP_IOS_KEYCHAIN_PASSWORD +# - TEMPLATE_APP_IOS_CODESIGN_KEY +# - TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 +# - TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 +# - TEMPLATE_APPSTORE_CONNECT_ISSUER_ID +# - TEMPLATE_APPSTORE_CONNECT_KEY_ID +# - TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY (raw .p8 content or base64) +# +# Optional variables/secrets: +# - TEMPLATE_APP_VARIANTS_JSON (variable): adds or overrides variant definitions. +# - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. +# - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) +# - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. + +name: Template App Distribution + +run-name: Template app distribution (${{ inputs.source_ref }}, publish=${{ inputs.publish }}) + +on: + workflow_dispatch: + inputs: + source_ref: + description: Branch, tag, or full commit SHA to use for template source. + required: true + default: main + type: string + dotnet_sdk: + description: Exact .NET SDK version, or global-json to read tools.dotnet/sdk.version from the selected source ref. + required: true + default: global-json + type: string + variants: + description: Comma-separated variants to build, or all. + required: true + default: all + type: string + platforms: + description: Comma-separated platforms to build, or all. Known values are android, ios. + required: true + default: all + type: string + publish: + description: Publish to Google Play/TestFlight. If false, only build and upload GitHub artifacts. + required: true + default: true + type: boolean + app_display_version: + description: Store-facing display version applied to generated apps. + required: true + default: "1.0" + type: string + play_track: + description: Google Play track for Android publishing. + required: true + default: internal + type: string + testflight_groups: + description: Optional comma-separated TestFlight beta group names to receive the build after processing. + required: false + default: "" + type: string + release_notes: + description: Optional release notes appended to the generated source SHA note. + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.source_ref }}-${{ inputs.publish }} + cancel-in-progress: false + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + prepare: + name: Prepare matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + dotnet_sdk: ${{ steps.sdk.outputs.dotnet_sdk }} + dotnet_tfm: ${{ steps.sdk.outputs.dotnet_tfm }} + source_sha: ${{ steps.source.outputs.source_sha }} + trusted: ${{ steps.source.outputs.trusted }} + steps: + - name: Checkout workflow scripts + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Resolve source ref trust + id: source + shell: pwsh + run: | + $publish = [System.Boolean]::Parse('${{ inputs.publish }}') + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -SourceRef "${{ inputs.source_ref }}" ` + -WorkflowRef "${{ github.ref }}" ` + -DefaultBranch "${{ github.event.repository.default_branch }}" ` + -Publish $publish + + - name: Resolve .NET SDK + id: sdk + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -DotNetSdk "${{ inputs.dotnet_sdk }}" + + - name: Prepare build matrix + id: matrix + shell: pwsh + env: + TEMPLATE_APP_VARIANTS_JSON: ${{ vars.TEMPLATE_APP_VARIANTS_JSON }} + TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID }} + TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID }} + TEMPLATE_APP_BLANK_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_BLANK_IOS_BUNDLE_ID }} + TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Prepare-Matrix.ps1" ` + -Variants "${{ inputs.variants }}" ` + -Platforms "${{ inputs.platforms }}" ` + -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" + + dry-run-build: + name: Dry-run ${{ matrix.variant }} ${{ matrix.platform }} + if: ${{ inputs.publish == false }} + needs: prepare + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + - name: Checkout workflow scripts + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + path: source + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }} + + - name: Setup Java + if: ${{ matrix.platform == 'android' }} + uses: actions/setup-java@v4 + with: + distribution: microsoft + java-version: "17" + + - name: Setup Xcode + if: ${{ matrix.platform == 'ios' }} + 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: Install MAUI workload + run: dotnet workload install ${{ matrix.workload }} + + - name: Set app version + shell: pwsh + run: | + $buildNumber = ([int]'${{ github.run_number }}' * 100) + [int]'${{ github.run_attempt }}' + "APP_BUILD_NUMBER=$buildNumber" >> $env:GITHUB_ENV + "APP_DISPLAY_VERSION=${{ inputs.app_display_version }}" >> $env:GITHUB_ENV + + - name: Pack local templates + id: pack + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Pack-Templates.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -PackageVersion "99.0.0-templateapp.${{ github.run_id }}.${{ github.run_attempt }}" ` + -OutputPath "${{ runner.temp }}/template-packages/${{ matrix.variant }}-${{ matrix.platform }}" ` + -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${{ matrix.variant }}-${{ matrix.platform }}" ` + -NuGetPackages "${{ runner.temp }}/nuget-packages/${{ matrix.variant }}-${{ matrix.platform }}" + + - name: Create generated app + id: app + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` + -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` + -BuildRoot "${{ runner.temp }}/template-app-build" ` + -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` + -ProjectName "${{ matrix.projectName }}" ` + -Template "${{ matrix.template }}" ` + -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -ApplicationId "${{ matrix.applicationId }}" ` + -DisplayName "${{ matrix.displayName }}" ` + -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" + + - name: Build generated app + id: build + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` + -ProjectPath "${{ steps.app.outputs.project_path }}" ` + -Platform "${{ matrix.platform }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -RuntimeIdentifier "${{ matrix.runtimeIdentifier }}" ` + -OutputPath "${{ runner.temp }}/template-app-output/${{ matrix.variant }}-${{ matrix.platform }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" + + - name: Upload dry-run artifact + uses: actions/upload-artifact@v4 + with: + name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} + path: | + ${{ steps.build.outputs.package_path }} + ${{ steps.build.outputs.binlog_path }} + retention-days: 14 + + publish: + name: Publish ${{ matrix.variant }} ${{ matrix.platform }} + if: ${{ inputs.publish }} + needs: prepare + runs-on: ${{ matrix.runner }} + environment: template-app-distribution + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + - name: Checkout workflow scripts + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + path: source + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }} + + - name: Setup Java + if: ${{ matrix.platform == 'android' }} + uses: actions/setup-java@v4 + with: + distribution: microsoft + java-version: "17" + + - name: Setup Xcode + if: ${{ matrix.platform == 'ios' }} + 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 Ruby and fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + + - name: Install MAUI workload + run: dotnet workload install ${{ matrix.workload }} + + - name: Set app version and release notes + shell: pwsh + env: + INPUT_RELEASE_NOTES: ${{ inputs.release_notes }} + run: | + $buildNumber = ([int]'${{ github.run_number }}' * 100) + [int]'${{ github.run_attempt }}' + "APP_BUILD_NUMBER=$buildNumber" >> $env:GITHUB_ENV + "APP_DISPLAY_VERSION=${{ inputs.app_display_version }}" >> $env:GITHUB_ENV + + $notes = "MAUI template app build from ${{ needs.prepare.outputs.source_sha }}." + if (-not [string]::IsNullOrWhiteSpace($env:INPUT_RELEASE_NOTES)) { + $notes = "$notes`n$env:INPUT_RELEASE_NOTES" + } + + $delimiter = [guid]::NewGuid().ToString("N") + "TEMPLATE_APP_RELEASE_NOTES<<$delimiter" >> $env:GITHUB_ENV + $notes >> $env:GITHUB_ENV + "$delimiter" >> $env:GITHUB_ENV + + - name: Pack local templates + id: pack + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Pack-Templates.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -PackageVersion "99.0.0-templateapp.${{ github.run_id }}.${{ github.run_attempt }}" ` + -OutputPath "${{ runner.temp }}/template-packages/${{ matrix.variant }}-${{ matrix.platform }}" ` + -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${{ matrix.variant }}-${{ matrix.platform }}" ` + -NuGetPackages "${{ runner.temp }}/nuget-packages/${{ matrix.variant }}-${{ matrix.platform }}" + + - name: Create generated app + id: app + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` + -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` + -BuildRoot "${{ runner.temp }}/template-app-build" ` + -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` + -ProjectName "${{ matrix.projectName }}" ` + -Template "${{ matrix.template }}" ` + -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -ApplicationId "${{ matrix.applicationId }}" ` + -DisplayName "${{ matrix.displayName }}" ` + -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" + + - name: Install Apple signing assets + if: ${{ matrix.platform == 'ios' }} + shell: pwsh + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} + IOS_KEYCHAIN_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_KEYCHAIN_PASSWORD }} + IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} + IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1" ` + -Variant "${{ matrix.variant }}" + + - name: Build generated app + id: build + shell: pwsh + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_PASSWORD }} + IOS_CODESIGN_KEY: ${{ secrets.TEMPLATE_APP_IOS_CODESIGN_KEY }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` + -ProjectPath "${{ steps.app.outputs.project_path }}" ` + -Platform "${{ matrix.platform }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -RuntimeIdentifier "${{ matrix.runtimeIdentifier }}" ` + -OutputPath "${{ runner.temp }}/template-app-output/${{ matrix.variant }}-${{ matrix.platform }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -Publish + + - name: Upload artifact copy + if: always() + uses: actions/upload-artifact@v4 + with: + name: template-app-publish-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} + path: | + ${{ steps.build.outputs.package_path }} + ${{ steps.build.outputs.binlog_path }} + retention-days: 14 + + - name: Write Google Play credentials + if: ${{ matrix.platform == 'android' }} + shell: pwsh + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + if ([string]::IsNullOrWhiteSpace($env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)) { + throw "TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is required for Android publishing." + } + + $jsonPath = Join-Path $env:RUNNER_TEMP "google-play-service-account.json" + $value = $env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON.Trim() + if ($value.StartsWith("{")) { + Set-Content -Path $jsonPath -Value $env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -NoNewline + } else { + [System.IO.File]::WriteAllBytes($jsonPath, [Convert]::FromBase64String($value)) + } + + "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH=$jsonPath" >> $env:GITHUB_ENV + + - name: Publish Android to Google Play with fastlane + if: ${{ matrix.platform == 'android' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + run: | + & bundle exec fastlane android template_app_play ` + "package_name:${{ matrix.androidApplicationId }}" ` + "aab:${{ steps.build.outputs.package_path }}" ` + "track:${{ inputs.play_track }}" ` + "json_key:$env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH" ` + "version_name:$env:APP_DISPLAY_VERSION" + + - name: Write App Store Connect API key + if: ${{ matrix.platform == 'ios' }} + shell: pwsh + env: + APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:APPSTORE_CONNECT_PRIVATE_KEY)) { + throw "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY is required for iOS publishing." + } + + $keyPath = Join-Path $env:RUNNER_TEMP "appstore-connect-key.p8" + $value = $env:APPSTORE_CONNECT_PRIVATE_KEY.Trim() + if ($value.StartsWith("-----BEGIN")) { + Set-Content -Path $keyPath -Value $env:APPSTORE_CONNECT_PRIVATE_KEY -NoNewline + } else { + [System.IO.File]::WriteAllBytes($keyPath, [Convert]::FromBase64String($value)) + } + + "APPSTORE_CONNECT_PRIVATE_KEY_PATH=$keyPath" >> $env:GITHUB_ENV + + - name: Publish iOS to TestFlight with fastlane + if: ${{ matrix.platform == 'ios' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + env: + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + run: | + & bundle exec fastlane ios template_app_testflight ` + "ipa:${{ steps.build.outputs.package_path }}" ` + "app_identifier:${{ matrix.iosBundleId }}" ` + "issuer_id:$env:APPSTORE_CONNECT_ISSUER_ID" ` + "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` + "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` + "groups:${{ inputs.testflight_groups }}" From 07e913dd5049849641f77ae630f25d40f21c00ea Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 10:53:11 +0200 Subject: [PATCH 02/30] Simplify template app distribution inputs Derive app versions and identifiers automatically, reduce manual workflow inputs, and remove unnecessary iOS signing secrets by generating the keychain password and discovering the imported signing identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Install-AppleSigningAssets.ps1 | 27 +++++- .../Prepare-Matrix.ps1 | 29 +++++- .../workflows/template-app-distribution.yml | 97 +++++++------------ 3 files changed, 86 insertions(+), 67 deletions(-) diff --git a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 index a4c0edd26a37..d67c48f9b6cc 100644 --- a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -66,7 +66,10 @@ Write-Base64File (Assert-EnvironmentValue "IOS_CERTIFICATE_BASE64") $certificate Write-Base64File (Get-VariantProvisioningProfile $Variant) $profilePath $certificatePassword = Assert-EnvironmentValue "IOS_CERTIFICATE_PASSWORD" -$keychainPassword = Assert-EnvironmentValue "IOS_KEYCHAIN_PASSWORD" +$keychainPassword = [Environment]::GetEnvironmentVariable("IOS_KEYCHAIN_PASSWORD") +if ([string]::IsNullOrWhiteSpace($keychainPassword)) { + $keychainPassword = [guid]::NewGuid().ToString("N") +} & security create-keychain -p $keychainPassword $keychainPath & security set-keychain-settings -lut 21600 $keychainPath @@ -77,6 +80,25 @@ $existingKeychains = & security list-keychains -d user | ForEach-Object { $_.Tri & security import $certificatePath -k $keychainPath -P $certificatePassword -T /usr/bin/codesign -T /usr/bin/security & security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychainPath +$identities = @() +foreach ($line in (& security find-identity -v -p codesigning $keychainPath)) { + if ($line -match '"(.+)"') { + $identities += $Matches[1] + } +} + +$codesignIdentity = $identities | + Where-Object { $_ -match "Apple Distribution|iPhone Distribution" } | + Select-Object -First 1 + +if ([string]::IsNullOrWhiteSpace($codesignIdentity)) { + $codesignIdentity = $identities | Select-Object -First 1 +} + +if ([string]::IsNullOrWhiteSpace($codesignIdentity)) { + throw "No code signing identity was found in the imported certificate." +} + & security cms -D -i $profilePath | Out-File -FilePath $profilePlistPath -Encoding utf8 $profileUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $profilePlistPath).Trim() $profileName = (& /usr/libexec/PlistBuddy -c "Print :Name" $profilePlistPath).Trim() @@ -90,13 +112,16 @@ New-Item -ItemType Directory -Path $profilesDirectory -Force | Out-Null Copy-Item -Path $profilePath -Destination (Join-Path $profilesDirectory "$profileUuid.mobileprovision") -Force Write-Host "Installed provisioning profile '$profileName' ($profileUuid)" +Write-Host "Using code signing identity '$codesignIdentity'" if ($env:GITHUB_ENV) { + "IOS_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV "IOS_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV "IOS_KEYCHAIN_PATH=$keychainPath" >> $env:GITHUB_ENV } if ($env:GITHUB_OUTPUT) { + "codesign_key=$codesignIdentity" >> $env:GITHUB_OUTPUT "codesign_provision=$profileName" >> $env:GITHUB_OUTPUT "keychain_path=$keychainPath" >> $env:GITHUB_OUTPUT } diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 index b5e8fb3e7a54..d36646bf0caf 100644 --- a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -26,6 +26,25 @@ function Get-EnvironmentOrDefault([string]$Name, [string]$DefaultValue) { return $value } +function Get-DefaultIdentifierPrefix { + $prefix = [Environment]::GetEnvironmentVariable("TEMPLATE_APP_IDENTIFIER_PREFIX") + if (-not [string]::IsNullOrWhiteSpace($prefix)) { + return $prefix.Trim().TrimEnd(".").ToLowerInvariant() + } + + $owner = [Environment]::GetEnvironmentVariable("GITHUB_REPOSITORY_OWNER") + if ([string]::IsNullOrWhiteSpace($owner)) { + $owner = "maui" + } + + $ownerSegment = $owner.ToLowerInvariant() -replace "[^a-z0-9]+", "" + if ([string]::IsNullOrWhiteSpace($ownerSegment)) { + $ownerSegment = "maui" + } + + return "com.$ownerSegment.maui.template" +} + function ConvertTo-StringArray($Value) { if ($null -eq $Value) { return @() @@ -48,22 +67,24 @@ function Merge-VariantDefinition($Definitions, [string]$Name, $Definition) { } } +$identifierPrefix = Get-DefaultIdentifierPrefix + $variantDefinitions = [ordered]@{ blank = [ordered]@{ displayName = "MAUI Template" projectName = "MauiTemplateBlank" template = "maui" templateArgs = @() - androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" "com.microsoft.maui.template.blank" - iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" "com.microsoft.maui.template.blank" + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" "$identifierPrefix.blank" + iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" "$identifierPrefix.blank" } sample = [ordered]@{ displayName = "MAUI Template Sample" projectName = "MauiTemplateSample" template = "maui" templateArgs = @("--sample-content") - androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" "com.microsoft.maui.template.sample" - iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" "com.microsoft.maui.template.sample" + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" "$identifierPrefix.sample" + iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" "$identifierPrefix.sample" } } diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 557429837646..15ce7e0064f2 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -8,8 +8,6 @@ # - TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON # - TEMPLATE_APP_IOS_CERTIFICATE_BASE64 # - TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD -# - TEMPLATE_APP_IOS_KEYCHAIN_PASSWORD -# - TEMPLATE_APP_IOS_CODESIGN_KEY # - TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 # - TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 # - TEMPLATE_APPSTORE_CONNECT_ISSUER_ID @@ -17,10 +15,13 @@ # - TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY (raw .p8 content or base64) # # Optional variables/secrets: +# - TEMPLATE_APP_IDENTIFIER_PREFIX (variable): defaults to com..maui.template. # - TEMPLATE_APP_VARIANTS_JSON (variable): adds or overrides variant definitions. # - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. # - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) # - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. +# - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. # Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. name: Template App Distribution @@ -35,46 +36,11 @@ on: required: true default: main type: string - dotnet_sdk: - description: Exact .NET SDK version, or global-json to read tools.dotnet/sdk.version from the selected source ref. - required: true - default: global-json - type: string - variants: - description: Comma-separated variants to build, or all. - required: true - default: all - type: string - platforms: - description: Comma-separated platforms to build, or all. Known values are android, ios. - required: true - default: all - type: string publish: description: Publish to Google Play/TestFlight. If false, only build and upload GitHub artifacts. required: true - default: true + default: false type: boolean - app_display_version: - description: Store-facing display version applied to generated apps. - required: true - default: "1.0" - type: string - play_track: - description: Google Play track for Android publishing. - required: true - default: internal - type: string - testflight_groups: - description: Optional comma-separated TestFlight beta group names to receive the build after processing. - required: false - default: "" - type: string - release_notes: - description: Optional release notes appended to the generated source SHA note. - required: false - default: "" - type: string permissions: contents: read @@ -97,6 +63,8 @@ jobs: dotnet_tfm: ${{ steps.sdk.outputs.dotnet_tfm }} source_sha: ${{ steps.source.outputs.source_sha }} trusted: ${{ steps.source.outputs.trusted }} + app_display_version: ${{ steps.version.outputs.app_display_version }} + app_build_number: ${{ steps.version.outputs.app_build_number }} steps: - name: Checkout workflow scripts uses: actions/checkout@v4 @@ -131,12 +99,27 @@ jobs: run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1" ` -RepositoryPath "${{ github.workspace }}/source" ` - -DotNetSdk "${{ inputs.dotnet_sdk }}" + -DotNetSdk "global-json" + + - name: Resolve app version + id: version + shell: pwsh + run: | + $epoch = [DateTimeOffset]::Parse("2020-01-01T00:00:00Z") + $now = [DateTimeOffset]::UtcNow + $buildNumber = [int][Math]::Floor(($now - $epoch).TotalSeconds) + $displayVersion = "${{ steps.sdk.outputs.dotnet_tfm }}" -replace '^net', '' + + "App display version: $displayVersion" + "App build number: $buildNumber" + "app_display_version=$displayVersion" >> $env:GITHUB_OUTPUT + "app_build_number=$buildNumber" >> $env:GITHUB_OUTPUT - name: Prepare build matrix id: matrix shell: pwsh env: + TEMPLATE_APP_IDENTIFIER_PREFIX: ${{ vars.TEMPLATE_APP_IDENTIFIER_PREFIX }} TEMPLATE_APP_VARIANTS_JSON: ${{ vars.TEMPLATE_APP_VARIANTS_JSON }} TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID }} TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID }} @@ -144,8 +127,8 @@ jobs: TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Prepare-Matrix.ps1" ` - -Variants "${{ inputs.variants }}" ` - -Platforms "${{ inputs.platforms }}" ` + -Variants "all" ` + -Platforms "all" ` -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" dry-run-build: @@ -153,6 +136,9 @@ jobs: if: ${{ inputs.publish == false }} needs: prepare runs-on: ${{ matrix.runner }} + env: + APP_DISPLAY_VERSION: ${{ needs.prepare.outputs.app_display_version }} + APP_BUILD_NUMBER: ${{ needs.prepare.outputs.app_build_number }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} @@ -197,13 +183,6 @@ jobs: - name: Install MAUI workload run: dotnet workload install ${{ matrix.workload }} - - name: Set app version - shell: pwsh - run: | - $buildNumber = ([int]'${{ github.run_number }}' * 100) + [int]'${{ github.run_attempt }}' - "APP_BUILD_NUMBER=$buildNumber" >> $env:GITHUB_ENV - "APP_DISPLAY_VERSION=${{ inputs.app_display_version }}" >> $env:GITHUB_ENV - - name: Pack local templates id: pack shell: pwsh @@ -262,6 +241,11 @@ jobs: needs: prepare runs-on: ${{ matrix.runner }} environment: template-app-distribution + env: + APP_DISPLAY_VERSION: ${{ needs.prepare.outputs.app_display_version }} + APP_BUILD_NUMBER: ${{ needs.prepare.outputs.app_build_number }} + TEMPLATE_APP_PLAY_TRACK: ${{ vars.TEMPLATE_APP_PLAY_TRACK || 'internal' }} + TEMPLATE_APP_TESTFLIGHT_GROUPS: ${{ vars.TEMPLATE_APP_TESTFLIGHT_GROUPS }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} @@ -315,17 +299,8 @@ jobs: - name: Set app version and release notes shell: pwsh - env: - INPUT_RELEASE_NOTES: ${{ inputs.release_notes }} run: | - $buildNumber = ([int]'${{ github.run_number }}' * 100) + [int]'${{ github.run_attempt }}' - "APP_BUILD_NUMBER=$buildNumber" >> $env:GITHUB_ENV - "APP_DISPLAY_VERSION=${{ inputs.app_display_version }}" >> $env:GITHUB_ENV - - $notes = "MAUI template app build from ${{ needs.prepare.outputs.source_sha }}." - if (-not [string]::IsNullOrWhiteSpace($env:INPUT_RELEASE_NOTES)) { - $notes = "$notes`n$env:INPUT_RELEASE_NOTES" - } + $notes = "MAUI template app build from ${{ inputs.source_ref }} (${{ needs.prepare.outputs.source_sha }}).`n.NET SDK ${{ needs.prepare.outputs.dotnet_sdk }}, app version $env:APP_DISPLAY_VERSION, build $env:APP_BUILD_NUMBER." $delimiter = [guid]::NewGuid().ToString("N") "TEMPLATE_APP_RELEASE_NOTES<<$delimiter" >> $env:GITHUB_ENV @@ -368,7 +343,6 @@ jobs: env: IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} - IOS_KEYCHAIN_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_KEYCHAIN_PASSWORD }} IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} run: | @@ -383,7 +357,6 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_PASSWORD }} - IOS_CODESIGN_KEY: ${{ secrets.TEMPLATE_APP_IOS_CODESIGN_KEY }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` -ProjectPath "${{ steps.app.outputs.project_path }}" ` @@ -433,7 +406,7 @@ jobs: & bundle exec fastlane android template_app_play ` "package_name:${{ matrix.androidApplicationId }}" ` "aab:${{ steps.build.outputs.package_path }}" ` - "track:${{ inputs.play_track }}" ` + "track:$env:TEMPLATE_APP_PLAY_TRACK" ` "json_key:$env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH" ` "version_name:$env:APP_DISPLAY_VERSION" @@ -471,4 +444,4 @@ jobs: "issuer_id:$env:APPSTORE_CONNECT_ISSUER_ID" ` "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` - "groups:${{ inputs.testflight_groups }}" + "groups:$env:TEMPLATE_APP_TESTFLIGHT_GROUPS" From 81c671b153cc35c9f9d4208c034033436efea63f Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 11:01:08 +0200 Subject: [PATCH 03/30] Validate template app publish secrets early Adds an early publish-job preflight that reports missing store signing secrets before expensive setup/build steps, and directs dry-run testing to publish=false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/template-app-distribution.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 15ce7e0064f2..080cf4d63c15 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -143,6 +143,54 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: + - name: Validate publishing configuration + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + VARIANT: ${{ matrix.variant }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} + IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} + run: | + $missing = [System.Collections.Generic.List[string]]::new() + + function Test-RequiredEnvironment([string]$Name, [string]$SecretName) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($Name))) { + $missing.Add($SecretName) + } + } + + if ($env:PLATFORM -eq "android") { + Test-RequiredEnvironment "ANDROID_KEYSTORE_BASE64" "TEMPLATE_APP_ANDROID_KEYSTORE_BASE64" + Test-RequiredEnvironment "ANDROID_KEYSTORE_PASSWORD" "TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD" + Test-RequiredEnvironment "ANDROID_KEY_ALIAS" "TEMPLATE_APP_ANDROID_KEY_ALIAS" + Test-RequiredEnvironment "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" "TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" + } elseif ($env:PLATFORM -eq "ios") { + Test-RequiredEnvironment "IOS_CERTIFICATE_BASE64" "TEMPLATE_APP_IOS_CERTIFICATE_BASE64" + Test-RequiredEnvironment "IOS_CERTIFICATE_PASSWORD" "TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD" + if ([string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILE_BASE64) -and [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILES_JSON)) { + $variantSecretName = $env:VARIANT.ToUpperInvariant() + $missing.Add("TEMPLATE_APP_${variantSecretName}_IOS_PROVISIONING_PROFILE_BASE64 or TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON") + } + Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" + } + + if ($missing.Count -gt 0) { + $message = "Publishing '$env:VARIANT' for '$env:PLATFORM' is missing required environment secrets: $($missing -join ', '). Configure these in the protected 'template-app-distribution' environment, or rerun with publish=false for a dry-run build." + Write-Error $message + exit 1 + } + - name: Checkout workflow scripts uses: actions/checkout@v4 with: From 93fec7e7832ca32e33e90d34ac295759252daf70 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:09:51 +0200 Subject: [PATCH 04/30] Move publish secret validation to publish job Keeps dry-run builds independent from store signing secrets while preserving early validation for publish=true runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/template-app-distribution.yml | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 080cf4d63c15..df2b90f38949 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -143,54 +143,6 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: - - name: Validate publishing configuration - shell: pwsh - env: - PLATFORM: ${{ matrix.platform }} - VARIANT: ${{ matrix.variant }} - ANDROID_KEYSTORE_BASE64: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} - IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} - APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} - APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} - APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} - run: | - $missing = [System.Collections.Generic.List[string]]::new() - - function Test-RequiredEnvironment([string]$Name, [string]$SecretName) { - if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($Name))) { - $missing.Add($SecretName) - } - } - - if ($env:PLATFORM -eq "android") { - Test-RequiredEnvironment "ANDROID_KEYSTORE_BASE64" "TEMPLATE_APP_ANDROID_KEYSTORE_BASE64" - Test-RequiredEnvironment "ANDROID_KEYSTORE_PASSWORD" "TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD" - Test-RequiredEnvironment "ANDROID_KEY_ALIAS" "TEMPLATE_APP_ANDROID_KEY_ALIAS" - Test-RequiredEnvironment "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" "TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" - } elseif ($env:PLATFORM -eq "ios") { - Test-RequiredEnvironment "IOS_CERTIFICATE_BASE64" "TEMPLATE_APP_IOS_CERTIFICATE_BASE64" - Test-RequiredEnvironment "IOS_CERTIFICATE_PASSWORD" "TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD" - if ([string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILE_BASE64) -and [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILES_JSON)) { - $variantSecretName = $env:VARIANT.ToUpperInvariant() - $missing.Add("TEMPLATE_APP_${variantSecretName}_IOS_PROVISIONING_PROFILE_BASE64 or TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON") - } - Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" - Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" - Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" - } - - if ($missing.Count -gt 0) { - $message = "Publishing '$env:VARIANT' for '$env:PLATFORM' is missing required environment secrets: $($missing -join ', '). Configure these in the protected 'template-app-distribution' environment, or rerun with publish=false for a dry-run build." - Write-Error $message - exit 1 - } - - name: Checkout workflow scripts uses: actions/checkout@v4 with: @@ -298,6 +250,54 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: + - name: Validate publishing configuration + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + VARIANT: ${{ matrix.variant }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} + IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} + run: | + $missing = [System.Collections.Generic.List[string]]::new() + + function Test-RequiredEnvironment([string]$Name, [string]$SecretName) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($Name))) { + $missing.Add($SecretName) + } + } + + if ($env:PLATFORM -eq "android") { + Test-RequiredEnvironment "ANDROID_KEYSTORE_BASE64" "TEMPLATE_APP_ANDROID_KEYSTORE_BASE64" + Test-RequiredEnvironment "ANDROID_KEYSTORE_PASSWORD" "TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD" + Test-RequiredEnvironment "ANDROID_KEY_ALIAS" "TEMPLATE_APP_ANDROID_KEY_ALIAS" + Test-RequiredEnvironment "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" "TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" + } elseif ($env:PLATFORM -eq "ios") { + Test-RequiredEnvironment "IOS_CERTIFICATE_BASE64" "TEMPLATE_APP_IOS_CERTIFICATE_BASE64" + Test-RequiredEnvironment "IOS_CERTIFICATE_PASSWORD" "TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD" + if ([string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILE_BASE64) -and [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILES_JSON)) { + $variantSecretName = $env:VARIANT.ToUpperInvariant() + $missing.Add("TEMPLATE_APP_${variantSecretName}_IOS_PROVISIONING_PROFILE_BASE64 or TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON") + } + Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" + } + + if ($missing.Count -gt 0) { + $message = "Publishing '$env:VARIANT' for '$env:PLATFORM' is missing required environment secrets: $($missing -join ', '). Configure these in the protected 'template-app-distribution' environment, or rerun with publish=false for a dry-run build." + Write-Error $message + exit 1 + } + - name: Checkout workflow scripts uses: actions/checkout@v4 with: From 3c489ce348fde5307b2d95a34a4637aed21d7df8 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:19:24 +0200 Subject: [PATCH 05/30] Restrict generated template app target frameworks Removes conditional TargetFrameworks entries from generated template apps so single-platform dry-run and publish jobs do not restore unrelated workloads such as MacCatalyst during iOS builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../New-TemplateApp.ps1 | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index 1c1687430e88..04334ad4c57b 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -94,7 +94,22 @@ if (-not $projectFile) { } $content = Get-Content $projectFile.FullName -Raw -$content = $content -replace "[^<]+", "$TargetFramework" +$targetFrameworksMatches = @([regex]::Matches($content, "[^<]+")) +if ($targetFrameworksMatches.Count -eq 0) { + throw "Could not find TargetFrameworks in '$($projectFile.FullName)'." +} + +for ($i = $targetFrameworksMatches.Count - 1; $i -ge 0; $i--) { + $match = $targetFrameworksMatches[$i] + $replacement = if ($i -eq 0) { + "$TargetFramework" + } else { + "" + } + + $content = $content.Remove($match.Index, $match.Length).Insert($match.Index, $replacement) +} + $content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $DisplayName)" $content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $ApplicationId)" $content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppDisplayVersion)" From f1d34983cd13143923651c36035c908da10794ae Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:26:29 +0200 Subject: [PATCH 06/30] Fix template app Xcode selection Select the newest installed Xcode that has both macOS and iPhoneOS SDKs before building iOS template apps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/template-app-distribution.yml | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index df2b90f38949..0da71f9159ed 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -173,12 +173,25 @@ jobs: if: ${{ matrix.platform == 'ios' }} 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" + SELECTED_XCODE="" + while IFS= read -r xcode; do + if compgen -G "$xcode/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX*.sdk" > /dev/null && + compgen -G "$xcode/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS*.sdk" > /dev/null; then + SELECTED_XCODE="$xcode" + break + fi + done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -type d | sort -r) + + if [ -z "$SELECTED_XCODE" ]; then + echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." + exit 1 fi + + echo "Selecting Xcode: $SELECTED_XCODE" + sudo xcode-select -s "$SELECTED_XCODE/Contents/Developer" xcodebuild -version + xcrun --sdk macosx --show-sdk-path + xcrun --sdk iphoneos --show-sdk-path - name: Install MAUI workload run: dotnet workload install ${{ matrix.workload }} @@ -328,12 +341,25 @@ jobs: if: ${{ matrix.platform == 'ios' }} 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" + SELECTED_XCODE="" + while IFS= read -r xcode; do + if compgen -G "$xcode/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX*.sdk" > /dev/null && + compgen -G "$xcode/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS*.sdk" > /dev/null; then + SELECTED_XCODE="$xcode" + break + fi + done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -type d | sort -r) + + if [ -z "$SELECTED_XCODE" ]; then + echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." + exit 1 fi + + echo "Selecting Xcode: $SELECTED_XCODE" + sudo xcode-select -s "$SELECTED_XCODE/Contents/Developer" xcodebuild -version + xcrun --sdk macosx --show-sdk-path + xcrun --sdk iphoneos --show-sdk-path - name: Setup Ruby and fastlane uses: ruby/setup-ruby@v1 From 46c985e4a0fde3dbe59e447fedf3f664d9070ceb Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:39:28 +0200 Subject: [PATCH 07/30] Prefer compatible Xcode SDK for template apps Select Xcode by highest installed iPhoneOS SDK and ensure SDK convenience symlinks exist on hosted macOS runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/template-app-distribution.yml | 148 +++++++++++++++--- 1 file changed, 126 insertions(+), 22 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 0da71f9159ed..cf02658c3bfd 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -173,22 +173,74 @@ jobs: if: ${{ matrix.platform == 'ios' }} shell: bash run: | - SELECTED_XCODE="" - while IFS= read -r xcode; do - if compgen -G "$xcode/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX*.sdk" > /dev/null && - compgen -G "$xcode/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS*.sdk" > /dev/null; then - SELECTED_XCODE="$xcode" - break - fi - done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -type d | sort -r) - - if [ -z "$SELECTED_XCODE" ]; then + XCODE_INFO=$(python3 - <<'PY' + import re + import sys + from pathlib import Path + + def version_tuple(path: Path, prefix: str) -> tuple[int, ...]: + match = re.search(rf"{prefix}([0-9.]+)?\.sdk$", path.name) + if not match or not match.group(1): + return (0,) + return tuple(int(part) for part in match.group(1).split(".") if part) + + best = None + for app in Path("/Applications").glob("Xcode*.app"): + developer = app / "Contents" / "Developer" + macos_sdks = list((developer / "Platforms" / "MacOSX.platform" / "Developer" / "SDKs").glob("MacOSX*.sdk")) + iphoneos_sdks = list((developer / "Platforms" / "iPhoneOS.platform" / "Developer" / "SDKs").glob("iPhoneOS*.sdk")) + if not macos_sdks or not iphoneos_sdks: + continue + + macos_sdk = max(macos_sdks, key=lambda sdk: version_tuple(sdk, "MacOSX")) + iphoneos_sdk = max(iphoneos_sdks, key=lambda sdk: version_tuple(sdk, "iPhoneOS")) + sort_key = (version_tuple(iphoneos_sdk, "iPhoneOS"), version_tuple(macos_sdk, "MacOSX"), app.name) + if best is None or sort_key > best[0]: + best = (sort_key, app, macos_sdk, iphoneos_sdk) + + if best is None: + sys.exit(1) + + _, app, macos_sdk, iphoneos_sdk = best + print(app) + print(macos_sdk) + print(iphoneos_sdk) + PY + ) + + if [ -z "$XCODE_INFO" ]; then echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." exit 1 fi + SELECTED_XCODE=$(printf '%s\n' "$XCODE_INFO" | sed -n '1p') + MACOS_SDK=$(printf '%s\n' "$XCODE_INFO" | sed -n '2p') + IPHONEOS_SDK=$(printf '%s\n' "$XCODE_INFO" | sed -n '3p') + DEVELOPER_DIR="$SELECTED_XCODE/Contents/Developer" + echo "Selecting Xcode: $SELECTED_XCODE" - sudo xcode-select -s "$SELECTED_XCODE/Contents/Developer" + echo "Selected macOS SDK: $MACOS_SDK" + echo "Selected iPhoneOS SDK: $IPHONEOS_SDK" + sudo xcode-select -s "$DEVELOPER_DIR" + + ensure_sdk_link() { + local link_path="$1" + local target_path="$2" + + if [ -e "$link_path" ]; then + return + fi + + if [ -L "$link_path" ]; then + sudo rm "$link_path" + fi + + sudo ln -s "$(basename "$target_path")" "$link_path" + } + + ensure_sdk_link "$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" "$MACOS_SDK" + ensure_sdk_link "$DEVELOPER_DIR/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk" "$IPHONEOS_SDK" + xcodebuild -version xcrun --sdk macosx --show-sdk-path xcrun --sdk iphoneos --show-sdk-path @@ -341,22 +393,74 @@ jobs: if: ${{ matrix.platform == 'ios' }} shell: bash run: | - SELECTED_XCODE="" - while IFS= read -r xcode; do - if compgen -G "$xcode/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX*.sdk" > /dev/null && - compgen -G "$xcode/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS*.sdk" > /dev/null; then - SELECTED_XCODE="$xcode" - break - fi - done < <(find /Applications -maxdepth 1 -name 'Xcode*.app' -type d | sort -r) - - if [ -z "$SELECTED_XCODE" ]; then + XCODE_INFO=$(python3 - <<'PY' + import re + import sys + from pathlib import Path + + def version_tuple(path: Path, prefix: str) -> tuple[int, ...]: + match = re.search(rf"{prefix}([0-9.]+)?\.sdk$", path.name) + if not match or not match.group(1): + return (0,) + return tuple(int(part) for part in match.group(1).split(".") if part) + + best = None + for app in Path("/Applications").glob("Xcode*.app"): + developer = app / "Contents" / "Developer" + macos_sdks = list((developer / "Platforms" / "MacOSX.platform" / "Developer" / "SDKs").glob("MacOSX*.sdk")) + iphoneos_sdks = list((developer / "Platforms" / "iPhoneOS.platform" / "Developer" / "SDKs").glob("iPhoneOS*.sdk")) + if not macos_sdks or not iphoneos_sdks: + continue + + macos_sdk = max(macos_sdks, key=lambda sdk: version_tuple(sdk, "MacOSX")) + iphoneos_sdk = max(iphoneos_sdks, key=lambda sdk: version_tuple(sdk, "iPhoneOS")) + sort_key = (version_tuple(iphoneos_sdk, "iPhoneOS"), version_tuple(macos_sdk, "MacOSX"), app.name) + if best is None or sort_key > best[0]: + best = (sort_key, app, macos_sdk, iphoneos_sdk) + + if best is None: + sys.exit(1) + + _, app, macos_sdk, iphoneos_sdk = best + print(app) + print(macos_sdk) + print(iphoneos_sdk) + PY + ) + + if [ -z "$XCODE_INFO" ]; then echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." exit 1 fi + SELECTED_XCODE=$(printf '%s\n' "$XCODE_INFO" | sed -n '1p') + MACOS_SDK=$(printf '%s\n' "$XCODE_INFO" | sed -n '2p') + IPHONEOS_SDK=$(printf '%s\n' "$XCODE_INFO" | sed -n '3p') + DEVELOPER_DIR="$SELECTED_XCODE/Contents/Developer" + echo "Selecting Xcode: $SELECTED_XCODE" - sudo xcode-select -s "$SELECTED_XCODE/Contents/Developer" + echo "Selected macOS SDK: $MACOS_SDK" + echo "Selected iPhoneOS SDK: $IPHONEOS_SDK" + sudo xcode-select -s "$DEVELOPER_DIR" + + ensure_sdk_link() { + local link_path="$1" + local target_path="$2" + + if [ -e "$link_path" ]; then + return + fi + + if [ -L "$link_path" ]; then + sudo rm "$link_path" + fi + + sudo ln -s "$(basename "$target_path")" "$link_path" + } + + ensure_sdk_link "$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" "$MACOS_SDK" + ensure_sdk_link "$DEVELOPER_DIR/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk" "$IPHONEOS_SDK" + xcodebuild -version xcrun --sdk macosx --show-sdk-path xcrun --sdk iphoneos --show-sdk-path From 91b2539e9663540335dc226aceff0daa8870ecce Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:47:43 +0200 Subject: [PATCH 08/30] Pin template app workload manifests Use SDK-bundled workload manifests so dry-run and publish builds do not pull an iOS workload newer than the hosted runner Xcode SDK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index cf02658c3bfd..108acb138af3 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -246,7 +246,7 @@ jobs: xcrun --sdk iphoneos --show-sdk-path - name: Install MAUI workload - run: dotnet workload install ${{ matrix.workload }} + run: dotnet workload install ${{ matrix.workload }} --skip-manifest-update - name: Pack local templates id: pack @@ -473,7 +473,7 @@ jobs: working-directory: trusted/.github/scripts/template-app-distribution/fastlane - name: Install MAUI workload - run: dotnet workload install ${{ matrix.workload }} + run: dotnet workload install ${{ matrix.workload }} --skip-manifest-update - name: Set app version and release notes shell: pwsh From 9e193f871d0ccaf908106ed9470439a666def907 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 23 Jun 2026 12:57:31 +0200 Subject: [PATCH 09/30] Use platform-specific workload install Keep normal workload manifest updates for Android while pinning iOS to SDK-bundled manifests for hosted runner Xcode compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 108acb138af3..9ba1c65d5544 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -246,7 +246,13 @@ jobs: xcrun --sdk iphoneos --show-sdk-path - name: Install MAUI workload - run: dotnet workload install ${{ matrix.workload }} --skip-manifest-update + shell: bash + run: | + if [ "${{ matrix.platform }}" = "ios" ]; then + dotnet workload install ${{ matrix.workload }} --skip-manifest-update + else + dotnet workload install ${{ matrix.workload }} + fi - name: Pack local templates id: pack @@ -473,7 +479,13 @@ jobs: working-directory: trusted/.github/scripts/template-app-distribution/fastlane - name: Install MAUI workload - run: dotnet workload install ${{ matrix.workload }} --skip-manifest-update + shell: bash + run: | + if [ "${{ matrix.platform }}" = "ios" ]; then + dotnet workload install ${{ matrix.workload }} --skip-manifest-update + else + dotnet workload install ${{ matrix.workload }} + fi - name: Set app version and release notes shell: pwsh From bdfb2b232d43a65c463a363a120e33052fc258cc Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 10:48:30 +0200 Subject: [PATCH 10/30] Support Android keystore type for template app publishing Allow template app publishing to pass AndroidSigningStoreType when the upload keystore is PKCS12 or another explicit keystore type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/template-app-distribution/Build-TemplateApp.ps1 | 5 +++++ .github/workflows/template-app-distribution.yml | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index be5582efaaf2..7834f16b5a58 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -89,6 +89,7 @@ switch ($Platform) { } $keyAlias = Assert-EnvironmentValue "ANDROID_KEY_ALIAS" + $keystoreType = [Environment]::GetEnvironmentVariable("ANDROID_KEYSTORE_TYPE") $arguments += @( "-p:AndroidKeyStore=true", @@ -97,6 +98,10 @@ switch ($Platform) { "-p:AndroidSigningStorePass=env:ANDROID_SIGNING_STORE_PASS", "-p:AndroidSigningKeyPass=env:ANDROID_SIGNING_KEY_PASS" ) + + if (-not [string]::IsNullOrWhiteSpace($keystoreType)) { + $arguments += "-p:AndroidSigningStoreType=$keystoreType" + } } else { $arguments += "-p:AndroidKeyStore=false" } diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 9ba1c65d5544..4f1d7f416ee5 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -20,6 +20,7 @@ # - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. # - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) # - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. # - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. # - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. # Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. @@ -547,6 +548,7 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.TEMPLATE_APP_ANDROID_KEY_PASSWORD }} + ANDROID_KEYSTORE_TYPE: ${{ vars.TEMPLATE_APP_ANDROID_KEYSTORE_TYPE }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` -ProjectPath "${{ steps.app.outputs.project_path }}" ` From 333a00fe525e3b7d155129a858cff803e1ab90f7 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 12:06:35 +0200 Subject: [PATCH 11/30] Allow draft Play releases for template apps Make the Google Play release status configurable so first uploads to draft Play apps can use draft while established apps keep completed by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/template-app-distribution/fastlane/Fastfile | 2 +- .github/workflows/template-app-distribution.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index e8761089befc..4374ea11ef58 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -37,7 +37,7 @@ platform :android do aab: required_option(options, :aab), track: required_option(options, :track), json_key: required_option(options, :json_key), - release_status: "completed", + release_status: optional_option(options, :release_status) || "completed", version_name: optional_option(options, :version_name), metadata_path: metadata_path, skip_upload_metadata: true, diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 4f1d7f416ee5..1f42dcf51fac 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -22,6 +22,7 @@ # - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) # - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. # - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. +# - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. # - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. # Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. @@ -317,6 +318,7 @@ jobs: APP_DISPLAY_VERSION: ${{ needs.prepare.outputs.app_display_version }} APP_BUILD_NUMBER: ${{ needs.prepare.outputs.app_build_number }} TEMPLATE_APP_PLAY_TRACK: ${{ vars.TEMPLATE_APP_PLAY_TRACK || 'internal' }} + TEMPLATE_APP_PLAY_RELEASE_STATUS: ${{ vars.TEMPLATE_APP_PLAY_RELEASE_STATUS || 'completed' }} TEMPLATE_APP_TESTFLIGHT_GROUPS: ${{ vars.TEMPLATE_APP_TESTFLIGHT_GROUPS }} strategy: fail-fast: false @@ -600,6 +602,7 @@ jobs: "aab:${{ steps.build.outputs.package_path }}" ` "track:$env:TEMPLATE_APP_PLAY_TRACK" ` "json_key:$env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH" ` + "release_status:$env:TEMPLATE_APP_PLAY_RELEASE_STATUS" ` "version_name:$env:APP_DISPLAY_VERSION" - name: Write App Store Connect API key From 779541d3e1f6042e15d5bbf93e17092d7eeaf762 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 14:17:48 +0200 Subject: [PATCH 12/30] Use static skipped dry-run job name Avoid showing unresolved matrix placeholders when the dry-run matrix job is skipped during publish runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 1f42dcf51fac..ef0a5b728256 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -134,7 +134,7 @@ jobs: -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" dry-run-build: - name: Dry-run ${{ matrix.variant }} ${{ matrix.platform }} + name: Dry-run builds if: ${{ inputs.publish == false }} needs: prepare runs-on: ${{ matrix.runner }} From eb1011182564a24f5bfb46c6652b90798d3e821b Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 19:24:51 +0200 Subject: [PATCH 13/30] Add Windows template app artifact builds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 33 ++++++++++++++++++- .../New-TemplateApp.ps1 | 13 ++++++++ .../Prepare-Matrix.ps1 | 26 ++++++++++++++- .../workflows/template-app-distribution.yml | 20 ++++++----- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 7834f16b5a58..89b814391092 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -5,7 +5,7 @@ param( [string]$ProjectPath, [Parameter(Mandatory)] - [ValidateSet("android", "ios")] + [ValidateSet("android", "ios", "windows")] [string]$Platform, [Parameter(Mandatory)] @@ -163,6 +163,37 @@ switch ($Platform) { } } } + + "windows" { + $publishOutputPath = Join-Path $OutputPath "publish" + Remove-Item -Path $publishOutputPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $publishOutputPath -Force | Out-Null + + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:RuntimeIdentifierOverride=$RuntimeIdentifier", + "-p:WindowsPackageType=None", + "-p:WindowsAppSDKSelfContained=true", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-o", $publishOutputPath, + "/bl:$binlogPath" + ) + + Write-Host "Building Windows unpackaged app for $($projectFile.FullName)" + & dotnet @arguments + + if ($LASTEXITCODE -ne 0) { + throw "Windows unpackaged publish failed with exit code $LASTEXITCODE." + } + + $zipPath = Join-Path $OutputPath "$($projectFile.BaseName)-windows-unpackaged.zip" + Remove-Item -Path $zipPath -Force -ErrorAction SilentlyContinue + Compress-Archive -Path (Join-Path $publishOutputPath "*") -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } } if (-not $package) { diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index 04334ad4c57b..6acea569d3db 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -114,6 +114,19 @@ $content = $content -replace "[^<]+", "[^<]+", "$(ConvertTo-XmlEscaped $ApplicationId)" $content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppDisplayVersion)" $content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppBuildNumber)" + +if ($TargetFramework.Contains("-windows", [System.StringComparison]::OrdinalIgnoreCase) -and + $content -notmatch "RuntimeIdentifierOverride") { + $runtimeIdentifierOverridePropertyGroup = @" + + + `$(RuntimeIdentifierOverride) + +"@ + + $content = $content -replace "\s*$", "$runtimeIdentifierOverridePropertyGroup`r`n" +} + Set-Content -Path $projectFile.FullName -Value $content -Encoding utf8 @{ diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 index d36646bf0caf..ab642d6194a7 100644 --- a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -77,6 +77,7 @@ $variantDefinitions = [ordered]@{ templateArgs = @() androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" "$identifierPrefix.blank" iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" "$identifierPrefix.blank" + windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID" "$identifierPrefix.blank" } sample = [ordered]@{ displayName = "MAUI Template Sample" @@ -85,6 +86,7 @@ $variantDefinitions = [ordered]@{ templateArgs = @("--sample-content") androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" "$identifierPrefix.sample" iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" "$identifierPrefix.sample" + windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID" "$identifierPrefix.sample" } } @@ -108,6 +110,12 @@ $platformDefinitions = [ordered]@{ targetFramework = "$DotNetTfm-ios" runtimeIdentifier = "ios-arm64" } + windows = [ordered]@{ + runner = "windows-latest" + workload = "maui-windows" + targetFramework = "$DotNetTfm-windows10.0.19041.0" + runtimeIdentifier = "win-x64" + } } $selectedVariants = Split-InputList $Variants @@ -137,13 +145,28 @@ foreach ($variantName in $selectedVariants) { } $platform = $platformDefinitions[$platformName] - $applicationId = if ($platformName -eq "ios") { $variant.iosBundleId } else { $variant.androidApplicationId } + $applicationId = switch ($platformName) { + "ios" { $variant.iosBundleId } + "windows" { + if ([string]::IsNullOrWhiteSpace($variant.windowsApplicationId)) { + $variant.androidApplicationId + } else { + $variant.windowsApplicationId + } + } + default { $variant.androidApplicationId } + } if ([string]::IsNullOrWhiteSpace($applicationId)) { throw "Variant '$variantName' does not define an application identifier for '$platformName'." } $templateArgs = @(ConvertTo-StringArray $variant.templateArgs) + $windowsApplicationId = if ([string]::IsNullOrWhiteSpace($variant.windowsApplicationId)) { + $variant.androidApplicationId + } else { + $variant.windowsApplicationId + } $templateArgsJson = if ($templateArgs.Count -eq 0) { "[]" } else { @@ -164,6 +187,7 @@ foreach ($variantName in $selectedVariants) { applicationId = [string]$applicationId androidApplicationId = [string]$variant.androidApplicationId iosBundleId = [string]$variant.iosBundleId + windowsApplicationId = [string]$windowsApplicationId } } } diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index ef0a5b728256..69fb35ab32b5 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -20,6 +20,7 @@ # - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. # - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) # - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# - TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID / TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID (variables) # - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. # - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. # - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. @@ -127,6 +128,8 @@ jobs: TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID }} TEMPLATE_APP_BLANK_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_BLANK_IOS_BUNDLE_ID }} TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID }} + TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID: ${{ vars.TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID }} + TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Prepare-Matrix.ps1" ` -Variants "all" ` @@ -248,13 +251,13 @@ jobs: xcrun --sdk iphoneos --show-sdk-path - name: Install MAUI workload - shell: bash + shell: pwsh run: | - if [ "${{ matrix.platform }}" = "ios" ]; then + if ("${{ matrix.platform }}" -eq "ios") { dotnet workload install ${{ matrix.workload }} --skip-manifest-update - else + } else { dotnet workload install ${{ matrix.workload }} - fi + } - name: Pack local templates id: pack @@ -475,6 +478,7 @@ jobs: xcrun --sdk iphoneos --show-sdk-path - name: Setup Ruby and fastlane + if: ${{ matrix.platform == 'android' || matrix.platform == 'ios' }} uses: ruby/setup-ruby@v1 with: ruby-version: "3.3" @@ -482,13 +486,13 @@ jobs: working-directory: trusted/.github/scripts/template-app-distribution/fastlane - name: Install MAUI workload - shell: bash + shell: pwsh run: | - if [ "${{ matrix.platform }}" = "ios" ]; then + if ("${{ matrix.platform }}" -eq "ios") { dotnet workload install ${{ matrix.workload }} --skip-manifest-update - else + } else { dotnet workload install ${{ matrix.workload }} - fi + } - name: Set app version and release notes shell: pwsh From 8a66e3374a24c30bdb1bdaa54839cc65bcf89453 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 19:36:07 +0200 Subject: [PATCH 14/30] Use static template app publish job name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 69fb35ab32b5..8ab73857afa8 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -312,7 +312,7 @@ jobs: retention-days: 14 publish: - name: Publish ${{ matrix.variant }} ${{ matrix.platform }} + name: Publish/build template apps if: ${{ inputs.publish }} needs: prepare runs-on: ${{ matrix.runner }} From 8bcb2fecb19ffd566dde47a9b6432bdc127dcd79 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 20:50:07 +0200 Subject: [PATCH 15/30] Add Mac Catalyst template app distribution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 65 +++++++++++++++- .../Install-AppleSigningAssets.ps1 | 75 +++++++++++++++++-- .../Prepare-Matrix.ps1 | 39 ++++++++-- .../fastlane/Fastfile | 11 ++- .../workflows/template-app-distribution.yml | 62 ++++++++++++--- 5 files changed, 227 insertions(+), 25 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 89b814391092..ab7097bfc0c4 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -5,13 +5,14 @@ param( [string]$ProjectPath, [Parameter(Mandatory)] - [ValidateSet("android", "ios", "windows")] + [ValidateSet("android", "ios", "maccatalyst", "windows")] [string]$Platform, [Parameter(Mandatory)] [string]$TargetFramework, [Parameter(Mandatory)] + [AllowEmptyString()] [string]$RuntimeIdentifier, [Parameter(Mandatory)] @@ -164,6 +165,68 @@ switch ($Platform) { } } + "maccatalyst" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:MtouchLink=SdkOnly", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "/bl:$binlogPath" + ) + + if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { + $arguments += @("-r", $RuntimeIdentifier) + } + + if ($Publish) { + $codesignKey = Assert-EnvironmentValue "APPLE_CODESIGN_KEY" + $codesignProvision = Assert-EnvironmentValue "APPLE_CODESIGN_PROVISION" + $packageSigningKey = Assert-EnvironmentValue "APPLE_PACKAGE_SIGNING_KEY" + $arguments += @( + "-p:CreatePackage=true", + "-p:EnableCodeSigning=true", + "-p:EnablePackageSigning=true", + "-p:CodesignKey=$codesignKey", + "-p:CodesignProvision=$codesignProvision", + "-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.plist", + "-p:PackageSigningKey=$packageSigningKey", + "-o", $OutputPath + ) + } else { + $arguments += @( + "-p:CreatePackage=false", + "-p:_RequireCodeSigning=false", + "-p:EnableCodeSigning=false", + "-p:CodesignKey=-", + "-o", $OutputPath + ) + } + + Write-Host "Building Mac Catalyst package for $($projectFile.FullName)" + & dotnet @arguments + + if ($Publish) { + $package = Get-NewestBuildOutput $ProjectPath "*.pkg" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.pkg" + } + } else { + $appBundle = Get-NewestBuildOutput $OutputPath "*.app" -Directory + if (-not $appBundle) { + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + } + + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Compress-Archive -Path $appBundle.FullName -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } + } + } + "windows" { $publishOutputPath = Join-Path $OutputPath "publish" Remove-Item -Path $publishOutputPath -Recurse -Force -ErrorAction SilentlyContinue diff --git a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 index d67c48f9b6cc..d3e4fa4bf59f 100644 --- a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -2,7 +2,10 @@ param( [Parameter(Mandatory)] - [string]$Variant + [string]$Variant, + + [ValidateSet("ios", "maccatalyst")] + [string]$Platform = "ios" ) $ErrorActionPreference = "Stop" @@ -30,6 +33,18 @@ function Get-SecretText([string]$Value) { } function Get-VariantProvisioningProfile([string]$VariantName) { + if (-not [string]::IsNullOrWhiteSpace($env:APPLE_PROVISIONING_PROFILES_JSON)) { + $profiles = Get-SecretText $env:APPLE_PROVISIONING_PROFILES_JSON | ConvertFrom-Json + $property = $profiles.PSObject.Properties | Where-Object { $_.Name -eq $VariantName } | Select-Object -First 1 + if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) { + return [string]$property.Value + } + } + + if (-not [string]::IsNullOrWhiteSpace($env:APPLE_PROVISIONING_PROFILE_BASE64)) { + return $env:APPLE_PROVISIONING_PROFILE_BASE64 + } + if (-not [string]::IsNullOrWhiteSpace($env:IOS_PROVISIONING_PROFILES_JSON)) { $profiles = Get-SecretText $env:IOS_PROVISIONING_PROFILES_JSON | ConvertFrom-Json $property = $profiles.PSObject.Properties | Where-Object { $_.Name -eq $VariantName } | Select-Object -First 1 @@ -42,7 +57,7 @@ function Get-VariantProvisioningProfile([string]$VariantName) { return $env:IOS_PROVISIONING_PROFILE_BASE64 } - throw "No iOS provisioning profile was provided for variant '$VariantName'. Set IOS_PROVISIONING_PROFILES_JSON or IOS_PROVISIONING_PROFILE_BASE64." + throw "No Apple provisioning profile was provided for variant '$VariantName' on '$Platform'. Set APPLE_PROVISIONING_PROFILES_JSON or APPLE_PROVISIONING_PROFILE_BASE64." } function Write-Base64File([string]$Base64Value, [string]$Path) { @@ -78,15 +93,35 @@ if ([string]::IsNullOrWhiteSpace($keychainPassword)) { $existingKeychains = & security list-keychains -d user | ForEach-Object { $_.Trim().Trim('"') } & security list-keychains -d user -s $keychainPath @existingKeychains & security import $certificatePath -k $keychainPath -P $certificatePassword -T /usr/bin/codesign -T /usr/bin/security + +if ($Platform -eq "maccatalyst" -and -not [string]::IsNullOrWhiteSpace($env:MAC_INSTALLER_CERTIFICATE_BASE64)) { + $installerCertificatePath = Join-Path $tempDirectory "mac-installer-certificate.p12" + Write-Base64File $env:MAC_INSTALLER_CERTIFICATE_BASE64 $installerCertificatePath + $installerCertificatePassword = if ([string]::IsNullOrWhiteSpace($env:MAC_INSTALLER_CERTIFICATE_PASSWORD)) { + $certificatePassword + } else { + $env:MAC_INSTALLER_CERTIFICATE_PASSWORD + } + + & security import $installerCertificatePath -k $keychainPath -P $installerCertificatePassword -T /usr/bin/productbuild -T /usr/bin/security +} + & security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychainPath -$identities = @() -foreach ($line in (& security find-identity -v -p codesigning $keychainPath)) { - if ($line -match '"(.+)"') { - $identities += $Matches[1] +function Get-SecurityIdentities([string[]]$Arguments) { + $result = @() + foreach ($line in (& security find-identity @Arguments $keychainPath)) { + if ($line -match '"(.+)"') { + $result += $Matches[1] + } } + + return $result } +$identities = Get-SecurityIdentities @("-v", "-p", "codesigning") +$allIdentities = Get-SecurityIdentities @("-v") + $codesignIdentity = $identities | Where-Object { $_ -match "Apple Distribution|iPhone Distribution" } | Select-Object -First 1 @@ -99,6 +134,23 @@ if ([string]::IsNullOrWhiteSpace($codesignIdentity)) { throw "No code signing identity was found in the imported certificate." } +$packageSigningIdentity = $null +if ($Platform -eq "maccatalyst") { + $packageSigningIdentity = $allIdentities | + Where-Object { $_ -match "3rd Party Mac Developer Installer|Mac Installer Distribution" } | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($packageSigningIdentity)) { + throw "No Mac installer package signing identity was found. Import a p12 containing a '3rd Party Mac Developer Installer' identity with MAC_INSTALLER_CERTIFICATE_BASE64." + } +} + +foreach ($line in (& security find-identity -v -p codesigning $keychainPath)) { + if ($line -match '"(.+)"') { + Write-Host "Code signing identity: $($Matches[1])" + } +} + & security cms -D -i $profilePath | Out-File -FilePath $profilePlistPath -Encoding utf8 $profileUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $profilePlistPath).Trim() $profileName = (& /usr/libexec/PlistBuddy -c "Print :Name" $profilePlistPath).Trim() @@ -113,15 +165,26 @@ Copy-Item -Path $profilePath -Destination (Join-Path $profilesDirectory "$profil Write-Host "Installed provisioning profile '$profileName' ($profileUuid)" Write-Host "Using code signing identity '$codesignIdentity'" +if ($Platform -eq "maccatalyst") { + Write-Host "Using package signing identity '$packageSigningIdentity'" +} if ($env:GITHUB_ENV) { "IOS_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV "IOS_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV + "APPLE_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV + "APPLE_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV + if ($Platform -eq "maccatalyst") { + "APPLE_PACKAGE_SIGNING_KEY=$packageSigningIdentity" >> $env:GITHUB_ENV + } "IOS_KEYCHAIN_PATH=$keychainPath" >> $env:GITHUB_ENV } if ($env:GITHUB_OUTPUT) { "codesign_key=$codesignIdentity" >> $env:GITHUB_OUTPUT "codesign_provision=$profileName" >> $env:GITHUB_OUTPUT + if ($Platform -eq "maccatalyst") { + "package_signing_key=$packageSigningIdentity" >> $env:GITHUB_OUTPUT + } "keychain_path=$keychainPath" >> $env:GITHUB_OUTPUT } diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 index ab642d6194a7..a70c13492338 100644 --- a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -68,6 +68,12 @@ function Merge-VariantDefinition($Definitions, [string]$Name, $Definition) { } $identifierPrefix = Get-DefaultIdentifierPrefix +$blankDefaultIdentifier = "$identifierPrefix.blank" +$blankIosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" $blankDefaultIdentifier +$blankMacCatalystBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_MACCATALYST_BUNDLE_ID" $blankIosBundleId +$sampleDefaultIdentifier = "$identifierPrefix.sample" +$sampleIosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" $sampleDefaultIdentifier +$sampleMacCatalystBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_MACCATALYST_BUNDLE_ID" $sampleIosBundleId $variantDefinitions = [ordered]@{ blank = [ordered]@{ @@ -75,18 +81,20 @@ $variantDefinitions = [ordered]@{ projectName = "MauiTemplateBlank" template = "maui" templateArgs = @() - androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" "$identifierPrefix.blank" - iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_IOS_BUNDLE_ID" "$identifierPrefix.blank" - windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID" "$identifierPrefix.blank" + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID" $blankDefaultIdentifier + iosBundleId = $blankIosBundleId + maccatalystBundleId = $blankMacCatalystBundleId + windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID" $blankDefaultIdentifier } sample = [ordered]@{ displayName = "MAUI Template Sample" projectName = "MauiTemplateSample" template = "maui" templateArgs = @("--sample-content") - androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" "$identifierPrefix.sample" - iosBundleId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID" "$identifierPrefix.sample" - windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID" "$identifierPrefix.sample" + androidApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID" $sampleDefaultIdentifier + iosBundleId = $sampleIosBundleId + maccatalystBundleId = $sampleMacCatalystBundleId + windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID" $sampleDefaultIdentifier } } @@ -110,6 +118,12 @@ $platformDefinitions = [ordered]@{ targetFramework = "$DotNetTfm-ios" runtimeIdentifier = "ios-arm64" } + maccatalyst = [ordered]@{ + runner = "macos-latest" + workload = "maui-maccatalyst" + targetFramework = "$DotNetTfm-maccatalyst" + runtimeIdentifier = "" + } windows = [ordered]@{ runner = "windows-latest" workload = "maui-windows" @@ -147,6 +161,13 @@ foreach ($variantName in $selectedVariants) { $platform = $platformDefinitions[$platformName] $applicationId = switch ($platformName) { "ios" { $variant.iosBundleId } + "maccatalyst" { + if ([string]::IsNullOrWhiteSpace($variant.maccatalystBundleId)) { + $variant.iosBundleId + } else { + $variant.maccatalystBundleId + } + } "windows" { if ([string]::IsNullOrWhiteSpace($variant.windowsApplicationId)) { $variant.androidApplicationId @@ -162,6 +183,11 @@ foreach ($variantName in $selectedVariants) { } $templateArgs = @(ConvertTo-StringArray $variant.templateArgs) + $maccatalystBundleId = if ([string]::IsNullOrWhiteSpace($variant.maccatalystBundleId)) { + $variant.iosBundleId + } else { + $variant.maccatalystBundleId + } $windowsApplicationId = if ([string]::IsNullOrWhiteSpace($variant.windowsApplicationId)) { $variant.androidApplicationId } else { @@ -187,6 +213,7 @@ foreach ($variantName in $selectedVariants) { applicationId = [string]$applicationId androidApplicationId = [string]$variant.androidApplicationId iosBundleId = [string]$variant.iosBundleId + maccatalystBundleId = [string]$maccatalystBundleId windowsApplicationId = [string]$windowsApplicationId } } diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index 4374ea11ef58..00eb111f1daa 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -49,7 +49,7 @@ platform :android do end platform :ios do - desc "Upload a generated MAUI template IPA to TestFlight" + desc "Upload a generated MAUI template IPA or Mac Catalyst PKG to TestFlight" lane :template_app_testflight do |options| groups = optional_option(options, :groups).to_s.split(",").map(&:strip).reject(&:empty?) changelog = release_notes @@ -60,13 +60,20 @@ platform :ios do ) upload_options = { - ipa: required_option(options, :ipa), app_identifier: required_option(options, :app_identifier), api_key: api_key, uses_non_exempt_encryption: false, skip_waiting_for_build_processing: groups.empty? && changelog.nil? } + pkg = optional_option(options, :pkg) + if pkg.nil? + upload_options[:ipa] = required_option(options, :ipa) + else + upload_options[:pkg] = pkg + upload_options[:app_platform] = optional_option(options, :app_platform) || "mac" + end + upload_options[:changelog] = changelog unless changelog.nil? unless groups.empty? diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 8ab73857afa8..5f1e0ad76736 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -18,9 +18,13 @@ # - TEMPLATE_APP_IDENTIFIER_PREFIX (variable): defaults to com..maui.template. # - TEMPLATE_APP_VARIANTS_JSON (variable): adds or overrides variant definitions. # - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. +# - TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 Mac App Store provisioning profiles. # - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) # - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# - TEMPLATE_APP_BLANK_MACCATALYST_BUNDLE_ID / TEMPLATE_APP_SAMPLE_MACCATALYST_BUNDLE_ID (variables) # - TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID / TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID (variables) +# - TEMPLATE_APP_BLANK_MACCATALYST_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_MACCATALYST_PROVISIONING_PROFILE_BASE64 (secrets) +# - TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64 / TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD (secrets) # - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. # - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. # - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. @@ -128,6 +132,8 @@ jobs: TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID }} TEMPLATE_APP_BLANK_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_BLANK_IOS_BUNDLE_ID }} TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID: ${{ vars.TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID }} + TEMPLATE_APP_BLANK_MACCATALYST_BUNDLE_ID: ${{ vars.TEMPLATE_APP_BLANK_MACCATALYST_BUNDLE_ID }} + TEMPLATE_APP_SAMPLE_MACCATALYST_BUNDLE_ID: ${{ vars.TEMPLATE_APP_SAMPLE_MACCATALYST_BUNDLE_ID }} TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID: ${{ vars.TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID }} TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID: ${{ vars.TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID }} run: | @@ -175,7 +181,7 @@ jobs: java-version: "17" - name: Setup Xcode - if: ${{ matrix.platform == 'ios' }} + if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} shell: bash run: | XCODE_INFO=$(python3 - <<'PY' @@ -253,7 +259,7 @@ jobs: - name: Install MAUI workload shell: pwsh run: | - if ("${{ matrix.platform }}" -eq "ios") { + if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { dotnet workload install ${{ matrix.workload }} --skip-manifest-update } else { dotnet workload install ${{ matrix.workload }} @@ -339,10 +345,14 @@ jobs: IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} + MACCATALYST_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_MACCATALYST_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_MACCATALYST_PROVISIONING_PROFILE_BASE64 || '' }} IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} + MACCATALYST_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON }} APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} + MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64 }} + MAC_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD }} run: | $missing = [System.Collections.Generic.List[string]]::new() @@ -367,6 +377,18 @@ jobs: Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" + } elseif ($env:PLATFORM -eq "maccatalyst") { + Test-RequiredEnvironment "IOS_CERTIFICATE_BASE64" "TEMPLATE_APP_IOS_CERTIFICATE_BASE64" + Test-RequiredEnvironment "IOS_CERTIFICATE_PASSWORD" "TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD" + if ([string]::IsNullOrWhiteSpace($env:MACCATALYST_PROVISIONING_PROFILE_BASE64) -and [string]::IsNullOrWhiteSpace($env:MACCATALYST_PROVISIONING_PROFILES_JSON)) { + $variantSecretName = $env:VARIANT.ToUpperInvariant() + $missing.Add("TEMPLATE_APP_${variantSecretName}_MACCATALYST_PROVISIONING_PROFILE_BASE64 or TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON") + } + Test-RequiredEnvironment "MAC_INSTALLER_CERTIFICATE_BASE64" "TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64" + Test-RequiredEnvironment "MAC_INSTALLER_CERTIFICATE_PASSWORD" "TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD" + Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" } if ($missing.Count -gt 0) { @@ -402,7 +424,7 @@ jobs: java-version: "17" - name: Setup Xcode - if: ${{ matrix.platform == 'ios' }} + if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} shell: bash run: | XCODE_INFO=$(python3 - <<'PY' @@ -478,7 +500,7 @@ jobs: xcrun --sdk iphoneos --show-sdk-path - name: Setup Ruby and fastlane - if: ${{ matrix.platform == 'android' || matrix.platform == 'ios' }} + if: ${{ matrix.platform == 'android' || matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} uses: ruby/setup-ruby@v1 with: ruby-version: "3.3" @@ -488,7 +510,7 @@ jobs: - name: Install MAUI workload shell: pwsh run: | - if ("${{ matrix.platform }}" -eq "ios") { + if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { dotnet workload install ${{ matrix.workload }} --skip-manifest-update } else { dotnet workload install ${{ matrix.workload }} @@ -535,16 +557,19 @@ jobs: -AppBuildNumber "$env:APP_BUILD_NUMBER" - name: Install Apple signing assets - if: ${{ matrix.platform == 'ios' }} + if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} shell: pwsh env: IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE_BASE64: ${{ matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || '' }} - IOS_PROVISIONING_PROFILES_JSON: ${{ secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON }} + APPLE_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'ios' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'ios' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_MACCATALYST_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_MACCATALYST_PROVISIONING_PROFILE_BASE64 || '' }} + APPLE_PROVISIONING_PROFILES_JSON: ${{ matrix.platform == 'ios' && secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON || matrix.platform == 'maccatalyst' && secrets.TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON || '' }} + MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64 }} + MAC_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1" ` - -Variant "${{ matrix.variant }}" + -Variant "${{ matrix.variant }}" ` + -Platform "${{ matrix.platform }}" - name: Build generated app id: build @@ -610,7 +635,7 @@ jobs: "version_name:$env:APP_DISPLAY_VERSION" - name: Write App Store Connect API key - if: ${{ matrix.platform == 'ios' }} + if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} shell: pwsh env: APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} @@ -644,3 +669,20 @@ jobs: "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` "groups:$env:TEMPLATE_APP_TESTFLIGHT_GROUPS" + + - name: Publish Mac Catalyst to TestFlight with fastlane + if: ${{ matrix.platform == 'maccatalyst' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + env: + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + run: | + & bundle exec fastlane ios template_app_testflight ` + "pkg:${{ steps.build.outputs.package_path }}" ` + "app_identifier:${{ matrix.maccatalystBundleId }}" ` + "app_platform:mac" ` + "issuer_id:$env:APPSTORE_CONNECT_ISSUER_ID" ` + "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` + "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` + "groups:$env:TEMPLATE_APP_TESTFLIGHT_GROUPS" From b2f54efb0c50aa0865fc51b329519a9fff0f193a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 24 Jun 2026 22:30:38 +0200 Subject: [PATCH 16/30] Clean up template app workflow annotations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../New-TemplateApp.ps1 | 2 +- .../Prepare-Matrix.ps1 | 4 ++-- .../workflows/template-app-distribution.yml | 24 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index 6acea569d3db..e1d500a7c1ef 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -84,7 +84,7 @@ if (-not [string]::IsNullOrWhiteSpace($TemplateArgsJson)) { $templateArgs = @(ConvertFrom-Json $TemplateArgsJson | ForEach-Object { [string]$_ }) } -$dotnetNewArgs = @("new", $Template, "-n", $ProjectName, "-o", $projectDir, "--framework", $DotNetTfm) + $templateArgs +$dotnetNewArgs = @("new", $Template, "-n", $ProjectName, "-o", $projectDir, "--framework", $DotNetTfm, "--no-restore") + $templateArgs Write-Host "Creating project: dotnet $($dotnetNewArgs -join ' ')" & dotnet @dotnetNewArgs diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 index a70c13492338..4762a142ee94 100644 --- a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -113,13 +113,13 @@ $platformDefinitions = [ordered]@{ runtimeIdentifier = "android-arm64" } ios = [ordered]@{ - runner = "macos-latest" + runner = "macos-15" workload = "maui-ios" targetFramework = "$DotNetTfm-ios" runtimeIdentifier = "ios-arm64" } maccatalyst = [ordered]@{ - runner = "macos-latest" + runner = "macos-15" workload = "maui-maccatalyst" targetFramework = "$DotNetTfm-maccatalyst" runtimeIdentifier = "" diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 5f1e0ad76736..9e86f73800ed 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -74,14 +74,14 @@ jobs: app_build_number: ${{ steps.version.outputs.app_build_number }} steps: - name: Checkout workflow scripts - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ inputs.source_ref }} path: source @@ -155,27 +155,27 @@ jobs: matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: - name: Checkout workflow scripts - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ inputs.source_ref }} path: source persist-credentials: false - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }} - name: Setup Java if: ${{ matrix.platform == 'android' }} - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: microsoft java-version: "17" @@ -309,7 +309,7 @@ jobs: -AppBuildNumber "$env:APP_BUILD_NUMBER" - name: Upload dry-run artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} path: | @@ -398,27 +398,27 @@ jobs: } - name: Checkout workflow scripts - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ inputs.source_ref }} path: source persist-credentials: false - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }} - name: Setup Java if: ${{ matrix.platform == 'android' }} - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: microsoft java-version: "17" @@ -593,7 +593,7 @@ jobs: - name: Upload artifact copy if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: template-app-publish-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} path: | From e744346b2d8034c41238600dc2f0ab9b95e7a3ac Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 09:52:18 +0200 Subject: [PATCH 17/30] Fix Mac Catalyst provisioning profile lookup Install Mac Catalyst provisioning profiles with the macOS profile extension and pass the profile UUID to MSBuild so the Mac signing task can locate the profile during package publishing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Install-AppleSigningAssets.ps1 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 index d3e4fa4bf59f..688dc491d629 100644 --- a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -161,7 +161,14 @@ if ([string]::IsNullOrWhiteSpace($profileUuid) -or [string]::IsNullOrWhiteSpace( $profilesDirectory = Join-Path $HOME "Library/MobileDevice/Provisioning Profiles" New-Item -ItemType Directory -Path $profilesDirectory -Force | Out-Null -Copy-Item -Path $profilePath -Destination (Join-Path $profilesDirectory "$profileUuid.mobileprovision") -Force +$installedProfileExtension = if ($Platform -eq "maccatalyst") { ".provisionprofile" } else { ".mobileprovision" } +Copy-Item -Path $profilePath -Destination (Join-Path $profilesDirectory "$profileUuid$installedProfileExtension") -Force + +$codesignProvision = if ($Platform -eq "maccatalyst") { + $profileUuid +} else { + $profileName +} Write-Host "Installed provisioning profile '$profileName' ($profileUuid)" Write-Host "Using code signing identity '$codesignIdentity'" @@ -171,9 +178,9 @@ if ($Platform -eq "maccatalyst") { if ($env:GITHUB_ENV) { "IOS_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV - "IOS_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV + "IOS_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV "APPLE_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV - "APPLE_CODESIGN_PROVISION=$profileName" >> $env:GITHUB_ENV + "APPLE_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV if ($Platform -eq "maccatalyst") { "APPLE_PACKAGE_SIGNING_KEY=$packageSigningIdentity" >> $env:GITHUB_ENV } @@ -182,7 +189,7 @@ if ($env:GITHUB_ENV) { if ($env:GITHUB_OUTPUT) { "codesign_key=$codesignIdentity" >> $env:GITHUB_OUTPUT - "codesign_provision=$profileName" >> $env:GITHUB_OUTPUT + "codesign_provision=$codesignProvision" >> $env:GITHUB_OUTPUT if ($Platform -eq "maccatalyst") { "package_signing_key=$packageSigningIdentity" >> $env:GITHUB_OUTPUT } From ff43084bfa56d620f95d6252a03a2af9d086e296 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 10:09:56 +0200 Subject: [PATCH 18/30] Fix Mac Catalyst TestFlight platform Use fastlane's macOS app_platform value when uploading Mac Catalyst PKG packages to TestFlight. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/template-app-distribution/fastlane/Fastfile | 2 +- .github/workflows/template-app-distribution.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index 00eb111f1daa..bd0d56717f58 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -71,7 +71,7 @@ platform :ios do upload_options[:ipa] = required_option(options, :ipa) else upload_options[:pkg] = pkg - upload_options[:app_platform] = optional_option(options, :app_platform) || "mac" + upload_options[:app_platform] = optional_option(options, :app_platform) || "osx" end upload_options[:changelog] = changelog unless changelog.nil? diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 9e86f73800ed..6cec30e2244b 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -681,7 +681,7 @@ jobs: & bundle exec fastlane ios template_app_testflight ` "pkg:${{ steps.build.outputs.package_path }}" ` "app_identifier:${{ matrix.maccatalystBundleId }}" ` - "app_platform:mac" ` + "app_platform:osx" ` "issuer_id:$env:APPSTORE_CONNECT_ISSUER_ID" ` "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` From f966bfeecb4f1608a3105aecedb0d1b1a350c2ef Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 10:37:04 +0200 Subject: [PATCH 19/30] Clean up template app distribution artifacts Name Mac Catalyst artifacts as macOS, suppress avoidable Git/Xcode log noise, disable overeager Mac entitlement validation for App Store profiles, and update the sample template SQLite bundle dependency to avoid NuGet audit warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 2 ++ .../Prepare-Matrix.ps1 | 5 ++++ .../workflows/template-app-distribution.yml | 26 ++++++++++++++++--- .../templates/maui-mobile/MauiApp.1.csproj | 2 +- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index ab7097bfc0c4..a4db91aa1ad4 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -185,10 +185,12 @@ switch ($Platform) { $codesignKey = Assert-EnvironmentValue "APPLE_CODESIGN_KEY" $codesignProvision = Assert-EnvironmentValue "APPLE_CODESIGN_PROVISION" $packageSigningKey = Assert-EnvironmentValue "APPLE_PACKAGE_SIGNING_KEY" + # App Store profiles include get-task-allow=false; the SDK validator still warns on that key for Mac Catalyst. $arguments += @( "-p:CreatePackage=true", "-p:EnableCodeSigning=true", "-p:EnablePackageSigning=true", + "-p:ValidateEntitlements=disable", "-p:CodesignKey=$codesignKey", "-p:CodesignProvision=$codesignProvision", "-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.plist", diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 index 4762a142ee94..89f732f14287 100644 --- a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -107,24 +107,28 @@ if (-not [string]::IsNullOrWhiteSpace($env:TEMPLATE_APP_VARIANTS_JSON)) { $platformDefinitions = [ordered]@{ android = [ordered]@{ + artifactPlatform = "android" runner = "ubuntu-latest" workload = "maui-android" targetFramework = "$DotNetTfm-android" runtimeIdentifier = "android-arm64" } ios = [ordered]@{ + artifactPlatform = "ios" runner = "macos-15" workload = "maui-ios" targetFramework = "$DotNetTfm-ios" runtimeIdentifier = "ios-arm64" } maccatalyst = [ordered]@{ + artifactPlatform = "macos" runner = "macos-15" workload = "maui-maccatalyst" targetFramework = "$DotNetTfm-maccatalyst" runtimeIdentifier = "" } windows = [ordered]@{ + artifactPlatform = "windows" runner = "windows-latest" workload = "maui-windows" targetFramework = "$DotNetTfm-windows10.0.19041.0" @@ -202,6 +206,7 @@ foreach ($variantName in $selectedVariants) { $matrix.include += [ordered]@{ variant = $variantName platform = $platformName + artifactPlatform = $platform.artifactPlatform runner = $platform.runner workload = $platform.workload targetFramework = $platform.targetFramework diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 6cec30e2244b..53ee49916cd7 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -73,6 +73,12 @@ jobs: app_display_version: ${{ steps.version.outputs.app_display_version }} app_build_number: ${{ steps.version.outputs.app_build_number }} steps: + - name: Configure Git defaults + shell: pwsh + run: | + git config --global init.defaultBranch main + git config --global advice.defaultBranchName false + - name: Checkout workflow scripts uses: actions/checkout@v7 with: @@ -154,6 +160,12 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: + - name: Configure Git defaults + shell: pwsh + run: | + git config --global init.defaultBranch main + git config --global advice.defaultBranchName false + - name: Checkout workflow scripts uses: actions/checkout@v7 with: @@ -220,7 +232,7 @@ jobs: ) if [ -z "$XCODE_INFO" ]; then - echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." + echo "No installed Xcode with both macOS and iPhoneOS SDKs was found." exit 1 fi @@ -311,7 +323,7 @@ jobs: - name: Upload dry-run artifact uses: actions/upload-artifact@v7 with: - name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} + name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} path: | ${{ steps.build.outputs.package_path }} ${{ steps.build.outputs.binlog_path }} @@ -333,6 +345,12 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: + - name: Configure Git defaults + shell: pwsh + run: | + git config --global init.defaultBranch main + git config --global advice.defaultBranchName false + - name: Validate publishing configuration shell: pwsh env: @@ -463,7 +481,7 @@ jobs: ) if [ -z "$XCODE_INFO" ]; then - echo "::error::No installed Xcode with both macOS and iPhoneOS SDKs was found." + echo "No installed Xcode with both macOS and iPhoneOS SDKs was found." exit 1 fi @@ -595,7 +613,7 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: template-app-publish-${{ matrix.variant }}-${{ matrix.platform }}-${{ needs.prepare.outputs.source_sha }} + name: template-app-publish-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} path: | ${{ steps.build.outputs.package_path }} ${{ steps.build.outputs.binlog_path }} diff --git a/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj b/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj index 57907b15ba88..224c5376e680 100644 --- a/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj +++ b/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj @@ -90,7 +90,7 @@ - + From f8dcab271e1d3c03184f19b8690055af2ddfd2bc Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 13:52:50 +0200 Subject: [PATCH 20/30] Secure template app distribution workflow Avoid publish binlog artifacts, use validated immutable source SHAs for build jobs, pin fastlane dependencies, and harden publishing ref validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 54 +-- .../New-TemplateApp.ps1 | 25 ++ .../Resolve-SourceRef.ps1 | 39 +- .../fastlane/Fastfile | 6 + .../fastlane/Gemfile | 2 +- .../fastlane/Gemfile.lock | 344 ++++++++++++++++++ .../workflows/template-app-distribution.yml | 37 +- 7 files changed, 464 insertions(+), 43 deletions(-) create mode 100644 .github/scripts/template-app-distribution/fastlane/Gemfile.lock diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index a4db91aa1ad4..3b3e2687507c 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -26,7 +26,9 @@ param( [string]$Configuration = "Release", - [switch]$Publish + [switch]$Publish, + + [switch]$CreateBinlog ) $ErrorActionPreference = "Stop" @@ -53,13 +55,21 @@ function Get-NewestBuildOutput([string]$Root, [string]$Filter, [switch]$Director Select-Object -First 1 } +function Invoke-DotNetPublish([string[]]$Arguments, [string]$Description) { + & dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE." + } +} + $projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 if (-not $projectFile) { throw "No project file was found in '$ProjectPath'." } New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null -$binlogPath = Join-Path $OutputPath "build.binlog" +$binlogPath = if ($CreateBinlog) { Join-Path $OutputPath "build.binlog" } else { $null } +$binlogArguments = if ($CreateBinlog) { @("/bl:$binlogPath") } else { @() } switch ($Platform) { "android" { @@ -70,9 +80,8 @@ switch ($Platform) { "-p:AndroidPackageFormat=aab", "-p:ApplicationDisplayVersion=$AppDisplayVersion", "-p:ApplicationVersion=$AppBuildNumber", - "-o", $OutputPath, - "/bl:$binlogPath" - ) + "-o", $OutputPath + ) + $binlogArguments if ($Publish) { $keystorePath = $env:ANDROID_KEYSTORE_PATH @@ -108,7 +117,7 @@ switch ($Platform) { } Write-Host "Building Android package for $($projectFile.FullName)" - & dotnet @arguments + Invoke-DotNetPublish $arguments "Android publish" $package = Get-NewestBuildOutput $ProjectPath "*.aab" if (-not $package) { @@ -124,9 +133,8 @@ switch ($Platform) { "-r", $RuntimeIdentifier, "-p:ApplicationDisplayVersion=$AppDisplayVersion", "-p:ApplicationVersion=$AppBuildNumber", - "-p:ValidateXcodeVersion=false", - "/bl:$binlogPath" - ) + "-p:ValidateXcodeVersion=false" + ) + $binlogArguments if ($Publish) { $codesignKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" @@ -148,7 +156,7 @@ switch ($Platform) { } Write-Host "Building iOS package for $($projectFile.FullName)" - & dotnet @arguments + Invoke-DotNetPublish $arguments "iOS publish" if ($Publish) { $package = Get-NewestBuildOutput $ProjectPath "*.ipa" @@ -173,9 +181,8 @@ switch ($Platform) { "-p:MtouchLink=SdkOnly", "-p:ApplicationDisplayVersion=$AppDisplayVersion", "-p:ApplicationVersion=$AppBuildNumber", - "-p:ValidateXcodeVersion=false", - "/bl:$binlogPath" - ) + "-p:ValidateXcodeVersion=false" + ) + $binlogArguments if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { $arguments += @("-r", $RuntimeIdentifier) @@ -208,7 +215,7 @@ switch ($Platform) { } Write-Host "Building Mac Catalyst package for $($projectFile.FullName)" - & dotnet @arguments + Invoke-DotNetPublish $arguments "Mac Catalyst publish" if ($Publish) { $package = Get-NewestBuildOutput $ProjectPath "*.pkg" @@ -243,16 +250,11 @@ switch ($Platform) { "-p:WindowsAppSDKSelfContained=true", "-p:ApplicationDisplayVersion=$AppDisplayVersion", "-p:ApplicationVersion=$AppBuildNumber", - "-o", $publishOutputPath, - "/bl:$binlogPath" - ) + "-o", $publishOutputPath + ) + $binlogArguments Write-Host "Building Windows unpackaged app for $($projectFile.FullName)" - & dotnet @arguments - - if ($LASTEXITCODE -ne 0) { - throw "Windows unpackaged publish failed with exit code $LASTEXITCODE." - } + Invoke-DotNetPublish $arguments "Windows unpackaged publish" $zipPath = Join-Path $OutputPath "$($projectFile.BaseName)-windows-unpackaged.zip" Remove-Item -Path $zipPath -Force -ErrorAction SilentlyContinue @@ -266,9 +268,13 @@ if (-not $package) { } Write-Host "Package artifact: $($package.FullName)" -Write-Host "Build binlog: $binlogPath" +if ($CreateBinlog) { + Write-Host "Build binlog: $binlogPath" +} if ($env:GITHUB_OUTPUT) { "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT - "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT + if ($CreateBinlog) { + "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT + } } diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index e1d500a7c1ef..4826dd194a00 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -47,6 +47,25 @@ function ConvertTo-XmlEscaped([string]$Value) { return [System.Security.SecurityElement]::Escape($Value) } +function Set-PlistBooleanFalse([string]$Path, [string]$Key) { + if (-not (Test-Path $Path)) { + return + } + + $plistContent = Get-Content $Path -Raw + $escapedKey = [regex]::Escape($Key) + $booleanKeyPattern = "(?s)($escapedKey\s*)<(true|false)\s*/>" + if ($plistContent -match $booleanKeyPattern) { + $plistContent = [regex]::Replace($plistContent, $booleanKeyPattern, '$1', 1) + Set-Content -Path $Path -Value $plistContent -Encoding utf8 + return + } + + $entry = "`t$Key`r`n`t`r`n" + $plistContent = $plistContent -replace "(?m)^", "$entry" + Set-Content -Path $Path -Value $plistContent -Encoding utf8 +} + if (-not (Test-Path $TemplatePackagePath)) { throw "Template package was not found at '$TemplatePackagePath'." } @@ -129,6 +148,12 @@ if ($TargetFramework.Contains("-windows", [System.StringComparison]::OrdinalIgno Set-Content -Path $projectFile.FullName -Value $content -Encoding utf8 +if ($TargetFramework.Contains("-ios", [System.StringComparison]::OrdinalIgnoreCase)) { + Set-PlistBooleanFalse (Join-Path $projectDir "Platforms/iOS/Info.plist") "ITSAppUsesNonExemptEncryption" +} elseif ($TargetFramework.Contains("-maccatalyst", [System.StringComparison]::OrdinalIgnoreCase)) { + Set-PlistBooleanFalse (Join-Path $projectDir "Platforms/MacCatalyst/Info.plist") "ITSAppUsesNonExemptEncryption" +} + @{ sdk = @{ version = $DotNetSdk diff --git a/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 index 2d04f65404cc..2449a1fe8f30 100644 --- a/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 +++ b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 @@ -33,10 +33,33 @@ function Test-GitSuccess([string[]]$Arguments) { return $LASTEXITCODE -eq 0 } +function Test-SafePublishSourceRef([string]$Value) { + $ref = $Value.Trim() + + if ($ref -match "^[0-9a-fA-F]{40}$") { + return $true + } + + if ($ref.StartsWith("refs/") -and $ref -notmatch "^refs/(heads|tags)/") { + return $false + } + + if ($ref -notmatch "^(refs/(heads|tags)/)?[A-Za-z0-9][A-Za-z0-9._/-]*$") { + return $false + } + + return -not ($ref.Contains("..") -or $ref.Contains("//") -or $ref.Contains("@{") -or $ref.EndsWith("/") -or $ref.EndsWith(".")) +} + Push-Location $RepositoryPath try { $sourceSha = (Invoke-Git -Arguments @("rev-parse", "HEAD")).Trim() $normalizedSourceRef = $SourceRef.Trim() + + if ($Publish -and -not (Test-SafePublishSourceRef $normalizedSourceRef)) { + throw "Publishing source_ref '$SourceRef' contains characters or ref syntax that are not allowed for protected publishing. Use a trusted branch name, tag name, or full commit SHA." + } + $trustedBranches = @( Invoke-Git -Arguments @("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin") | Where-Object { $_ -eq "origin/$DefaultBranch" -or $_ -match "^origin/net\d+\.0$" } @@ -47,16 +70,22 @@ try { $sourceBranchName = $normalizedSourceRef -replace "^refs/heads/", "" -replace "^origin/", "" if ($sourceBranchName -eq $DefaultBranch -or $sourceBranchName -match "^net\d+\.0$") { - $isTrusted = $true - $trustedReason = "trusted branch '$sourceBranchName'" + $branchRef = "origin/$sourceBranchName" + if (Test-GitSuccess -Arguments @("rev-parse", "--verify", $branchRef)) { + $branchSha = (Invoke-Git -Arguments @("rev-parse", $branchRef)).Trim() + if ($branchSha -eq $sourceSha) { + $isTrusted = $true + $trustedReason = "trusted branch '$sourceBranchName'" + } + } } + $sourceTagName = $null if (-not $isTrusted -and ($normalizedSourceRef -match "^refs/tags/.+" -or (Test-GitSuccess -Arguments @("rev-parse", "--verify", "refs/tags/$normalizedSourceRef")))) { $tagName = $normalizedSourceRef -replace "^refs/tags/", "" $tagSha = (Invoke-Git -Arguments @("rev-list", "-n", "1", "refs/tags/$tagName")).Trim() if ($tagSha -eq $sourceSha) { - $isTrusted = $true - $trustedReason = "tag '$tagName'" + $sourceTagName = $tagName } } @@ -64,7 +93,7 @@ try { foreach ($branch in $trustedBranches) { if (Test-GitSuccess -Arguments @("merge-base", "--is-ancestor", $sourceSha, $branch)) { $isTrusted = $true - $trustedReason = "commit reachable from '$branch'" + $trustedReason = if ($sourceTagName) { "tag '$sourceTagName' reachable from '$branch'" } else { "commit reachable from '$branch'" } break } } diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index bd0d56717f58..ded15a716baa 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -17,6 +17,10 @@ def release_notes value.empty? ? nil : value end +def truthy_environment?(key) + ["1", "true", "yes"].include?(ENV[key].to_s.downcase) +end + default_platform(:ios) platform :android do @@ -79,6 +83,8 @@ platform :ios do unless groups.empty? upload_options[:groups] = groups upload_options[:distribute_external] = true + upload_options[:notify_external_testers] = true + upload_options[:reject_build_waiting_for_review] = true if truthy_environment?("TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW") end upload_to_testflight(upload_options) diff --git a/.github/scripts/template-app-distribution/fastlane/Gemfile b/.github/scripts/template-app-distribution/fastlane/Gemfile index 7a118b49be75..b18916633909 100644 --- a/.github/scripts/template-app-distribution/fastlane/Gemfile +++ b/.github/scripts/template-app-distribution/fastlane/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "fastlane" +gem "fastlane", "2.236.1" diff --git a/.github/scripts/template-app-distribution/fastlane/Gemfile.lock b/.github/scripts/template-app-distribution/fastlane/Gemfile.lock new file mode 100644 index 000000000000..4763cefec191 --- /dev/null +++ b/.github/scripts/template-app-distribution/fastlane/Gemfile.lock @@ -0,0 +1,344 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1262.0) + aws-sdk-core (3.252.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.226.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.6) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.236.1) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.103.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.64.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.20.0) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin + ruby + x64-mingw-ucrt + x86_64-darwin + x86_64-linux + +DEPENDENCIES + fastlane (= 2.236.1) + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1262.0) sha256=77e9b1dd3e8f616673f2959d2fc4e762065f83a43140e2cb82274525afbaccf7 + aws-sdk-core (3.252.0) sha256=09c042cbfc2acf2239441cc9b982ebab2a999bed2ef6bdc51849e7b3d6e48a1c + aws-sdk-kms (1.129.0) sha256=363f548df321f4a4fcfd05523384e591060b400f8e65133ed7ef0793155a3343 + aws-sdk-s3 (1.226.0) sha256=e599f431e006ec9b92c61ee0f14d3f658a1f6c8a1d623d2160be927ac958e2bf + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.236.1) sha256=fb89e618e0f38636e487743622cf710ad722723f5d33a63f19e367f84d3770bc + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.103.0) sha256=8075b9da398b201493ad1cd4074ff1a76ae67abf7eaad4242c0c75d22d61b559 + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.64.0) sha256=75b11afa2edcee859b84c7a6972ee4456314eeef5f762827fd6cf5c5ffaf93f2 + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 + google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 + google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1 + google-cloud-storage (1.61.0) sha256=a77f10f4a603289948b09e81c3e23d762a18733fd69d70a4c1399663fbd96002 + google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b + googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3 + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 2.6.9 diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 53ee49916cd7..d20c960b9620 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -29,6 +29,7 @@ # - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. # - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. # - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. +# - TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW (variable): set to true to reject a prior waiting Beta App Review build and submit the latest one. # Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. name: Template App Distribution @@ -59,6 +60,7 @@ concurrency: env: DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + FASTLANE_OPT_OUT_USAGE: "1" jobs: prepare: @@ -97,13 +99,18 @@ jobs: - name: Resolve source ref trust id: source shell: pwsh + env: + SOURCE_REF: ${{ inputs.source_ref }} + WORKFLOW_REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PUBLISH: ${{ inputs.publish }} run: | - $publish = [System.Boolean]::Parse('${{ inputs.publish }}') + $publish = [System.Boolean]::Parse($env:PUBLISH) & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1" ` -RepositoryPath "${{ github.workspace }}/source" ` - -SourceRef "${{ inputs.source_ref }}" ` - -WorkflowRef "${{ github.ref }}" ` - -DefaultBranch "${{ github.event.repository.default_branch }}" ` + -SourceRef "$env:SOURCE_REF" ` + -WorkflowRef "$env:WORKFLOW_REF" ` + -DefaultBranch "$env:DEFAULT_BRANCH" ` -Publish $publish - name: Resolve .NET SDK @@ -149,7 +156,7 @@ jobs: -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" dry-run-build: - name: Dry-run builds + name: Dry-run builds (${{ matrix.variant }}, ${{ matrix.artifactPlatform }}) if: ${{ inputs.publish == false }} needs: prepare runs-on: ${{ matrix.runner }} @@ -176,7 +183,7 @@ jobs: - name: Checkout template source uses: actions/checkout@v7 with: - ref: ${{ inputs.source_ref }} + ref: ${{ needs.prepare.outputs.source_sha }} path: source persist-credentials: false @@ -318,7 +325,8 @@ jobs: -RuntimeIdentifier "${{ matrix.runtimeIdentifier }}" ` -OutputPath "${{ runner.temp }}/template-app-output/${{ matrix.variant }}-${{ matrix.platform }}" ` -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` - -AppBuildNumber "$env:APP_BUILD_NUMBER" + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -CreateBinlog - name: Upload dry-run artifact uses: actions/upload-artifact@v7 @@ -330,7 +338,7 @@ jobs: retention-days: 14 publish: - name: Publish/build template apps + name: Publish/build template apps (${{ matrix.variant }}, ${{ matrix.artifactPlatform }}) if: ${{ inputs.publish }} needs: prepare runs-on: ${{ matrix.runner }} @@ -341,6 +349,7 @@ jobs: TEMPLATE_APP_PLAY_TRACK: ${{ vars.TEMPLATE_APP_PLAY_TRACK || 'internal' }} TEMPLATE_APP_PLAY_RELEASE_STATUS: ${{ vars.TEMPLATE_APP_PLAY_RELEASE_STATUS || 'completed' }} TEMPLATE_APP_TESTFLIGHT_GROUPS: ${{ vars.TEMPLATE_APP_TESTFLIGHT_GROUPS }} + TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW: ${{ vars.TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW || 'false' }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} @@ -425,7 +434,7 @@ jobs: - name: Checkout template source uses: actions/checkout@v7 with: - ref: ${{ inputs.source_ref }} + ref: ${{ needs.prepare.outputs.source_sha }} path: source persist-credentials: false @@ -536,8 +545,12 @@ jobs: - name: Set app version and release notes shell: pwsh + env: + SOURCE_REF: ${{ inputs.source_ref }} + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} + DOTNET_SDK: ${{ needs.prepare.outputs.dotnet_sdk }} run: | - $notes = "MAUI template app build from ${{ inputs.source_ref }} (${{ needs.prepare.outputs.source_sha }}).`n.NET SDK ${{ needs.prepare.outputs.dotnet_sdk }}, app version $env:APP_DISPLAY_VERSION, build $env:APP_BUILD_NUMBER." + $notes = "MAUI template app build from $env:SOURCE_REF ($env:SOURCE_SHA).`n.NET SDK $env:DOTNET_SDK, app version $env:APP_DISPLAY_VERSION, build $env:APP_BUILD_NUMBER." $delimiter = [guid]::NewGuid().ToString("N") "TEMPLATE_APP_RELEASE_NOTES<<$delimiter" >> $env:GITHUB_ENV @@ -614,9 +627,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: template-app-publish-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} - path: | - ${{ steps.build.outputs.package_path }} - ${{ steps.build.outputs.binlog_path }} + path: ${{ steps.build.outputs.package_path }} retention-days: 14 - name: Write Google Play credentials From 36c5d6dea4ab335fa1ec572ff41597ed3d25911a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 14:19:09 +0200 Subject: [PATCH 21/30] Clean up skipped template app job names Use conditional job-name expressions so skipped matrix jobs show static names instead of raw matrix placeholders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index d20c960b9620..ec83db70ba08 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -156,7 +156,7 @@ jobs: -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" dry-run-build: - name: Dry-run builds (${{ matrix.variant }}, ${{ matrix.artifactPlatform }}) + name: ${{ inputs.publish == false && format('Dry-run builds ({0}, {1})', matrix.variant, matrix.artifactPlatform) || 'Dry-run builds' }} if: ${{ inputs.publish == false }} needs: prepare runs-on: ${{ matrix.runner }} @@ -338,7 +338,7 @@ jobs: retention-days: 14 publish: - name: Publish/build template apps (${{ matrix.variant }}, ${{ matrix.artifactPlatform }}) + name: ${{ inputs.publish && format('Publish/build template apps ({0}, {1})', matrix.variant, matrix.artifactPlatform) || 'Publish/build template apps' }} if: ${{ inputs.publish }} needs: prepare runs-on: ${{ matrix.runner }} From bc2a9f30a7a4afd0e6f29af0ac4b688d8bb1c23f Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 14:21:39 +0200 Subject: [PATCH 22/30] Use static template app matrix job names Avoid raw matrix expressions on skipped workflow jobs; GitHub still appends matrix values to expanded jobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/template-app-distribution.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index ec83db70ba08..29388fc3030d 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -156,7 +156,7 @@ jobs: -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}" dry-run-build: - name: ${{ inputs.publish == false && format('Dry-run builds ({0}, {1})', matrix.variant, matrix.artifactPlatform) || 'Dry-run builds' }} + name: Dry-run builds if: ${{ inputs.publish == false }} needs: prepare runs-on: ${{ matrix.runner }} @@ -338,7 +338,7 @@ jobs: retention-days: 14 publish: - name: ${{ inputs.publish && format('Publish/build template apps ({0}, {1})', matrix.variant, matrix.artifactPlatform) || 'Publish/build template apps' }} + name: Publish/build template apps if: ${{ inputs.publish }} needs: prepare runs-on: ${{ matrix.runner }} From f4898c956b6f6163d9d4b99d3fab7c6c2d8f2d90 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 25 Jun 2026 16:24:33 +0200 Subject: [PATCH 23/30] Allow release branches for template app publishing Treat release/* branches as trusted publish sources, while still requiring tags and SHAs to be reachable from trusted branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../template-app-distribution/Resolve-SourceRef.ps1 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 index 2449a1fe8f30..a42f9906a994 100644 --- a/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 +++ b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 @@ -51,6 +51,12 @@ function Test-SafePublishSourceRef([string]$Value) { return -not ($ref.Contains("..") -or $ref.Contains("//") -or $ref.Contains("@{") -or $ref.EndsWith("/") -or $ref.EndsWith(".")) } +function Test-TrustedBranchName([string]$BranchName, [string]$DefaultBranchName) { + return $BranchName -eq $DefaultBranchName -or + $BranchName -match "^net\d+\.0$" -or + $BranchName -match "^release/.+" +} + Push-Location $RepositoryPath try { $sourceSha = (Invoke-Git -Arguments @("rev-parse", "HEAD")).Trim() @@ -62,14 +68,17 @@ try { $trustedBranches = @( Invoke-Git -Arguments @("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin") | - Where-Object { $_ -eq "origin/$DefaultBranch" -or $_ -match "^origin/net\d+\.0$" } + Where-Object { + $branchName = $_ -replace "^origin/", "" + Test-TrustedBranchName $branchName $DefaultBranch + } ) $isTrusted = $false $trustedReason = "" $sourceBranchName = $normalizedSourceRef -replace "^refs/heads/", "" -replace "^origin/", "" - if ($sourceBranchName -eq $DefaultBranch -or $sourceBranchName -match "^net\d+\.0$") { + if (Test-TrustedBranchName $sourceBranchName $DefaultBranch) { $branchRef = "origin/$sourceBranchName" if (Test-GitSuccess -Arguments @("rev-parse", "--verify", $branchRef)) { $branchSha = (Invoke-Git -Arguments @("rev-parse", $branchRef)).Trim() From c475b388bee4682886994318a30eccf685b6b576 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 14:51:14 +0200 Subject: [PATCH 24/30] Use source NuGet config for template app workloads Use the selected template source branch's NuGet.config when installing MAUI workloads and when generating template apps so preview branches can resolve branch-specific packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../New-TemplateApp.ps1 | 21 ++++++------- .../workflows/template-app-distribution.yml | 30 +++++++++++++------ 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index 4826dd194a00..a39194c80414 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -38,7 +38,10 @@ param( [string]$AppDisplayVersion, [Parameter(Mandatory)] - [string]$AppBuildNumber + [string]$AppBuildNumber, + + [Parameter(Mandatory)] + [string]$NuGetConfigPath ) $ErrorActionPreference = "Stop" @@ -83,17 +86,11 @@ New-Item -ItemType Directory -Path $nugetPackages -Force | Out-Null $env:DOTNET_CLI_HOME = $dotnetHome $env:NUGET_PACKAGES = $nugetPackages -$nugetConfig = @" - - - - - - - - -"@ -$nugetConfig | Out-File -FilePath (Join-Path $projectRoot "NuGet.config") -Encoding utf8 +if (-not (Test-Path $NuGetConfigPath)) { + throw "NuGet.config was not found at '$NuGetConfigPath'." +} + +Copy-Item -Path $NuGetConfigPath -Destination (Join-Path $projectRoot "NuGet.config") -Force Write-Host "Installing template package $TemplatePackagePath" dotnet new install $TemplatePackagePath diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 29388fc3030d..c8b594b4d2c0 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -278,10 +278,15 @@ jobs: - name: Install MAUI workload shell: pwsh run: | + $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config" + $installArgs = @("workload", "install", "${{ matrix.workload }}", "--configfile", $nugetConfig) if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { - dotnet workload install ${{ matrix.workload }} --skip-manifest-update - } else { - dotnet workload install ${{ matrix.workload }} + $installArgs += "--skip-manifest-update" + } + + dotnet @installArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } - name: Pack local templates @@ -312,7 +317,8 @@ jobs: -DisplayName "${{ matrix.displayName }}" ` -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` - -AppBuildNumber "$env:APP_BUILD_NUMBER" + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config" - name: Build generated app id: build @@ -537,10 +543,15 @@ jobs: - name: Install MAUI workload shell: pwsh run: | + $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config" + $installArgs = @("workload", "install", "${{ matrix.workload }}", "--configfile", $nugetConfig) if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { - dotnet workload install ${{ matrix.workload }} --skip-manifest-update - } else { - dotnet workload install ${{ matrix.workload }} + $installArgs += "--skip-manifest-update" + } + + dotnet @installArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } - name: Set app version and release notes @@ -585,7 +596,8 @@ jobs: -DisplayName "${{ matrix.displayName }}" ` -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` - -AppBuildNumber "$env:APP_BUILD_NUMBER" + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config" - name: Install Apple signing assets if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} @@ -623,7 +635,7 @@ jobs: -Publish - name: Upload artifact copy - if: always() + if: ${{ always() && steps.build.outputs.package_path != '' }} uses: actions/upload-artifact@v7 with: name: template-app-publish-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} From 56bf468581fcf217fc58a5125216f296af0cfabb Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 15:08:03 +0200 Subject: [PATCH 25/30] Handle net11 template app builds Enable the existing implicit XAML namespace compatibility flag when generated net11 apps need it, and use CoreCLR for net11+ Apple targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 16 +++++ .../New-TemplateApp.ps1 | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 3b3e2687507c..51de0504bb08 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -62,6 +62,14 @@ function Invoke-DotNetPublish([string[]]$Arguments, [string]$Description) { } } +function Test-IsNet11OrLater([string]$TargetFramework) { + if ($TargetFramework -notmatch "^net(?\d+)\.") { + return $false + } + + return [int]$Matches.Major -ge 11 +} + $projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 if (-not $projectFile) { throw "No project file was found in '$ProjectPath'." @@ -136,6 +144,10 @@ switch ($Platform) { "-p:ValidateXcodeVersion=false" ) + $binlogArguments + if (Test-IsNet11OrLater $TargetFramework) { + $arguments += "-p:UseMonoRuntime=false" + } + if ($Publish) { $codesignKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" $codesignProvision = Assert-EnvironmentValue "IOS_CODESIGN_PROVISION" @@ -184,6 +196,10 @@ switch ($Platform) { "-p:ValidateXcodeVersion=false" ) + $binlogArguments + if (Test-IsNet11OrLater $TargetFramework) { + $arguments += "-p:UseMonoRuntime=false" + } + if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { $arguments += @("-r", $RuntimeIdentifier) } diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 index a39194c80414..18bf6121c681 100644 --- a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -69,6 +69,59 @@ function Set-PlistBooleanFalse([string]$Path, [string]$Key) { Set-Content -Path $Path -Value $plistContent -Encoding utf8 } +function Get-DotNetMajorVersion([string]$DotNetTfm) { + if ($DotNetTfm -notmatch "^net(?\d+)\.") { + return $null + } + + return [int]$Matches.Major +} + +function Test-UsesImplicitXamlXmlns([string]$ProjectDirectory) { + $xamlFiles = Get-ChildItem -Path $ProjectDirectory -Filter "*.xaml" -Recurse -File + foreach ($xamlFile in $xamlFiles) { + $xaml = Get-Content -Path $xamlFile.FullName -Raw + $usesXamlPrefixWithoutDeclaration = $xaml -match "\bx:[A-Za-z_][A-Za-z0-9_]*" -and $xaml -notmatch "\sxmlns:x\s*=" + $usesDefaultImplicitNamespace = $xaml -notmatch "\sxmlns\s*=" -and $xaml -match "<\s*[A-Za-z_][A-Za-z0-9_.]*" + if ($usesXamlPrefixWithoutDeclaration -or $usesDefaultImplicitNamespace) { + return $true + } + } + + return $false +} + +function Set-ProjectProperty([string]$Content, [string]$Name, [string]$Value) { + $propertyPattern = "(?s)<$([regex]::Escape($Name))>.*?" + $property = "<$Name>$Value" + if ($Content -match $propertyPattern) { + return [regex]::Replace($Content, $propertyPattern, $property, 1) + } + + $firstPropertyGroupEnd = [regex]::Match($Content, "\r?\n\s*") + if (-not $firstPropertyGroupEnd.Success) { + throw "Could not find a PropertyGroup in the generated project." + } + + return $Content.Insert($firstPropertyGroupEnd.Index, "`r`n`t`t$property") +} + +function Add-ProjectDefineConstant([string]$Content, [string]$Constant) { + $defineConstantsPattern = "(?s)(?.*?)" + $defineConstantsMatch = [regex]::Match($Content, $defineConstantsPattern) + if ($defineConstantsMatch.Success) { + $constants = [string]$defineConstantsMatch.Groups["Value"].Value + if ($constants.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) -contains $Constant) { + return $Content + } + + $value = "$constants;$Constant" + return [regex]::Replace($Content, $defineConstantsPattern, "$value", 1) + } + + return Set-ProjectProperty $Content "DefineConstants" "`$(DefineConstants);$Constant" +} + if (-not (Test-Path $TemplatePackagePath)) { throw "Template package was not found at '$TemplatePackagePath'." } @@ -143,6 +196,13 @@ if ($TargetFramework.Contains("-windows", [System.StringComparison]::OrdinalIgno $content = $content -replace "\s*$", "$runtimeIdentifierOverridePropertyGroup`r`n" } +$dotNetMajorVersion = Get-DotNetMajorVersion $DotNetTfm +if ($dotNetMajorVersion -and $dotNetMajorVersion -ge 11 -and (Test-UsesImplicitXamlXmlns $projectDir)) { + Write-Host "Generated XAML uses implicit xmlns declarations; enabling MAUI implicit xmlns compatibility." + $content = Add-ProjectDefineConstant $content "MauiAllowImplicitXmlnsDeclaration" + $content = Set-ProjectProperty $content "EnablePreviewFeatures" "true" +} + Set-Content -Path $projectFile.FullName -Value $content -Encoding utf8 if ($TargetFramework.Contains("-ios", [System.StringComparison]::OrdinalIgnoreCase)) { From c66f1db4a05736de5bb46e8745d0da56c3449ee2 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 15:24:43 +0200 Subject: [PATCH 26/30] Adjust net11 Apple template app publishing Use NativeAOT for net11 iOS store packages and provide explicit Mac Catalyst runtime identifiers for net11 CoreCLR publishing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 51de0504bb08..332464e527f1 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -70,6 +70,16 @@ function Test-IsNet11OrLater([string]$TargetFramework) { return [int]$Matches.Major -ge 11 } +function Add-NativeAotArguments([string[]]$Arguments) { + return $Arguments + @( + "-p:PublishAot=true", + "-p:PublishAotUsingRuntimePack=true", + "-p:_IsPublishing=true", + "-p:IlcTreatWarningsAsErrors=false", + "-p:TrimmerSingleWarn=false" + ) +} + $projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 if (-not $projectFile) { throw "No project file was found in '$ProjectPath'." @@ -146,6 +156,7 @@ switch ($Platform) { if (Test-IsNet11OrLater $TargetFramework) { $arguments += "-p:UseMonoRuntime=false" + $arguments = Add-NativeAotArguments $arguments } if ($Publish) { @@ -196,12 +207,15 @@ switch ($Platform) { "-p:ValidateXcodeVersion=false" ) + $binlogArguments - if (Test-IsNet11OrLater $TargetFramework) { + $useNet11OrLater = Test-IsNet11OrLater $TargetFramework + if ($useNet11OrLater) { $arguments += "-p:UseMonoRuntime=false" } if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { $arguments += @("-r", $RuntimeIdentifier) + } elseif ($useNet11OrLater) { + $arguments += "-p:RuntimeIdentifiers=maccatalyst-x64;maccatalyst-arm64" } if ($Publish) { From 6375187f1864ba6d72aaaa38771f01733f75e0c6 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 15:37:34 +0200 Subject: [PATCH 27/30] Escape Mac Catalyst runtime identifiers Pass the net11 Mac Catalyst RuntimeIdentifiers list through MSBuild escaping so it remains a single property value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/template-app-distribution/Build-TemplateApp.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 332464e527f1..77db458c2301 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -215,7 +215,7 @@ switch ($Platform) { if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { $arguments += @("-r", $RuntimeIdentifier) } elseif ($useNet11OrLater) { - $arguments += "-p:RuntimeIdentifiers=maccatalyst-x64;maccatalyst-arm64" + $arguments += "-p:RuntimeIdentifiers=maccatalyst-x64%3Bmaccatalyst-arm64" } if ($Publish) { From cd76a7f4ed8da6be38d303be7a24e9ee00dc650e Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 15:49:23 +0200 Subject: [PATCH 28/30] Fix net11 template app publishing Use a single App Store-compatible Mac Catalyst RID for net11 publish builds and tolerate TestFlight beta-review conflicts after successful uploads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-TemplateApp.ps1 | 2 +- .../template-app-distribution/fastlane/Fastfile | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 77db458c2301..5c934973f4a7 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -215,7 +215,7 @@ switch ($Platform) { if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { $arguments += @("-r", $RuntimeIdentifier) } elseif ($useNet11OrLater) { - $arguments += "-p:RuntimeIdentifiers=maccatalyst-x64%3Bmaccatalyst-arm64" + $arguments += @("-r", "maccatalyst-x64") } if ($Publish) { diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index ded15a716baa..408daffa67b4 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -21,6 +21,12 @@ def truthy_environment?(key) ["1", "true", "yes"].include?(ENV[key].to_s.downcase) end +def testflight_review_conflict?(error) + message = error.to_s + message.include?("Another build is in review") || + message.include?("already in beta review") +end + default_platform(:ios) platform :android do @@ -87,6 +93,14 @@ platform :ios do upload_options[:reject_build_waiting_for_review] = true if truthy_environment?("TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW") end - upload_to_testflight(upload_options) + begin + upload_to_testflight(upload_options) + rescue => error + if testflight_review_conflict?(error) + UI.important("The build was uploaded, but another build in this train is already in beta review. Treating this as a successful upload.") + else + raise + end + end end end From 7e4e9e5474c4abbcda1cfc698e34a44072364251 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 17:10:01 +0200 Subject: [PATCH 29/30] Bound TestFlight processing wait Apply a finite fastlane processing timeout and treat post-upload TestFlight processing timeouts as successful uploads so template distribution runs do not hang indefinitely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../template-app-distribution/fastlane/Fastfile | 17 +++++++++++++++++ .github/workflows/template-app-distribution.yml | 2 ++ 2 files changed, 19 insertions(+) diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index 408daffa67b4..f67fd81772dc 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -21,12 +21,26 @@ def truthy_environment?(key) ["1", "true", "yes"].include?(ENV[key].to_s.downcase) end +def integer_environment(key, default_value) + value = ENV[key].to_s.strip + return default_value if value.empty? + Integer(value) +rescue ArgumentError + UI.user_error!("Invalid integer environment value for #{key}: #{value}") +end + def testflight_review_conflict?(error) message = error.to_s message.include?("Another build is in review") || message.include?("already in beta review") end +def testflight_processing_timeout?(error) + message = error.to_s + message.include?("processing") && + (message.include?("timeout") || message.include?("timed out") || message.include?("waited")) +end + default_platform(:ios) platform :android do @@ -73,6 +87,7 @@ platform :ios do app_identifier: required_option(options, :app_identifier), api_key: api_key, uses_non_exempt_encryption: false, + wait_processing_timeout_duration: integer_environment("TEMPLATE_APP_TESTFLIGHT_WAIT_TIMEOUT_SECONDS", 2700), skip_waiting_for_build_processing: groups.empty? && changelog.nil? } @@ -98,6 +113,8 @@ platform :ios do rescue => error if testflight_review_conflict?(error) UI.important("The build was uploaded, but another build in this train is already in beta review. Treating this as a successful upload.") + elsif testflight_processing_timeout?(error) + UI.important("The build was uploaded, but App Store Connect did not finish processing it before the configured wait timeout. Treating this as a successful upload.") else raise end diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index c8b594b4d2c0..6385225d5c44 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -30,6 +30,7 @@ # - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. # - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. # - TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW (variable): set to true to reject a prior waiting Beta App Review build and submit the latest one. +# - TEMPLATE_APP_TESTFLIGHT_WAIT_TIMEOUT_SECONDS (variable): defaults to 2700 seconds. # Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. name: Template App Distribution @@ -356,6 +357,7 @@ jobs: TEMPLATE_APP_PLAY_RELEASE_STATUS: ${{ vars.TEMPLATE_APP_PLAY_RELEASE_STATUS || 'completed' }} TEMPLATE_APP_TESTFLIGHT_GROUPS: ${{ vars.TEMPLATE_APP_TESTFLIGHT_GROUPS }} TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW: ${{ vars.TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW || 'false' }} + TEMPLATE_APP_TESTFLIGHT_WAIT_TIMEOUT_SECONDS: ${{ vars.TEMPLATE_APP_TESTFLIGHT_WAIT_TIMEOUT_SECONDS || '2700' }} strategy: fail-fast: false matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} From 742ef07b655171d308adaefdebf63e03567af6f4 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 18:05:29 +0200 Subject: [PATCH 30/30] Handle fastlane build watcher timeouts Recognize fastlane's BuildWatcher timeout text as a post-upload TestFlight processing timeout so Mac Catalyst uploads do not fail after the configured wait expires. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/template-app-distribution/fastlane/Fastfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/template-app-distribution/fastlane/Fastfile b/.github/scripts/template-app-distribution/fastlane/Fastfile index f67fd81772dc..fe718342b044 100644 --- a/.github/scripts/template-app-distribution/fastlane/Fastfile +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -37,8 +37,9 @@ end def testflight_processing_timeout?(error) message = error.to_s - message.include?("processing") && - (message.include?("timeout") || message.include?("timed out") || message.include?("waited")) + message.include?("BuildWatcher exceeded") || + (message.include?("processing") && + (message.include?("timeout") || message.include?("timed out") || message.include?("waited"))) end default_platform(:ios)