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..5c934973f4a7 --- /dev/null +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -0,0 +1,310 @@ +#!/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) { + $bytes = [Convert]::FromBase64String($Base64Value) + [System.IO.File]::WriteAllBytes($Path, $bytes) +} + +function Get-NewestBuildOutput([string]$Root, [string]$Filter, [switch]$Directory) { + $itemType = if ($Directory) { "Directory" } else { "File" } + return Get-ChildItem -Path $Root -Filter $Filter -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.PSIsContainer -eq [bool]$Directory -and $_.FullName -notmatch "[\\/](obj)[\\/]" } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 +} + +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" + ) +} + +$projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 +if (-not $projectFile) { + throw "No project file was found in '$ProjectPath'." +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +$binlogPath = if ($CreateBinlog) { Join-Path $OutputPath "build.binlog" } else { $null } +$binlogArguments = if ($CreateBinlog) { @("/bl:$binlogPath") } else { @() } + +switch ($Platform) { + "android" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:AndroidPackageFormat=aab", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-o", $OutputPath + ) + $binlogArguments + + 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") + + $arguments += @( + "-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)) { + $arguments += "-p:AndroidSigningStoreType=$keystoreType" + } + } else { + $arguments += "-p:AndroidKeyStore=false" + } + + Write-Host "Building Android package for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "Android publish" + + $package = Get-NewestBuildOutput $ProjectPath "*.aab" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.aab" + } + } + + "ios" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false" + ) + $binlogArguments + + if (Test-IsNet11OrLater $TargetFramework) { + $arguments += "-p:UseMonoRuntime=false" + $arguments = Add-NativeAotArguments $arguments + } + + if ($Publish) { + $codesignKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" + $codesignProvision = Assert-EnvironmentValue "IOS_CODESIGN_PROVISION" + $arguments += @( + "-p:BuildIpa=true", + "-p:ArchiveOnBuild=true", + "-p:CodesignKey=$codesignKey", + "-p:CodesignProvision=$codesignProvision", + "-o", $OutputPath + ) + } else { + $arguments += @( + "-p:_RequireCodeSigning=false", + "-p:EnableCodeSigning=false", + "-p:CodesignKey=-", + "-p:BuildIpa=false" + ) + } + + Write-Host "Building iOS package for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "iOS publish" + + if ($Publish) { + $package = Get-NewestBuildOutput $ProjectPath "*.ipa" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.ipa" + } + } else { + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Compress-Archive -Path $appBundle.FullName -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } + } + } + + "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) { + $arguments += @("-r", "maccatalyst-x64") + } + + 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" + } + } else { + $appBundle = Get-NewestBuildOutput $OutputPath "*.app" -Directory + if (-not $appBundle) { + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + } + + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Compress-Archive -Path $appBundle.FullName -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } + } + } + + "windows" { + $publishOutputPath = Join-Path $OutputPath "publish" + Remove-Item -Path $publishOutputPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $publishOutputPath -Force | Out-Null + + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:RuntimeIdentifierOverride=$RuntimeIdentifier", + "-p:WindowsPackageType=None", + "-p:WindowsAppSDKSelfContained=true", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-o", $publishOutputPath + ) + $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)" +if ($CreateBinlog) { + Write-Host "Build binlog: $binlogPath" +} + +if ($env:GITHUB_OUTPUT) { + "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT + if ($CreateBinlog) { + "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT + } +} diff --git a/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 new file mode 100644 index 000000000000..688dc491d629 --- /dev/null +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -0,0 +1,197 @@ +#!/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 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." +} + +function Write-Base64File([string]$Base64Value, [string]$Path) { + $bytes = [Convert]::FromBase64String($Base64Value.Trim()) + [System.IO.File]::WriteAllBytes($Path, $bytes) +} + +if (-not $IsMacOS) { + throw "Apple signing assets can only be installed on macOS runners." +} + +$tempDirectory = Join-Path $env:RUNNER_TEMP "template-app-apple-signing" +New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null + +$certificatePath = Join-Path $tempDirectory "certificate.p12" +$profilePath = Join-Path $tempDirectory "$Variant.mobileprovision" +$profilePlistPath = Join-Path $tempDirectory "$Variant.plist" +$keychainPath = Join-Path $tempDirectory "template-app-distribution.keychain-db" + +Write-Base64File (Assert-EnvironmentValue "IOS_CERTIFICATE_BASE64") $certificatePath +Write-Base64File (Get-VariantProvisioningProfile $Variant) $profilePath + +$certificatePassword = Assert-EnvironmentValue "IOS_CERTIFICATE_PASSWORD" +$keychainPassword = [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'" +} + +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 + } + "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 + } + "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..18bf6121c681 --- /dev/null +++ b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 @@ -0,0 +1,226 @@ +#!/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-PlistBooleanFalse([string]$Path, [string]$Key) { + if (-not (Test-Path $Path)) { + return + } + + $plistContent = Get-Content $Path -Raw + $escapedKey = [regex]::Escape($Key) + $booleanKeyPattern = "(?s)($escapedKey\s*)<(true|false)\s*/>" + if ($plistContent -match $booleanKeyPattern) { + $plistContent = [regex]::Replace($plistContent, $booleanKeyPattern, '$1', 1) + Set-Content -Path $Path -Value $plistContent -Encoding utf8 + return + } + + $entry = "`t$Key`r`n`t`r`n" + $plistContent = $plistContent -replace "(?m)^", "$entry" + Set-Content -Path $Path -Value $plistContent -Encoding utf8 +} + +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 + +$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 + +$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 = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $DisplayName)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $ApplicationId)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppDisplayVersion)" +$content = $content -replace "[^<]+", "$(ConvertTo-XmlEscaped $AppBuildNumber)" + +if ($TargetFramework.Contains("-windows", [System.StringComparison]::OrdinalIgnoreCase) -and + $content -notmatch "RuntimeIdentifierOverride") { + $runtimeIdentifierOverridePropertyGroup = @" + + + `$(RuntimeIdentifierOverride) + +"@ + + $content = $content -replace "\s*$", "$runtimeIdentifierOverridePropertyGroup`r`n" +} + +$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..20bc53cf849e --- /dev/null +++ b/.github/scripts/template-app-distribution/Pack-Templates.ps1 @@ -0,0 +1,52 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$DotNetCliHome, + + [Parameter(Mandatory)] + [string]$NuGetPackages +) + +$ErrorActionPreference = "Stop" + +$templatesProject = Join-Path $RepositoryPath "src/Templates/src/Microsoft.Maui.Templates.csproj" +if (-not (Test-Path $templatesProject)) { + throw "Template project was not found at '$templatesProject'." +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +New-Item -ItemType Directory -Path $DotNetCliHome -Force | Out-Null +New-Item -ItemType Directory -Path $NuGetPackages -Force | Out-Null + +$env:DOTNET_CLI_HOME = $DotNetCliHome +$env:NUGET_PACKAGES = $NuGetPackages + +Write-Host "Building MAUI templates from $templatesProject" +dotnet build -t:Rebuild $templatesProject -p:PackageVersion=$PackageVersion -p:GenerateCgManifest=false + +Write-Host "Packing MAUI templates with PackageVersion=$PackageVersion" +dotnet pack $templatesProject -p:PackageVersion=$PackageVersion -p:GenerateCgManifest=false -o $OutputPath + +$package = Get-ChildItem -Path $OutputPath -Filter "*.nupkg" -Recurse | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + +if (-not $package) { + throw "No template package was produced in '$OutputPath'." +} + +Write-Host "Template package: $($package.FullName)" + +if ($env:GITHUB_OUTPUT) { + "template_package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 new file mode 100644 index 000000000000..89f732f14287 --- /dev/null +++ b/.github/scripts/template-app-distribution/Prepare-Matrix.ps1 @@ -0,0 +1,236 @@ +#!/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-15" + workload = "maui-ios" + targetFramework = "$DotNetTfm-ios" + runtimeIdentifier = "ios-arm64" + } + maccatalyst = [ordered]@{ + artifactPlatform = "macos" + runner = "macos-15" + workload = "maui-maccatalyst" + targetFramework = "$DotNetTfm-maccatalyst" + runtimeIdentifier = "" + } + windows = [ordered]@{ + artifactPlatform = "windows" + runner = "windows-latest" + workload = "maui-windows" + targetFramework = "$DotNetTfm-windows10.0.19041.0" + 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 ($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/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..a42f9906a994 --- /dev/null +++ b/.github/scripts/template-app-distribution/Resolve-SourceRef.ps1 @@ -0,0 +1,133 @@ +#!/usr/bin/env pwsh + +param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + + [Parameter(Mandatory)] + [string]$SourceRef, + + [Parameter(Mandatory)] + [string]$WorkflowRef, + + [Parameter(Mandatory)] + [string]$DefaultBranch, + + [Parameter(Mandatory)] + [bool]$Publish +) + +$ErrorActionPreference = "Stop" + +function Invoke-Git([string[]]$Arguments, [switch]$IgnoreExitCode) { + $output = & git @Arguments 2>&1 + if (-not $IgnoreExitCode -and $LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed: $output" + } + + return $output +} + +function Test-GitSuccess([string[]]$Arguments) { + & git @Arguments *> $null + return $LASTEXITCODE -eq 0 +} + +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..fe718342b044 --- /dev/null +++ b/.github/scripts/template-app-distribution/fastlane/Fastfile @@ -0,0 +1,124 @@ +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) + UI.important("The build was uploaded, but another build in this train is already in beta review. Treating this as a successful upload.") + elsif testflight_processing_timeout?(error) + UI.important("The build was uploaded, but App Store Connect did not finish processing it before the configured wait timeout. Treating this as a successful upload.") + else + raise + end + 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..6385225d5c44 --- /dev/null +++ b/.github/workflows/template-app-distribution.yml @@ -0,0 +1,731 @@ +# Required protected environment: template-app-distribution +# +# Required secrets for publishing: +# - TEMPLATE_APP_ANDROID_KEYSTORE_BASE64 +# - TEMPLATE_APP_ANDROID_KEYSTORE_PASSWORD +# - TEMPLATE_APP_ANDROID_KEY_ALIAS +# - TEMPLATE_APP_ANDROID_KEY_PASSWORD +# - TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON +# - TEMPLATE_APP_IOS_CERTIFICATE_BASE64 +# - TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD +# - TEMPLATE_APP_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_IDENTIFIER_PREFIX (variable): defaults to com..maui.template. +# - TEMPLATE_APP_VARIANTS_JSON (variable): adds or overrides variant definitions. +# - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. +# - TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 Mac App Store provisioning profiles. +# - TEMPLATE_APP_BLANK_ANDROID_APPLICATION_ID / TEMPLATE_APP_SAMPLE_ANDROID_APPLICATION_ID (variables) +# - TEMPLATE_APP_BLANK_IOS_BUNDLE_ID / TEMPLATE_APP_SAMPLE_IOS_BUNDLE_ID (variables) +# - TEMPLATE_APP_BLANK_MACCATALYST_BUNDLE_ID / TEMPLATE_APP_SAMPLE_MACCATALYST_BUNDLE_ID (variables) +# - TEMPLATE_APP_BLANK_WINDOWS_APPLICATION_ID / TEMPLATE_APP_SAMPLE_WINDOWS_APPLICATION_ID (variables) +# - TEMPLATE_APP_BLANK_MACCATALYST_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_MACCATALYST_PROVISIONING_PROFILE_BASE64 (secrets) +# - TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64 / TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD (secrets) +# - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. +# - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. +# - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. +# - 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: + prepare: + name: Prepare matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + dotnet_sdk: ${{ steps.sdk.outputs.dotnet_sdk }} + dotnet_tfm: ${{ steps.sdk.outputs.dotnet_tfm }} + source_sha: ${{ steps.source.outputs.source_sha }} + trusted: ${{ steps.source.outputs.trusted }} + app_display_version: ${{ steps.version.outputs.app_display_version }} + app_build_number: ${{ steps.version.outputs.app_build_number }} + steps: + - name: Configure Git defaults + shell: pwsh + run: | + git config --global init.defaultBranch main + git config --global advice.defaultBranchName false + + - name: Checkout workflow scripts + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_ref }} + path: source + fetch-depth: 0 + 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@v7 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v7 + 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 + run: | + $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config" + $installArgs = @("workload", "install", "${{ matrix.workload }}", "--configfile", $nugetConfig) + if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { + $installArgs += "--skip-manifest-update" + } + + dotnet @installArgs + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + - name: Pack local templates + id: pack + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Pack-Templates.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -PackageVersion "99.0.0-templateapp.${{ github.run_id }}.${{ github.run_attempt }}" ` + -OutputPath "${{ runner.temp }}/template-packages/${{ matrix.variant }}-${{ matrix.platform }}" ` + -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${{ matrix.variant }}-${{ matrix.platform }}" ` + -NuGetPackages "${{ runner.temp }}/nuget-packages/${{ matrix.variant }}-${{ matrix.platform }}" + + - name: Create generated app + id: app + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` + -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` + -BuildRoot "${{ runner.temp }}/template-app-build" ` + -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` + -ProjectName "${{ matrix.projectName }}" ` + -Template "${{ matrix.template }}" ` + -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -ApplicationId "${{ matrix.applicationId }}" ` + -DisplayName "${{ matrix.displayName }}" ` + -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config" + + - name: Build generated app + id: build + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` + -ProjectPath "${{ steps.app.outputs.project_path }}" ` + -Platform "${{ matrix.platform }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -RuntimeIdentifier "${{ matrix.runtimeIdentifier }}" ` + -OutputPath "${{ runner.temp }}/template-app-output/${{ matrix.variant }}-${{ matrix.platform }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -CreateBinlog + + - name: Upload dry-run artifact + uses: actions/upload-artifact@v7 + with: + name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} + path: | + ${{ steps.build.outputs.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") + } + Test-RequiredEnvironment "MAC_INSTALLER_CERTIFICATE_BASE64" "TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64" + Test-RequiredEnvironment "MAC_INSTALLER_CERTIFICATE_PASSWORD" "TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD" + Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" + Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" + } + + if ($missing.Count -gt 0) { + $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@v7 + with: + ref: ${{ github.ref }} + path: trusted + persist-credentials: false + + - name: Checkout template source + uses: actions/checkout@v7 + 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 + run: | + $nugetConfig = Join-Path "${{ github.workspace }}" "source/NuGet.config" + $installArgs = @("workload", "install", "${{ matrix.workload }}", "--configfile", $nugetConfig) + if ("${{ matrix.platform }}" -eq "ios" -or "${{ matrix.platform }}" -eq "maccatalyst") { + $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 + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Pack-Templates.ps1" ` + -RepositoryPath "${{ github.workspace }}/source" ` + -PackageVersion "99.0.0-templateapp.${{ github.run_id }}.${{ github.run_attempt }}" ` + -OutputPath "${{ runner.temp }}/template-packages/${{ matrix.variant }}-${{ matrix.platform }}" ` + -DotNetCliHome "${{ runner.temp }}/dotnet-cli-home/${{ matrix.variant }}-${{ matrix.platform }}" ` + -NuGetPackages "${{ runner.temp }}/nuget-packages/${{ matrix.variant }}-${{ matrix.platform }}" + + - name: Create generated app + id: app + shell: pwsh + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` + -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` + -BuildRoot "${{ runner.temp }}/template-app-build" ` + -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` + -ProjectName "${{ matrix.projectName }}" ` + -Template "${{ matrix.template }}" ` + -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -ApplicationId "${{ matrix.applicationId }}" ` + -DisplayName "${{ matrix.displayName }}" ` + -DotNetSdk "${{ needs.prepare.outputs.dotnet_sdk }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -NuGetConfigPath "${{ github.workspace }}/source/NuGet.config" + + - name: Install Apple signing assets + if: ${{ matrix.platform == 'ios' || matrix.platform == 'maccatalyst' }} + shell: pwsh + env: + IOS_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_IOS_CERTIFICATE_PASSWORD }} + APPLE_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'ios' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'ios' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_MACCATALYST_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_MACCATALYST_PROVISIONING_PROFILE_BASE64 || '' }} + APPLE_PROVISIONING_PROFILES_JSON: ${{ matrix.platform == 'ios' && secrets.TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON || matrix.platform == 'maccatalyst' && secrets.TEMPLATE_APP_MACCATALYST_PROVISIONING_PROFILES_JSON || '' }} + MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_BASE64 }} + MAC_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_MAC_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1" ` + -Variant "${{ matrix.variant }}" ` + -Platform "${{ 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 }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Build-TemplateApp.ps1" ` + -ProjectPath "${{ steps.app.outputs.project_path }}" ` + -Platform "${{ matrix.platform }}" ` + -TargetFramework "${{ matrix.targetFramework }}" ` + -RuntimeIdentifier "${{ matrix.runtimeIdentifier }}" ` + -OutputPath "${{ runner.temp }}/template-app-output/${{ matrix.variant }}-${{ matrix.platform }}" ` + -AppDisplayVersion "$env:APP_DISPLAY_VERSION" ` + -AppBuildNumber "$env:APP_BUILD_NUMBER" ` + -Publish + + - name: Upload artifact copy + if: ${{ always() && steps.build.outputs.package_path != '' }} + uses: actions/upload-artifact@v7 + with: + name: template-app-publish-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} + path: ${{ steps.build.outputs.package_path }} + retention-days: 14 + + - name: Write Google Play credentials + if: ${{ matrix.platform == 'android' }} + shell: pwsh + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + if ([string]::IsNullOrWhiteSpace($env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON)) { + throw "TEMPLATE_APP_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is required for Android publishing." + } + + $jsonPath = Join-Path $env:RUNNER_TEMP "google-play-service-account.json" + $value = $env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON.Trim() + if ($value.StartsWith("{")) { + Set-Content -Path $jsonPath -Value $env:GOOGLE_PLAY_SERVICE_ACCOUNT_JSON -NoNewline + } else { + [System.IO.File]::WriteAllBytes($jsonPath, [Convert]::FromBase64String($value)) + } + + "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH=$jsonPath" >> $env:GITHUB_ENV + + - name: Publish Android to Google Play with fastlane + if: ${{ matrix.platform == 'android' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + run: | + & bundle exec fastlane android template_app_play ` + "package_name:${{ matrix.androidApplicationId }}" ` + "aab:${{ steps.build.outputs.package_path }}" ` + "track:$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 publishing." + } + + $keyPath = Join-Path $env:RUNNER_TEMP "appstore-connect-key.p8" + $value = $env:APPSTORE_CONNECT_PRIVATE_KEY.Trim() + if ($value.StartsWith("-----BEGIN")) { + Set-Content -Path $keyPath -Value $env:APPSTORE_CONNECT_PRIVATE_KEY -NoNewline + } else { + [System.IO.File]::WriteAllBytes($keyPath, [Convert]::FromBase64String($value)) + } + + "APPSTORE_CONNECT_PRIVATE_KEY_PATH=$keyPath" >> $env:GITHUB_ENV + + - name: Publish iOS to TestFlight with fastlane + if: ${{ matrix.platform == 'ios' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + env: + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + run: | + & bundle exec fastlane ios template_app_testflight ` + "ipa:${{ steps.build.outputs.package_path }}" ` + "app_identifier:${{ matrix.iosBundleId }}" ` + "issuer_id:$env:APPSTORE_CONNECT_ISSUER_ID" ` + "api_key_id:$env:APPSTORE_CONNECT_KEY_ID" ` + "api_private_key_path:$env:APPSTORE_CONNECT_PRIVATE_KEY_PATH" ` + "groups:$env:TEMPLATE_APP_TESTFLIGHT_GROUPS" + + - name: Publish Mac Catalyst to TestFlight with fastlane + if: ${{ matrix.platform == 'maccatalyst' }} + shell: pwsh + working-directory: trusted/.github/scripts/template-app-distribution/fastlane + env: + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + run: | + & bundle exec fastlane ios template_app_testflight ` + "pkg:${{ steps.build.outputs.package_path }}" ` + "app_identifier:${{ matrix.maccatalystBundleId }}" ` + "app_platform: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" diff --git a/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj b/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj index 57907b15ba88..224c5376e680 100644 --- a/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj +++ b/src/Templates/src/templates/maui-mobile/MauiApp.1.csproj @@ -90,7 +90,7 @@ - +