From 4a0e1c854d43be47459fbced29d22bd1076026a5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:24:09 +0200 Subject: [PATCH 01/16] Add template app distribution workflow (installable artifacts) Supersedes #36136. Adds a manually triggered workflow that builds the MAUI templates into tester apps for Android, iOS, Mac Catalyst, and Windows, in both blank and sample-content variants. The dry-run path (publish=false) uploads directly-installable GitHub artifacts; publish=true optionally ships to Google Play test tracks and TestFlight via fastlane, gated on the protected template-app-distribution environment secrets. The original PR produced store-shaped artifacts that testers could not install or launch. This version fixes each platform so the uploaded artifact runs on a tester device with no extra tooling: - Android: build a signed, directly-installable APK (v2+v3) for the dry-run path instead of an AAB, which cannot be sideloaded. - Windows: publish self-contained (SelfContained=true plus WindowsAppSDKSelfContained=true) so testers do not need a matching .NET or WindowsAppSDK runtime installed. - iOS: the dry-run now builds an arm64 iOS Simulator app (dotnet build -r iossimulator-arm64, with -p:UseMonoRuntime=false for the net11 CoreCLR build) that actually launches; the device path stays gated behind ad-hoc signing secrets. - Mac Catalyst: pin the arm64-native RID, zip the .app with ditto instead of Compress-Archive (which strips the exec bit and framework symlinks), and re-sign the bundle ad-hoc inside-out so it launches on macOS 15+ and macOS 26 without a Code Signature Invalid SIGKILL. All four artifacts were validated end-to-end from CI output (installed and launched, not just built). See .github/scripts/template-app-distribution/README.md for per-platform tester install steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 92e506ca-933d-4c5b-ab06-62cf471e259c --- .../Build-TemplateApp.ps1 | 617 +++++++++++++++ .../Install-AppleSigningAssets.ps1 | 274 +++++++ .../New-TemplateApp.ps1 | 226 ++++++ .../Pack-Templates.ps1 | 52 ++ .../Prepare-Matrix.ps1 | 236 ++++++ .../template-app-distribution/README.md | 99 +++ .../Resolve-DotNetSdk.ps1 | 41 + .../Resolve-SourceRef.ps1 | 133 ++++ .../fastlane/Fastfile | 124 +++ .../fastlane/Gemfile | 3 + .../fastlane/Gemfile.lock | 344 ++++++++ .../workflows/template-app-distribution.yml | 749 ++++++++++++++++++ 12 files changed, 2898 insertions(+) create mode 100644 .github/scripts/template-app-distribution/Build-TemplateApp.ps1 create mode 100644 .github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 create mode 100644 .github/scripts/template-app-distribution/New-TemplateApp.ps1 create mode 100644 .github/scripts/template-app-distribution/Pack-Templates.ps1 create mode 100644 .github/scripts/template-app-distribution/Prepare-Matrix.ps1 create mode 100644 .github/scripts/template-app-distribution/README.md create mode 100644 .github/scripts/template-app-distribution/Resolve-DotNetSdk.ps1 create mode 100644 .github/scripts/template-app-distribution/Resolve-SourceRef.ps1 create mode 100644 .github/scripts/template-app-distribution/fastlane/Fastfile create mode 100644 .github/scripts/template-app-distribution/fastlane/Gemfile create mode 100644 .github/scripts/template-app-distribution/fastlane/Gemfile.lock create mode 100644 .github/workflows/template-app-distribution.yml diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 new file mode 100644 index 000000000000..e393cd9c2197 --- /dev/null +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -0,0 +1,617 @@ +#!/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 Compress-AppBundle([string]$AppBundlePath, [string]$ZipPath) { + # macOS .app bundles rely on symlinks (e.g. a framework's Versions/Current), + # executable permission bits, and embedded (ad-hoc) code signatures. PowerShell's + # Compress-Archive drops symlinks + exec bits and mangles the framework layout, + # which invalidates the code signature so the unzipped app is SIGKILL'd on launch + # with "Code Signature Invalid" / "Invalid Page". ditto preserves the bundle + # exactly (symlinks, perms, xattrs, bytes), keeping the app launchable. + Remove-Item -Path $ZipPath -Force -ErrorAction SilentlyContinue + if (Get-Command ditto -ErrorAction SilentlyContinue) { + & ditto -c -k --sequesterRsrc --keepParent $AppBundlePath $ZipPath + if ($LASTEXITCODE -ne 0) { + throw "ditto failed to archive '$AppBundlePath' (exit code $LASTEXITCODE)." + } + } else { + Compress-Archive -Path $AppBundlePath -DestinationPath $ZipPath -Force + } +} + +function Repair-AppleAdhocSignature([string]$AppBundlePath) { + # The .NET iOS-Simulator / Mac Catalyst build leaves the app *linker-signed* ad-hoc + # (flags 0x20002), while its bundled native libraries (*.dylib/*.so — e.g. libcoreclr, + # libxamarin-dotnet-coreclr) carry a mix of signatures. macOS 15+/26 and the modern iOS + # Simulator refuse to launch that inconsistent bundle: dyld kills it at load with + # CODESIGNING "Invalid Page" / SIGKILL "Code Signature Invalid" (empirically reproduced + # on macOS 26.5.2 / M2 for both a Mac Catalyst .app launch and a Simulator install+launch). + # Re-signing every Mach-O ad-hoc from the inside out (loose dylibs first, since + # `codesign --deep` does not reach every nested lib, then a deep sign of the whole app) + # gives all components a consistent, valid ad-hoc signature (flags 0x2) so the app + # launches. It is still ad-hoc (not notarized): the Simulator accepts ad-hoc directly, and + # a Mac tester clears quarantine / uses "Open Anyway". The notarized Developer ID path + # (secret-gated, below) is the seamless option for a direct-download macOS launch. + if (-not (Get-Command codesign -ErrorAction SilentlyContinue)) { + Write-Warning "codesign not available; skipping ad-hoc re-sign of '$AppBundlePath'." + return + } + Get-ChildItem -Path $AppBundlePath -Recurse -Include *.dylib, *.so -File -ErrorAction SilentlyContinue | + ForEach-Object { & codesign --force --sign - $_.FullName 2>$null } + & codesign --force --deep --sign - $AppBundlePath + if ($LASTEXITCODE -ne 0) { + Write-Warning "Ad-hoc re-sign of '$AppBundlePath' failed (exit $LASTEXITCODE); the app may not launch until it is signed." + } +} + +function Invoke-DotNetPublish([string[]]$Arguments, [string]$Description) { + & dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE." + } +} + +function Test-IsNet11OrLater([string]$TargetFramework) { + if ($TargetFramework -notmatch "^net(?\d+)\.") { + return $false + } + + return [int]$Matches.Major -ge 11 +} + +function Add-NativeAotArguments([string[]]$Arguments) { + return $Arguments + @( + "-p:PublishAot=true", + "-p:PublishAotUsingRuntimePack=true", + "-p:_IsPublishing=true", + "-p:IlcTreatWarningsAsErrors=false", + "-p:TrimmerSingleWarn=false" + ) +} + +function Invoke-MacNotarization([string]$AppBundlePath) { + # Notarize + staple a Developer ID-signed .app so Gatekeeper lets it launch on other Macs. + # Reuses the App Store Connect API key already configured for TestFlight publishing. + $keyId = Assert-EnvironmentValue "APPSTORE_CONNECT_KEY_ID" + $issuerId = Assert-EnvironmentValue "APPSTORE_CONNECT_ISSUER_ID" + + $keyPath = $env:APPSTORE_CONNECT_PRIVATE_KEY_PATH + if ([string]::IsNullOrWhiteSpace($keyPath) -or -not (Test-Path $keyPath)) { + $rawKey = Assert-EnvironmentValue "APPSTORE_CONNECT_PRIVATE_KEY" + $keyPath = Join-Path $env:RUNNER_TEMP "notarytool-key.p8" + $trimmed = $rawKey.Trim() + if ($trimmed.StartsWith("-----BEGIN")) { + Set-Content -Path $keyPath -Value $rawKey -NoNewline + } else { + [System.IO.File]::WriteAllBytes($keyPath, [Convert]::FromBase64String($trimmed)) + } + } + + $notarizeZip = Join-Path ([System.IO.Path]::GetDirectoryName($AppBundlePath)) "notarize-upload.zip" + Remove-Item -Path $notarizeZip -Force -ErrorAction SilentlyContinue + & ditto -c -k --keepParent $AppBundlePath $notarizeZip + if ($LASTEXITCODE -ne 0) { throw "Failed to create the notarization upload archive." } + + Write-Host "Submitting '$AppBundlePath' to the Apple notary service (this can take a few minutes)..." + & xcrun notarytool submit $notarizeZip --key $keyPath --key-id $keyId --issuer $issuerId --wait + if ($LASTEXITCODE -ne 0) { throw "notarytool submit failed." } + + & xcrun stapler staple $AppBundlePath + if ($LASTEXITCODE -ne 0) { throw "stapler staple failed." } + + Remove-Item -Path $notarizeZip -Force -ErrorAction SilentlyContinue +} + +function New-MacCatalystDeveloperIdSideload { + param( + [System.IO.FileInfo]$ProjectFile, + [string]$TargetFramework, + [string]$Configuration, + [string]$OutputPath, + [string]$AppDisplayVersion, + [string]$AppBuildNumber, + [string]$RuntimeIdentifier, + [switch]$UseNet11OrLater + ) + + # Secret-gated. Only runs when a Developer ID Application identity + provisioning profile + # were installed (Install-AppleSigningAssets.ps1). Produces a Developer ID-signed, + # notarized, stapled .app that launches directly on any Mac. The Mac App Store .pkg is + # killed with "Code Signature Invalid" when installed outside the store, so it stays + # TestFlight-only. + $devIdKey = [Environment]::GetEnvironmentVariable("APPLE_DEVELOPERID_CODESIGN_KEY") + $devIdProvision = [Environment]::GetEnvironmentVariable("APPLE_DEVELOPERID_CODESIGN_PROVISION") + if ([string]::IsNullOrWhiteSpace($devIdKey) -or [string]::IsNullOrWhiteSpace($devIdProvision)) { + Write-Host "No Developer ID signing assets provided; skipping the notarized macOS sideload build." + return $null + } + + try { + $devIdOutput = Join-Path $OutputPath "developer-id" + New-Item -ItemType Directory -Path $devIdOutput -Force | Out-Null + + $devIdArgs = @( + "publish", $ProjectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:MtouchLink=SdkOnly", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "-p:CreatePackage=false", + "-p:EnableCodeSigning=true", + "-p:EnableHardenedRuntime=true", + "-p:ValidateEntitlements=disable", + "-p:CodesignKey=$devIdKey", + "-p:CodesignProvision=$devIdProvision", + "-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.plist", + "-o", $devIdOutput + ) + + if ($UseNet11OrLater) { $devIdArgs += "-p:UseMonoRuntime=false" } + if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { + $devIdArgs += @("-r", $RuntimeIdentifier) + } elseif ($UseNet11OrLater) { + # Match the dry-run: pin arm64-native for net11+ (the universal multi-RID publish + # trips PublishReadyToRun inference). arm64 runs natively on Apple Silicon Macs. + $devIdArgs += @("-r", "maccatalyst-arm64") + } + + Write-Host "Building Developer ID (notarizable) Mac Catalyst app for $($ProjectFile.FullName)" + Invoke-DotNetPublish $devIdArgs "Mac Catalyst Developer ID publish" + + $devIdApp = Get-NewestBuildOutput $devIdOutput "*.app" -Directory + if (-not $devIdApp) { + Write-Warning "Developer ID publish did not produce a .app; skipping notarized sideload." + return $null + } + + # Hardened runtime is required for notarization. Re-sign deeply, preserving entitlements. + $entitlementsPath = Join-Path $devIdOutput "developerid-entitlements.plist" + & codesign -d "--entitlements" ":$entitlementsPath" $devIdApp.FullName 2>$null + $signArgs = @("--force", "--deep", "--options", "runtime", "--timestamp", "--sign", $devIdKey) + if (Test-Path $entitlementsPath) { $signArgs += @("--entitlements", $entitlementsPath) } + & codesign @signArgs $devIdApp.FullName + if ($LASTEXITCODE -ne 0) { throw "Developer ID hardened-runtime re-sign failed." } + + Invoke-MacNotarization $devIdApp.FullName + + $devIdZip = Join-Path $OutputPath "$($devIdApp.BaseName)-macos-developerid.zip" + Remove-Item -Path $devIdZip -Force -ErrorAction SilentlyContinue + & ditto -c -k --keepParent $devIdApp.FullName $devIdZip + if ($LASTEXITCODE -ne 0) { throw "Failed to archive the notarized macOS app." } + + Write-Host "Notarized macOS sideload artifact: $devIdZip" + return (Get-Item $devIdZip) + } catch { + Write-Warning "Developer ID / notarized macOS sideload build failed: $($_.Exception.Message). The Mac App Store .pkg (TestFlight) build is unaffected." + return $null + } +} + +function New-IosAdHocSideload { + param( + [System.IO.FileInfo]$ProjectFile, + [string]$TargetFramework, + [string]$Configuration, + [string]$RuntimeIdentifier, + [string]$OutputPath, + [string]$AppDisplayVersion, + [string]$AppBuildNumber + ) + + # Secret-gated. Only runs when an ad-hoc distribution provisioning profile was installed + # (Install-AppleSigningAssets.ps1). Produces an ad-hoc-signed IPA that installs directly on + # registered devices. The App Store IPA fails direct install with "Attempted to install a + # Beta profile without the proper entitlement", so it stays TestFlight-only. + $adhocProvision = [Environment]::GetEnvironmentVariable("IOS_ADHOC_CODESIGN_PROVISION") + if ([string]::IsNullOrWhiteSpace($adhocProvision)) { + Write-Host "No ad-hoc provisioning profile provided; skipping the sideloadable iOS IPA." + return $null + } + + try { + $adhocKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" + $adhocOutput = Join-Path $OutputPath "adhoc" + New-Item -ItemType Directory -Path $adhocOutput -Force | Out-Null + + $adhocArgs = @( + "publish", $ProjectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "-p:BuildIpa=true", + "-p:ArchiveOnBuild=true", + "-p:CodesignKey=$adhocKey", + "-p:CodesignProvision=$adhocProvision", + "-o", $adhocOutput + ) + + if (Test-IsNet11OrLater $TargetFramework) { + $adhocArgs += "-p:UseMonoRuntime=false" + $adhocArgs = Add-NativeAotArguments $adhocArgs + } + + Write-Host "Building ad-hoc iOS IPA (sideloadable on registered devices) for $($ProjectFile.FullName)" + Invoke-DotNetPublish $adhocArgs "iOS ad-hoc publish" + + $adhocIpa = Get-NewestBuildOutput $adhocOutput "*.ipa" + if (-not $adhocIpa) { $adhocIpa = Get-NewestBuildOutput $ProjectFile.DirectoryName "*.ipa" } + if (-not $adhocIpa) { + Write-Warning "Ad-hoc publish did not produce an IPA; skipping the sideloadable iOS artifact." + return $null + } + + Write-Host "Ad-hoc iOS sideload artifact: $($adhocIpa.FullName)" + return $adhocIpa + } catch { + Write-Warning "Ad-hoc iOS sideload build failed: $($_.Exception.Message). The App Store IPA (TestFlight) build is unaffected." + return $null + } +} + +$projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 +if (-not $projectFile) { + throw "No project file was found in '$ProjectPath'." +} + +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +$binlogPath = if ($CreateBinlog) { Join-Path $OutputPath "build.binlog" } else { $null } +$binlogArguments = if ($CreateBinlog) { @("/bl:$binlogPath") } else { @() } + +# $package => the "store" package (aab/ipa/pkg/zip) consumed by the Play/TestFlight steps. +# $sideloadPackage => a directly-installable artifact for testers (apk / ad-hoc ipa / notarized app). +# When no distinct sideload artifact exists it falls back to $package on emit. +$sideloadPackage = $null + +switch ($Platform) { + "android" { + $commonArgs = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber" + ) + + # Signing arguments shared by the APK (sideload) and AAB (Play) builds. + if ($Publish) { + $keystorePath = $env:ANDROID_KEYSTORE_PATH + if ([string]::IsNullOrWhiteSpace($keystorePath)) { + $keystoreBase64 = Assert-EnvironmentValue "ANDROID_KEYSTORE_BASE64" + $keystorePath = Join-Path $env:RUNNER_TEMP "template-app-distribution.keystore" + Write-Base64File $keystoreBase64 $keystorePath + } + + $env:ANDROID_SIGNING_STORE_PASS = Assert-EnvironmentValue "ANDROID_KEYSTORE_PASSWORD" + $env:ANDROID_SIGNING_KEY_PASS = if ([string]::IsNullOrWhiteSpace($env:ANDROID_KEY_PASSWORD)) { + $env:ANDROID_SIGNING_STORE_PASS + } else { + $env:ANDROID_KEY_PASSWORD + } + + $keyAlias = Assert-EnvironmentValue "ANDROID_KEY_ALIAS" + $keystoreType = [Environment]::GetEnvironmentVariable("ANDROID_KEYSTORE_TYPE") + + $signingArgs = @( + "-p:AndroidKeyStore=true", + "-p:AndroidSigningKeyStore=$keystorePath", + "-p:AndroidSigningKeyAlias=$keyAlias", + "-p:AndroidSigningStorePass=env:ANDROID_SIGNING_STORE_PASS", + "-p:AndroidSigningKeyPass=env:ANDROID_SIGNING_KEY_PASS" + ) + + if (-not [string]::IsNullOrWhiteSpace($keystoreType)) { + $signingArgs += "-p:AndroidSigningStoreType=$keystoreType" + } + } else { + # A debug-signed APK is directly installable on devices/emulators for testing. + $signingArgs = @("-p:AndroidKeyStore=false") + } + + # 1) Always build an installable APK. This is what testers sideload; an .aab cannot be + # installed directly (only Google Play can consume it), which is why the previous + # artifact ZIP had nothing installable in it. + $apkOutput = Join-Path $OutputPath "apk" + New-Item -ItemType Directory -Path $apkOutput -Force | Out-Null + $apkArgs = $commonArgs + @("-p:AndroidPackageFormat=apk", "-o", $apkOutput) + $signingArgs + $binlogArguments + + Write-Host "Building installable Android APK for $($projectFile.FullName)" + Invoke-DotNetPublish $apkArgs "Android APK publish" + + $apkPackage = Get-NewestBuildOutput $apkOutput "*-Signed.apk" + if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $apkOutput "*.apk" } + if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $ProjectPath "*-Signed.apk" } + if (-not $apkPackage) { $apkPackage = Get-NewestBuildOutput $ProjectPath "*.apk" } + $sideloadPackage = $apkPackage + + if ($Publish) { + # 2) Also build an .aab for the Google Play upload step. + $aabOutput = Join-Path $OutputPath "aab" + New-Item -ItemType Directory -Path $aabOutput -Force | Out-Null + $aabArgs = $commonArgs + @("-p:AndroidPackageFormat=aab", "-o", $aabOutput) + $signingArgs + + Write-Host "Building Android App Bundle (Google Play) for $($projectFile.FullName)" + Invoke-DotNetPublish $aabArgs "Android AAB publish" + + $package = Get-NewestBuildOutput $aabOutput "*.aab" + if (-not $package) { $package = Get-NewestBuildOutput $ProjectPath "*.aab" } + } else { + # Dry-run: the installable APK is the primary artifact. + $package = $apkPackage + } + } + + "ios" { + if ($Publish) { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false" + ) + $binlogArguments + + if (Test-IsNet11OrLater $TargetFramework) { + $arguments += "-p:UseMonoRuntime=false" + $arguments = Add-NativeAotArguments $arguments + } + + $codesignKey = Assert-EnvironmentValue "IOS_CODESIGN_KEY" + $codesignProvision = Assert-EnvironmentValue "IOS_CODESIGN_PROVISION" + $arguments += @( + "-p:BuildIpa=true", + "-p:ArchiveOnBuild=true", + "-p:CodesignKey=$codesignKey", + "-p:CodesignProvision=$codesignProvision", + "-o", $OutputPath + ) + + Write-Host "Building iOS package for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "iOS publish" + + $package = Get-NewestBuildOutput $ProjectPath "*.ipa" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.ipa" + } + + $sideloadPackage = New-IosAdHocSideload ` + -ProjectFile $projectFile ` + -TargetFramework $TargetFramework ` + -Configuration $Configuration ` + -RuntimeIdentifier $RuntimeIdentifier ` + -OutputPath $OutputPath ` + -AppDisplayVersion $AppDisplayVersion ` + -AppBuildNumber $AppBuildNumber + } else { + # A dry-run has no signing secrets, so an unsigned *device* (ios-arm64, + # iPhoneOS) .app can neither install on hardware nor launch in the Simulator. + # Build a Simulator app instead so testers can actually run it. `dotnet publish` + # rejects simulator RIDs, so use `dotnet build` + iossimulator-arm64 (macos-15 + # runners and Apple Silicon testers are arm64). Physical-device installs require + # the secret-gated ad-hoc IPA path above. + $simulatorRuntimeIdentifier = "iossimulator-arm64" + $arguments = @( + "build", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $simulatorRuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "-p:_RequireCodeSigning=false", + "-p:EnableCodeSigning=false", + "-p:CodesignKey=-", + "-p:BuildIpa=false" + ) + $binlogArguments + + if (Test-IsNet11OrLater $TargetFramework) { + # net11+ iOS can't build with Mono (NETSDK1242). Use CoreCLR (JIT in the + # Simulator); NativeAOT is device-only and isn't needed for a dry-run. + $arguments += "-p:UseMonoRuntime=false" + } + + Write-Host "Building iOS Simulator app for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "iOS simulator build" + + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Repair-AppleAdhocSignature $appBundle.FullName + Compress-AppBundle $appBundle.FullName $zipPath + $package = Get-Item $zipPath + } + } + } + + "maccatalyst" { + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:MtouchLink=SdkOnly", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false" + ) + $binlogArguments + + $useNet11OrLater = Test-IsNet11OrLater $TargetFramework + if ($useNet11OrLater) { + $arguments += "-p:UseMonoRuntime=false" + } + + if (-not [string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { + $arguments += @("-r", $RuntimeIdentifier) + } elseif ($useNet11OrLater) { + # net11+ Mac Catalyst cannot publish the SDK's default universal + # RuntimeIdentifiers=maccatalyst-x64;maccatalyst-arm64 unattended: the multi-RID + # publish trips NETSDK "PublishReadyToRun couldn't be inferred". Pin a single RID. + # arm64 (the original forced x64) runs NATIVELY on Apple Silicon with no Rosetta, + # which is exactly what the crashing M2 (Mac14,7) tester needs. + $arguments += @("-r", "maccatalyst-arm64") + } + + if ($Publish) { + $codesignKey = Assert-EnvironmentValue "APPLE_CODESIGN_KEY" + $codesignProvision = Assert-EnvironmentValue "APPLE_CODESIGN_PROVISION" + $packageSigningKey = Assert-EnvironmentValue "APPLE_PACKAGE_SIGNING_KEY" + # App Store profiles include get-task-allow=false; the SDK validator still warns on that key for Mac Catalyst. + $arguments += @( + "-p:CreatePackage=true", + "-p:EnableCodeSigning=true", + "-p:EnablePackageSigning=true", + "-p:ValidateEntitlements=disable", + "-p:CodesignKey=$codesignKey", + "-p:CodesignProvision=$codesignProvision", + "-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.plist", + "-p:PackageSigningKey=$packageSigningKey", + "-o", $OutputPath + ) + } else { + $arguments += @( + "-p:CreatePackage=false", + "-p:_RequireCodeSigning=false", + "-p:EnableCodeSigning=false", + "-p:CodesignKey=-", + "-o", $OutputPath + ) + } + + Write-Host "Building Mac Catalyst package for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "Mac Catalyst publish" + + if ($Publish) { + $package = Get-NewestBuildOutput $ProjectPath "*.pkg" + if (-not $package) { + $package = Get-NewestBuildOutput $OutputPath "*.pkg" + } + + $sideloadPackage = New-MacCatalystDeveloperIdSideload ` + -ProjectFile $projectFile ` + -TargetFramework $TargetFramework ` + -Configuration $Configuration ` + -OutputPath $OutputPath ` + -AppDisplayVersion $AppDisplayVersion ` + -AppBuildNumber $AppBuildNumber ` + -RuntimeIdentifier $RuntimeIdentifier ` + -UseNet11OrLater:$useNet11OrLater + } else { + $appBundle = Get-NewestBuildOutput $OutputPath "*.app" -Directory + if (-not $appBundle) { + $appBundle = Get-NewestBuildOutput $ProjectPath "*.app" -Directory + } + + if ($appBundle) { + $zipPath = Join-Path $OutputPath "$($appBundle.Name).zip" + Repair-AppleAdhocSignature $appBundle.FullName + Compress-AppBundle $appBundle.FullName $zipPath + $package = Get-Item $zipPath + } + } + } + + "windows" { + $publishOutputPath = Join-Path $OutputPath "publish" + Remove-Item -Path $publishOutputPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $publishOutputPath -Force | Out-Null + + $arguments = @( + "publish", $projectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-p:RuntimeIdentifierOverride=$RuntimeIdentifier", + "-p:WindowsPackageType=None", + "-p:WindowsAppSDKSelfContained=true", + "-p:SelfContained=true", + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-o", $publishOutputPath + ) + $binlogArguments + + Write-Host "Building Windows unpackaged app for $($projectFile.FullName)" + Invoke-DotNetPublish $arguments "Windows unpackaged publish" + + $zipPath = Join-Path $OutputPath "$($projectFile.BaseName)-windows-unpackaged.zip" + Remove-Item -Path $zipPath -Force -ErrorAction SilentlyContinue + Compress-Archive -Path (Join-Path $publishOutputPath "*") -DestinationPath $zipPath -Force + $package = Get-Item $zipPath + } +} + +if (-not $package) { + throw "Build completed but no package artifact was found for platform '$Platform'." +} + +Write-Host "Package artifact: $($package.FullName)" +$sideloadResolved = if ($sideloadPackage) { $sideloadPackage.FullName } else { $package.FullName } +Write-Host "Sideload artifact: $sideloadResolved" +if ($CreateBinlog) { + Write-Host "Build binlog: $binlogPath" +} + +if ($env:GITHUB_OUTPUT) { + "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT + "sideload_package_path=$sideloadResolved" >> $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..8ff559e1a1b3 --- /dev/null +++ b/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1 @@ -0,0 +1,274 @@ +#!/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'" +} + +# Optional (secret-gated): Developer ID Application identity + provisioning profile so we can +# also produce a notarizable, directly-launchable macOS app (the Mac App Store .pkg cannot be +# launched outside the store). +$developerIdIdentity = $null +$developerIdProvisionValue = $null +if ($Platform -eq "maccatalyst" -and -not [string]::IsNullOrWhiteSpace($env:APPLE_DEVELOPERID_CERTIFICATE_BASE64)) { + $developerIdCertPath = Join-Path $tempDirectory "developer-id-certificate.p12" + Write-Base64File $env:APPLE_DEVELOPERID_CERTIFICATE_BASE64 $developerIdCertPath + $developerIdCertPassword = if ([string]::IsNullOrWhiteSpace($env:APPLE_DEVELOPERID_CERTIFICATE_PASSWORD)) { + $certificatePassword + } else { + $env:APPLE_DEVELOPERID_CERTIFICATE_PASSWORD + } + + & security import $developerIdCertPath -k $keychainPath -P $developerIdCertPassword -T /usr/bin/codesign -T /usr/bin/security + & security set-key-partition-list -S apple-tool:,apple: -s -k $keychainPassword $keychainPath | Out-Null + + $developerIdIdentity = (Get-SecurityIdentities @("-v")) | + Where-Object { $_ -match "Developer ID Application" } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($developerIdIdentity)) { + throw "APPLE_DEVELOPERID_CERTIFICATE_BASE64 was provided but no 'Developer ID Application' identity was found in it." + } + + if ([string]::IsNullOrWhiteSpace($env:APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64)) { + throw "APPLE_DEVELOPERID_CERTIFICATE_BASE64 also requires APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64 (a Developer ID Mac Catalyst provisioning profile)." + } + + $developerIdProfilePath = Join-Path $tempDirectory "$Variant-developerid.provisionprofile" + Write-Base64File $env:APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64 $developerIdProfilePath + $developerIdPlistPath = Join-Path $tempDirectory "$Variant-developerid.plist" + & security cms -D -i $developerIdProfilePath | Out-File -FilePath $developerIdPlistPath -Encoding utf8 + $developerIdUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $developerIdPlistPath).Trim() + if ([string]::IsNullOrWhiteSpace($developerIdUuid)) { + throw "Could not read UUID from the Developer ID provisioning profile." + } + + Copy-Item -Path $developerIdProfilePath -Destination (Join-Path $profilesDirectory "$developerIdUuid.provisionprofile") -Force + $developerIdProvisionValue = $developerIdUuid + Write-Host "Installed Developer ID provisioning profile ($developerIdUuid)" + Write-Host "Using Developer ID signing identity '$developerIdIdentity'" +} + +# Optional (secret-gated): ad-hoc distribution provisioning profile so we can also produce a +# directly-installable IPA (the App Store IPA cannot be sideloaded). Reuses the Apple +# Distribution certificate already imported above. +$adhocProvisionValue = $null +if ($Platform -eq "ios" -and -not [string]::IsNullOrWhiteSpace($env:APPLE_ADHOC_PROVISIONING_PROFILE_BASE64)) { + $adhocProfilePath = Join-Path $tempDirectory "$Variant-adhoc.mobileprovision" + Write-Base64File $env:APPLE_ADHOC_PROVISIONING_PROFILE_BASE64 $adhocProfilePath + $adhocPlistPath = Join-Path $tempDirectory "$Variant-adhoc.plist" + & security cms -D -i $adhocProfilePath | Out-File -FilePath $adhocPlistPath -Encoding utf8 + $adhocUuid = (& /usr/libexec/PlistBuddy -c "Print :UUID" $adhocPlistPath).Trim() + $adhocName = (& /usr/libexec/PlistBuddy -c "Print :Name" $adhocPlistPath).Trim() + if ([string]::IsNullOrWhiteSpace($adhocUuid) -or [string]::IsNullOrWhiteSpace($adhocName)) { + throw "Could not read UUID/Name from the ad-hoc provisioning profile." + } + + Copy-Item -Path $adhocProfilePath -Destination (Join-Path $profilesDirectory "$adhocUuid.mobileprovision") -Force + $adhocProvisionValue = $adhocName + Write-Host "Installed ad-hoc provisioning profile '$adhocName' ($adhocUuid)" +} + +if ($env:GITHUB_ENV) { + "IOS_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV + "IOS_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV + "APPLE_CODESIGN_KEY=$codesignIdentity" >> $env:GITHUB_ENV + "APPLE_CODESIGN_PROVISION=$codesignProvision" >> $env:GITHUB_ENV + if ($Platform -eq "maccatalyst") { + "APPLE_PACKAGE_SIGNING_KEY=$packageSigningIdentity" >> $env:GITHUB_ENV + } + if (-not [string]::IsNullOrWhiteSpace($developerIdIdentity)) { + "APPLE_DEVELOPERID_CODESIGN_KEY=$developerIdIdentity" >> $env:GITHUB_ENV + "APPLE_DEVELOPERID_CODESIGN_PROVISION=$developerIdProvisionValue" >> $env:GITHUB_ENV + } + if (-not [string]::IsNullOrWhiteSpace($adhocProvisionValue)) { + "IOS_ADHOC_CODESIGN_PROVISION=$adhocProvisionValue" >> $env:GITHUB_ENV + } + "IOS_KEYCHAIN_PATH=$keychainPath" >> $env:GITHUB_ENV +} + +if ($env:GITHUB_OUTPUT) { + "codesign_key=$codesignIdentity" >> $env:GITHUB_OUTPUT + "codesign_provision=$codesignProvision" >> $env:GITHUB_OUTPUT + if ($Platform -eq "maccatalyst") { + "package_signing_key=$packageSigningIdentity" >> $env:GITHUB_OUTPUT + } + if (-not [string]::IsNullOrWhiteSpace($developerIdIdentity)) { + "developerid_codesign_key=$developerIdIdentity" >> $env:GITHUB_OUTPUT + "developerid_codesign_provision=$developerIdProvisionValue" >> $env:GITHUB_OUTPUT + } + if (-not [string]::IsNullOrWhiteSpace($adhocProvisionValue)) { + "adhoc_codesign_provision=$adhocProvisionValue" >> $env:GITHUB_OUTPUT + } + "keychain_path=$keychainPath" >> $env:GITHUB_OUTPUT +} diff --git a/.github/scripts/template-app-distribution/New-TemplateApp.ps1 b/.github/scripts/template-app-distribution/New-TemplateApp.ps1 new file mode 100644 index 000000000000..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/README.md b/.github/scripts/template-app-distribution/README.md new file mode 100644 index 000000000000..4370d983607c --- /dev/null +++ b/.github/scripts/template-app-distribution/README.md @@ -0,0 +1,99 @@ +# Template App Distribution + +Builds a fresh .NET MAUI app from the packaged templates for each platform/variant and +either (a) uploads the results as GitHub artifacts (`publish=false`, a **dry run**) or +(b) signs and publishes them to Google Play / TestFlight (`publish=true`). + +The workflow lives in `.github/workflows/template-app-distribution.yml`. Trigger it from the +**Actions** tab with *Run workflow* and pick the source branch (`main`, `net10.0`, `net11.0` +or a `release/*` branch) plus whether to publish. + +## What you get, per platform + +The goal is that **every artifact a tester downloads can actually be installed** without an +App Store / Play account. The build script therefore emits two things: + +- `package_path` — the **store** package (`.aab` / App Store `.ipa` / Mac App Store `.pkg`). + Consumed only by the Google Play / TestFlight upload steps. +- `sideload_package_path` — the **directly installable** artifact. This is what the dry-run + job and the publish "artifact copy" step upload for testers. + +| Platform | Dry-run artifact (`publish=false`) | Publish store target | Sideloadable artifact on publish | +| --- | --- | --- | --- | +| **Android** | Debug-signed **APK** (installs via `adb install` / file manager) | `.aab` → Google Play | Release-signed **APK** | +| **Windows** | **Self-contained** unpackaged zip (no runtime install needed) | same zip | same zip | +| **iOS** | `.app` zip (Simulator) | App Store `.ipa` → TestFlight | ad-hoc `.ipa` *(only if the ad-hoc secret is set — see below)* | +| **macOS (Mac Catalyst)** | Native **arm64** `.app` zip (Apple Silicon) | Mac App Store `.pkg` → TestFlight | notarized `.app` zip *(only if the Developer ID secrets are set — see below)* | + +### Why the previous artifacts failed to install + +- **Android** — only an `.aab` was produced. An `.aab` can *only* be consumed by Google Play, + so the ZIP had nothing to sideload. Fixed by also building an installable APK. +- **Windows** — published framework-dependent, so it needed the exact .NET preview desktop + runtime and still showed the "install .NET" screen. Fixed with `-p:SelfContained=true`. +- **iOS** — two problems. (1) The publish IPA was signed with the App Store / TestFlight profile, + which Apple refuses to install directly (`0xe800801f "Attempted to install a Beta profile without + the proper entitlement"`) — fixed by an optional ad-hoc-signed IPA (secret-gated). (2) The dry-run + `.app` was an unsigned *device* (`ios-arm64`, iPhoneOS) build that installs nowhere: it can't go on + hardware (unsigned) and won't launch in the Simulator (device platform — launch is denied). Fixed by + building an **arm64 iOS Simulator** app (`dotnet build -r iossimulator-arm64`; `dotnet publish` + rejects simulator RIDs) and ad-hoc re-signing it so the Simulator (which enforces code signing on + macOS 15+/26) actually launches it. +- **macOS** — the `.pkg` was Mac App Store signed and defaulted to `maccatalyst-x64` (Rosetta), + so launching it outside the store gave `SIGKILL (Code Signature Invalid)` / + `Taskgated Invalid Signature`. Fixed by shipping a directly-launchable **arm64-native** `.app` + that is (1) zipped with `ditto` so the framework symlinks, exec bits and signature survive the + round-trip, and (2) **re-signed ad-hoc from the inside out** so macOS 15+/26 accepts it (the + stock .NET linker-signed bundle is SIGKILL'd with "Invalid Page" — reproduced on macOS 26.5.2 / + M2). It runs natively on Apple Silicon (the reporting Mac was an M2) with no Rosetta. For a + seamless, notarized experience there is an optional Developer-ID-signed `.app`. (net11 Mac + Catalyst can't publish the SDK's default universal `maccatalyst-x64;maccatalyst-arm64` + unattended — the multi-RID publish trips `PublishReadyToRun couldn't be inferred` — so a single + native RID is pinned; Intel Macs would need a separate `maccatalyst-x64` build.) + +## Install instructions for testers + +- **Android** — download the APK, then `adb install app.apk` (or copy to the device and open + it; enable "install unknown apps"). The dry-run APK is debug-signed and installs on any + device/emulator. +- **Windows** — unzip and run the `.exe`. Because the app is self-contained no .NET runtime + install is required. (SmartScreen may warn for an unsigned app — *More info → Run anyway*.) +- **iOS** — unzip and run the `.app` in the iOS **Simulator**: + `xcrun simctl install booted MyApp.app && xcrun simctl launch booted `. The dry-run + build targets the **arm64 Simulator** (Apple Silicon) and is ad-hoc re-signed so it launches; + the Simulator accepts ad-hoc signatures directly. Installing on a **physical device** requires the + ad-hoc IPA (secret-gated, below) with the device UDID registered in the ad-hoc profile. +- **macOS** — the dry-run `.app` is **ad-hoc signed** (not notarized), so Gatekeeper blocks it on + first launch. Clear quarantine and open it: + `xattr -dr com.apple.quarantine "MyApp.app"` then double-click — **or** double-click, dismiss the + warning, and approve it under *System Settings → Privacy & Security → Open Anyway*. (The bundle is + re-signed ad-hoc during the build; without that, macOS 15+/26 SIGKILLs it at launch with "Code + Signature Invalid".) A double-click-clean, launch-anywhere build for other users requires the + notarized artifact (secret-gated, below). + +## Secrets & variables + +The header of `template-app-distribution.yml` is the source of truth. Summary: + +**Required for `publish=true`** (protected `template-app-distribution` environment): the Android +keystore, the Google Play service account JSON, the Apple distribution certificate, the App Store +/ Mac App Store provisioning profiles, and the App Store Connect API key. `publish=false` needs +**none** of these — it produces the installable Android APK and self-contained Windows zip +immediately. + +**Optional — enable the sideloadable iOS / macOS artifacts:** + +- `TEMPLATE_APP_{BLANK,SAMPLE}_IOS_ADHOC_PROVISIONING_PROFILE_BASE64` — ad-hoc distribution + provisioning profiles (they reuse the existing Apple Distribution certificate). With these set, + `publish=true` also produces an installable ad-hoc `.ipa`. +- `TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64` / + `TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD` — a **Developer ID + Application** signing certificate (`.p12`). +- `TEMPLATE_APP_{BLANK,SAMPLE}_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64` — Developer ID + Mac Catalyst provisioning profiles. With these set, `publish=true` also produces a + Developer-ID-signed, **notarized** `.app` zip that launches on any Mac. Notarization reuses the + existing `TEMPLATE_APPSTORE_CONNECT_*` API key. + +If the optional Apple secrets are absent, the workflow still succeeds and simply falls back to +uploading the store `.ipa` / `.pkg` (which stay TestFlight-only). No secret is ever required for +the Android and Windows fixes. 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..fb15d83b5acb --- /dev/null +++ b/.github/workflows/template-app-distribution.yml @@ -0,0 +1,749 @@ +# 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) +# +# Optional secrets that produce directly-installable (sideloadable) artifacts on publish=true. +# Without these, publish=true still uploads the store packages (App Store IPA / Mac App Store .pkg), +# which are TestFlight-only and cannot be installed directly. Add them to also get a sideloadable +# ad-hoc IPA (iOS) and a notarized .app zip (macOS): +# - TEMPLATE_APP_BLANK_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 (secrets): +# ad-hoc distribution provisioning profiles (reuse the existing Apple Distribution certificate). +# - TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 / TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD (secrets): +# a "Developer ID Application" signing certificate (.p12) for notarized, launch-anywhere macOS apps. +# - TEMPLATE_APP_BLANK_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 / TEMPLATE_APP_SAMPLE_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 (secrets): +# Developer ID Mac Catalyst provisioning profiles. Notarization reuses TEMPLATE_APPSTORE_CONNECT_* above. +# - TEMPLATE_APP_ANDROID_KEYSTORE_TYPE (variable): optional keystore type, for example pkcs12. +# - TEMPLATE_APP_PLAY_TRACK (variable): defaults to internal. +# - TEMPLATE_APP_PLAY_RELEASE_STATUS (variable): defaults to completed; use draft for first uploads to draft Play apps. +# - TEMPLATE_APP_TESTFLIGHT_GROUPS (variable): defaults to no explicit group distribution. +# - TEMPLATE_APP_REPLACE_WAITING_TESTFLIGHT_REVIEW (variable): set to true to reject a prior waiting Beta App Review build and submit the latest one. +# - TEMPLATE_APP_TESTFLIGHT_WAIT_TIMEOUT_SECONDS (variable): defaults to 2700 seconds. +# Store uploads use fastlane lanes from .github/scripts/template-app-distribution/fastlane. + +name: Template App Distribution + +run-name: Template app distribution (${{ inputs.source_ref }}, publish=${{ inputs.publish }}) + +on: + workflow_dispatch: + inputs: + source_ref: + description: Branch, tag, or full commit SHA to use for template source. + required: true + default: main + type: string + publish: + description: Publish to Google Play/TestFlight. If false, only build and upload GitHub artifacts. + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ inputs.source_ref }}-${{ inputs.publish }} + cancel-in-progress: false + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + FASTLANE_OPT_OUT_USAGE: "1" + +jobs: + 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.sideload_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 }} + APPLE_DEVELOPERID_CERTIFICATE_BASE64: ${{ secrets.TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPERID_CERTIFICATE_PASSWORD: ${{ secrets.TEMPLATE_APP_MAC_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }} + APPLE_DEVELOPERID_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'maccatalyst' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'maccatalyst' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_MACCATALYST_DEVELOPERID_PROVISIONING_PROFILE_BASE64 || '' }} + APPLE_ADHOC_PROVISIONING_PROFILE_BASE64: ${{ matrix.platform == 'ios' && matrix.variant == 'blank' && secrets.TEMPLATE_APP_BLANK_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 || matrix.platform == 'ios' && matrix.variant == 'sample' && secrets.TEMPLATE_APP_SAMPLE_IOS_ADHOC_PROVISIONING_PROFILE_BASE64 || '' }} + run: | + & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/Install-AppleSigningAssets.ps1" ` + -Variant "${{ 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 }} + APPSTORE_CONNECT_ISSUER_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_ISSUER_ID }} + APPSTORE_CONNECT_KEY_ID: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_KEY_ID }} + APPSTORE_CONNECT_PRIVATE_KEY: ${{ secrets.TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY }} + run: | + & "${{ 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.sideload_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.sideload_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" From 3ec1350d5eebd84c561b0b29add4716e0661ac4f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:12:24 +0200 Subject: [PATCH 02/16] iOS dry-run: also emit an unsigned device .ipa for sideloading The iOS dry-run only produced a Simulator .app.zip, so testers reported there was no .ipa to install on a real iPhone/iPad. Add a best-effort unsigned ios-arm64 device build wrapped as a Payload/*.app .ipa, which testers can install via AltStore/Sideloadly (re-signed with their own Apple ID). The Simulator app is still uploaded as an additional artifact for Mac-only smoke testing, and the device IPA build is wrapped in try/catch so a failure never regresses the existing Simulator artifact. A directly-installable device build still requires the secret-gated ad-hoc IPA or TestFlight publish path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 92e506ca-933d-4c5b-ab06-62cf471e259c --- .../Build-TemplateApp.ps1 | 133 +++++++++++++++++- .../template-app-distribution/README.md | 28 +++- .../workflows/template-app-distribution.yml | 1 + 3 files changed, 149 insertions(+), 13 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index e393cd9c2197..214fcfef3aed 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -307,6 +307,95 @@ function New-IosAdHocSideload { } } +function New-IosUnsignedDeviceIpa { + param( + [System.IO.FileInfo]$ProjectFile, + [string]$TargetFramework, + [string]$Configuration, + [string]$RuntimeIdentifier, + [string]$OutputPath, + [string]$AppDisplayVersion, + [string]$AppBuildNumber + ) + + # A dry-run has no Apple signing secrets, so we cannot produce an IPA that installs + # *directly* on a device (that needs an ad-hoc profile listing the device UDID, or + # TestFlight - both live on the secret-gated publish path). We can still build the + # unsigned device (iphoneos/arm64) .app and wrap it as a Payload/*.app IPA so a tester + # can install it with AltStore or Sideloadly, which re-signs the app with the tester's + # own Apple ID. Without this the iOS dry-run only produced a Simulator .app - i.e. there + # was no .ipa in the artifact at all, which is exactly what testers reported missing. + try { + if ([string]::IsNullOrWhiteSpace($RuntimeIdentifier)) { + $RuntimeIdentifier = "ios-arm64" + } + + $deviceArgs = @( + "build", $ProjectFile.FullName, + "-f", $TargetFramework, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:ApplicationDisplayVersion=$AppDisplayVersion", + "-p:ApplicationVersion=$AppBuildNumber", + "-p:ValidateXcodeVersion=false", + "-p:EnableCodeSigning=false", + "-p:_RequireCodeSigning=false", + "-p:CodesignKey=-", + "-p:BuildIpa=false" + ) + + if (Test-IsNet11OrLater $TargetFramework) { + # net11+ iOS can't build with Mono (NETSDK1242); use CoreCLR. NativeAOT is not + # needed for an unsigned, sideload-only artifact. + $deviceArgs += "-p:UseMonoRuntime=false" + } + + Write-Host "Building unsigned iOS device app (for a sideloadable IPA) for $($ProjectFile.FullName)" + Invoke-DotNetPublish $deviceArgs "iOS unsigned device build" + + $ridEscaped = [regex]::Escape($RuntimeIdentifier) + $deviceApp = Get-ChildItem -Path $ProjectFile.DirectoryName -Filter "*.app" -Recurse -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "[\\/]$ridEscaped[\\/]" -and $_.FullName -notmatch "[\\/]obj[\\/]" } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if (-not $deviceApp) { + Write-Warning "Unsigned iOS device build did not produce a device .app; skipping the sideloadable IPA." + return $null + } + + $stageRoot = Join-Path $OutputPath "device-ipa" + Remove-Item -Path $stageRoot -Recurse -Force -ErrorAction SilentlyContinue + $payloadDir = Join-Path $stageRoot "Payload" + New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null + + $stagedApp = Join-Path $payloadDir $deviceApp.Name + if (Get-Command ditto -ErrorAction SilentlyContinue) { + & ditto $deviceApp.FullName $stagedApp + if ($LASTEXITCODE -ne 0) { throw "ditto failed to stage the device .app (exit $LASTEXITCODE)." } + } else { + Copy-Item -Path $deviceApp.FullName -Destination $stagedApp -Recurse -Force + } + + # An IPA is a zip whose root contains Payload/.app. --keepParent embeds the + # "Payload" directory as the top-level entry, giving a valid IPA layout. + $ipaPath = Join-Path $OutputPath "$($deviceApp.BaseName).ipa" + Remove-Item -Path $ipaPath -Force -ErrorAction SilentlyContinue + if (Get-Command ditto -ErrorAction SilentlyContinue) { + & ditto -c -k --keepParent $payloadDir $ipaPath + if ($LASTEXITCODE -ne 0) { throw "ditto failed to archive the IPA (exit $LASTEXITCODE)." } + } else { + Compress-Archive -Path $payloadDir -DestinationPath $ipaPath -Force + } + + Write-Host "Unsigned iOS device IPA (install with AltStore/Sideloadly): $ipaPath" + return Get-Item $ipaPath + } catch { + Write-Warning "Unsigned iOS device IPA build failed: $($_.Exception.Message). The Simulator app artifact is unaffected." + return $null + } +} + $projectFile = Get-ChildItem -Path $ProjectPath -Filter "*.csproj" -Recurse | Select-Object -First 1 if (-not $projectFile) { throw "No project file was found in '$ProjectPath'." @@ -319,7 +408,10 @@ $binlogArguments = if ($CreateBinlog) { @("/bl:$binlogPath") } else { @() } # $package => the "store" package (aab/ipa/pkg/zip) consumed by the Play/TestFlight steps. # $sideloadPackage => a directly-installable artifact for testers (apk / ad-hoc ipa / notarized app). # When no distinct sideload artifact exists it falls back to $package on emit. +# $additionalPackage => an optional extra artifact uploaded alongside the sideload one (e.g. the iOS +# Simulator .app.zip that accompanies the device .ipa on a dry-run). $sideloadPackage = $null +$additionalPackage = $null switch ($Platform) { "android" { @@ -443,12 +535,16 @@ switch ($Platform) { -AppDisplayVersion $AppDisplayVersion ` -AppBuildNumber $AppBuildNumber } else { - # A dry-run has no signing secrets, so an unsigned *device* (ios-arm64, - # iPhoneOS) .app can neither install on hardware nor launch in the Simulator. - # Build a Simulator app instead so testers can actually run it. `dotnet publish` - # rejects simulator RIDs, so use `dotnet build` + iossimulator-arm64 (macos-15 - # runners and Apple Silicon testers are arm64). Physical-device installs require - # the secret-gated ad-hoc IPA path above. + # A dry-run has no signing secrets. We produce two complementary iOS artifacts: + # + # 1. A Simulator app (.app.zip) - runnable in the iOS Simulator on any Mac, so a + # maintainer can smoke-test the build with no device. `dotnet publish` rejects + # simulator RIDs, so use `dotnet build` + iossimulator-arm64 (macos-15 runners + # and Apple Silicon testers are arm64). + # 2. An unsigned device IPA (.ipa) - what testers install on real hardware via + # AltStore/Sideloadly (which re-signs with their own Apple ID). A *directly* + # installable IPA needs an ad-hoc profile with the device UDID, or TestFlight, + # both of which are on the secret-gated publish path. $simulatorRuntimeIdentifier = "iossimulator-arm64" $arguments = @( "build", $projectFile.FullName, @@ -479,6 +575,25 @@ switch ($Platform) { Repair-AppleAdhocSignature $appBundle.FullName Compress-AppBundle $appBundle.FullName $zipPath $package = Get-Item $zipPath + $sideloadPackage = $package + } + + # Best-effort: also build the unsigned device IPA testers asked for. If it fails, + # the Simulator app above is still uploaded, so the dry-run never regresses. + $deviceIpa = New-IosUnsignedDeviceIpa ` + -ProjectFile $projectFile ` + -TargetFramework $TargetFramework ` + -Configuration $Configuration ` + -RuntimeIdentifier $RuntimeIdentifier ` + -OutputPath $OutputPath ` + -AppDisplayVersion $AppDisplayVersion ` + -AppBuildNumber $AppBuildNumber + + if ($deviceIpa) { + # The installable IPA becomes the primary sideload artifact; keep the Simulator + # app as an additional upload for Mac-only smoke testing. + $additionalPackage = $package + $sideloadPackage = $deviceIpa } } } @@ -604,6 +719,9 @@ if (-not $package) { Write-Host "Package artifact: $($package.FullName)" $sideloadResolved = if ($sideloadPackage) { $sideloadPackage.FullName } else { $package.FullName } Write-Host "Sideload artifact: $sideloadResolved" +if ($additionalPackage) { + Write-Host "Additional artifact: $($additionalPackage.FullName)" +} if ($CreateBinlog) { Write-Host "Build binlog: $binlogPath" } @@ -611,6 +729,9 @@ if ($CreateBinlog) { if ($env:GITHUB_OUTPUT) { "package_path=$($package.FullName)" >> $env:GITHUB_OUTPUT "sideload_package_path=$sideloadResolved" >> $env:GITHUB_OUTPUT + if ($additionalPackage) { + "additional_package_path=$($additionalPackage.FullName)" >> $env:GITHUB_OUTPUT + } if ($CreateBinlog) { "binlog_path=$binlogPath" >> $env:GITHUB_OUTPUT } diff --git a/.github/scripts/template-app-distribution/README.md b/.github/scripts/template-app-distribution/README.md index 4370d983607c..fd8afb326535 100644 --- a/.github/scripts/template-app-distribution/README.md +++ b/.github/scripts/template-app-distribution/README.md @@ -17,12 +17,14 @@ App Store / Play account. The build script therefore emits two things: Consumed only by the Google Play / TestFlight upload steps. - `sideload_package_path` — the **directly installable** artifact. This is what the dry-run job and the publish "artifact copy" step upload for testers. +- `additional_package_path` — an optional extra file uploaded next to the sideload one. Used on + iOS to include the Simulator `.app` zip alongside the device `.ipa`. | Platform | Dry-run artifact (`publish=false`) | Publish store target | Sideloadable artifact on publish | | --- | --- | --- | --- | | **Android** | Debug-signed **APK** (installs via `adb install` / file manager) | `.aab` → Google Play | Release-signed **APK** | | **Windows** | **Self-contained** unpackaged zip (no runtime install needed) | same zip | same zip | -| **iOS** | `.app` zip (Simulator) | App Store `.ipa` → TestFlight | ad-hoc `.ipa` *(only if the ad-hoc secret is set — see below)* | +| **iOS** | unsigned device **`.ipa`** (AltStore/Sideloadly) + Simulator `.app` zip | App Store `.ipa` → TestFlight | ad-hoc `.ipa` *(only if the ad-hoc secret is set — see below)* | | **macOS (Mac Catalyst)** | Native **arm64** `.app` zip (Apple Silicon) | Mac App Store `.pkg` → TestFlight | notarized `.app` zip *(only if the Developer ID secrets are set — see below)* | ### Why the previous artifacts failed to install @@ -38,7 +40,10 @@ App Store / Play account. The build script therefore emits two things: hardware (unsigned) and won't launch in the Simulator (device platform — launch is denied). Fixed by building an **arm64 iOS Simulator** app (`dotnet build -r iossimulator-arm64`; `dotnet publish` rejects simulator RIDs) and ad-hoc re-signing it so the Simulator (which enforces code signing on - macOS 15+/26) actually launches it. + macOS 15+/26) actually launches it. The dry-run **also** wraps an unsigned `ios-arm64` device + build as a `Payload/*.app` **`.ipa`** so testers who want to run on real hardware have an IPA to + sideload with AltStore/Sideloadly (which re-sign it with their own Apple ID). A *directly* + installable device build still needs the ad-hoc IPA (secret-gated) or TestFlight. - **macOS** — the `.pkg` was Mac App Store signed and defaulted to `maccatalyst-x64` (Rosetta), so launching it outside the store gave `SIGKILL (Code Signature Invalid)` / `Taskgated Invalid Signature`. Fixed by shipping a directly-launchable **arm64-native** `.app` @@ -58,11 +63,20 @@ App Store / Play account. The build script therefore emits two things: device/emulator. - **Windows** — unzip and run the `.exe`. Because the app is self-contained no .NET runtime install is required. (SmartScreen may warn for an unsigned app — *More info → Run anyway*.) -- **iOS** — unzip and run the `.app` in the iOS **Simulator**: - `xcrun simctl install booted MyApp.app && xcrun simctl launch booted `. The dry-run - build targets the **arm64 Simulator** (Apple Silicon) and is ad-hoc re-signed so it launches; - the Simulator accepts ad-hoc signatures directly. Installing on a **physical device** requires the - ad-hoc IPA (secret-gated, below) with the device UDID registered in the ad-hoc profile. +- **iOS** — the dry-run artifact contains two files: + - **`MyApp.ipa`** — an **unsigned device** build for a **physical iPhone/iPad**. iOS refuses to + run unsigned or ad-hoc code on a device, so install it with **[AltStore](https://altstore.io)** + or **[Sideloadly](https://sideloadly.io)**, which re-sign the app with your own Apple ID (a free + Apple ID works but must be refreshed every 7 days; a paid Developer account lasts a year). A + plain Finder drag / double-click will *not* install an unsigned IPA. + - **`MyApp.app.zip`** — an **arm64 iOS Simulator** build. Unzip and run it in the Simulator: + `xcrun simctl install booted MyApp.app && xcrun simctl launch booted `. It is ad-hoc + re-signed so the Simulator (which enforces code signing on macOS 15+/26) launches it. + + For a **directly installable** device build (no AltStore, no re-signing) use one of the + secret-gated publish paths: **TestFlight** (`publish=true`, the smoothest — testers install from + the TestFlight app, no UDID needed) or the **ad-hoc `.ipa`** (below) with each tester's device + UDID registered in the ad-hoc profile. - **macOS** — the dry-run `.app` is **ad-hoc signed** (not notarized), so Gatekeeper blocks it on first launch. Clear quarantine and open it: `xattr -dr com.apple.quarantine "MyApp.app"` then double-click — **or** double-click, dismiss the diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index fb15d83b5acb..a6ed6470a917 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -352,6 +352,7 @@ jobs: name: template-app-dryrun-${{ matrix.variant }}-${{ matrix.artifactPlatform }}-${{ needs.prepare.outputs.source_sha }} path: | ${{ steps.build.outputs.sideload_package_path }} + ${{ steps.build.outputs.additional_package_path }} ${{ steps.build.outputs.binlog_path }} retention-days: 14 From b203ffa912bd4f0e8484f3c68528dba0b83db898 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:58:32 +0200 Subject: [PATCH 03/16] Address review: trim base64 before decode, pin checkout@v4, pass template args via env - Build-TemplateApp.ps1: Write-Base64File now trims surrounding whitespace/newlines before [Convert]::FromBase64String (secrets often carry a trailing newline, which otherwise throws and breaks keystore/cert materialization). Matches the existing trim pattern used for the Android keystore later in the same script. - template-app-distribution.yml: pin all 6 actions/checkout@v7 -> @v4 to match the repo standard and avoid a runtime 'unresolved action' failure (v7 does not exist). - template-app-distribution.yml: pass matrix.templateArgsJson to New-TemplateApp.ps1 via a TEMPLATE_ARGS_JSON env var ($env:TEMPLATE_ARGS_JSON) in both the dry-run and publish jobs, instead of single-quote-interpolating it into the PowerShell command text. Removes the quoting/command-injection vector if the JSON ever contains a single quote. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../Build-TemplateApp.ps1 | 4 +++- .../workflows/template-app-distribution.yml | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 index 214fcfef3aed..2c4563967dd3 100644 --- a/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 +++ b/.github/scripts/template-app-distribution/Build-TemplateApp.ps1 @@ -43,7 +43,9 @@ function Assert-EnvironmentValue([string]$Name) { } function Write-Base64File([string]$Base64Value, [string]$Path) { - $bytes = [Convert]::FromBase64String($Base64Value) + # Secrets (and env values derived from them) commonly carry a trailing newline; + # FromBase64String throws on any surrounding whitespace, so trim before decoding. + $bytes = [Convert]::FromBase64String($Base64Value.Trim()) [System.IO.File]::WriteAllBytes($Path, $bytes) } diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index a6ed6470a917..0c35551078a7 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -94,14 +94,14 @@ jobs: git config --global advice.defaultBranchName false - name: Checkout workflow scripts - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ inputs.source_ref }} path: source @@ -186,14 +186,14 @@ jobs: git config --global advice.defaultBranchName false - name: Checkout workflow scripts - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.source_sha }} path: source @@ -315,6 +315,10 @@ jobs: - name: Create generated app id: app shell: pwsh + env: + # Pass the (matrix-controlled) template args via env so the JSON is never + # interpolated into the PowerShell command text; avoids quoting/injection issues. + TEMPLATE_ARGS_JSON: ${{ matrix.templateArgsJson }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` @@ -322,7 +326,7 @@ jobs: -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` -ProjectName "${{ matrix.projectName }}" ` -Template "${{ matrix.template }}" ` - -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -TemplateArgsJson $env:TEMPLATE_ARGS_JSON ` -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` -TargetFramework "${{ matrix.targetFramework }}" ` -ApplicationId "${{ matrix.applicationId }}" ` @@ -445,14 +449,14 @@ jobs: } - name: Checkout workflow scripts - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} path: trusted persist-credentials: false - name: Checkout template source - uses: actions/checkout@v7 + uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.source_sha }} path: source @@ -596,6 +600,10 @@ jobs: - name: Create generated app id: app shell: pwsh + env: + # Pass the (matrix-controlled) template args via env so the JSON is never + # interpolated into the PowerShell command text; avoids quoting/injection issues. + TEMPLATE_ARGS_JSON: ${{ matrix.templateArgsJson }} run: | & "${{ github.workspace }}/trusted/.github/scripts/template-app-distribution/New-TemplateApp.ps1" ` -TemplatePackagePath "${{ steps.pack.outputs.template_package_path }}" ` @@ -603,7 +611,7 @@ jobs: -Variant "${{ matrix.variant }}-${{ matrix.platform }}" ` -ProjectName "${{ matrix.projectName }}" ` -Template "${{ matrix.template }}" ` - -TemplateArgsJson '${{ matrix.templateArgsJson }}' ` + -TemplateArgsJson $env:TEMPLATE_ARGS_JSON ` -DotNetTfm "${{ needs.prepare.outputs.dotnet_tfm }}" ` -TargetFramework "${{ matrix.targetFramework }}" ` -ApplicationId "${{ matrix.applicationId }}" ` From 06e4349c6f04563c029329f32096d807936ecece Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:09:48 +0200 Subject: [PATCH 04/16] Address Copilot re-review: docs + validation accuracy - README: iOS dry-run artifact is "one or two files" (unsigned device .ipa is best-effort, so it may be absent, leaving only the Simulator .app.zip). - Header: TEMPLATE_APP_ANDROID_KEY_PASSWORD is Optional (defaults to the keystore password when unset), not Required. - Pre-flight validation no longer hard-requires MAC_INSTALLER_CERTIFICATE_* for maccatalyst: Install-AppleSigningAssets.ps1 accepts an installer identity from any imported cert (the main IOS_CERTIFICATE p12 can already contain one), so requiring the dedicated secret was a false-negative. - App Store Connect private-key check message now says "iOS/Mac Catalyst publishing" (the step runs for both platforms). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .github/scripts/template-app-distribution/README.md | 2 +- .github/workflows/template-app-distribution.yml | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/scripts/template-app-distribution/README.md b/.github/scripts/template-app-distribution/README.md index fd8afb326535..12d9659caa17 100644 --- a/.github/scripts/template-app-distribution/README.md +++ b/.github/scripts/template-app-distribution/README.md @@ -63,7 +63,7 @@ App Store / Play account. The build script therefore emits two things: device/emulator. - **Windows** — unzip and run the `.exe`. Because the app is self-contained no .NET runtime install is required. (SmartScreen may warn for an unsigned app — *More info → Run anyway*.) -- **iOS** — the dry-run artifact contains two files: +- **iOS** — the dry-run artifact contains one or two files (the unsigned device `.ipa` is built on a best-effort basis, so it may be absent — leaving only the Simulator `.app.zip`): - **`MyApp.ipa`** — an **unsigned device** build for a **physical iPhone/iPad**. iOS refuses to run unsigned or ad-hoc code on a device, so install it with **[AltStore](https://altstore.io)** or **[Sideloadly](https://sideloadly.io)**, which re-sign the app with your own Apple ID (a free diff --git a/.github/workflows/template-app-distribution.yml b/.github/workflows/template-app-distribution.yml index 0c35551078a7..8f98dffdb250 100644 --- a/.github/workflows/template-app-distribution.yml +++ b/.github/workflows/template-app-distribution.yml @@ -4,7 +4,6 @@ # - 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 @@ -15,6 +14,7 @@ # - TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY (raw .p8 content or base64) # # Optional variables/secrets: +# - TEMPLATE_APP_ANDROID_KEY_PASSWORD (secret): the signing key password; defaults to the keystore password when unset. # - TEMPLATE_APP_IDENTIFIER_PREFIX (variable): defaults to com..maui.template. # - TEMPLATE_APP_VARIANTS_JSON (variable): adds or overrides variant definitions. # - TEMPLATE_APP_IOS_PROVISIONING_PROFILES_JSON (secret): maps variant names to base64 provisioning profiles. @@ -435,8 +435,10 @@ jobs: $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" + # MAC_INSTALLER_CERTIFICATE_* is intentionally NOT required here: Install-AppleSigningAssets.ps1 + # accepts a "3rd Party Mac Developer Installer" identity from ANY imported cert (the main + # IOS_CERTIFICATE p12 can already contain one) and only throws if no installer identity is + # present after import. Hard-requiring the dedicated secret would be a false-negative failure. Test-RequiredEnvironment "APPSTORE_CONNECT_ISSUER_ID" "TEMPLATE_APPSTORE_CONNECT_ISSUER_ID" Test-RequiredEnvironment "APPSTORE_CONNECT_KEY_ID" "TEMPLATE_APPSTORE_CONNECT_KEY_ID" Test-RequiredEnvironment "APPSTORE_CONNECT_PRIVATE_KEY" "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY" @@ -711,7 +713,7 @@ jobs: 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." + throw "TEMPLATE_APPSTORE_CONNECT_PRIVATE_KEY is required for iOS/Mac Catalyst publishing." } $keyPath = Join-Path $env:RUNNER_TEMP "appstore-connect-key.p8" From 02b19a3c9c9517d64ab673e528622b6048cb3279 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:16:19 +0200 Subject: [PATCH 05/16] Address Copilot re-review: pin upload-artifact@v4 + check dotnet exit codes - actions/upload-artifact@v7 -> @v4 (v7 does not exist; @v4 is the repo standard) in both the dry-run and publish jobs. - Pack-Templates.ps1: throw when `dotnet build`/`dotnet pack` fail. With $ErrorActionPreference='Stop', native-command failures still don't throw in PowerShell, so an earlier failing build could otherwise fall through to a stale package. Matches the explicit $LASTEXITCODE checks in Build-TemplateApp.ps1. - New-TemplateApp.ps1: throw when `dotnet new install` or `dotnet new