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))>[^<]+" + $replacement = "<$Name>$(ConvertTo-XmlEscaped $Value)" + 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))>.*?" + $property = "<$Name>$Value" + if ($Content -match $propertyPattern) { + return [regex]::Replace($Content, $propertyPattern, $property, 1) + } + + $firstPropertyGroupEnd = [regex]::Match($Content, "\r?\n\s*") + if (-not $firstPropertyGroupEnd.Success) { + throw "Could not find a PropertyGroup in the generated project." + } + + return $Content.Insert($firstPropertyGroupEnd.Index, "`r`n`t`t$property") +} + +function Add-ProjectDefineConstant([string]$Content, [string]$Constant) { + $defineConstantsPattern = "(?s)(?.*?)" + $defineConstantsMatch = [regex]::Match($Content, $defineConstantsPattern) + if ($defineConstantsMatch.Success) { + $constants = [string]$defineConstantsMatch.Groups["Value"].Value + if ($constants.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) -contains $Constant) { + return $Content + } + + $value = "$constants;$Constant" + return [regex]::Replace($Content, $defineConstantsPattern, "$value", 1) + } + + return Set-ProjectProperty $Content "DefineConstants" "`$(DefineConstants);$Constant" +} + +if (-not (Test-Path $TemplatePackagePath)) { + throw "Template package was not found at '$TemplatePackagePath'." +} + +$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"