diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.Tests.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.Tests.ps1
new file mode 100644
index 000000000000..4b89e7e26977
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Build-TemplateApp.Tests.ps1
@@ -0,0 +1,765 @@
+#!/usr/bin/env pwsh
+#Requires -Modules Pester
+
+BeforeAll {
+ $scriptPath = Join-Path $PSScriptRoot 'Build-TemplateApp.ps1'
+ $tokens = $null
+ $parseErrors = $null
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors)
+ if ($parseErrors -and $parseErrors.Count -gt 0) {
+ throw ($parseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
+ }
+
+ foreach ($functionName in @(
+ 'Assert-EnvironmentValue',
+ 'Get-NewestBuildOutput',
+ 'Invoke-DotNetPublish',
+ 'Test-IsNet11OrLater',
+ 'Add-NativeAotArguments',
+ 'Get-BinlogConfiguration',
+ 'Invoke-MacNotarization',
+ 'New-MacCatalystDeveloperIdSideload',
+ 'New-IosAdHocSideload'
+ )) {
+ $function = $ast.Find({
+ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $args[0].Name -eq $functionName
+ }, $true)
+
+ if (-not $function) {
+ throw "Function '$functionName' not found"
+ }
+
+ Invoke-Expression $function.Extent.Text
+ }
+
+ $testRoot = Join-Path $PSScriptRoot '.test-results'
+ New-Item -ItemType Directory -Path $testRoot -Force | Out-Null
+ $projectPath = Join-Path $testRoot 'TestApp.csproj'
+ Set-Content -Path $projectPath -Value ''
+ $projectFile = Get-Item $projectPath
+
+ $installerScriptPath = Join-Path $PSScriptRoot 'Install-AppleSigningAssets.ps1'
+ $installerTokens = $null
+ $installerParseErrors = $null
+ $installerAst = [System.Management.Automation.Language.Parser]::ParseFile(
+ $installerScriptPath,
+ [ref]$installerTokens,
+ [ref]$installerParseErrors
+ )
+ if ($installerParseErrors -and $installerParseErrors.Count -gt 0) {
+ throw ($installerParseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
+ }
+
+ $pairedEnvironmentFunction = $installerAst.Find({
+ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $args[0].Name -eq 'Assert-PairedEnvironmentValues'
+ }, $true)
+ if (-not $pairedEnvironmentFunction) {
+ throw "Function 'Assert-PairedEnvironmentValues' not found"
+ }
+ Invoke-Expression $pairedEnvironmentFunction.Extent.Text
+
+ $script:prepareMatrixScriptPath = Join-Path $PSScriptRoot 'Prepare-Matrix.ps1'
+ $script:fastfilePath = Join-Path $PSScriptRoot 'fastlane/Fastfile'
+ $script:workflowPath = Join-Path $PSScriptRoot '../../workflows/template-app-distribution.yml'
+ $script:workflowText = Get-Content -Path $script:workflowPath -Raw
+ $script:pwshPath = (Get-Command pwsh -ErrorAction Stop).Source
+ $script:rubyPath = (Get-Command ruby -ErrorAction SilentlyContinue).Source
+ $script:originalPath = $env:PATH
+ $script:testEnvironmentNames = @(
+ 'FAKE_DOTNET_MODE',
+ 'FAKE_TESTFLIGHT_ERROR',
+ 'FAKE_TESTFLIGHT_GROUPS',
+ 'FASTFILE_PATH',
+ 'GITHUB_OUTPUT',
+ 'RUNNER_TEMP',
+ 'TEMPLATE_APP_VARIANTS_JSON',
+ 'ANDROID_KEYSTORE_PATH',
+ 'ANDROID_KEYSTORE_PASSWORD',
+ 'ANDROID_KEY_PASSWORD',
+ 'ANDROID_KEY_ALIAS',
+ 'APPLE_DEVELOPERID_CERTIFICATE_BASE64',
+ 'APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64',
+ 'IOS_ADHOC_CODESIGN_PROVISION',
+ 'IOS_CODESIGN_KEY',
+ 'APPLE_DEVELOPERID_CODESIGN_KEY',
+ 'APPLE_DEVELOPERID_CODESIGN_PROVISION',
+ 'GH_TOKEN',
+ 'GITHUB_TOKEN',
+ 'COPILOT_GITHUB_TOKEN'
+ )
+ $script:originalEnvironment = @{}
+ foreach ($name in $script:testEnvironmentNames) {
+ $script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
+ }
+
+ $script:fakeCommandDirectory = Join-Path $testRoot 'fake-commands'
+ New-Item -ItemType Directory -Path $script:fakeCommandDirectory -Force | Out-Null
+ $fakeDotNetScriptPath = Join-Path $script:fakeCommandDirectory 'fake-dotnet.ps1'
+ @'
+$ErrorActionPreference = "Stop"
+
+$outputPath = $null
+$runtimeIdentifier = $null
+$projectPath = $args | Where-Object { $_ -like "*.csproj" } | Select-Object -First 1
+for ($index = 0; $index -lt $args.Count; $index++) {
+ if ($args[$index] -eq "-o" -and $index + 1 -lt $args.Count) {
+ $outputPath = $args[$index + 1]
+ }
+ if ($args[$index] -eq "-r" -and $index + 1 -lt $args.Count) {
+ $runtimeIdentifier = $args[$index + 1]
+ }
+ if ($args[$index].StartsWith("/bl:")) {
+ $binlogPath = $args[$index].Substring(4)
+ New-Item -ItemType Directory -Path (Split-Path $binlogPath -Parent) -Force | Out-Null
+ Set-Content -Path $binlogPath -Value "fake binlog"
+ }
+}
+$argumentText = $args -join "`n"
+
+switch ($env:FAKE_DOTNET_MODE) {
+ "android-success" {
+ New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
+ if ($argumentText -match "AndroidPackageFormat=apk") {
+ New-Item -ItemType File -Path (Join-Path $outputPath "TestApp-Signed.apk") -Force | Out-Null
+ } elseif ($argumentText -match "AndroidPackageFormat=aab") {
+ New-Item -ItemType File -Path (Join-Path $outputPath "TestApp.aab") -Force | Out-Null
+ }
+ }
+ "ios-device-only" {
+ if ($runtimeIdentifier -eq "ios-arm64") {
+ $projectDirectory = Split-Path $projectPath -Parent
+ $appPath = Join-Path $projectDirectory "bin/$runtimeIdentifier/TestApp.app"
+ New-Item -ItemType Directory -Path $appPath -Force | Out-Null
+ Set-Content -Path (Join-Path $appPath "Info.plist") -Value "fake app"
+ }
+ }
+ "ios-publish-success" {
+ New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
+ New-Item -ItemType File -Path (Join-Path $outputPath "TestApp.ipa") -Force | Out-Null
+ }
+ "ios-adhoc-failure" {
+ New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
+ if ($argumentText -match "CodesignProvision=Ad Hoc Profile") {
+ exit 23
+ }
+ New-Item -ItemType File -Path (Join-Path $outputPath "TestApp.ipa") -Force | Out-Null
+ }
+}
+
+exit 0
+'@ | Set-Content -Path $fakeDotNetScriptPath -Encoding utf8
+
+ if ($IsWindows) {
+ @"
+@echo off
+pwsh -NoLogo -NoProfile -File "$fakeDotNetScriptPath" %*
+exit /b %ERRORLEVEL%
+"@ | Set-Content -Path (Join-Path $script:fakeCommandDirectory 'dotnet.cmd') -Encoding ascii
+ } else {
+ @"
+#!/bin/sh
+exec pwsh -NoLogo -NoProfile -File "$fakeDotNetScriptPath" "`$@"
+"@ | Set-Content -Path (Join-Path $script:fakeCommandDirectory 'dotnet') -Encoding utf8NoBOM
+ & chmod +x (Join-Path $script:fakeCommandDirectory 'dotnet')
+ }
+
+ $pathSeparator = [System.IO.Path]::PathSeparator
+ $env:PATH = "$($script:fakeCommandDirectory)$pathSeparator$($env:PATH)"
+
+ $script:fastfileHarnessPath = Join-Path $testRoot 'fastfile-harness.rb'
+ @'
+$lanes = {}
+
+module UI
+ def self.user_error!(message)
+ raise message
+ end
+
+ def self.important(message)
+ puts message
+ end
+
+ def self.error(message)
+ warn message
+ end
+end
+
+def default_platform(*_args)
+end
+
+def platform(*_args)
+ yield
+end
+
+def desc(*_args)
+end
+
+def lane(name, &block)
+ $lanes[name] = block
+end
+
+def app_store_connect_api_key(**_kwargs)
+ {}
+end
+
+def upload_to_play_store(**_kwargs)
+end
+
+def upload_to_testflight(*_args)
+ raise ENV.fetch("FAKE_TESTFLIGHT_ERROR")
+end
+
+load ENV.fetch("FASTFILE_PATH")
+
+options = {
+ app_identifier: "com.example.test",
+ api_key_id: "key",
+ issuer_id: "issuer",
+ api_private_key_path: "key.p8",
+ ipa: "TestApp.ipa",
+ groups: ENV.fetch("FAKE_TESTFLIGHT_GROUPS", "")
+}
+
+begin
+ $lanes.fetch(:template_app_testflight).call(options)
+ puts "lane succeeded"
+rescue => error
+ warn error.message
+ exit 42
+end
+'@ | Set-Content -Path $script:fastfileHarnessPath -Encoding utf8
+
+ function New-BuildTestCase {
+ $caseRoot = Join-Path $testRoot ([guid]::NewGuid().ToString("N"))
+ $projectRoot = Join-Path $caseRoot 'project'
+ $outputRoot = Join-Path $caseRoot 'output'
+ $runnerTemp = Join-Path $caseRoot 'runner-temp'
+ New-Item -ItemType Directory -Path $projectRoot, $outputRoot, $runnerTemp -Force | Out-Null
+ Set-Content -Path (Join-Path $projectRoot 'TestApp.csproj') -Value ''
+
+ return [pscustomobject]@{
+ Root = $caseRoot
+ ProjectRoot = $projectRoot
+ OutputRoot = $outputRoot
+ RunnerTemp = $runnerTemp
+ GitHubOutput = Join-Path $caseRoot 'github-output.txt'
+ }
+ }
+
+ function Invoke-ExternalPowerShell([string]$FilePath, [string[]]$Arguments) {
+ $output = @(& $script:pwshPath -NoLogo -NoProfile -File $FilePath @Arguments 2>&1)
+ return [pscustomobject]@{
+ ExitCode = $LASTEXITCODE
+ Output = ($output | Out-String)
+ }
+ }
+
+ function Invoke-BuildTemplateApp(
+ $TestCase,
+ [string]$Platform,
+ [string]$TargetFramework,
+ [string]$RuntimeIdentifier,
+ [switch]$Publish,
+ [switch]$CreateBinlog
+ ) {
+ $arguments = @(
+ '-ProjectPath', $TestCase.ProjectRoot,
+ '-Platform', $Platform,
+ '-TargetFramework', $TargetFramework,
+ '-RuntimeIdentifier', $RuntimeIdentifier,
+ '-OutputPath', $TestCase.OutputRoot,
+ '-AppDisplayVersion', '11.0',
+ '-AppBuildNumber', '1'
+ )
+ if ($Publish) {
+ $arguments += '-Publish'
+ }
+ if ($CreateBinlog) {
+ $arguments += '-CreateBinlog'
+ }
+
+ return Invoke-ExternalPowerShell $scriptPath $arguments
+ }
+
+ function Invoke-PrepareMatrix([string]$Variants, [string]$Platforms) {
+ return Invoke-ExternalPowerShell $script:prepareMatrixScriptPath @(
+ '-Variants', $Variants,
+ '-Platforms', $Platforms,
+ '-DotNetTfm', 'net11.0'
+ )
+ }
+
+ function Invoke-FastfileHarness {
+ $output = @(& $script:rubyPath $script:fastfileHarnessPath 2>&1)
+ return [pscustomobject]@{
+ ExitCode = $LASTEXITCODE
+ Output = ($output | Out-String)
+ }
+ }
+
+ function Reset-BuildTestEnvironment {
+ foreach ($name in $script:testEnvironmentNames) {
+ [Environment]::SetEnvironmentVariable($name, $null)
+ }
+ $env:FASTFILE_PATH = $script:fastfilePath
+ }
+}
+
+AfterAll {
+ $env:PATH = $script:originalPath
+ foreach ($name in $script:testEnvironmentNames) {
+ [Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
+ }
+ Remove-Item -Path $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Describe 'optional Apple sideload signing' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'preserves the no-secret iOS fallback' {
+ $result = New-IosAdHocSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework 'net11.0-ios' `
+ -Configuration 'Release' `
+ -RuntimeIdentifier 'ios-arm64' `
+ -OutputPath $testRoot `
+ -AppDisplayVersion '11.0' `
+ -AppBuildNumber '1'
+
+ $result | Should -BeNullOrEmpty
+ }
+
+ It 'fails when a configured iOS ad-hoc publish fails' {
+ $env:IOS_ADHOC_CODESIGN_PROVISION = 'AdHoc Profile'
+ $env:IOS_CODESIGN_KEY = 'Apple Distribution'
+ $binlogPath = Join-Path $testRoot 'ios-adhoc-build.binlog'
+ $script:publishArguments = $null
+ Mock Invoke-DotNetPublish {
+ param($Arguments)
+ $script:publishArguments = $Arguments
+ throw 'simulated ad-hoc publish failure'
+ }
+
+ {
+ New-IosAdHocSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework 'net11.0-ios' `
+ -Configuration 'Release' `
+ -RuntimeIdentifier 'ios-arm64' `
+ -OutputPath $testRoot `
+ -AppDisplayVersion '11.0' `
+ -AppBuildNumber '1' `
+ -BinlogArguments @("/bl:$binlogPath")
+ } | Should -Throw '*simulated ad-hoc publish failure*'
+
+ $script:publishArguments | Should -Contain "/bl:$binlogPath"
+ }
+
+ It 'fails when Developer ID signing is only partially configured' {
+ $env:APPLE_DEVELOPERID_CODESIGN_KEY = 'Developer ID Application'
+
+ {
+ New-MacCatalystDeveloperIdSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework 'net11.0-maccatalyst' `
+ -Configuration 'Release' `
+ -OutputPath $testRoot `
+ -AppDisplayVersion '11.0' `
+ -AppBuildNumber '1' `
+ -RuntimeIdentifier 'maccatalyst-arm64'
+ } | Should -Throw '*partially configured*'
+ }
+
+ It 'fails when a configured Developer ID publish fails' {
+ $env:APPLE_DEVELOPERID_CODESIGN_KEY = 'Developer ID Application'
+ $env:APPLE_DEVELOPERID_CODESIGN_PROVISION = 'Developer ID Profile'
+ $binlogPath = Join-Path $testRoot 'maccatalyst-developer-id-build.binlog'
+ $script:publishArguments = $null
+ Mock Invoke-DotNetPublish {
+ param($Arguments)
+ $script:publishArguments = $Arguments
+ throw 'simulated Developer ID publish failure'
+ }
+
+ {
+ New-MacCatalystDeveloperIdSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework 'net11.0-maccatalyst' `
+ -Configuration 'Release' `
+ -OutputPath $testRoot `
+ -AppDisplayVersion '11.0' `
+ -AppBuildNumber '1' `
+ -RuntimeIdentifier 'maccatalyst-arm64' `
+ -BinlogArguments @("/bl:$binlogPath")
+ } | Should -Throw '*simulated Developer ID publish failure*'
+
+ $script:publishArguments | Should -Contain "/bl:$binlogPath"
+ }
+}
+
+Describe 'Developer ID installer configuration' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'accepts Developer ID assets when both are absent' {
+ Assert-PairedEnvironmentValues `
+ 'APPLE_DEVELOPERID_CERTIFICATE_BASE64' `
+ 'APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64' `
+ 'Developer ID sideload signing' |
+ Should -BeFalse
+ }
+
+ It 'rejects a Developer ID certificate without its profile' {
+ $env:APPLE_DEVELOPERID_CERTIFICATE_BASE64 = 'certificate'
+
+ {
+ Assert-PairedEnvironmentValues `
+ 'APPLE_DEVELOPERID_CERTIFICATE_BASE64' `
+ 'APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64' `
+ 'Developer ID sideload signing'
+ } | Should -Throw '*partially configured*'
+ }
+
+ It 'rejects a Developer ID profile without its certificate' {
+ $env:APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64 = 'profile'
+
+ {
+ Assert-PairedEnvironmentValues `
+ 'APPLE_DEVELOPERID_CERTIFICATE_BASE64' `
+ 'APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64' `
+ 'Developer ID sideload signing'
+ } | Should -Throw '*partially configured*'
+ }
+
+ It 'accepts Developer ID assets when both are present' {
+ $env:APPLE_DEVELOPERID_CERTIFICATE_BASE64 = 'certificate'
+ $env:APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64 = 'profile'
+
+ Assert-PairedEnvironmentValues `
+ 'APPLE_DEVELOPERID_CERTIFICATE_BASE64' `
+ 'APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64' `
+ 'Developer ID sideload signing' |
+ Should -BeTrue
+ }
+}
+
+Describe 'custom template variant validation' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'rejects a custom variant without a template' {
+ $env:TEMPLATE_APP_VARIANTS_JSON = @{
+ custom = @{
+ displayName = 'Custom App'
+ projectName = 'CustomApp'
+ androidApplicationId = 'com.example.custom'
+ }
+ } | ConvertTo-Json -Compress
+
+ $result = Invoke-PrepareMatrix 'custom' 'android'
+
+ $result.ExitCode | Should -Not -Be 0
+ $result.Output | Should -Match "Variant 'custom' does not define required field 'template'"
+ }
+
+ It 'rejects a custom variant without a project name' {
+ $env:TEMPLATE_APP_VARIANTS_JSON = @{
+ custom = @{
+ displayName = 'Custom App'
+ template = 'maui'
+ androidApplicationId = 'com.example.custom'
+ }
+ } | ConvertTo-Json -Compress
+
+ $result = Invoke-PrepareMatrix 'custom' 'android'
+
+ $result.ExitCode | Should -Not -Be 0
+ $result.Output | Should -Match "Variant 'custom' does not define required field 'projectName'"
+ }
+}
+
+Describe 'Android artifact safety' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'requires an APK before assigning the sideload output' {
+ $case = New-BuildTestCase
+ $env:FAKE_DOTNET_MODE = 'no-artifacts'
+ $env:GITHUB_OUTPUT = $case.GitHubOutput
+ $env:RUNNER_TEMP = $case.RunnerTemp
+
+ $result = Invoke-BuildTemplateApp `
+ -TestCase $case `
+ -Platform 'android' `
+ -TargetFramework 'net11.0-android' `
+ -RuntimeIdentifier 'android-arm64'
+
+ $result.ExitCode | Should -Not -Be 0
+ $result.Output | Should -Match 'Android APK publish completed but no APK artifact was found'
+ if (Test-Path $case.GitHubOutput) {
+ Get-Content -Path $case.GitHubOutput -Raw | Should -Not -Match 'sideload_package_path='
+ }
+ }
+
+ It 'emits installable APK, store AAB, and distinct binlog outputs' {
+ $case = New-BuildTestCase
+ $keystorePath = Join-Path $case.Root 'test.keystore'
+ Set-Content -Path $keystorePath -Value 'fake keystore'
+ $env:FAKE_DOTNET_MODE = 'android-success'
+ $env:GITHUB_OUTPUT = $case.GitHubOutput
+ $env:RUNNER_TEMP = $case.RunnerTemp
+ $env:ANDROID_KEYSTORE_PATH = $keystorePath
+ $env:ANDROID_KEYSTORE_PASSWORD = 'password'
+ $env:ANDROID_KEY_ALIAS = 'alias'
+
+ $result = Invoke-BuildTemplateApp `
+ -TestCase $case `
+ -Platform 'android' `
+ -TargetFramework 'net11.0-android' `
+ -RuntimeIdentifier 'android-arm64' `
+ -Publish `
+ -CreateBinlog
+
+ $result.ExitCode | Should -Be 0 -Because $result.Output
+ $outputValues = @{}
+ foreach ($line in Get-Content -Path $case.GitHubOutput) {
+ $name, $value = $line -split '=', 2
+ $outputValues[$name] = $value
+ }
+
+ $outputValues.package_path | Should -Match '\.aab$'
+ $outputValues.sideload_package_path | Should -Match '\.apk$'
+ $outputValues.binlog_path | Should -Be (Join-Path $case.OutputRoot 'build.binlog')
+ $outputValues.store_binlog_path | Should -Be (Join-Path $case.OutputRoot 'store-build.binlog')
+ Test-Path $outputValues.package_path | Should -BeTrue
+ Test-Path $outputValues.sideload_package_path | Should -BeTrue
+ Test-Path $outputValues.binlog_path | Should -BeTrue
+ Test-Path $outputValues.store_binlog_path | Should -BeTrue
+ }
+}
+
+Describe 'iOS dry-run artifact safety' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'keeps a device IPA when simulator artifact discovery fails' {
+ $case = New-BuildTestCase
+ $env:FAKE_DOTNET_MODE = 'ios-device-only'
+ $env:GITHUB_OUTPUT = $case.GitHubOutput
+ $env:RUNNER_TEMP = $case.RunnerTemp
+
+ $result = Invoke-BuildTemplateApp `
+ -TestCase $case `
+ -Platform 'ios' `
+ -TargetFramework 'net11.0-ios' `
+ -RuntimeIdentifier 'ios-arm64'
+
+ $result.ExitCode | Should -Be 0
+ $outputValues = @{}
+ foreach ($line in Get-Content -Path $case.GitHubOutput) {
+ $name, $value = $line -split '=', 2
+ $outputValues[$name] = $value
+ }
+
+ $outputValues.package_path | Should -Match '\.ipa$'
+ $outputValues.sideload_package_path | Should -Be $outputValues.package_path
+ Test-Path $outputValues.package_path | Should -BeTrue
+ }
+}
+
+Describe 'publish binlogs' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'uses a distinct store binlog for an Android publish' {
+ $configuration = Get-BinlogConfiguration `
+ -OutputPath $testRoot `
+ -Platform 'android' `
+ -Publish `
+ -CreateBinlog
+
+ $configuration.BuildPath | Should -Be (Join-Path $testRoot 'build.binlog')
+ $configuration.StorePath | Should -Be (Join-Path $testRoot 'store-build.binlog')
+ $configuration.BuildArguments | Should -Contain "/bl:$($configuration.BuildPath)"
+ $configuration.StoreArguments | Should -Contain "/bl:$($configuration.StorePath)"
+ }
+
+ It 'uses a dedicated binlog for an iOS ad-hoc publish' {
+ $configuration = Get-BinlogConfiguration `
+ -OutputPath $testRoot `
+ -Platform 'ios' `
+ -Publish `
+ -CreateBinlog
+
+ $configuration.SideloadPath | Should -Be (Join-Path $testRoot 'ios-adhoc-build.binlog')
+ $configuration.SideloadArguments | Should -Contain "/bl:$($configuration.SideloadPath)"
+ }
+
+ It 'uses a dedicated binlog for a Mac Catalyst Developer ID publish' {
+ $configuration = Get-BinlogConfiguration `
+ -OutputPath $testRoot `
+ -Platform 'maccatalyst' `
+ -Publish `
+ -CreateBinlog
+
+ $configuration.SideloadPath | Should -Be (Join-Path $testRoot 'maccatalyst-developer-id-build.binlog')
+ $configuration.SideloadArguments | Should -Contain "/bl:$($configuration.SideloadPath)"
+ }
+
+ It 'emits both primary and ad-hoc iOS binlogs from the publish path' {
+ $case = New-BuildTestCase
+ $env:FAKE_DOTNET_MODE = 'ios-publish-success'
+ $env:GITHUB_OUTPUT = $case.GitHubOutput
+ $env:RUNNER_TEMP = $case.RunnerTemp
+ $env:IOS_CODESIGN_KEY = 'Apple Distribution'
+ $env:IOS_CODESIGN_PROVISION = 'App Store Profile'
+ $env:IOS_ADHOC_CODESIGN_PROVISION = 'Ad Hoc Profile'
+
+ $result = Invoke-BuildTemplateApp `
+ -TestCase $case `
+ -Platform 'ios' `
+ -TargetFramework 'net11.0-ios' `
+ -RuntimeIdentifier 'ios-arm64' `
+ -Publish `
+ -CreateBinlog
+
+ $result.ExitCode | Should -Be 0 -Because $result.Output
+ $outputValues = @{}
+ foreach ($line in Get-Content -Path $case.GitHubOutput) {
+ $name, $value = $line -split '=', 2
+ $outputValues[$name] = $value
+ }
+
+ $outputValues.binlog_path | Should -Be (Join-Path $case.OutputRoot 'build.binlog')
+ $outputValues.sideload_binlog_path | Should -Be (Join-Path $case.OutputRoot 'ios-adhoc-build.binlog')
+ Test-Path $outputValues.binlog_path | Should -BeTrue
+ Test-Path $outputValues.sideload_binlog_path | Should -BeTrue
+ }
+
+ It 'preserves the ad-hoc binlog output when the secondary publish fails' {
+ $case = New-BuildTestCase
+ $env:FAKE_DOTNET_MODE = 'ios-adhoc-failure'
+ $env:GITHUB_OUTPUT = $case.GitHubOutput
+ $env:RUNNER_TEMP = $case.RunnerTemp
+ $env:IOS_CODESIGN_KEY = 'Apple Distribution'
+ $env:IOS_CODESIGN_PROVISION = 'App Store Profile'
+ $env:IOS_ADHOC_CODESIGN_PROVISION = 'Ad Hoc Profile'
+
+ $result = Invoke-BuildTemplateApp `
+ -TestCase $case `
+ -Platform 'ios' `
+ -TargetFramework 'net11.0-ios' `
+ -RuntimeIdentifier 'ios-arm64' `
+ -Publish `
+ -CreateBinlog
+
+ $result.ExitCode | Should -Not -Be 0
+ $result.Output | Should -Match 'iOS ad-hoc publish failed with exit code 23'
+ $outputValues = @{}
+ foreach ($line in Get-Content -Path $case.GitHubOutput) {
+ $name, $value = $line -split '=', 2
+ $outputValues[$name] = $value
+ }
+
+ $outputValues.sideload_binlog_path | Should -Be (Join-Path $case.OutputRoot 'ios-adhoc-build.binlog')
+ Test-Path $outputValues.sideload_binlog_path | Should -BeTrue
+ }
+}
+
+Describe 'workflow test gate' {
+ It 'runs the behavioral suite before matrix preparation' {
+ $script:workflowText | Should -Match (
+ '(?ms)^ script-tests:.*?Invoke-Pester.*?-CI')
+ $script:workflowText | Should -Match (
+ '(?ms)^ prepare:.*?^\s{4}needs: script-tests\s*$')
+ $script:workflowText | Should -Not -Match (
+ '(?ms)uses:\s*actions/checkout@v4\s+with:\s+' +
+ 'ref:\s*\$\{\{\s*github\.ref\s*\}\}')
+ [regex]::Matches(
+ $script:workflowText,
+ 'ref:\s*\$\{\{\s*github\.sha\s*\}\}').Count |
+ Should -Be 4
+ }
+}
+
+Describe 'template metadata replacement' {
+ BeforeAll {
+ $newTemplateScriptPath = Join-Path $PSScriptRoot 'New-TemplateApp.ps1'
+ $newTemplateTokens = $null
+ $newTemplateParseErrors = $null
+ $newTemplateAst = [System.Management.Automation.Language.Parser]::ParseFile(
+ $newTemplateScriptPath,
+ [ref]$newTemplateTokens,
+ [ref]$newTemplateParseErrors
+ )
+ if ($newTemplateParseErrors -and $newTemplateParseErrors.Count -gt 0) {
+ throw ($newTemplateParseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
+ }
+
+ foreach ($functionName in @('ConvertTo-XmlEscaped', 'Set-ProjectElementValue')) {
+ $function = $newTemplateAst.Find({
+ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $args[0].Name -eq $functionName
+ }, $true)
+
+ if (-not $function) {
+ throw "Function '$functionName' not found"
+ }
+
+ Invoke-Expression $function.Extent.Text
+ }
+ }
+
+ It 'treats dollar signs as literal replacement text' {
+ $content = 'Old'
+
+ Set-ProjectElementValue $content 'ApplicationTitle' 'Cash $$ App $&' |
+ Should -Be 'Cash $$ App $&'
+ }
+}
+
+Describe 'TestFlight error handling' {
+ BeforeEach {
+ Reset-BuildTestEnvironment
+ }
+
+ It 'fails when external groups cannot receive a build due to a beta-review conflict' {
+ $env:FAKE_TESTFLIGHT_ERROR = 'Another build is in review'
+ $env:FAKE_TESTFLIGHT_GROUPS = 'External Testers'
+
+ $result = Invoke-FastfileHarness
+
+ $result.ExitCode | Should -Be 42
+ $result.Output | Should -Match 'requested external TestFlight groups did not receive it'
+ }
+
+ It 'allows an upload-only beta-review conflict when no external groups were requested' {
+ $env:FAKE_TESTFLIGHT_ERROR = 'Another build is in review'
+ $env:FAKE_TESTFLIGHT_GROUPS = ''
+
+ $result = Invoke-FastfileHarness
+
+ $result.ExitCode | Should -Be 0
+ $result.Output | Should -Match 'no external distribution was requested'
+ $result.Output | Should -Match 'lane succeeded'
+ }
+
+ It 'fails instead of reporting a processing timeout as successful' {
+ $env:FAKE_TESTFLIGHT_ERROR = 'BuildWatcher exceeded processing timeout'
+ $env:FAKE_TESTFLIGHT_GROUPS = 'External Testers'
+
+ $result = Invoke-FastfileHarness
+
+ $result.ExitCode | Should -Be 42
+ $result.Output | Should -Match 'requested TestFlight distribution could not be completed'
+ }
+}
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..5b59999d1ede
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1
@@ -0,0 +1,814 @@
+#!/usr/bin/env pwsh
+
+param(
+ [Parameter(Mandatory)]
+ [string]$ProjectPath,
+
+ [Parameter(Mandatory)]
+ [ValidateSet("android", "ios", "maccatalyst", "windows")]
+ [string]$Platform,
+
+ [Parameter(Mandatory)]
+ [string]$TargetFramework,
+
+ [Parameter(Mandatory)]
+ [AllowEmptyString()]
+ [string]$RuntimeIdentifier,
+
+ [Parameter(Mandatory)]
+ [string]$OutputPath,
+
+ [Parameter(Mandatory)]
+ [string]$AppDisplayVersion,
+
+ [Parameter(Mandatory)]
+ [string]$AppBuildNumber,
+
+ [string]$Configuration = "Release",
+
+ [switch]$Publish,
+
+ [switch]$CreateBinlog
+)
+
+$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) {
+ # Secrets (and env values derived from them) commonly carry a trailing newline;
+ # FromBase64String throws on any surrounding whitespace, so trim before decoding.
+ $bytes = [Convert]::FromBase64String($Base64Value.Trim())
+ [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
+}
+
+function Compress-AppBundle([string]$AppBundlePath, [string]$ZipPath) {
+ # macOS .app bundles rely on symlinks (e.g. a framework's Versions/Current),
+ # executable permission bits, and embedded (ad-hoc) code signatures. PowerShell's
+ # Compress-Archive drops symlinks + exec bits and mangles the framework layout,
+ # which invalidates the code signature so the unzipped app is SIGKILL'd on launch
+ # with "Code Signature Invalid" / "Invalid Page". ditto preserves the bundle
+ # exactly (symlinks, perms, xattrs, bytes), keeping the app launchable.
+ Remove-Item -Path $ZipPath -Force -ErrorAction SilentlyContinue
+ if (Get-Command ditto -ErrorAction SilentlyContinue) {
+ & ditto -c -k --sequesterRsrc --keepParent $AppBundlePath $ZipPath
+ if ($LASTEXITCODE -ne 0) {
+ throw "ditto failed to archive '$AppBundlePath' (exit code $LASTEXITCODE)."
+ }
+ } else {
+ Compress-Archive -Path $AppBundlePath -DestinationPath $ZipPath -Force
+ }
+}
+
+function Repair-AppleAdhocSignature([string]$AppBundlePath) {
+ # The .NET iOS-Simulator / Mac Catalyst build leaves the app *linker-signed* ad-hoc
+ # (flags 0x20002), while its bundled native libraries (*.dylib/*.so — e.g. libcoreclr,
+ # libxamarin-dotnet-coreclr) carry a mix of signatures. macOS 15+/26 and the modern iOS
+ # Simulator refuse to launch that inconsistent bundle: dyld kills it at load with
+ # CODESIGNING "Invalid Page" / SIGKILL "Code Signature Invalid" (empirically reproduced
+ # on macOS 26.5.2 / M2 for both a Mac Catalyst .app launch and a Simulator install+launch).
+ # Re-signing every Mach-O ad-hoc from the inside out (loose dylibs first, since
+ # `codesign --deep` does not reach every nested lib, then a deep sign of the whole app)
+ # gives all components a consistent, valid ad-hoc signature (flags 0x2) so the app
+ # launches. It is still ad-hoc (not notarized): the Simulator accepts ad-hoc directly, and
+ # a Mac tester clears quarantine / uses "Open Anyway". The notarized Developer ID path
+ # (secret-gated, below) is the seamless option for a direct-download macOS launch.
+ if (-not (Get-Command codesign -ErrorAction SilentlyContinue)) {
+ Write-Warning "codesign not available; skipping ad-hoc re-sign of '$AppBundlePath'."
+ return
+ }
+ Get-ChildItem -Path $AppBundlePath -Recurse -Include *.dylib, *.so -File -ErrorAction SilentlyContinue |
+ ForEach-Object { & codesign --force --sign - $_.FullName 2>$null }
+ & codesign --force --deep --sign - $AppBundlePath
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "Ad-hoc re-sign of '$AppBundlePath' failed (exit $LASTEXITCODE); the app may not launch until it is signed."
+ }
+}
+
+function Invoke-DotNetPublish([string[]]$Arguments, [string]$Description) {
+ & dotnet @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Description failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Test-IsNet11OrLater([string]$TargetFramework) {
+ if ($TargetFramework -notmatch "^net(?\d+)\.") {
+ return $false
+ }
+
+ 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"
+ )
+}
+
+function Get-BinlogConfiguration {
+ param(
+ [string]$OutputPath,
+ [string]$Platform,
+ [switch]$Publish,
+ [switch]$CreateBinlog
+ )
+
+ $buildPath = if ($CreateBinlog) { Join-Path $OutputPath "build.binlog" } else { $null }
+ $storePath = if ($CreateBinlog -and $Publish -and $Platform -eq "android") {
+ Join-Path $OutputPath "store-build.binlog"
+ } else {
+ $null
+ }
+ $sideloadPath = if ($CreateBinlog -and $Publish) {
+ switch ($Platform) {
+ "ios" { Join-Path $OutputPath "ios-adhoc-build.binlog" }
+ "maccatalyst" { Join-Path $OutputPath "maccatalyst-developer-id-build.binlog" }
+ default { $null }
+ }
+ } else {
+ $null
+ }
+
+ return [pscustomobject]@{
+ BuildPath = $buildPath
+ BuildArguments = if ($buildPath) { @("/bl:$buildPath") } else { @() }
+ StorePath = $storePath
+ StoreArguments = if ($storePath) { @("/bl:$storePath") } else { @() }
+ SideloadPath = $sideloadPath
+ SideloadArguments = if ($sideloadPath) { @("/bl:$sideloadPath") } else { @() }
+ }
+}
+
+function Invoke-MacNotarization([string]$AppBundlePath) {
+ # Notarize + staple a Developer ID-signed .app so Gatekeeper lets it launch on other Macs.
+ # Reuses the App Store Connect API key already configured for TestFlight publishing.
+ $keyId = Assert-EnvironmentValue "APPSTORE_CONNECT_KEY_ID"
+ $issuerId = Assert-EnvironmentValue "APPSTORE_CONNECT_ISSUER_ID"
+
+ $keyPath = $env:APPSTORE_CONNECT_PRIVATE_KEY_PATH
+ if ([string]::IsNullOrWhiteSpace($keyPath) -or -not (Test-Path $keyPath)) {
+ $rawKey = Assert-EnvironmentValue "APPSTORE_CONNECT_PRIVATE_KEY"
+ $keyPath = Join-Path $env:RUNNER_TEMP "notarytool-key.p8"
+ $trimmed = $rawKey.Trim()
+ if ($trimmed.StartsWith("-----BEGIN")) {
+ Set-Content -Path $keyPath -Value $rawKey -NoNewline
+ } else {
+ [System.IO.File]::WriteAllBytes($keyPath, [Convert]::FromBase64String($trimmed))
+ }
+ }
+
+ $notarizeZip = Join-Path ([System.IO.Path]::GetDirectoryName($AppBundlePath)) "notarize-upload.zip"
+ Remove-Item -Path $notarizeZip -Force -ErrorAction SilentlyContinue
+ & ditto -c -k --keepParent $AppBundlePath $notarizeZip
+ if ($LASTEXITCODE -ne 0) { throw "Failed to create the notarization upload archive." }
+
+ Write-Host "Submitting '$AppBundlePath' to the Apple notary service (this can take a few minutes)..."
+ & xcrun notarytool submit $notarizeZip --key $keyPath --key-id $keyId --issuer $issuerId --wait
+ if ($LASTEXITCODE -ne 0) { throw "notarytool submit failed." }
+
+ & xcrun stapler staple $AppBundlePath
+ if ($LASTEXITCODE -ne 0) { throw "stapler staple failed." }
+
+ Remove-Item -Path $notarizeZip -Force -ErrorAction SilentlyContinue
+}
+
+function New-MacCatalystDeveloperIdSideload {
+ param(
+ [System.IO.FileInfo]$ProjectFile,
+ [string]$TargetFramework,
+ [string]$Configuration,
+ [string]$OutputPath,
+ [string]$AppDisplayVersion,
+ [string]$AppBuildNumber,
+ [string]$RuntimeIdentifier,
+ [switch]$UseNet11OrLater,
+ [string[]]$BinlogArguments = @()
+ )
+
+ # Secret-gated. Only runs when a Developer ID Application identity + provisioning profile
+ # were installed (Install-AppleSigningAssets.ps1). Produces a Developer ID-signed,
+ # notarized, stapled .app that launches directly on any Mac. The Mac App Store .pkg is
+ # killed with "Code Signature Invalid" when installed outside the store, so it stays
+ # TestFlight-only.
+ $devIdKey = [Environment]::GetEnvironmentVariable("APPLE_DEVELOPERID_CODESIGN_KEY")
+ $devIdProvision = [Environment]::GetEnvironmentVariable("APPLE_DEVELOPERID_CODESIGN_PROVISION")
+ $hasDevIdKey = -not [string]::IsNullOrWhiteSpace($devIdKey)
+ $hasDevIdProvision = -not [string]::IsNullOrWhiteSpace($devIdProvision)
+ if (-not $hasDevIdKey -and -not $hasDevIdProvision) {
+ Write-Host "No Developer ID signing assets provided; skipping the notarized macOS sideload build."
+ return $null
+ }
+ if (-not $hasDevIdKey -or -not $hasDevIdProvision) {
+ throw "Developer ID sideload signing is partially configured. Both the signing identity and provisioning profile are required."
+ }
+
+ $devIdOutput = Join-Path $OutputPath "developer-id"
+ New-Item -ItemType Directory -Path $devIdOutput -Force | Out-Null
+
+ $devIdArgs = @(
+ "publish", $ProjectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-p:MtouchLink=SdkOnly",
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false",
+ "-p:CreatePackage=false",
+ "-p:EnableCodeSigning=true",
+ "-p:EnableHardenedRuntime=true",
+ "-p:ValidateEntitlements=disable",
+ "-p:CodesignKey=$devIdKey",
+ "-p:CodesignProvision=$devIdProvision",
+ "-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.plist",
+ "-o", $devIdOutput
+ )
+
+ if ($UseNet11OrLater) { $devIdArgs += "-p:UseMonoRuntime=false" }
+ if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) {
+ $devIdArgs += @("-r", $RuntimeIdentifier)
+ } elseif ($UseNet11OrLater) {
+ # Match the dry-run: pin arm64-native for net11+ (the universal multi-RID publish
+ # trips PublishReadyToRun inference). arm64 runs natively on Apple Silicon Macs.
+ $devIdArgs += @("-r", "maccatalyst-arm64")
+ }
+ $devIdArgs += $BinlogArguments
+
+ Write-Host "Building Developer ID (notarizable) Mac Catalyst app for $($ProjectFile.FullName)"
+ Invoke-DotNetPublish $devIdArgs "Mac Catalyst Developer ID publish"
+
+ $devIdApp = Get-NewestBuildOutput $devIdOutput "*.app" -Directory
+ if (-not $devIdApp) {
+ throw "Developer ID publish completed but did not produce a .app."
+ }
+
+ # Hardened runtime is required for notarization. Re-sign deeply, preserving entitlements.
+ # Extract the current entitlements as XML (--xml avoids codesign's deprecated ':' path
+ # syntax) and only re-apply them when the bundle actually declares some.
+ $entitlementsPath = Join-Path $devIdOutput "developerid-entitlements.plist"
+ Remove-Item -Path $entitlementsPath -Force -ErrorAction SilentlyContinue
+ $capturedEntitlements = & codesign -d --xml --entitlements - $devIdApp.FullName 2>$null
+ if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($capturedEntitlements)) {
+ Set-Content -Path $entitlementsPath -Value $capturedEntitlements -Encoding utf8
+ }
+ $signArgs = @("--force", "--deep", "--options", "runtime", "--timestamp", "--sign", $devIdKey)
+ if (Test-Path $entitlementsPath) { $signArgs += @("--entitlements", $entitlementsPath) }
+ & codesign @signArgs $devIdApp.FullName
+ if ($LASTEXITCODE -ne 0) { throw "Developer ID hardened-runtime re-sign failed." }
+
+ Invoke-MacNotarization $devIdApp.FullName
+
+ $devIdZip = Join-Path $OutputPath "$($devIdApp.BaseName)-macos-developerid.zip"
+ Remove-Item -Path $devIdZip -Force -ErrorAction SilentlyContinue
+ & ditto -c -k --keepParent $devIdApp.FullName $devIdZip
+ if ($LASTEXITCODE -ne 0) { throw "Failed to archive the notarized macOS app." }
+
+ Write-Host "Notarized macOS sideload artifact: $devIdZip"
+ return (Get-Item $devIdZip)
+}
+
+function New-IosAdHocSideload {
+ param(
+ [System.IO.FileInfo]$ProjectFile,
+ [string]$TargetFramework,
+ [string]$Configuration,
+ [string]$RuntimeIdentifier,
+ [string]$OutputPath,
+ [string]$AppDisplayVersion,
+ [string]$AppBuildNumber,
+ [string[]]$BinlogArguments = @()
+ )
+
+ # Secret-gated. Only runs when an ad-hoc distribution provisioning profile was installed
+ # (Install-AppleSigningAssets.ps1). Produces an ad-hoc-signed IPA that installs directly on
+ # registered devices. The App Store IPA fails direct install with "Attempted to install a
+ # Beta profile without the proper entitlement", so it stays TestFlight-only.
+ $adhocProvision = [Environment]::GetEnvironmentVariable("IOS_ADHOC_CODESIGN_PROVISION")
+ if ([string]::IsNullOrWhiteSpace($adhocProvision)) {
+ Write-Host "No ad-hoc provisioning profile provided; skipping the sideloadable iOS IPA."
+ return $null
+ }
+
+ $adhocKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY"
+ $adhocOutput = Join-Path $OutputPath "adhoc"
+ New-Item -ItemType Directory -Path $adhocOutput -Force | Out-Null
+
+ $adhocArgs = @(
+ "publish", $ProjectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-r", $RuntimeIdentifier,
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false",
+ "-p:BuildIpa=true",
+ "-p:ArchiveOnBuild=true",
+ "-p:CodesignKey=$adhocKey",
+ "-p:CodesignProvision=$adhocProvision",
+ "-o", $adhocOutput
+ )
+
+ if (Test-IsNet11OrLater $TargetFramework) {
+ $adhocArgs += "-p:UseMonoRuntime=false"
+ $adhocArgs = Add-NativeAotArguments $adhocArgs
+ }
+ $adhocArgs += $BinlogArguments
+
+ Write-Host "Building ad-hoc iOS IPA (sideloadable on registered devices) for $($ProjectFile.FullName)"
+ Invoke-DotNetPublish $adhocArgs "iOS ad-hoc publish"
+
+ $adhocIpa = Get-NewestBuildOutput $adhocOutput "*.ipa"
+ if (-not $adhocIpa) { $adhocIpa = Get-NewestBuildOutput $ProjectFile.DirectoryName "*.ipa" }
+ if (-not $adhocIpa) {
+ throw "Ad-hoc publish completed but did not produce an IPA."
+ }
+
+ Write-Host "Ad-hoc iOS sideload artifact: $($adhocIpa.FullName)"
+ return $adhocIpa
+}
+
+function New-IosUnsignedDeviceIpa {
+ param(
+ [System.IO.FileInfo]$ProjectFile,
+ [string]$TargetFramework,
+ [string]$Configuration,
+ [string]$RuntimeIdentifier,
+ [string]$OutputPath,
+ [string]$AppDisplayVersion,
+ [string]$AppBuildNumber
+ )
+
+ # A dry-run has no Apple signing secrets, so we cannot produce an IPA that installs
+ # *directly* on a device (that needs an ad-hoc profile listing the device UDID, or
+ # TestFlight - both live on the secret-gated publish path). We can still build the
+ # unsigned device (iphoneos/arm64) .app and wrap it as a Payload/*.app IPA so a tester
+ # can install it with AltStore or Sideloadly, which re-signs the app with the tester's
+ # own Apple ID. Without this the iOS dry-run only produced a Simulator .app - i.e. there
+ # was no .ipa in the artifact at all, which is exactly what testers reported missing.
+ try {
+ if ([string]::IsNullOrWhiteSpace($RuntimeIdentifier)) {
+ $RuntimeIdentifier = "ios-arm64"
+ }
+
+ $deviceArgs = @(
+ "build", $ProjectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-r", $RuntimeIdentifier,
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false",
+ "-p:EnableCodeSigning=false",
+ "-p:_RequireCodeSigning=false",
+ "-p:CodesignKey=-",
+ "-p:BuildIpa=false"
+ )
+
+ if (Test-IsNet11OrLater $TargetFramework) {
+ # net11+ iOS can't build with Mono (NETSDK1242); use CoreCLR. NativeAOT is not
+ # needed for an unsigned, sideload-only artifact.
+ $deviceArgs += "-p:UseMonoRuntime=false"
+ }
+
+ Write-Host "Building unsigned iOS device app (for a sideloadable IPA) for $($ProjectFile.FullName)"
+ Invoke-DotNetPublish $deviceArgs "iOS unsigned device build"
+
+ $ridEscaped = [regex]::Escape($RuntimeIdentifier)
+ $deviceApp = Get-ChildItem -Path $ProjectFile.DirectoryName -Filter "*.app" -Recurse -Directory -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -match "[\\/]$ridEscaped[\\/]" -and $_.FullName -notmatch "[\\/]obj[\\/]" } |
+ Sort-Object LastWriteTimeUtc -Descending |
+ Select-Object -First 1
+
+ if (-not $deviceApp) {
+ Write-Warning "Unsigned iOS device build did not produce a device .app; skipping the sideloadable IPA."
+ return $null
+ }
+
+ $stageRoot = Join-Path $OutputPath "device-ipa"
+ Remove-Item -Path $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
+ $payloadDir = Join-Path $stageRoot "Payload"
+ New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null
+
+ $stagedApp = Join-Path $payloadDir $deviceApp.Name
+ if (Get-Command ditto -ErrorAction SilentlyContinue) {
+ & ditto $deviceApp.FullName $stagedApp
+ if ($LASTEXITCODE -ne 0) { throw "ditto failed to stage the device .app (exit $LASTEXITCODE)." }
+ } else {
+ Copy-Item -Path $deviceApp.FullName -Destination $stagedApp -Recurse -Force
+ }
+
+ # An IPA is a zip whose root contains Payload/.app. --keepParent embeds the
+ # "Payload" directory as the top-level entry, giving a valid IPA layout.
+ $ipaPath = Join-Path $OutputPath "$($deviceApp.BaseName).ipa"
+ Remove-Item -Path $ipaPath -Force -ErrorAction SilentlyContinue
+ if (Get-Command ditto -ErrorAction SilentlyContinue) {
+ & ditto -c -k --keepParent $payloadDir $ipaPath
+ if ($LASTEXITCODE -ne 0) { throw "ditto failed to archive the IPA (exit $LASTEXITCODE)." }
+ } else {
+ Compress-Archive -Path $payloadDir -DestinationPath $ipaPath -Force
+ }
+
+ Write-Host "Unsigned iOS device IPA (install with AltStore/Sideloadly): $ipaPath"
+ return Get-Item $ipaPath
+ } catch {
+ Write-Warning "Unsigned iOS device IPA build failed: $($_.Exception.Message). The Simulator app artifact is unaffected."
+ return $null
+ }
+}
+
+$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
+$binlogs = Get-BinlogConfiguration -OutputPath $OutputPath -Platform $Platform -Publish:$Publish -CreateBinlog:$CreateBinlog
+$binlogPath = $binlogs.BuildPath
+$binlogArguments = $binlogs.BuildArguments
+$storeBinlogPath = $binlogs.StorePath
+$storeBinlogArguments = $binlogs.StoreArguments
+$sideloadBinlogPath = $binlogs.SideloadPath
+$sideloadBinlogArguments = $binlogs.SideloadArguments
+
+if ($CreateBinlog -and $env:GITHUB_OUTPUT) {
+ # Emit the binlog path up-front so a failed build still exposes the (partial) binlog to the
+ # upload step for diagnosis, even though the script throws before reaching the final emit below.
+ "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT
+ if ($storeBinlogPath) {
+ "store_binlog_path=$storeBinlogPath" >> $env:GITHUB_OUTPUT
+ }
+ if ($sideloadBinlogPath) {
+ "sideload_binlog_path=$sideloadBinlogPath" >> $env:GITHUB_OUTPUT
+ }
+}
+
+# $package => the "store" package (aab/ipa/pkg/zip) consumed by the Play/TestFlight steps.
+# $sideloadPackage => a directly-installable artifact for testers (apk / ad-hoc ipa / notarized app).
+# When no distinct sideload artifact exists it falls back to $package on emit.
+# $additionalPackage => an optional extra artifact uploaded alongside the sideload one (e.g. the iOS
+# Simulator .app.zip that accompanies the device .ipa on a dry-run).
+$sideloadPackage = $null
+$additionalPackage = $null
+
+switch ($Platform) {
+ "android" {
+ $commonArgs = @(
+ "publish", $projectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber"
+ )
+
+ # The matrix RID (e.g. android-arm64) is intentionally NOT added to $commonArgs. Unlike
+ # iOS/Windows, an Android build is multi-ABI by nature: pinning a single RID makes the APK
+ # ABI-specific, which breaks the "installs on any device/emulator" promise for the sideload
+ # APK (x86_64 emulators cannot run an arm64-only APK). The sideload APK is therefore built
+ # universal (all ABIs from the project's RuntimeIdentifiers); the RID is applied only to the
+ # Google Play AAB below.
+
+ # Signing arguments shared by the APK (sideload) and AAB (Play) builds.
+ 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"
+ $keystoreType = [Environment]::GetEnvironmentVariable("ANDROID_KEYSTORE_TYPE")
+
+ $signingArgs = @(
+ "-p:AndroidKeyStore=true",
+ "-p:AndroidSigningKeyStore=$keystorePath",
+ "-p:AndroidSigningKeyAlias=$keyAlias",
+ "-p:AndroidSigningStorePass=env:ANDROID_SIGNING_STORE_PASS",
+ "-p:AndroidSigningKeyPass=env:ANDROID_SIGNING_KEY_PASS"
+ )
+
+ if (-not [string]::IsNullOrWhiteSpace($keystoreType)) {
+ $signingArgs += "-p:AndroidSigningStoreType=$keystoreType"
+ }
+ } else {
+ # A debug-signed APK is directly installable on devices/emulators for testing.
+ $signingArgs = @("-p:AndroidKeyStore=false")
+ }
+
+ # 1) Always build an installable APK. This is what testers sideload; an .aab cannot be
+ # installed directly (only Google Play can consume it), which is why the previous
+ # artifact ZIP had nothing installable in it.
+ $apkOutput = Join-Path $OutputPath "apk"
+ New-Item -ItemType Directory -Path $apkOutput -Force | Out-Null
+ $apkArgs = $commonArgs + @("-p:AndroidPackageFormat=apk", "-o", $apkOutput) + $signingArgs + $binlogArguments
+
+ Write-Host "Building installable Android APK for $($projectFile.FullName)"
+ Invoke-DotNetPublish $apkArgs "Android APK publish"
+
+ $apkPackage = Get-NewestBuildOutput $apkOutput "*-Signed.apk"
+ if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $apkOutput "*.apk" }
+ if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $ProjectPath "*-Signed.apk" }
+ if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $ProjectPath "*.apk" }
+ if (-not $apkPackage) {
+ throw "Android APK publish completed but no APK artifact was found. Refusing to substitute the non-installable App Bundle."
+ }
+ $sideloadPackage = $apkPackage
+
+ if ($Publish) {
+ # 2) Also build an .aab for the Google Play upload step. The matrix RID is applied here
+ # (not to the universal sideload APK) so Play gets the intended ABI target.
+ $aabOutput = Join-Path $OutputPath "aab"
+ New-Item -ItemType Directory -Path $aabOutput -Force | Out-Null
+ $aabArgs = $commonArgs + @("-p:AndroidPackageFormat=aab", "-o", $aabOutput) + $signingArgs + $storeBinlogArguments
+ if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) {
+ $aabArgs += @("-r", $RuntimeIdentifier)
+ }
+
+ Write-Host "Building Android App Bundle (Google Play) for $($projectFile.FullName)"
+ Invoke-DotNetPublish $aabArgs "Android AAB publish"
+
+ $package = Get-NewestBuildOutput $aabOutput "*.aab"
+ if (-not $package) { $package = Get-NewestBuildOutput $ProjectPath "*.aab" }
+ } else {
+ # Dry-run: the installable APK is the primary artifact.
+ $package = $apkPackage
+ }
+ }
+
+ "ios" {
+ if ($Publish) {
+ $arguments = @(
+ "publish", $projectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-r", $RuntimeIdentifier,
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false"
+ ) + $binlogArguments
+
+ if (Test-IsNet11OrLater $TargetFramework) {
+ $arguments += "-p:UseMonoRuntime=false"
+ $arguments = Add-NativeAotArguments $arguments
+ }
+
+ $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
+ )
+
+ Write-Host "Building iOS package for $($projectFile.FullName)"
+ Invoke-DotNetPublish $arguments "iOS publish"
+
+ $package = Get-NewestBuildOutput $ProjectPath "*.ipa"
+ if (-not $package) {
+ $package = Get-NewestBuildOutput $OutputPath "*.ipa"
+ }
+
+ $sideloadPackage = New-IosAdHocSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework $TargetFramework `
+ -Configuration $Configuration `
+ -RuntimeIdentifier $RuntimeIdentifier `
+ -OutputPath $OutputPath `
+ -AppDisplayVersion $AppDisplayVersion `
+ -AppBuildNumber $AppBuildNumber `
+ -BinlogArguments $sideloadBinlogArguments
+ } else {
+ # A dry-run has no signing secrets. We produce two complementary iOS artifacts:
+ #
+ # 1. A Simulator app (.app.zip) - runnable in the iOS Simulator on any Mac, so a
+ # maintainer can smoke-test the build with no device. `dotnet publish` rejects
+ # simulator RIDs, so use `dotnet build` + iossimulator-arm64 (macos-26 runners
+ # and Apple Silicon testers are arm64).
+ # 2. An unsigned device IPA (.ipa) - what testers install on real hardware via
+ # AltStore/Sideloadly (which re-signs with their own Apple ID). A *directly*
+ # installable IPA needs an ad-hoc profile with the device UDID, or TestFlight,
+ # both of which are on the secret-gated publish path.
+ $simulatorRuntimeIdentifier = "iossimulator-arm64"
+ $arguments = @(
+ "build", $projectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-r", $simulatorRuntimeIdentifier,
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false",
+ "-p:_RequireCodeSigning=false",
+ "-p:EnableCodeSigning=false",
+ "-p:CodesignKey=-",
+ "-p:BuildIpa=false"
+ ) + $binlogArguments
+
+ if (Test-IsNet11OrLater $TargetFramework) {
+ # net11+ iOS can't build with Mono (NETSDK1242). Use CoreCLR (JIT in the
+ # Simulator); NativeAOT is device-only and isn't needed for a dry-run.
+ $arguments += "-p:UseMonoRuntime=false"
+ }
+
+ Write-Host "Building iOS Simulator app for $($projectFile.FullName)"
+ Invoke-DotNetPublish $arguments "iOS simulator build"
+
+ $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory
+ if ($appBundle) {
+ $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip"
+ Repair-AppleAdhocSignature $appBundle.FullName
+ Compress-AppBundle $appBundle.FullName $zipPath
+ $package = Get-Item $zipPath
+ $sideloadPackage = $package
+ }
+
+ # Best-effort: also build the unsigned device IPA testers asked for. If it fails,
+ # the Simulator app above is still uploaded, so the dry-run never regresses.
+ $deviceIpa = New-IosUnsignedDeviceIpa `
+ -ProjectFile $projectFile `
+ -TargetFramework $TargetFramework `
+ -Configuration $Configuration `
+ -RuntimeIdentifier $RuntimeIdentifier `
+ -OutputPath $OutputPath `
+ -AppDisplayVersion $AppDisplayVersion `
+ -AppBuildNumber $AppBuildNumber
+
+ if ($deviceIpa) {
+ # The installable IPA becomes the primary sideload artifact; keep the Simulator
+ # app as an additional upload for Mac-only smoke testing.
+ $additionalPackage = $package
+ $package = $deviceIpa
+ $sideloadPackage = $deviceIpa
+ }
+ }
+ }
+
+ "maccatalyst" {
+ $arguments = @(
+ "publish", $projectFile.FullName,
+ "-f", $TargetFramework,
+ "-c", $Configuration,
+ "-p:MtouchLink=SdkOnly",
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-p:ValidateXcodeVersion=false"
+ ) + $binlogArguments
+
+ $useNet11OrLater = Test-IsNet11OrLater $TargetFramework
+ if ($useNet11OrLater) {
+ $arguments += "-p:UseMonoRuntime=false"
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) {
+ $arguments += @("-r", $RuntimeIdentifier)
+ } elseif ($useNet11OrLater) {
+ # net11+ Mac Catalyst cannot publish the SDK's default universal
+ # RuntimeIdentifiers=maccatalyst-x64;maccatalyst-arm64 unattended: the multi-RID
+ # publish trips NETSDK "PublishReadyToRun couldn't be inferred". Pin a single RID.
+ # arm64 (the original forced x64) runs NATIVELY on Apple Silicon with no Rosetta,
+ # which is exactly what the crashing M2 (Mac14,7) tester needs.
+ $arguments += @("-r", "maccatalyst-arm64")
+ }
+
+ if ($Publish) {
+ $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",
+ "-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)"
+ Invoke-DotNetPublish $arguments "Mac Catalyst publish"
+
+ if ($Publish) {
+ $package = Get-NewestBuildOutput $ProjectPath "*.pkg"
+ if (-not $package) {
+ $package = Get-NewestBuildOutput $OutputPath "*.pkg"
+ }
+
+ $sideloadPackage = New-MacCatalystDeveloperIdSideload `
+ -ProjectFile $projectFile `
+ -TargetFramework $TargetFramework `
+ -Configuration $Configuration `
+ -OutputPath $OutputPath `
+ -AppDisplayVersion $AppDisplayVersion `
+ -AppBuildNumber $AppBuildNumber `
+ -RuntimeIdentifier $RuntimeIdentifier `
+ -UseNet11OrLater:$useNet11OrLater `
+ -BinlogArguments $sideloadBinlogArguments
+ } 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"
+ Repair-AppleAdhocSignature $appBundle.FullName
+ Compress-AppBundle $appBundle.FullName $zipPath
+ $package = Get-Item $zipPath
+ }
+ }
+ }
+
+ "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:SelfContained=true",
+ "-p:ApplicationDisplayVersion=$AppDisplayVersion",
+ "-p:ApplicationVersion=$AppBuildNumber",
+ "-o", $publishOutputPath
+ ) + $binlogArguments
+
+ Write-Host "Building Windows unpackaged app for $($projectFile.FullName)"
+ Invoke-DotNetPublish $arguments "Windows unpackaged publish"
+
+ $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) {
+ throw "Build completed but no package artifact was found for platform '$Platform'."
+}
+
+Write-Host "Package artifact: $($package.FullName)"
+$sideloadResolved = if ($sideloadPackage) { $sideloadPackage.FullName } else { $package.FullName }
+Write-Host "Sideload artifact: $sideloadResolved"
+if ($additionalPackage) {
+ Write-Host "Additional artifact: $($additionalPackage.FullName)"
+}
+if ($CreateBinlog) {
+ Write-Host "Build binlog: $binlogPath"
+ if ($storeBinlogPath) {
+ Write-Host "Store build binlog: $storeBinlogPath"
+ }
+ if ($sideloadBinlogPath) {
+ Write-Host "Sideload build binlog: $sideloadBinlogPath"
+ }
+}
+
+if ($env:GITHUB_OUTPUT) {
+ "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT
+ "sideload_package_path=$sideloadResolved" >> $env:GITHUB_OUTPUT
+ if ($additionalPackage) {
+ "additional_package_path=$($additionalPackage.FullName)" >> $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..6cb8a4a5f515
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1
@@ -0,0 +1,293 @@
+#!/usr/bin/env pwsh
+
+param(
+ [Parameter(Mandatory)]
+ [string]$Variant,
+
+ [ValidateSet("ios", "maccatalyst")]
+ [string]$Platform = "ios"
+)
+
+$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 Assert-PairedEnvironmentValues(
+ [string]$FirstName,
+ [string]$SecondName,
+ [string]$Description
+) {
+ $firstConfigured = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($FirstName))
+ $secondConfigured = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($SecondName))
+
+ if ($firstConfigured -ne $secondConfigured) {
+ throw "$Description is partially configured. Set both $FirstName and $SecondName, or leave both unset."
+ }
+
+ return $firstConfigured
+}
+
+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
+ 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 Apple provisioning profile was provided for variant '$VariantName' on '$Platform'. Set APPLE_PROVISIONING_PROFILES_JSON or APPLE_PROVISIONING_PROFILE_BASE64 (or the IOS_PROVISIONING_PROFILES_JSON / IOS_PROVISIONING_PROFILE_BASE64 fallbacks)."
+}
+
+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."
+}
+
+$hasDeveloperIdSigningAssets = $false
+if ($Platform -eq "maccatalyst") {
+ $hasDeveloperIdSigningAssets = Assert-PairedEnvironmentValues `
+ "APPLE_DEVELOPERID_CERTIFICATE_BASE64" `
+ "APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64" `
+ "Developer ID sideload signing"
+}
+
+$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 = [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
+& 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
+
+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
+
+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
+
+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."
+}
+
+$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()
+
+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
+$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'"
+if ($Platform -eq "maccatalyst") {
+ Write-Host "Using package signing identity '$packageSigningIdentity'"
+}
+
+# Optional (secret-gated): Developer ID Application identity + provisioning profile so we can
+# also produce a notarizable, directly-launchable macOS app (the Mac App Store .pkg cannot be
+# launched outside the store).
+$developerIdIdentity = $null
+$developerIdProvisionValue = $null
+if ($Platform -eq "maccatalyst" -and $hasDeveloperIdSigningAssets) {
+ $developerIdCertPath = Join-Path $tempDirectory "developer-id-certificate.p12"
+ Write-Base64File $env:APPLE_DEVELOPERID_CERTIFICATE_BASE64 $developerIdCertPath
+ $developerIdCertPassword = if ([string]::IsNullOrWhiteSpace($env:APPLE_DEVELOPERID_CERTIFICATE_PASSWORD)) {
+ $certificatePassword
+ } else {
+ $env:APPLE_DEVELOPERID_CERTIFICATE_PASSWORD
+ }
+
+ & security import $developerIdCertPath -k $keychainPath -P $developerIdCertPassword -T /usr/bin/codesign -T /usr/bin/security
+ & security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychainPath | Out-Null
+
+ $developerIdIdentity = (Get-SecurityIdentities @("-v")) |
+ Where-Object { $_ -match "Developer ID Application" } |
+ Select-Object -First 1
+ if ([string]::IsNullOrWhiteSpace($developerIdIdentity)) {
+ throw "APPLE_DEVELOPERID_CERTIFICATE_BASE64 was provided but no 'Developer ID Application' identity was found in it."
+ }
+
+ $developerIdProfilePath = Join-Path $tempDirectory "$Variant-developerid.provisionprofile"
+ Write-Base64File $env:APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64 $developerIdProfilePath
+ $developerIdPlistPath = Join-Path $tempDirectory "$Variant-developerid.plist"
+ & security cms -D -i $developerIdProfilePath | Out-File -FilePath $developerIdPlistPath -Encoding utf8
+ $developerIdUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $developerIdPlistPath).Trim()
+ if ([string]::IsNullOrWhiteSpace($developerIdUuid)) {
+ throw "Could not read UUID from the Developer ID provisioning profile."
+ }
+
+ Copy-Item -Path $developerIdProfilePath -Destination (Join-Path $profilesDirectory "$developerIdUuid.provisionprofile") -Force
+ $developerIdProvisionValue = $developerIdUuid
+ Write-Host "Installed Developer ID provisioning profile ($developerIdUuid)"
+ Write-Host "Using Developer ID signing identity '$developerIdIdentity'"
+}
+
+# Optional (secret-gated): ad-hoc distribution provisioning profile so we can also produce a
+# directly-installable IPA (the App Store IPA cannot be sideloaded). Reuses the Apple
+# Distribution certificate already imported above.
+$adhocProvisionValue = $null
+if ($Platform -eq "ios" -and -not [string]::IsNullOrWhiteSpace($env:APPLE_ADHOC_PROVISIONING_PROFILE_BASE64)) {
+ $adhocProfilePath = Join-Path $tempDirectory "$Variant-adhoc.mobileprovision"
+ Write-Base64File $env:APPLE_ADHOC_PROVISIONING_PROFILE_BASE64 $adhocProfilePath
+ $adhocPlistPath = Join-Path $tempDirectory "$Variant-adhoc.plist"
+ & security cms -D -i $adhocProfilePath | Out-File -FilePath $adhocPlistPath -Encoding utf8
+ $adhocUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $adhocPlistPath).Trim()
+ $adhocName = (& /usr/libexec/PlistBuddy -c "Print :Name" $adhocPlistPath).Trim()
+ if ([string]::IsNullOrWhiteSpace($adhocUuid) -or [string]::IsNullOrWhiteSpace($adhocName)) {
+ throw "Could not read UUID/Name from the ad-hoc provisioning profile."
+ }
+
+ Copy-Item -Path $adhocProfilePath -Destination (Join-Path $profilesDirectory "$adhocUuid.mobileprovision") -Force
+ $adhocProvisionValue = $adhocName
+ Write-Host "Installed ad-hoc provisioning profile '$adhocName' ($adhocUuid)"
+}
+
+if ($env:GITHUB_ENV) {
+ "IOS_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV
+ "IOS_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV
+ "APPLE_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV
+ "APPLE_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV
+ if ($Platform -eq "maccatalyst") {
+ "APPLE_PACKAGE_SIGNING_KEY=$packageSigningIdentity" >> $env:GITHUB_ENV
+ }
+ if (-not [string]::IsNullOrWhiteSpace($developerIdIdentity)) {
+ "APPLE_DEVELOPERID_CODESIGN_KEY=$developerIdIdentity" >> $env:GITHUB_ENV
+ "APPLE_DEVELOPERID_CODESIGN_PROVISION=$developerIdProvisionValue" >> $env:GITHUB_ENV
+ }
+ if (-not [string]::IsNullOrWhiteSpace($adhocProvisionValue)) {
+ "IOS_ADHOC_CODESIGN_PROVISION=$adhocProvisionValue" >> $env:GITHUB_ENV
+ }
+ "IOS_KEYCHAIN_PATH=$keychainPath" >> $env:GITHUB_ENV
+}
+
+if ($env:GITHUB_OUTPUT) {
+ "codesign_key=$codesignIdentity" >> $env:GITHUB_OUTPUT
+ "codesign_provision=$codesignProvision" >> $env:GITHUB_OUTPUT
+ if ($Platform -eq "maccatalyst") {
+ "package_signing_key=$packageSigningIdentity" >> $env:GITHUB_OUTPUT
+ }
+ if (-not [string]::IsNullOrWhiteSpace($developerIdIdentity)) {
+ "developerid_codesign_key=$developerIdIdentity" >> $env:GITHUB_OUTPUT
+ "developerid_codesign_provision=$developerIdProvisionValue" >> $env:GITHUB_OUTPUT
+ }
+ if (-not [string]::IsNullOrWhiteSpace($adhocProvisionValue)) {
+ "adhoc_codesign_provision=$adhocProvisionValue" >> $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..54862170214e
--- /dev/null
+++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1
@@ -0,0 +1,238 @@
+#!/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,
+
+ [Parameter(Mandatory)]
+ [string]$NuGetConfigPath
+)
+
+$ErrorActionPreference = "Stop"
+
+function ConvertTo-XmlEscaped([string]$Value) {
+ return [System.Security.SecurityElement]::Escape($Value)
+}
+
+function Set-ProjectElementValue([string]$Content, [string]$Name, [string]$Value) {
+ $elementPattern = "<$([regex]::Escape($Name))>[^<]+$([regex]::Escape($Name))>"
+ $replacement = "<$Name>$(ConvertTo-XmlEscaped $Value)$Name>"
+ return ([regex]$elementPattern).Replace($Content, { param($match) $replacement }, 1)
+}
+
+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 = [regex]::Replace($plistContent, "(?s)(\s*)", "$entry`$1", 1)
+ 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))>.*?$([regex]::Escape($Name))>"
+ $property = "<$Name>$Value$Name>"
+ 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'."
+}
+
+$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
+
+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
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet new install of '$TemplatePackagePath' failed with exit code $LASTEXITCODE."
+}
+
+$templateArgs = @()
+if (-not [string]::IsNullOrWhiteSpace($TemplateArgsJson)) {
+ $templateArgs = @(ConvertFrom-Json $TemplateArgsJson | ForEach-Object { [string]$_ })
+}
+
+$dotnetNewArgs = @("new", $Template, "-n", $ProjectName, "-o", $projectDir, "--framework", $DotNetTfm, "--no-restore") + $templateArgs
+Write-Host "Creating project: dotnet $($dotnetNewArgs -join ' ')"
+& dotnet @dotnetNewArgs
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet new $Template failed with exit code $LASTEXITCODE."
+}
+
+$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
+$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 = Set-ProjectElementValue $content "ApplicationTitle" $DisplayName
+$content = Set-ProjectElementValue $content "ApplicationId" $ApplicationId
+$content = Set-ProjectElementValue $content "ApplicationDisplayVersion" $AppDisplayVersion
+$content = Set-ProjectElementValue $content "ApplicationVersion" $AppBuildNumber
+
+if ($TargetFramework.Contains("-windows", [System.StringComparison]::OrdinalIgnoreCase) -and
+ $content -notmatch "RuntimeIdentifierOverride") {
+ $runtimeIdentifierOverridePropertyGroup = @"
+
+
+ `$(RuntimeIdentifierOverride)
+
+"@
+
+ $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)) {
+ 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
+ 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..feb99798774c
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Pack-Templates.ps1
@@ -0,0 +1,61 @@
+#!/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
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet build of the MAUI templates failed with exit code $LASTEXITCODE."
+}
+
+Write-Host "Packing MAUI templates with PackageVersion=$PackageVersion"
+# --no-build: the -t:Rebuild above already produced the outputs, and the template content is
+# assembled by the project's BeforePack target (runs during pack regardless of --no-build), so
+# this avoids a redundant second build without dropping any packaged content.
+dotnet pack $templatesProject --no-build -p:PackageVersion=$PackageVersion -p:GenerateCgManifest=false -o $OutputPath
+if ($LASTEXITCODE -ne 0) {
+ throw "dotnet pack of the MAUI templates failed with exit code $LASTEXITCODE."
+}
+
+$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..6455c794d682
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1
@@ -0,0 +1,241 @@
+#!/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 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 @()
+ }
+
+ 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
+ }
+}
+
+$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]@{
+ displayName = "MAUI Template"
+ projectName = "MauiTemplateBlank"
+ template = "maui"
+ templateArgs = @()
+ 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" $sampleDefaultIdentifier
+ iosBundleId = $sampleIosBundleId
+ maccatalystBundleId = $sampleMacCatalystBundleId
+ windowsApplicationId = Get-EnvironmentOrDefault "TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID" $sampleDefaultIdentifier
+ }
+}
+
+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]@{
+ artifactPlatform = "android"
+ runner = "ubuntu-latest"
+ workload = "maui-android"
+ targetFramework = "$DotNetTfm-android"
+ runtimeIdentifier = "android-arm64"
+ }
+ ios = [ordered]@{
+ artifactPlatform = "ios"
+ runner = "macos-26"
+ workload = "maui-ios"
+ targetFramework = "$DotNetTfm-ios"
+ runtimeIdentifier = "ios-arm64"
+ }
+ maccatalyst = [ordered]@{
+ artifactPlatform = "macos"
+ runner = "macos-26"
+ workload = "maui-maccatalyst"
+ targetFramework = "$DotNetTfm-maccatalyst"
+ runtimeIdentifier = ""
+ }
+ windows = [ordered]@{
+ artifactPlatform = "windows"
+ runner = "windows-latest"
+ workload = "maui-windows"
+ targetFramework = "$DotNetTfm-windows10.0.19041.0"
+ runtimeIdentifier = "win-x64"
+ }
+}
+
+$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 ($requiredField in @("displayName", "projectName", "template")) {
+ if ([string]::IsNullOrWhiteSpace([string]$variant[$requiredField])) {
+ throw "Variant '$variantName' does not define required field '$requiredField'."
+ }
+ }
+
+ foreach ($platformName in $selectedPlatforms) {
+ if (-not $platformDefinitions.Contains($platformName)) {
+ throw "Unknown platform '$platformName'. Known platforms: $($platformDefinitions.Keys -join ', ')"
+ }
+
+ $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
+ } 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)
+ $maccatalystBundleId = if ([string]::IsNullOrWhiteSpace($variant.maccatalystBundleId)) {
+ $variant.iosBundleId
+ } else {
+ $variant.maccatalystBundleId
+ }
+ $windowsApplicationId = if ([string]::IsNullOrWhiteSpace($variant.windowsApplicationId)) {
+ $variant.androidApplicationId
+ } else {
+ $variant.windowsApplicationId
+ }
+ $templateArgsJson = if ($templateArgs.Count -eq 0) {
+ "[]"
+ } else {
+ ConvertTo-Json -InputObject $templateArgs -Compress
+ }
+
+ $matrix.include += [ordered]@{
+ variant = $variantName
+ platform = $platformName
+ artifactPlatform = $platform.artifactPlatform
+ 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
+ maccatalystBundleId = [string]$maccatalystBundleId
+ windowsApplicationId = [string]$windowsApplicationId
+ }
+ }
+}
+
+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/README.md b/.github/scripts/template-app-distribution/README.md
new file mode 100644
index 000000000000..144ec4bb7b30
--- /dev/null
+++ b/.github/scripts/template-app-distribution/README.md
@@ -0,0 +1,121 @@
+# Template App Distribution
+
+Builds a fresh .NET MAUI app from the packaged templates for each platform/variant and
+either (a) uploads the results as GitHub artifacts (`publish=false`, a **dry run**) or
+(b) signs and publishes them to Google Play / TestFlight (`publish=true`).
+
+The workflow lives in `.github/workflows/template-app-distribution.yml`. Trigger it from the
+**Actions** tab with *Run workflow* and pick the source branch (`main`, `net10.0`, `net11.0`
+or a `release/*` branch) plus whether to publish.
+
+## What you get, per platform
+
+The goal is that **every artifact a tester downloads can actually be installed** without an
+App Store / Play account. The build script therefore emits two things:
+
+- `package_path` — the **store** package (`.aab` / App Store `.ipa` / Mac App Store `.pkg`).
+ Consumed only by the Google Play / TestFlight upload steps.
+- `sideload_package_path` — the **directly installable** artifact. This is what the dry-run
+ job and the publish "artifact copy" step upload for testers. When optional Apple sideload
+ signing is not configured, the publish job intentionally falls back to the store package.
+- `additional_package_path` — an optional extra file uploaded next to the sideload one. Used on
+ iOS to include the Simulator `.app` zip alongside the device `.ipa`.
+
+| Platform | Dry-run artifact (`publish=false`) | Publish store target | Sideloadable artifact on publish |
+| --- | --- | --- | --- |
+| **Android** | Debug-signed **APK** (installs via `adb install` / file manager) | `.aab` → Google Play | Release-signed **APK** |
+| **Windows** | **Self-contained** unpackaged zip (no runtime install needed) | same zip | same zip |
+| **iOS** | unsigned device **`.ipa`** (AltStore/Sideloadly) + Simulator `.app` zip | App Store `.ipa` → TestFlight | ad-hoc `.ipa` *(only if the ad-hoc secret is set — see below)* |
+| **macOS (Mac Catalyst)** | Native **arm64** `.app` zip (Apple Silicon) | Mac App Store `.pkg` → TestFlight | notarized `.app` zip *(only if the Developer ID secrets are set — see below)* |
+
+### Why the previous artifacts failed to install
+
+- **Android** — only an `.aab` was produced. An `.aab` can *only* be consumed by Google Play,
+ so the ZIP had nothing to sideload. Fixed by also building an installable APK.
+- **Windows** — published framework-dependent, so it needed the exact .NET preview desktop
+ runtime and still showed the "install .NET" screen. Fixed with `-p:SelfContained=true`.
+- **iOS** — two problems. (1) The publish IPA was signed with the App Store / TestFlight profile,
+ which Apple refuses to install directly (`0xe800801f "Attempted to install a Beta profile without
+ the proper entitlement"`) — fixed by an optional ad-hoc-signed IPA (secret-gated). (2) The dry-run
+ `.app` was an unsigned *device* (`ios-arm64`, iPhoneOS) build that installs nowhere: it can't go on
+ hardware (unsigned) and won't launch in the Simulator (device platform — launch is denied). Fixed by
+ building an **arm64 iOS Simulator** app (`dotnet build -r iossimulator-arm64`; `dotnet publish`
+ rejects simulator RIDs) and ad-hoc re-signing it so the Simulator (which enforces code signing on
+ macOS 15+/26) actually launches it. The dry-run **also** wraps an unsigned `ios-arm64` device
+ build as a `Payload/*.app` **`.ipa`** so testers who want to run on real hardware have an IPA to
+ sideload with AltStore/Sideloadly (which re-sign it with their own Apple ID). A *directly*
+ installable device build still needs the ad-hoc IPA (secret-gated) or TestFlight.
+- **macOS** — the `.pkg` was Mac App Store signed and defaulted to `maccatalyst-x64` (Rosetta),
+ so launching it outside the store gave `SIGKILL (Code Signature Invalid)` /
+ `Taskgated Invalid Signature`. Fixed by shipping a directly-launchable **arm64-native** `.app`
+ that is (1) zipped with `ditto` so the framework symlinks, exec bits and signature survive the
+ round-trip, and (2) **re-signed ad-hoc from the inside out** so macOS 15+/26 accepts it (the
+ stock .NET linker-signed bundle is SIGKILL'd with "Invalid Page" — reproduced on macOS 26.5.2 /
+ M2). It runs natively on Apple Silicon (the reporting Mac was an M2) with no Rosetta. For a
+ seamless, notarized experience there is an optional Developer-ID-signed `.app`. (net11 Mac
+ Catalyst can't publish the SDK's default universal `maccatalyst-x64;maccatalyst-arm64`
+ unattended — the multi-RID publish trips `PublishReadyToRun couldn't be inferred` — so a single
+ native RID is pinned; Intel Macs would need a separate `maccatalyst-x64` build.)
+
+## Install instructions for testers
+
+- **Android** — download the APK, then `adb install app.apk` (or copy to the device and open
+ it; enable "install unknown apps"). The dry-run APK is debug-signed and installs on any
+ device/emulator.
+- **Windows** — unzip and run the `.exe`. Because the app is self-contained no .NET runtime
+ install is required. (SmartScreen may warn for an unsigned app — *More info → Run anyway*.)
+- **iOS** — the dry-run artifact contains one or two files (the unsigned device `.ipa` is built on a best-effort basis, so it may be absent — leaving only the Simulator `.app.zip`):
+ - **`MyApp.ipa`** — an **unsigned device** build for a **physical iPhone/iPad**. iOS refuses to
+ run unsigned or ad-hoc code on a device, so install it with **[AltStore](https://altstore.io)**
+ or **[Sideloadly](https://sideloadly.io)**, which re-sign the app with your own Apple ID (a free
+ Apple ID works but must be refreshed every 7 days; a paid Developer account lasts a year). A
+ plain Finder drag / double-click will *not* install an unsigned IPA.
+ - **`MyApp.app.zip`** — an **arm64 iOS Simulator** build. Unzip and run it in the Simulator:
+ `xcrun simctl install booted MyApp.app && xcrun simctl launch booted `. It is ad-hoc
+ re-signed so the Simulator (which enforces code signing on macOS 15+/26) launches it.
+
+ For a **directly installable** device build (no AltStore, no re-signing) use one of the
+ secret-gated publish paths: **TestFlight** (`publish=true`, the smoothest — testers install from
+ the TestFlight app, no UDID needed) or the **ad-hoc `.ipa`** (below) with each tester's device
+ UDID registered in the ad-hoc profile.
+- **macOS** — the dry-run `.app` is **ad-hoc signed** (not notarized), so Gatekeeper blocks it on
+ first launch. Clear quarantine and open it:
+ `xattr -dr com.apple.quarantine "MyApp.app"` then double-click — **or** double-click, dismiss the
+ warning, and approve it under *System Settings → Privacy & Security → Open Anyway*. (The bundle is
+ re-signed ad-hoc during the build; without that, macOS 15+/26 SIGKILLs it at launch with "Code
+ Signature Invalid".) A double-click-clean, launch-anywhere build for other users requires the
+ notarized artifact (secret-gated, below).
+
+## Secrets & variables
+
+The header of `template-app-distribution.yml` is the source of truth. Summary:
+
+**Required for `publish=true`** (protected `template-app-distribution` environment): the Android
+keystore, the Google Play service account JSON, the Apple distribution certificate, the App Store
+/ Mac App Store provisioning profiles, and the App Store Connect API key. `publish=false` needs
+**none** of these — it produces the installable Android APK and self-contained Windows zip
+immediately.
+
+**Optional — enable the sideloadable iOS / macOS artifacts:**
+
+- `TEMPLATE_APP_{BLANK,SAMPLE}_IOS_ADHOC_PROVISIONING_PROFILE_BASE64` — ad-hoc distribution
+ provisioning profiles (they reuse the existing Apple Distribution certificate). With these set,
+ `publish=true` also produces an installable ad-hoc `.ipa`.
+- `TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64` /
+ `TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD` — a **Developer ID
+ Application** signing certificate (`.p12`).
+- `TEMPLATE_APP_{BLANK,SAMPLE}_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64` — Developer ID
+ Mac Catalyst provisioning profiles. With these set, `publish=true` also produces a
+ Developer-ID-signed, **notarized** `.app` zip that launches on any Mac. Notarization reuses the
+ existing `TEMPLATE_APPSTORE_CONNECT_*` API key.
+
+If the optional Apple secrets are absent, the workflow still succeeds and simply falls back to
+uploading the store `.ipa` / `.pkg` (which stay TestFlight-only). No secret is ever required for
+the Android and Windows fixes. If optional Apple signing is configured but its publish, signing,
+or notarization step fails, the build fails instead of silently uploading the non-installable
+store package as though it were a sideload artifact.
+
+The workflow runs the behavioral Pester suite before preparing the build matrix. Publish builds
+upload MSBuild binlogs even on failure. Android publishes include separate binlogs for the
+installable APK and the store-critical AAB; optional iOS ad-hoc and Mac Catalyst Developer ID
+publishes each emit their own sideload binlog so signing failures remain diagnosable.
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..64eaf538b625
--- /dev/null
+++ b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1
@@ -0,0 +1,135 @@
+#!/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
+ $succeeded = $LASTEXITCODE -eq 0
+ $global:LASTEXITCODE = 0
+ return $succeeded
+}
+
+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("."))
+}
+
+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()
+ $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 {
+ $branchName = $_ -replace "^origin/", ""
+ Test-TrustedBranchName $branchName $DefaultBranch
+ }
+ )
+
+ $isTrusted = $false
+ $trustedReason = ""
+
+ $sourceBranchName = $normalizedSourceRef -replace "^refs/heads/", "" -replace "^origin/", ""
+ if (Test-TrustedBranchName $sourceBranchName $DefaultBranch) {
+ $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) {
+ $sourceTagName = $tagName
+ }
+ }
+
+ if (-not $isTrusted) {
+ foreach ($branch in $trustedBranches) {
+ if (Test-GitSuccess -Arguments @("merge-base", "--is-ancestor", $sourceSha, $branch)) {
+ $isTrusted = $true
+ $trustedReason = if ($sourceTagName) { "tag '$sourceTagName' reachable from '$branch'" } else { "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..1cba5b7aff9e
--- /dev/null
+++ b/.github/scripts/template-app-distribution/fastlane/Fastfile
@@ -0,0 +1,130 @@
+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
+
+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?("BuildWatcher exceeded") ||
+ (message.include?("processing") &&
+ (message.include?("timeout") || message.include?("timed out") || message.include?("waited")))
+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: optional_option(options, :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 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
+ 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 = {
+ 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?
+ }
+
+ 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) || "osx"
+ end
+
+ upload_options[:changelog] = changelog unless changelog.nil?
+
+ 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
+
+ begin
+ upload_to_testflight(upload_options)
+ rescue => error
+ if testflight_review_conflict?(error)
+ if groups.empty?
+ UI.important("The build was uploaded, but another build in this train is already in beta review. Treating this as a successful upload because no external distribution was requested.")
+ else
+ UI.error("The build was uploaded, but another build in this train is already in beta review, so the requested external TestFlight groups did not receive it.")
+ raise
+ end
+ elsif testflight_processing_timeout?(error)
+ UI.error("The build was uploaded, but App Store Connect did not finish processing it before the configured wait timeout, so requested TestFlight distribution could not be completed.")
+ raise
+ else
+ raise
+ end
+ end
+ 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..b18916633909
--- /dev/null
+++ b/.github/scripts/template-app-distribution/fastlane/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+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
new file mode 100644
index 000000000000..ce096dd3bb8c
--- /dev/null
+++ b/.github/workflows/template-app-distribution.yml
@@ -0,0 +1,849 @@
+# 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_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON
+# - TEMPLATE_APP_IOS_CERTIFICATE_BASE64
+# - TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD
+# - 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_ANDROID_KEY_PASSWORD (secret): the signing key password; defaults to the keystore password when unset.
+# - 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)
+#
+# Optional secrets that produce directly-installable (sideloadable) artifacts on publish=true.
+# Without these, publish=true still uploads the store packages (App Store IPA / Mac App Store .pkg),
+# which are TestFlight-only and cannot be installed directly. Add them to also get a sideloadable
+# ad-hoc IPA (iOS) and a notarized .app zip (macOS):
+# - TEMPLATE_APP_BLANK_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 (secrets):
+# ad-hoc distribution provisioning profiles (reuse the existing Apple Distribution certificate).
+# - TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 / TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD (secrets):
+# a "Developer ID Application" signing certificate (.p12) for notarized, launch-anywhere macOS apps.
+# - TEMPLATE_APP_BLANK_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 (secrets):
+# Developer ID Mac Catalyst provisioning profiles. Notarization reuses TEMPLATE_APPSTORE_CONNECT_* above.
+# - 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.
+# - 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
+
+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
+ publish:
+ description: Publish to Google Play/TestFlight. If false, only build and upload GitHub artifacts.
+ required: true
+ default: false
+ type: boolean
+
+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
+ FASTLANE_OPT_OUT_USAGE: "1"
+
+jobs:
+ script-tests:
+ name: Validate distribution scripts
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout workflow scripts
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.sha }}
+ path: trusted
+ persist-credentials: false
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.3"
+
+ - name: Install Pester
+ shell: pwsh
+ run: |
+ Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
+ Install-Module Pester -RequiredVersion 6.0.1 -Scope CurrentUser -Force
+
+ - name: Run distribution script tests
+ shell: pwsh
+ run: |
+ Import-Module Pester -RequiredVersion 6.0.1 -Force
+ Invoke-Pester `
+ -Path "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.Tests.ps1" `
+ -CI
+
+ prepare:
+ name: Prepare matrix
+ needs: script-tests
+ 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 }}
+ 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@v4
+ with:
+ ref: ${{ github.sha }}
+ path: trusted
+ persist-credentials: false
+
+ - name: Checkout template source
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.source_ref }}
+ path: source
+ fetch-depth: 0
+ fetch-tags: true
+ persist-credentials: false
+
+ - 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($env:PUBLISH)
+ & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1" `
+ -RepositoryPath "${{ github.workspace }}/source" `
+ -SourceRef "$env:SOURCE_REF" `
+ -WorkflowRef "$env:WORKFLOW_REF" `
+ -DefaultBranch "$env: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 "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 }}
+ 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: |
+ & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Prepare-Matrix.ps1" `
+ -Variants "all" `
+ -Platforms "all" `
+ -DotNetTfm "${{ steps.sdk.outputs.dotnet_tfm }}"
+
+ dry-run-build:
+ name: Dry-run builds
+ 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) }}
+ 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@v4
+ with:
+ ref: ${{ github.sha }}
+ path: trusted
+ persist-credentials: false
+
+ - name: Checkout template source
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare.outputs.source_sha }}
+ path: source
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }}
+
+ - name: Setup Java
+ if: ${{ matrix.platform == 'android' }}
+ uses: actions/setup-java@v5
+ with:
+ distribution: microsoft
+ java-version: "17"
+
+ - name: Setup Xcode
+ if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }}
+ shell: bash
+ run: |
+ 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 "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"
+ 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
+
+ - name: Install MAUI workload
+ shell: pwsh
+ env:
+ MATRIX_WORKLOAD: ${{ matrix.workload }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ run: |
+ $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config"
+ $installArgs = @("workload", "install", $env:MATRIX_WORKLOAD, "--configfile", $nugetConfig)
+ if ($env:MATRIX_PLATFORM -eq "ios" -or $env:MATRIX_PLATFORM -eq "maccatalyst") {
+ $installArgs += "--skip-manifest-update"
+ }
+
+ dotnet @installArgs
+ if ($LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+
+ - name: Pack local templates
+ id: pack
+ shell: pwsh
+ env:
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ 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/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -NuGetPackages "${{ runner.temp }}/nuget-packages/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}"
+
+ - name: Create generated app
+ id: app
+ shell: pwsh
+ env:
+ # Pass every matrix-controlled value via env so it is never interpolated into the
+ # PowerShell command text; avoids quoting/injection issues for repo-variable-derived values.
+ TEMPLATE_ARGS_JSON: ${{ matrix.templateArgsJson }}
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ MATRIX_PROJECT_NAME: ${{ matrix.projectName }}
+ MATRIX_TEMPLATE: ${{ matrix.template }}
+ MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }}
+ MATRIX_APPLICATION_ID: ${{ matrix.applicationId }}
+ MATRIX_DISPLAY_NAME: ${{ matrix.displayName }}
+ 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 "${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -ProjectName "$env:MATRIX_PROJECT_NAME" `
+ -Template "$env:MATRIX_TEMPLATE" `
+ -TemplateArgsJson $env:TEMPLATE_ARGS_JSON `
+ -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" `
+ -TargetFramework "$env:MATRIX_TARGET_FRAMEWORK" `
+ -ApplicationId "$env:MATRIX_APPLICATION_ID" `
+ -DisplayName "$env:MATRIX_DISPLAY_NAME" `
+ -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" `
+ -AppDisplayVersion "$env:APP_DISPLAY_VERSION" `
+ -AppBuildNumber "$env:APP_BUILD_NUMBER" `
+ -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config"
+
+ - name: Build generated app
+ id: build
+ shell: pwsh
+ env:
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }}
+ MATRIX_RUNTIME_IDENTIFIER: ${{ matrix.runtimeIdentifier }}
+ run: |
+ & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" `
+ -ProjectPath "${{ steps.app.outputs.project_path }}" `
+ -Platform "$env:MATRIX_PLATFORM" `
+ -TargetFramework "$env:MATRIX_TARGET_FRAMEWORK" `
+ -RuntimeIdentifier "$env:MATRIX_RUNTIME_IDENTIFIER" `
+ -OutputPath "${{ runner.temp }}/template-app-output/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -AppDisplayVersion "$env:APP_DISPLAY_VERSION" `
+ -AppBuildNumber "$env:APP_BUILD_NUMBER" `
+ -CreateBinlog
+
+ - name: Upload dry-run artifact
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }}
+ path: |
+ ${{ steps.build.outputs.sideload_package_path }}
+ ${{ steps.build.outputs.additional_package_path }}
+ ${{ steps.build.outputs.binlog_path }}
+ retention-days: 14
+
+ publish:
+ name: Publish/build template apps
+ if: ${{ inputs.publish }}
+ 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_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) }}
+ 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:
+ 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 || '' }}
+ 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()
+
+ 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"
+ } 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")
+ }
+ # MAC_INSTALLER_CERTIFICATE_* is intentionally NOT required here: Install-AppleSigningAssets.ps1
+ # accepts a "3rd Party Mac Developer Installer" identity from ANY imported cert (the main
+ # IOS_CERTIFICATE p12 can already contain one) and only throws if no installer identity is
+ # present after import. Hard-requiring the dedicated secret would be a false-negative failure.
+ 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:
+ ref: ${{ github.sha }}
+ path: trusted
+ persist-credentials: false
+
+ - name: Checkout template source
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.prepare.outputs.source_sha }}
+ path: source
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: ${{ needs.prepare.outputs.dotnet_sdk }}
+
+ - name: Setup Java
+ if: ${{ matrix.platform == 'android' }}
+ uses: actions/setup-java@v5
+ with:
+ distribution: microsoft
+ java-version: "17"
+
+ - name: Setup Xcode
+ if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }}
+ shell: bash
+ run: |
+ 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 "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"
+ 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
+
+ - name: Setup Ruby and fastlane
+ if: ${{ matrix.platform == 'android' || matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }}
+ 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
+ shell: pwsh
+ env:
+ MATRIX_WORKLOAD: ${{ matrix.workload }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ run: |
+ $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config"
+ $installArgs = @("workload", "install", $env:MATRIX_WORKLOAD, "--configfile", $nugetConfig)
+ if ($env:MATRIX_PLATFORM -eq "ios" -or $env:MATRIX_PLATFORM -eq "maccatalyst") {
+ $installArgs += "--skip-manifest-update"
+ }
+
+ dotnet @installArgs
+ if ($LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+
+ - 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 $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
+ $notes >> $env:GITHUB_ENV
+ "$delimiter" >> $env:GITHUB_ENV
+
+ - name: Pack local templates
+ id: pack
+ shell: pwsh
+ env:
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ 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/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -NuGetPackages "${{ runner.temp }}/nuget-packages/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}"
+
+ - name: Create generated app
+ id: app
+ shell: pwsh
+ env:
+ # Pass every matrix-controlled value via env so it is never interpolated into the
+ # PowerShell command text; avoids quoting/injection issues for repo-variable-derived values.
+ TEMPLATE_ARGS_JSON: ${{ matrix.templateArgsJson }}
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ MATRIX_PROJECT_NAME: ${{ matrix.projectName }}
+ MATRIX_TEMPLATE: ${{ matrix.template }}
+ MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }}
+ MATRIX_APPLICATION_ID: ${{ matrix.applicationId }}
+ MATRIX_DISPLAY_NAME: ${{ matrix.displayName }}
+ 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 "${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -ProjectName "$env:MATRIX_PROJECT_NAME" `
+ -Template "$env:MATRIX_TEMPLATE" `
+ -TemplateArgsJson $env:TEMPLATE_ARGS_JSON `
+ -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" `
+ -TargetFramework "$env:MATRIX_TARGET_FRAMEWORK" `
+ -ApplicationId "$env:MATRIX_APPLICATION_ID" `
+ -DisplayName "$env:MATRIX_DISPLAY_NAME" `
+ -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" `
+ -AppDisplayVersion "$env:APP_DISPLAY_VERSION" `
+ -AppBuildNumber "$env:APP_BUILD_NUMBER" `
+ -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config"
+
+ - name: Install Apple signing assets
+ if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }}
+ shell: pwsh
+ env:
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }}
+ IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }}
+ 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 }}
+ APPLE_DEVELOPERID_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }}
+ APPLE_DEVELOPERID_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }}
+ APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'maccatalyst' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 || '' }}
+ APPLE_ADHOC_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'ios' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'ios' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 || '' }}
+ run: |
+ & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1" `
+ -Variant "$env:MATRIX_VARIANT" `
+ -Platform "$env:MATRIX_PLATFORM"
+
+ - 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 }}
+ ANDROID_KEYSTORE_TYPE: ${{ vars.TEMPLATE_APP_ANDROID_KEYSTORE_TYPE }}
+ 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 }}
+ MATRIX_VARIANT: ${{ matrix.variant }}
+ MATRIX_PLATFORM: ${{ matrix.platform }}
+ MATRIX_TARGET_FRAMEWORK: ${{ matrix.targetFramework }}
+ MATRIX_RUNTIME_IDENTIFIER: ${{ matrix.runtimeIdentifier }}
+ run: |
+ & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" `
+ -ProjectPath "${{ steps.app.outputs.project_path }}" `
+ -Platform "$env:MATRIX_PLATFORM" `
+ -TargetFramework "$env:MATRIX_TARGET_FRAMEWORK" `
+ -RuntimeIdentifier "$env:MATRIX_RUNTIME_IDENTIFIER" `
+ -OutputPath "${{ runner.temp }}/template-app-output/${env:MATRIX_VARIANT}-${env:MATRIX_PLATFORM}" `
+ -AppDisplayVersion "$env:APP_DISPLAY_VERSION" `
+ -AppBuildNumber "$env:APP_BUILD_NUMBER" `
+ -CreateBinlog `
+ -Publish
+
+ - name: Upload artifact copy
+ if: ${{ always() && steps.build.outputs.sideload_package_path != '' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: template-app-publish-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }}
+ path: ${{ steps.build.outputs.sideload_package_path }}
+ retention-days: 14
+
+ - name: Upload publish binlog
+ if: ${{ always() && steps.build.outputs.binlog_path != '' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: template-app-publish-binlog-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }}
+ path: |
+ ${{ steps.build.outputs.binlog_path }}
+ ${{ steps.build.outputs.store_binlog_path }}
+ ${{ steps.build.outputs.sideload_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
+ env:
+ MATRIX_ANDROID_APPLICATION_ID: ${{ matrix.androidApplicationId }}
+ BUILD_PACKAGE_PATH: ${{ steps.build.outputs.package_path }}
+ run: |
+ & bundle exec fastlane android template_app_play `
+ "package_name:$env:MATRIX_ANDROID_APPLICATION_ID" `
+ "aab:$env:BUILD_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
+ if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }}
+ 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/Mac Catalyst 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 }}
+ MATRIX_IOS_BUNDLE_ID: ${{ matrix.iosBundleId }}
+ BUILD_PACKAGE_PATH: ${{ steps.build.outputs.package_path }}
+ run: |
+ & bundle exec fastlane ios template_app_testflight `
+ "ipa:$env:BUILD_PACKAGE_PATH" `
+ "app_identifier:$env:MATRIX_IOS_BUNDLE_ID" `
+ "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"
+
+ - 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 }}
+ MATRIX_MACCATALYST_BUNDLE_ID: ${{ matrix.maccatalystBundleId }}
+ BUILD_PACKAGE_PATH: ${{ steps.build.outputs.package_path }}
+ run: |
+ & bundle exec fastlane ios template_app_testflight `
+ "pkg:$env:BUILD_PACKAGE_PATH" `
+ "app_identifier:$env:MATRIX_MACCATALYST_BUNDLE_ID" `
+ "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" `
+ "groups:$env:TEMPLATE_APP_TESTFLIGHT_GROUPS"