From aec73814f15b1243c0949a89536f13c84dc7fd03 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Thu, 5 Nov 2026 14:32:58 -0500 Subject: [PATCH 1/3] Validate native cache inputs and completed outputs --- Readme.md | 52 ++--- common.cake | 131 +++++++++-- components.cake | 48 ++-- custom_externals_download.cake | 26 ++- docs/CI.md | 3 + docs/NATIVE_CACHES.md | 58 +++++ eng/firebase-release.json | 2 +- eng/signin-release.json | 2 +- .../Fixtures/NativeCacheHarness.cs.txt | 102 +++++++++ .../NativeCacheTests.cs | 212 ++++++++++++++++++ .../BindingSurfaceCoverage.cs | 2 +- source/Firebase/ABTesting/ABTesting.csproj | 6 +- source/Firebase/Analytics/Analytics.csproj | 6 +- source/Firebase/AppCheck/AppCheck.csproj | 6 +- .../AppDistribution/AppDistribution.csproj | 6 +- source/Firebase/Auth/Auth.csproj | 6 +- .../CloudFirestore/CloudFirestore.csproj | 6 +- .../CloudFunctions/CloudFunctions.csproj | 6 +- .../CloudMessaging/CloudMessaging.csproj | 6 +- source/Firebase/Core/Core.csproj | 6 +- .../Firebase/Crashlytics/Crashlytics.csproj | 6 +- .../Firebase/Crashlytics/Crashlytics.targets | 4 +- source/Firebase/Database/Database.csproj | 6 +- .../InAppMessaging/InAppMessaging.csproj | 6 +- .../Installations/Installations.csproj | 6 +- .../PerformanceMonitoring.csproj | 6 +- .../Firebase/RemoteConfig/RemoteConfig.csproj | 6 +- source/Firebase/Storage/Storage.csproj | 6 +- .../Google/AppCheckCore/AppCheckCore.csproj | 6 +- .../GTMSessionFetcher.csproj | 6 +- .../GoogleAppMeasurement.csproj | 6 +- .../GoogleDataTransport.csproj | 6 +- .../GoogleUtilities/GoogleUtilities.csproj | 6 +- source/Google/Nanopb/Nanopb.csproj | 6 +- .../Google/PromisesObjC/PromisesObjC.csproj | 6 +- source/Google/SignIn/SignIn.csproj | 6 +- .../FirebaseFoundationE2E.csproj | 4 +- .../binding-surface-coverage.json | 4 +- .../runtime-drift-cases.json | 40 ++-- 39 files changed, 655 insertions(+), 179 deletions(-) create mode 100644 docs/NATIVE_CACHES.md create mode 100644 scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt create mode 100644 scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs diff --git a/Readme.md b/Readme.md index e2e85e9b..1ef29ea4 100644 --- a/Readme.md +++ b/Readme.md @@ -146,7 +146,7 @@ If your project targets multiple platforms, keep these packages inside Apple-onl ```xml - + ``` @@ -172,7 +172,7 @@ The lists below distinguish the required published baseline on [nuget.org](https Firebase and GoogleAppMeasurement `12.19.0` are the required published baseline for this update. Verify their publication through the [approved CI publishing workflow](docs/FIREBASE_RELEASES.md) before merging the 12.19.3 preparation. See the [12.19 native review and validation notes](docs/firebase-12.19-update.md), the [12.18 notes](docs/firebase-12.18-update.md), and the earlier [12.17 notes](docs/firebase-12.17-update.md). -This checkout prepares Firebase and GoogleAppMeasurement `12.19.3`. Publication remains pending. See the [12.19.3 native review and validation notes](docs/firebase-12.19.3-update.md). +This checkout prepares Firebase and GoogleAppMeasurement `12.19.3.1`, plus Google Sign-In `10.1.0.1`, with native-cache validation fixes. Native SDK versions are unchanged; publication remains pending. See the [12.19.3 native review and validation notes](docs/firebase-12.19.3-update.md). ### Published and prepared versions @@ -180,22 +180,22 @@ This checkout prepares Firebase and GoogleAppMeasurement `12.19.3`. Publication | Package | Required published baseline | Prepared version | | --- | --- | --- | -| `ABTesting` | `12.19.0` | `12.19.3` | -| `Analytics` | `12.19.0` | `12.19.3` | -| `AppCheck` | `12.19.0` | `12.19.3` | -| `AppDistribution` | `12.19.0` | `12.19.3` | -| `Auth` | `12.19.0` | `12.19.3` | -| `CloudFirestore` | `12.19.0` | `12.19.3` | -| `CloudFunctions` | `12.19.0` | `12.19.3` | -| `CloudMessaging` | `12.19.0` | `12.19.3` | -| `Core` | `12.19.0` | `12.19.3` | -| `Crashlytics` | `12.19.0` | `12.19.3` | -| `Database` | `12.19.0` | `12.19.3` | -| `InAppMessaging` | `12.19.0` | `12.19.3` | -| `Installations` | `12.19.0` | `12.19.3` | -| `PerformanceMonitoring` | `12.19.0` | `12.19.3` | -| `RemoteConfig` | `12.19.0` | `12.19.3` | -| `Storage` | `12.19.0` | `12.19.3` | +| `ABTesting` | `12.19.0` | `12.19.3.1` | +| `Analytics` | `12.19.0` | `12.19.3.1` | +| `AppCheck` | `12.19.0` | `12.19.3.1` | +| `AppDistribution` | `12.19.0` | `12.19.3.1` | +| `Auth` | `12.19.0` | `12.19.3.1` | +| `CloudFirestore` | `12.19.0` | `12.19.3.1` | +| `CloudFunctions` | `12.19.0` | `12.19.3.1` | +| `CloudMessaging` | `12.19.0` | `12.19.3.1` | +| `Core` | `12.19.0` | `12.19.3.1` | +| `Crashlytics` | `12.19.0` | `12.19.3.1` | +| `Database` | `12.19.0` | `12.19.3.1` | +| `InAppMessaging` | `12.19.0` | `12.19.3.1` | +| `Installations` | `12.19.0` | `12.19.3.1` | +| `PerformanceMonitoring` | `12.19.0` | `12.19.3.1` | +| `RemoteConfig` | `12.19.0` | `12.19.3.1` | +| `Storage` | `12.19.0` | `12.19.3.1` | #### Google packages (`AdamE.Google.iOS.*`) @@ -203,7 +203,7 @@ This checkout prepares Firebase and GoogleAppMeasurement `12.19.3`. Publication | --- | --- | --- | | `Maps` | `9.2.0.8` | `9.2.0.10` | | `Places` | `7.4.0.2` | `7.4.0.4` | -| `SignIn` | `10.0.0` | `10.1.0.0` | +| `SignIn` | `10.0.0` | `10.1.0.1` | #### Google support packages (`AdamE.Google.iOS.*`) @@ -211,13 +211,13 @@ These packages are usually consumed transitively rather than referenced directly | Package | Required published baseline | Prepared version | | --- | --- | --- | -| `AppCheckCore` | `11.3.2` | `11.3.2` | -| `GoogleAppMeasurement` | `12.19.0` | `12.19.3` | -| `GoogleDataTransport` | `10.1.0.6` | `10.1.0.6` | -| `GoogleUtilities` | `8.1.5` | `8.1.5` | -| `GTMSessionFetcher` | `3.5.0.8` | `3.5.0.8` | -| `Nanopb` | `3.30910.0.6` | `3.30910.0.6` | -| `PromisesObjC` | `2.4.0.8` | `2.4.0.8` | +| `AppCheckCore` | `11.3.2` | `11.3.2.1` | +| `GoogleAppMeasurement` | `12.19.0` | `12.19.3.1` | +| `GoogleDataTransport` | `10.1.0.6` | `10.1.0.7` | +| `GoogleUtilities` | `8.1.5` | `8.1.5.1` | +| `GTMSessionFetcher` | `3.5.0.8` | `3.5.0.9` | +| `Nanopb` | `3.30910.0.6` | `3.30910.0.7` | +| `PromisesObjC` | `2.4.0.8` | `2.4.0.9` | #### ML Kit packages (`AdamE.MLKit.iOS.*`) diff --git a/common.cake b/common.cake index c94642ee..6bde3a4b 100644 --- a/common.cake +++ b/common.cake @@ -551,6 +551,73 @@ void BuildXcodeFatFramework (FilePath xcodeProject, PodSpec [] podSpecs, Platfor } } +// A completion record binds the requested inputs to every output file. Rechecking the +// inventory rejects deleted/truncated binaries and bundles, not just missing directories. +class NativeCacheRecord +{ + public static string Hash (string text) + { + using (var sha = System.Security.Cryptography.SHA256.Create ()) + return Convert.ToBase64String (sha.ComputeHash (System.Text.Encoding.UTF8.GetBytes (text))); + } + + public static string FileHash (string path) + { + using (var stream = System.IO.File.OpenRead (path)) + using (var sha = System.Security.Cryptography.SHA256.Create ()) + return Convert.ToBase64String (sha.ComputeHash (stream)); + } + + public static string Inventory (string[] roots) + { + var entries = new List (); + foreach (var root in roots) { + var files = System.IO.Directory.GetFiles (root, "*", System.IO.SearchOption.AllDirectories); + if (files.Length == 0) + throw new System.IO.IOException ($"Native cache output is empty: {root}"); + entries.Add ("root"); + foreach (var file in files.OrderBy (p => p, StringComparer.Ordinal)) { + var relative = System.IO.Path.GetRelativePath (root, file).Replace ('\\', '/'); + entries.Add (Convert.ToBase64String (System.Text.Encoding.UTF8.GetBytes (relative)) + ":" + FileHash (file)); + } + } + return Hash (string.Join ("\n", entries)); + } + + public static bool Matches (string stamp, string identity, string[] roots) + { + if (!System.IO.File.Exists (stamp)) + return false; + try { + return System.IO.File.ReadAllText (stamp) == Hash (identity) + "\n" + Inventory (roots); + } catch (System.IO.IOException) { + return false; + } catch (UnauthorizedAccessException) { + return false; + } + } + + public static void Complete (string stamp, string identity, string inventory) + { + var temporary = stamp + ".tmp"; + System.IO.File.WriteAllText (temporary, Hash (identity) + "\n" + inventory); + System.IO.File.Move (temporary, stamp, true); + } +} + +string NativeXcodeIdentity () +{ + var args = new ProcessArgumentBuilder (); + args.Append ("-version"); + var settings = new ProcessSettings { Arguments = args, RedirectStandardOutput = true }; + using (var process = StartAndReturnProcess ("xcodebuild", settings)) { + var output = string.Join ("\n", process.GetStandardOutput ()); + process.WaitForExit (); + ThrowIfProcessFailed ("xcodebuild -version", process.GetExitCode ()); + return output + "\n" + EnvironmentVariable ("DEVELOPER_DIR"); + } +} + void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, PlatformV2 [] platforms, DirectoryPath workingDirectory = null, Dictionary buildSettings = null) { if (!IsRunningOnUnix ()) { @@ -558,6 +625,9 @@ void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, Platform return; } + if (podSpecs.Length == 0) + return; + Information ("Building the following frameworks:"); Information (string.Join (",\n", podSpecs.Select (ps => ps.FrameworkName).ToArray ())); @@ -572,6 +642,7 @@ void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, Platform if (!buildSettings.ContainsKey("EXPANDED_CODE_SIGN_IDENTITY")) buildSettings["EXPANDED_CODE_SIGN_IDENTITY"] = ""; + var xcodeIdentity = NativeXcodeIdentity (); foreach (var podSpec in podSpecs) { Information ($"Building the following framework: {podSpec.FrameworkName}..."); @@ -591,9 +662,6 @@ void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, Platform var archiveFrameworksDirectory = archiveDirectory.Combine ("Products/Library/Frameworks"); xcodeBuildArgs.Append ($"-framework {archiveFrameworksDirectory}/{podSpec.FrameworkName}.framework"); - if (DirectoryExists (archiveDirectory)) - continue; - var buildSettingsCopy = new Dictionary (buildSettings); buildSettingsCopy ["SKIP_INSTALL"] = "NO"; buildSettingsCopy ["BUILD_LIBRARY_FOR_DISTRIBUTION"] = "YES"; @@ -613,28 +681,49 @@ void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, Platform if (archs != null) xcodeBuildSettings.Arch = string.Join (',', archs.Select (a => a.Value).ToArray ()); + var archivePath = MakeAbsolute (archiveDirectory).FullPath; + var stamp = archivePath + ".cache"; + var projectPath = MakeAbsolute (workingDirectory.CombineWithFilePath (xcodeProject)).FullPath; + var lockPath = MakeAbsolute (workingDirectory.CombineWithFilePath ("Podfile.lock")).FullPath; + var manifestPath = MakeAbsolute (workingDirectory.CombineWithFilePath ("Pods/Manifest.lock")).FullPath; + // Unlocked and local source builds cannot safely reuse archives. + var supportPath = MakeAbsolute (workingDirectory.Combine ("Pods/Target Support Files")).FullPath; + var podfilePath = MakeAbsolute (workingDirectory.CombineWithFilePath ("Podfile")).FullPath; + var cacheable = DirectoryExists (supportPath) && FileExists (podfilePath) && FileExists (lockPath) && FileExists (manifestPath) && + podSpecs.All (p => p.Source != PodSpec.PodSource.LocalPath); + var identity = string.Join ("\n", new [] { + "native-archive-v1", xcodeIdentity, projectPath, + NativeCacheRecord.Inventory (new [] { projectPath }), + cacheable ? NativeCacheRecord.Inventory (new [] { supportPath }) : "unlocked", + cacheable ? NativeCacheRecord.FileHash (podfilePath) : "unlocked", + cacheable ? NativeCacheRecord.FileHash (lockPath) : "unlocked", + cacheable ? NativeCacheRecord.FileHash (manifestPath) : "unlocked", + string.Join ("\n", podSpecs.SelectMany (p => p.BuildPodLines ())), + target, podSpec.FrameworkName, platform.Sdk.ToString (), sdk.ToString (), + xcodeBuildSettings.Arch ?? "default", + string.Join ("\n", buildSettingsCopy.OrderBy (p => p.Key, StringComparer.Ordinal).Select (p => p.Key + "=" + p.Value)) + }); + var roots = new [] { archivePath }; + if (cacheable && NativeCacheRecord.Matches (stamp, identity, roots)) + continue; + + // Invalidate before any destructive work. An interrupted build never leaves a hit. + if (System.IO.File.Exists (stamp)) + System.IO.File.Delete (stamp); + if (DirectoryExists (archiveDirectory)) + DeleteDirectory (archiveDirectory, new DeleteDirectorySettings { Recursive = true, Force = true }); XCodeBuild (xcodeBuildSettings); + var frameworkPath = archiveFrameworksDirectory.Combine ($"{podSpec.FrameworkName}.framework"); + if (!FileExists (frameworkPath.CombineWithFilePath ("Info.plist")) || + !FileExists (frameworkPath.CombineWithFilePath (podSpec.FrameworkName))) + throw new Exception ($"Incomplete native archive: {archiveDirectory}"); + if (cacheable) + NativeCacheRecord.Complete (stamp, identity, NativeCacheRecord.Inventory (roots)); Information ($"Done building {podSpec.FrameworkName} framework with {platform.Sdk} platform SDK..."); - var builtFrameworkDirectories = GetDirectories (archiveFrameworksDirectory.Combine ("*.framework").ToString ()); - - Information ($"Copying the following built frameworks to its respective archives:"); - Information (string.Join (",\n", builtFrameworkDirectories.Select (b => b.ToString ()).ToArray ())); - - foreach (var directory in builtFrameworkDirectories) { - var builtFrameworkName = directory.GetDirectoryName ().Replace (".framework", ""); - - if (string.Equals (builtFrameworkName, podSpec.FrameworkName)) - continue; - - DirectoryPath destinationDirectory = Directory (archiveFrameworksDirectory.ToString ().Replace ($"{podSpec.FrameworkName}", $"{builtFrameworkName}")); - destinationDirectory = destinationDirectory.Combine ($"{builtFrameworkName}.framework"); - - Information ($"Copying {directory} to {destinationDirectory}"); - - CopyDirectoryWithSymlinks (directory, destinationDirectory); - } + // Each scheme owns its archive and completion record. Do not copy dependency + // frameworks into another scheme's archive behind that record's back. } Information ($"Building {podSpec.FrameworkName} xcframework..."); diff --git a/components.cake b/components.cake index 7613c379..64e04da9 100644 --- a/components.cake +++ b/components.cake @@ -1,20 +1,20 @@ // Firebase artifacts available to be built. These artifacts generate NuGets. -Artifact FIREBASE_AB_TESTING_ARTIFACT = new Artifact ("Firebase.ABTesting", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "ABTesting"); -Artifact FIREBASE_ANALYTICS_ARTIFACT = new Artifact ("Firebase.Analytics", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Analytics"); -Artifact FIREBASE_AUTH_ARTIFACT = new Artifact ("Firebase.Auth", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Auth"); -Artifact FIREBASE_CLOUD_FIRESTORE_ARTIFACT = new Artifact ("Firebase.CloudFirestore", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "CloudFirestore"); -Artifact FIREBASE_CLOUD_FUNCTIONS_ARTIFACT = new Artifact ("Firebase.CloudFunctions", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "CloudFunctions"); -Artifact FIREBASE_CLOUD_MESSAGING_ARTIFACT = new Artifact ("Firebase.CloudMessaging", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "CloudMessaging"); -Artifact FIREBASE_CORE_ARTIFACT = new Artifact ("Firebase.Core", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Core"); -Artifact FIREBASE_CRASHLYTICS_ARTIFACT = new Artifact ("Firebase.Crashlytics", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Crashlytics"); -Artifact FIREBASE_DATABASE_ARTIFACT = new Artifact ("Firebase.Database", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Database"); -Artifact FIREBASE_IN_APP_MESSAGING_ARTIFACT = new Artifact ("Firebase.InAppMessaging", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "InAppMessaging"); -Artifact FIREBASE_INSTALLATIONS_ARTIFACT = new Artifact ("Firebase.Installations", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Installations"); -Artifact FIREBASE_PERFORMANCE_MONITORING_ARTIFACT = new Artifact ("Firebase.PerformanceMonitoring", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "PerformanceMonitoring"); -Artifact FIREBASE_REMOTE_CONFIG_ARTIFACT = new Artifact ("Firebase.RemoteConfig", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "RemoteConfig"); -Artifact FIREBASE_STORAGE_ARTIFACT = new Artifact ("Firebase.Storage", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "Storage"); -Artifact FIREBASE_APP_DISTRIBUTION_ARTIFACT = new Artifact ("Firebase.AppDistribution", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "AppDistribution"); -Artifact FIREBASE_APP_CHECK_ARTIFACT = new Artifact ("Firebase.AppCheck", "12.19.3", "15.0", ComponentGroup.Firebase, csprojName: "AppCheck"); +Artifact FIREBASE_AB_TESTING_ARTIFACT = new Artifact ("Firebase.ABTesting", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "ABTesting"); +Artifact FIREBASE_ANALYTICS_ARTIFACT = new Artifact ("Firebase.Analytics", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Analytics"); +Artifact FIREBASE_AUTH_ARTIFACT = new Artifact ("Firebase.Auth", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Auth"); +Artifact FIREBASE_CLOUD_FIRESTORE_ARTIFACT = new Artifact ("Firebase.CloudFirestore", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "CloudFirestore"); +Artifact FIREBASE_CLOUD_FUNCTIONS_ARTIFACT = new Artifact ("Firebase.CloudFunctions", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "CloudFunctions"); +Artifact FIREBASE_CLOUD_MESSAGING_ARTIFACT = new Artifact ("Firebase.CloudMessaging", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "CloudMessaging"); +Artifact FIREBASE_CORE_ARTIFACT = new Artifact ("Firebase.Core", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Core"); +Artifact FIREBASE_CRASHLYTICS_ARTIFACT = new Artifact ("Firebase.Crashlytics", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Crashlytics"); +Artifact FIREBASE_DATABASE_ARTIFACT = new Artifact ("Firebase.Database", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Database"); +Artifact FIREBASE_IN_APP_MESSAGING_ARTIFACT = new Artifact ("Firebase.InAppMessaging", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "InAppMessaging"); +Artifact FIREBASE_INSTALLATIONS_ARTIFACT = new Artifact ("Firebase.Installations", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Installations"); +Artifact FIREBASE_PERFORMANCE_MONITORING_ARTIFACT = new Artifact ("Firebase.PerformanceMonitoring", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "PerformanceMonitoring"); +Artifact FIREBASE_REMOTE_CONFIG_ARTIFACT = new Artifact ("Firebase.RemoteConfig", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "RemoteConfig"); +Artifact FIREBASE_STORAGE_ARTIFACT = new Artifact ("Firebase.Storage", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "Storage"); +Artifact FIREBASE_APP_DISTRIBUTION_ARTIFACT = new Artifact ("Firebase.AppDistribution", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "AppDistribution"); +Artifact FIREBASE_APP_CHECK_ARTIFACT = new Artifact ("Firebase.AppCheck", "12.19.3.1", "15.0", ComponentGroup.Firebase, csprojName: "AppCheck"); // Google artifacts available to be built. These artifacts generate NuGets. Artifact GOOGLE_ANALYTICS_ARTIFACT = new Artifact ("Google.Analytics", "3.20.0.2", "15.0", ComponentGroup.Google, csprojName: "Analytics"); @@ -22,16 +22,16 @@ Artifact GOOGLE_CAST_ARTIFACT = new Artifact ("Google.Cast" Artifact GOOGLE_MAPS_ARTIFACT = new Artifact ("Google.Maps", "9.2.0.10", "15.0", ComponentGroup.Google, csprojName: "Maps"); Artifact GOOGLE_UMP_ARTIFACT = new Artifact ("Google.UserMessagingPlatform", "1.1.0.1", "15.0", ComponentGroup.Google, csprojName: "UserMessagingPlatform"); Artifact GOOGLE_PLACES_ARTIFACT = new Artifact ("Google.Places", "7.4.0.4", "15.0", ComponentGroup.Google, csprojName: "Places"); -Artifact GOOGLE_APP_CHECK_CORE_ARTIFACT = new Artifact ("Google.AppCheckCore", "11.3.2", "15.0", ComponentGroup.Google, csprojName: "AppCheckCore"); -Artifact GOOGLE_SIGN_IN_ARTIFACT = new Artifact ("Google.SignIn", "10.1.0.0", "15.0", ComponentGroup.Google, csprojName: "SignIn"); +Artifact GOOGLE_APP_CHECK_CORE_ARTIFACT = new Artifact ("Google.AppCheckCore", "11.3.2.1", "15.0", ComponentGroup.Google, csprojName: "AppCheckCore"); +Artifact GOOGLE_SIGN_IN_ARTIFACT = new Artifact ("Google.SignIn", "10.1.0.1", "15.0", ComponentGroup.Google, csprojName: "SignIn"); Artifact GOOGLE_TAG_MANAGER_ARTIFACT = new Artifact ("Google.TagManager", "9.2.0.0", "15.0", ComponentGroup.Google, csprojName: "TagManager"); -Artifact GOOGLE_GOOGLE_APP_MEASUREMENT_ARTIFACT = new Artifact ("Google.AppMeasurement", "12.19.3", "15.0", ComponentGroup.Google, csprojName: "GoogleAppMeasurement"); -Artifact GOOGLE_PROMISES_OBJC_ARTIFACT = new Artifact ("Google.PromisesObjC", "2.4.0.8", "15.0", ComponentGroup.Google, csprojName: "PromisesObjC"); -Artifact GOOGLE_GTM_SESSION_FETCHER_ARTIFACT = new Artifact ("Google.GTMSessionFetcher", "3.5.0.8", "15.0", ComponentGroup.Google, csprojName: "GTMSessionFetcher"); -Artifact GOOGLE_NANOPB_ARTIFACT = new Artifact ("Google.Nanopb", "3.30910.0.6", "15.0", ComponentGroup.Google, csprojName: "Nanopb"); -Artifact GOOGLE_GOOGLE_UTILITIES_ARTIFACT = new Artifact ("Google.GoogleUtilities", "8.1.5", "15.0", ComponentGroup.Google, csprojName: "GoogleUtilities"); -Artifact GOOGLE_GOOGLE_DATA_TRANSPORT_ARTIFACT = new Artifact ("Google.GoogleDataTransport", "10.1.0.6", "15.0", ComponentGroup.Google, csprojName: "GoogleDataTransport"); +Artifact GOOGLE_GOOGLE_APP_MEASUREMENT_ARTIFACT = new Artifact ("Google.AppMeasurement", "12.19.3.1", "15.0", ComponentGroup.Google, csprojName: "GoogleAppMeasurement"); +Artifact GOOGLE_PROMISES_OBJC_ARTIFACT = new Artifact ("Google.PromisesObjC", "2.4.0.9", "15.0", ComponentGroup.Google, csprojName: "PromisesObjC"); +Artifact GOOGLE_GTM_SESSION_FETCHER_ARTIFACT = new Artifact ("Google.GTMSessionFetcher", "3.5.0.9", "15.0", ComponentGroup.Google, csprojName: "GTMSessionFetcher"); +Artifact GOOGLE_NANOPB_ARTIFACT = new Artifact ("Google.Nanopb", "3.30910.0.7", "15.0", ComponentGroup.Google, csprojName: "Nanopb"); +Artifact GOOGLE_GOOGLE_UTILITIES_ARTIFACT = new Artifact ("Google.GoogleUtilities", "8.1.5.1", "15.0", ComponentGroup.Google, csprojName: "GoogleUtilities"); +Artifact GOOGLE_GOOGLE_DATA_TRANSPORT_ARTIFACT = new Artifact ("Google.GoogleDataTransport", "10.1.0.7", "15.0", ComponentGroup.Google, csprojName: "GoogleDataTransport"); // MLKit artifacts available to be built. These artifacts generate NuGets. Artifact MLKIT_CORE_ARTIFACT = new Artifact ("MLKit.Core", "12.0.0.3", "16.0", ComponentGroup.MLKit, csprojName: "Core"); Artifact MLKIT_TEXT_RECOGNITION = new Artifact ("MLKit.TextRecognition", "1.0.0.3", "15.0", ComponentGroup.MLKit, csprojName: "TextRecognition"); diff --git a/custom_externals_download.cake b/custom_externals_download.cake index d0be868f..054fa8c4 100644 --- a/custom_externals_download.cake +++ b/custom_externals_download.cake @@ -53,16 +53,22 @@ FilePath GetArchivePath (ExternalDownloadSource source, DirectoryPath externalsP DirectoryPath GetExtractionRoot (ExternalDownloadSource source, DirectoryPath externalsPath) => externalsPath.Combine (source.ExtractionRootName); -void DownloadAndExtract (ExternalDownloadSource source, Func artifactsAlreadyPresent, Action copyArtifacts) +void DownloadAndExtract (ExternalDownloadSource source, string[] outputNames, Action copyArtifacts) { - var externalsPath = new DirectoryPath ("./externals"); - - if (artifactsAlreadyPresent ()) { + var externalsPath = MakeAbsolute (new DirectoryPath ("./externals")); + + var outputs = outputNames.Select (name => MakeAbsolute (externalsPath.Combine (name)).FullPath).ToArray (); + var stamp = MakeAbsolute (externalsPath.CombineWithFilePath (source.Id + ".download-cache")).FullPath; + var identity = string.Join ("\n", new [] { "native-download-v1", source.Id, source.Version, + source.ArchiveKey, source.Url, source.ExpectedSha256, source.ExtractionRootName, + string.Join ("\n", outputNames) }); + if (NativeCacheRecord.Matches (stamp, identity, outputs)) { Information ($"{source.Id} artifacts already available in externals. Skipping download."); return; } EnsureDirectoryExists (externalsPath); + System.IO.File.Delete (stamp); var archivePath = GetArchivePath (source, externalsPath); var extractionRoot = GetExtractionRoot (source, externalsPath); @@ -82,10 +88,17 @@ void DownloadAndExtract (ExternalDownloadSource source, Func artifactsAlre if (exitCode != 0) throw new Exception ($"tar failed with exit code {exitCode} while extracting {archivePath.GetFilename ()}."); + // Compare the entire verified extraction with the copy, including all framework + // slices and resource bundles. A partial copy must never publish a completion record. + var expected = NativeCacheRecord.Inventory (outputNames.Select (name => + MakeAbsolute (extractionRoot.Combine ("Frameworks").Combine (name)).FullPath).ToArray ()); copyArtifacts (extractionRoot, externalsPath, deleteSettings); + if (NativeCacheRecord.Inventory (outputs) != expected) + throw new Exception ($"Incomplete native copy for {source.Id}."); DeleteDirectory (extractionRoot, deleteSettings); DeleteFile (archivePath); + NativeCacheRecord.Complete (stamp, identity, expected); } int ExtractArchive (ExternalDownloadSource source, DirectoryPath externalsPath, FilePath archivePath) @@ -122,7 +135,7 @@ void FirebaseAnalyticsDownload () DownloadAndExtract ( source, - () => DirectoryExists (new DirectoryPath ("./externals/FirebaseAnalytics.xcframework")), + new [] { "FirebaseAnalytics.xcframework" }, (extractionRoot, externalsPath, deleteSettings) => { var frameworkSource = extractionRoot.Combine ("Frameworks").Combine ("FirebaseAnalytics.xcframework"); var frameworkDestination = externalsPath.Combine ("FirebaseAnalytics.xcframework"); @@ -143,8 +156,7 @@ void GoogleAppMeasurementDownload () DownloadAndExtract ( source, - () => DirectoryExists (new DirectoryPath ("./externals/GoogleAppMeasurementIdentitySupport.xcframework")) && - DirectoryExists (new DirectoryPath ("./externals/GoogleAppMeasurement.xcframework")), + new [] { "GoogleAppMeasurementIdentitySupport.xcframework", "GoogleAppMeasurement.xcframework" }, (extractionRoot, externalsPath, deleteSettings) => { var frameworkSource = extractionRoot.Combine ("Frameworks"); var identitySupportSource = frameworkSource.Combine ("GoogleAppMeasurementIdentitySupport.xcframework"); diff --git a/docs/CI.md b/docs/CI.md index 71361624..6774b4b7 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -43,3 +43,6 @@ Run the planner and fingerprint regressions with: python3 -m unittest discover -s tests/ci -v dotnet test scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj ``` + +Native cache behavior and fixture regression coverage are documented in +[NATIVE_CACHES.md](NATIVE_CACHES.md). diff --git a/docs/NATIVE_CACHES.md b/docs/NATIVE_CACHES.md new file mode 100644 index 00000000..0c49cbfd --- /dev/null +++ b/docs/NATIVE_CACHES.md @@ -0,0 +1,58 @@ +# Native build caches + +The CocoaPods/Xcode archive helper and the direct native archive downloader verify +cache inputs and outputs before reuse. Existing directories without completion +records are rebuilt or downloaded once; deleting a record is also a way to force +that operation without clearing unrelated native outputs. + +## Xcode archives + +`BuildXcodeXcframework` records completion beside each platform archive as +`.xcarchive.cache`. Its identity includes the requested pod definitions, +Podfile, both CocoaPods lockfiles, generated Xcode project and target-support files, +Xcode version, developer-directory override, target, platform, architectures and +build settings. Unlocked builds and explicit local-path pod sources always rebuild. + +Before rebuilding, the helper removes the old completion record and archive. A +successful build must contain the requested framework's Info.plist and binary. +The record then binds the input identity to an inventory of all archive files, +including resources. Each scheme builds and records its own archive; it does not +populate another scheme's cache by copying dependency frameworks. + +## Direct downloads + +`DownloadAndExtract` records completion in `externals/.download-cache`. Its +identity includes the SDK version, archive key, full URL, expected SHA-256, +extraction root and required outputs. GoogleAppMeasurement requires both its +measurement and IdentitySupport frameworks. + +A miss removes the old completion record before downloading. The helper verifies +the archive hash, extracts it, and compares every copied file against the verified +extraction before writing a record. This comparison includes framework slices and +embedded resource bundles. A failed extraction, interrupted copy, or incomplete +copy cannot leave a valid record. + +Both cache hits rehash the recorded outputs. A missing directory, deleted binary +or resource, empty output, or changed file invalidates the cache. This adds local +file reads to a hit in exchange for detecting incomplete and modified outputs. +Completion records are written through a temporary file and renamed only after +success. + +## Regression coverage and release policy + +`NativeCacheTests` compiles the production Cake cache helpers and executes them +against temporary fixtures. Only Cake filesystem/process aliases and external +build/download operations are simulated. The tests require no Apple SDKs, vendor +network access or application credentials, and run with the managed audit suite: + +```sh +dotnet test scripts/FirebaseBindingAudit.Tests/FirebaseBindingAudit.Tests.csproj +``` + +These shared helper changes affect the package fingerprints of both the Firebase +and Sign-In closures. The cache correction prepares Firebase and +GoogleAppMeasurement 12.19.3.1, Sign-In 10.1.0.1, and revisions of all seven Firebase +support packages (four of which are shared with Sign-In). Native SDK pins remain +unchanged. The checked-in initial release baselines and version guards are unchanged. +Publish the Firebase revision before the Sign-In revision so the latter can reuse +its four shared support packages, following the normal approved release workflow. diff --git a/eng/firebase-release.json b/eng/firebase-release.json index c937b915..2985ae16 100644 --- a/eng/firebase-release.json +++ b/eng/firebase-release.json @@ -1,5 +1,5 @@ { - "releaseVersion": "12.19.3", + "releaseVersion": "12.19.3.1", "minimumFirebaseVersion": "12.12.0", "nativeBuild": { "runner": "xcode-27.1", diff --git a/eng/signin-release.json b/eng/signin-release.json index cf324a20..368dae8f 100644 --- a/eng/signin-release.json +++ b/eng/signin-release.json @@ -1,5 +1,5 @@ { - "releaseVersion": "10.1.0", + "releaseVersion": "10.1.0.1", "nativeBuild": { "runner": "xcode-27.1", "xcode": "27.1", diff --git a/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt new file mode 100644 index 00000000..b44dc5b6 --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt @@ -0,0 +1,102 @@ +// Only Cake aliases and external download/Xcode operations are simulated. The cache +// decisions, identity generation, copy callbacks and completion writes come from production. +class FilePath { + public string FullPath; public FilePath(string path) { FullPath = path; } + public string GetFilename() => Path.GetFileName(FullPath); + public static implicit operator FilePath(string path) => new FilePath(path); + public override string ToString() => FullPath; +} +class DirectoryPath { + public string FullPath; public DirectoryPath(string path) { FullPath = path; } + public DirectoryPath Combine(string path) => new DirectoryPath(Path.Combine(FullPath, path)); + public FilePath CombineWithFilePath(FilePath path) => new FilePath(Path.Combine(FullPath, path.FullPath)); + public static implicit operator DirectoryPath(string path) => new DirectoryPath(path); + public override string ToString() => FullPath; +} +class DeleteDirectorySettings { public bool Recursive; public bool Force; } +class ProcessArgumentBuilder { public string Text = ""; public void Append(string text) { Text += text; } } +class ProcessSettings { public ProcessArgumentBuilder Arguments; } +class PodSpec { + public enum PodSource { PodVersion, LocalPath } + public string TargetName = "Widget", FrameworkName = "Widget", Version; + public PodSource Source; + public string[] BuildPodLines() => new[] { "pod 'Widget', '" + Version + "'" }; +} +public class Harness { + public string Version = "1.0", Key = "key", Hash = "", UrlPrefix = "https://example.test"; + public string Settings = "default", Architectures = "arm64", XcodeId = "Xcode 1", CopyFailure = "", BuildFailure = ""; + public bool Local; + public int Downloads, Copies, Builds, Creates; + string Root; + Dictionary ExternalDownloads = new Dictionary(); + public Harness(string root) { + Root = root; + Write(Path.Combine(root, "build/Podfile.lock"), "lock"); + Write(Path.Combine(root, "build/Podfile"), "podfile"); + Write(Path.Combine(root, "build/Pods/Target Support Files/Widget/Widget.xcconfig"), "settings"); + Write(Path.Combine(root, "build/Pods/Manifest.lock"), "lock"); + Write(Path.Combine(root, "build/Pods/Pods.xcodeproj/project.pbxproj"), "project"); + } + static void Write(string path, string text) { System.IO.Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllText(path, text); } + static void Framework(string root, string name) { + Write(Path.Combine(root, "Info.plist"), "plist"); Write(Path.Combine(root, name), "binary"); + Write(Path.Combine(root, "Resources/Privacy.bundle/Info.plist"), "bundle"); + } + DirectoryPath Directory(string path) => new DirectoryPath(path); + FilePath MakeAbsolute(FilePath path) => new FilePath(Path.GetFullPath(path.FullPath, Root)); + DirectoryPath MakeAbsolute(DirectoryPath path) => new DirectoryPath(Path.GetFullPath(path.FullPath, Root)); + bool FileExists(FilePath path) => File.Exists(MakeAbsolute(path).FullPath); + bool DirectoryExists(DirectoryPath path) => System.IO.Directory.Exists(MakeAbsolute(path).FullPath); + void EnsureDirectoryExists(DirectoryPath path) => System.IO.Directory.CreateDirectory(MakeAbsolute(path).FullPath); + void DeleteDirectory(DirectoryPath path, DeleteDirectorySettings settings) => System.IO.Directory.Delete(MakeAbsolute(path).FullPath, true); + void DeleteFile(FilePath path) => File.Delete(MakeAbsolute(path).FullPath); + bool IsRunningOnUnix() => true; + void Information(string message) { } + void Warning(string message, string value) { } + string NativeXcodeIdentity() => XcodeId; + void ThrowIfProcessFailed(string tool, int exit) { if (exit != 0) throw new Exception(tool); } + int StartProcess(string tool, ProcessSettings settings) { Creates++; return 0; } + void XCodeBuild(XCodeBuildSettings settings) { + Builds++; var path = Path.Combine(settings.ArchivePath, "Products/Library/Frameworks/Widget.framework"); + // A retry must start clean rather than accepting remnants of a failed build. + if (System.IO.Directory.Exists(settings.ArchivePath)) throw new Exception("Archive was not cleaned"); + Write(Path.Combine(path, "Info.plist"), "plist"); + if (BuildFailure == "throw") throw new IOException("Interrupted build"); + if (BuildFailure == "partial") return; + Framework(path, "Widget"); + Write(Path.Combine(path, "Widget"), Version); + } + void DownloadArchive(ExternalDownloadSource source, FilePath path) { Downloads++; Write(MakeAbsolute(path).FullPath, "archive"); } + int ExtractArchive(ExternalDownloadSource source, DirectoryPath externals, FilePath archive) { + var root = MakeAbsolute(GetExtractionRoot(source, externals)).Combine("Frameworks").FullPath; + var names = source.Id == "FirebaseAnalytics" ? new[] { "FirebaseAnalytics" } : new[] { "GoogleAppMeasurementIdentitySupport", "GoogleAppMeasurement" }; + foreach (var name in names) { + var output = Path.Combine(root, name + ".xcframework"); Write(Path.Combine(output, "Info.plist"), "xcframework"); + var framework = name == "FirebaseAnalytics" ? "Analytics" : "Measurement"; + Framework(Path.Combine(output, "ios", framework + ".framework"), framework); + } + return 0; + } + void CopyDirectory(DirectoryPath source, DirectoryPath dest) { + Copies++; var from = MakeAbsolute(source).FullPath; var to = MakeAbsolute(dest).FullPath; + System.IO.Directory.CreateDirectory(to); + foreach (var file in System.IO.Directory.GetFiles(from, "*", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal)) { + var target = Path.Combine(to, Path.GetRelativePath(from, file)); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(target)); File.Copy(file, target, true); + if (CopyFailure == "throw" || (CopyFailure == "second" && Copies % 2 == 0)) throw new IOException("Interrupted copy"); + if (CopyFailure == "partial") return; + } + } + void SetDownload(string id) { + var hash = NativeCacheRecord.FileHash(CreateArchiveFixture()); + ExternalDownloads[id] = new ExternalDownloadSource(id, Version, Key, UrlPrefix, + expectedSha256: Hash == "uppercase" ? Convert.ToHexString(Convert.FromBase64String(hash)) : + Hash == "wrong" ? new string('0', 64) : Convert.ToHexString(Convert.FromBase64String(hash)).ToLowerInvariant()); + } + string CreateArchiveFixture() { var path = Path.Combine(Root, "archive-fixture"); Write(path, "archive"); return path; } + public void Download() { SetDownload("GoogleAppMeasurement"); GoogleAppMeasurementDownload(); } + public void Analytics() { SetDownload("FirebaseAnalytics"); FirebaseAnalyticsDownload(); } + public void Build() => BuildXcodeXcframework("Pods/Pods.xcodeproj", + new[] { new PodSpec { Version = Version, Source = Local ? PodSpec.PodSource.LocalPath : PodSpec.PodSource.PodVersion } }, + new[] { PlatformV2.Create(Sdk.iPhoneOS, new[] { Architectures == "arm64" ? Arch.Arm64 : Arch.x86_64 }) }, + new DirectoryPath(Path.Combine(Root, "build")), new Dictionary { ["CUSTOM"] = Settings }); diff --git a/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs new file mode 100644 index 00000000..6e988c25 --- /dev/null +++ b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs @@ -0,0 +1,212 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +public class NativeCacheTests : IDisposable +{ + static readonly Lazy HarnessType = new(Compile); + readonly string root = Path.Combine(Path.GetTempPath(), "native-cache-" + Guid.NewGuid().ToString("N")); + readonly object harness; + + public NativeCacheTests() + { + Directory.CreateDirectory(root); + harness = Activator.CreateInstance(HarnessType.Value, root)!; + } + public void Dispose() => Directory.Delete(root, true); + void Set(string name, object value) => HarnessType.Value.GetField(name)!.SetValue(harness, value); + int Count(string name) => (int)HarnessType.Value.GetField(name)!.GetValue(harness)!; + void Run(string method) + { + try { HarnessType.Value.GetMethod(method)!.Invoke(harness, null); } + catch (TargetInvocationException e) when (e.InnerException is not null) { throw e.InnerException; } + } + string Output(string name) => Path.Combine(root, "externals", name); + string Archive => Path.Combine(root, "build/Widget/iphoneos.xcarchive"); + + [Fact] + public void Download_UnchangedInputReusesVerifiedOutputs() + { + Run("Download"); Run("Download"); + Assert.Equal(1, Count("Downloads")); + Assert.Equal(2, Count("Copies")); + } + + [Theory] + [InlineData("Version", "2.0")] + [InlineData("Key", "other-key")] + [InlineData("Hash", "uppercase")] + [InlineData("UrlPrefix", "https://other.example")] + public void Download_ChangedSourceInvalidates(string field, string value) + { + Run("Download"); + // SHA spelling is part of source identity even when its verified value is identical. + Set(field, value); Run("Download"); + Assert.Equal(2, Count("Downloads")); + } + + [Theory] + [InlineData("stamp")] + [InlineData("framework")] + [InlineData("binary")] + [InlineData("bundle")] + [InlineData("corruption")] + [InlineData("empty")] + public void Download_MissingOrIncompleteOutputIsNotAHit(string damage) + { + Run("Download"); + if (damage == "stamp") File.Delete(Output("GoogleAppMeasurement.download-cache")); + if (damage == "framework") Directory.Delete(Output("GoogleAppMeasurementIdentitySupport.xcframework"), true); + if (damage == "binary") File.Delete(Output("GoogleAppMeasurement.xcframework/ios/Measurement.framework/Measurement")); + if (damage == "bundle") File.Delete(Output("GoogleAppMeasurement.xcframework/ios/Measurement.framework/Resources/Privacy.bundle/Info.plist")); + if (damage == "corruption") File.WriteAllText(Output("GoogleAppMeasurementIdentitySupport.xcframework/Info.plist"), "truncated"); + if (damage == "empty") { Directory.Delete(Output("GoogleAppMeasurement.xcframework"), true); Directory.CreateDirectory(Output("GoogleAppMeasurement.xcframework")); } + Run("Download"); + Assert.Equal(2, Count("Downloads")); + Run("Download"); Assert.Equal(2, Count("Downloads")); + } + + [Theory] + [InlineData("throw")] + [InlineData("partial")] + [InlineData("second")] + public void Download_InterruptedOrSilentPartialCopyCannotBecomeAHit(string failure) + { + Run("Download"); Set("Version", "2.0"); Set("CopyFailure", failure); + Assert.ThrowsAny(() => Run("Download")); + Assert.False(File.Exists(Output("GoogleAppMeasurement.download-cache"))); + Set("CopyFailure", ""); Run("Download"); Run("Download"); + Assert.Equal(3, Count("Downloads")); + } + + [Fact] + public void Download_HashFailureLeavesNoCompletionRecord() + { + Set("Hash", "wrong"); Assert.ThrowsAny(() => Run("Download")); + Assert.Equal(0, Count("Copies")); + Assert.False(File.Exists(Output("GoogleAppMeasurement.download-cache"))); + Set("Hash", ""); Run("Download"); Run("Download"); + Assert.Equal(2, Count("Downloads")); + } + + [Fact] + public void Download_ChangedHashCannotAcceptPreviouslyVerifiedOutputs() + { + Run("Download"); Set("Hash", "wrong"); + Assert.ThrowsAny(() => Run("Download")); + Assert.Equal(2, Count("Downloads")); + Assert.False(File.Exists(Output("GoogleAppMeasurement.download-cache"))); + Set("Hash", ""); Run("Download"); Run("Download"); + Assert.Equal(3, Count("Downloads")); + } + + [Fact] + public void Analytics_UsesSameCompletionContract() + { + Run("Analytics"); Run("Analytics"); Assert.Equal(1, Count("Downloads")); + File.Delete(Output("FirebaseAnalytics.xcframework/ios/Analytics.framework/Analytics")); + Run("Analytics"); Assert.Equal(2, Count("Downloads")); + } + + [Fact] + public void Archive_UnchangedInputReusesButStillCreatesXcframework() + { + Run("Build"); Run("Build"); + Assert.Equal(1, Count("Builds")); Assert.Equal(2, Count("Creates")); + } + + [Theory] + [InlineData("Version")] + [InlineData("Settings")] + [InlineData("Architectures")] + [InlineData("XcodeId")] + [InlineData("Lock")] + [InlineData("Manifest")] + [InlineData("Project")] + [InlineData("Podfile")] + [InlineData("Support")] + public void Archive_ChangedBuildInputInvalidates(string change) + { + Run("Build"); + if (change == "Lock") File.AppendAllText(Path.Combine(root, "build/Podfile.lock"), "changed"); + else if (change == "Manifest") File.AppendAllText(Path.Combine(root, "build/Pods/Manifest.lock"), "changed"); + else if (change == "Podfile") File.AppendAllText(Path.Combine(root, "build/Podfile"), "changed"); + else if (change == "Support") File.AppendAllText(Path.Combine(root, "build/Pods/Target Support Files/Widget/Widget.xcconfig"), "changed"); + else if (change == "Project") File.AppendAllText(Path.Combine(root, "build/Pods/Pods.xcodeproj/project.pbxproj"), "changed"); + else Set(change, "changed"); + Run("Build"); Assert.Equal(2, Count("Builds")); + Assert.Equal(change == "Version" ? "changed" : "1.0", File.ReadAllText(Path.Combine(Archive, "Products/Library/Frameworks/Widget.framework/Widget"))); + Run("Build"); Assert.Equal(2, Count("Builds")); + } + + [Theory] + [InlineData("stamp")] + [InlineData("binary")] + [InlineData("plist")] + [InlineData("bundle")] + [InlineData("corruption")] + public void Archive_UnstampedOrIncompleteArchiveRebuilds(string damage) + { + Run("Build"); + var framework = Path.Combine(Archive, "Products/Library/Frameworks/Widget.framework"); + if (damage == "stamp") File.Delete(Archive + ".cache"); + if (damage == "binary") File.Delete(Path.Combine(framework, "Widget")); + if (damage == "plist") File.Delete(Path.Combine(framework, "Info.plist")); + if (damage == "bundle") File.Delete(Path.Combine(framework, "Resources/Privacy.bundle/Info.plist")); + if (damage == "corruption") File.WriteAllText(Path.Combine(framework, "Widget"), "bad"); + Run("Build"); Assert.Equal(2, Count("Builds")); + } + + [Theory] + [InlineData("throw")] + [InlineData("partial")] + public void Archive_FailedRebuildCannotReuseOldStamp(string failure) + { + Run("Build"); Set("Version", "2.0"); Set("BuildFailure", failure); + Assert.ThrowsAny(() => Run("Build")); + Assert.False(File.Exists(Archive + ".cache")); + Set("BuildFailure", ""); Run("Build"); Run("Build"); + Assert.Equal(3, Count("Builds")); + } + + [Theory] + [InlineData("unlocked")] + [InlineData("local")] + public void Archive_InputsWithoutStableSourceIdentityAlwaysRebuild(string mode) + { + if (mode == "unlocked") File.Delete(Path.Combine(root, "build/Podfile.lock")); + else Set("Local", true); + Run("Build"); Run("Build"); Assert.Equal(2, Count("Builds")); + Assert.False(File.Exists(Archive + ".cache")); + } + + static Type Compile() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "common.cake"))) dir = dir.Parent; + Assert.NotNull(dir); + CompilationUnitSyntax Parse(string path) => CSharpSyntaxTree.ParseText(File.ReadAllText(Path.Combine(dir.FullName, path)), + new CSharpParseOptions(kind: SourceCodeKind.Script)).GetCompilationUnitRoot(); + var common = Parse("common.cake"); var downloads = Parse("custom_externals_download.cake"); var poco = Parse("poco.cake"); + string Class(CompilationUnitSyntax source, string name) => source.Members.OfType().Single(c => c.Identifier.ValueText == name).ToFullString(); + string Method(CompilationUnitSyntax source, string name) => source.Members.OfType().Single(m => m.Identifier.ValueText == name).ToFullString(); + var code = """ + using System; + using System.IO; + using System.Linq; + using System.Collections.Generic; + """ + string.Join("\n", poco.Members.OfType().Where(c => new[] { "Arch", "Sdk", "PlatformV2" }.Contains(c.Identifier.ValueText)).Select(c => c.ToFullString())) + + Class(common, "NativeCacheRecord") + Class(common, "XCodeBuildSettings") + Class(downloads, "ExternalDownloadSource") + + File.ReadAllText(Path.Combine(dir.FullName, "scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt")) + + Method(common, "BuildXcodeXcframework") + + string.Join("\n", new[] { "DownloadAndExtract", "GetArchivePath", "GetExtractionRoot", "VerifyArchiveHash", "FirebaseAnalyticsDownload", "GoogleAppMeasurementDownload" }.Select(n => Method(downloads, n))) + "\n}"; + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator).Select(p => MetadataReference.CreateFromFile(p)); + var compilation = CSharpCompilation.Create("NativeCache" + Guid.NewGuid().ToString("N"), [CSharpSyntaxTree.ParseText(code)], refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var output = new MemoryStream(); var result = compilation.Emit(output); + Assert.True(result.Success, string.Join("\n", result.Diagnostics)); + return Assembly.Load(output.ToArray()).GetType("Harness")!; + } +} diff --git a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs index 6c802f34..3fc1a00a 100644 --- a/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs +++ b/scripts/FirebaseBindingAudit/BindingSurfaceCoverage.cs @@ -10,7 +10,7 @@ namespace FirebaseBindingAudit; internal static class FirebasePackageVersions { - public const string DefaultFirebasePackageVersion = "12.19.3"; + public const string DefaultFirebasePackageVersion = "12.19.3.1"; } internal sealed class BindingSurfaceCoverageManifest diff --git a/source/Firebase/ABTesting/ABTesting.csproj b/source/Firebase/ABTesting/ABTesting.csproj index d7fbd1b1..3adf568a 100644 --- a/source/Firebase/ABTesting/ABTesting.csproj +++ b/source/Firebase/ABTesting/ABTesting.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.ABTesting Firebase.ABTesting - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Analytics/Analytics.csproj b/source/Firebase/Analytics/Analytics.csproj index 609b2334..fa7b0ee0 100644 --- a/source/Firebase/Analytics/Analytics.csproj +++ b/source/Firebase/Analytics/Analytics.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Analytics Firebase.Analytics - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -28,7 +28,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/AppCheck/AppCheck.csproj b/source/Firebase/AppCheck/AppCheck.csproj index d9a6d882..b437e245 100644 --- a/source/Firebase/AppCheck/AppCheck.csproj +++ b/source/Firebase/AppCheck/AppCheck.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.AppCheck Firebase.AppCheck - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/AppDistribution/AppDistribution.csproj b/source/Firebase/AppDistribution/AppDistribution.csproj index 4a9c2499..a8da7d41 100644 --- a/source/Firebase/AppDistribution/AppDistribution.csproj +++ b/source/Firebase/AppDistribution/AppDistribution.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.AppDistribution Firebase.AppDistribution - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Auth/Auth.csproj b/source/Firebase/Auth/Auth.csproj index 6a2d3151..87a5e343 100644 --- a/source/Firebase/Auth/Auth.csproj +++ b/source/Firebase/Auth/Auth.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Auth Firebase.Auth - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/CloudFirestore/CloudFirestore.csproj b/source/Firebase/CloudFirestore/CloudFirestore.csproj index f8aac3d4..cb6a7c14 100644 --- a/source/Firebase/CloudFirestore/CloudFirestore.csproj +++ b/source/Firebase/CloudFirestore/CloudFirestore.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.CloudFirestore Firebase.CloudFirestore - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/CloudFunctions/CloudFunctions.csproj b/source/Firebase/CloudFunctions/CloudFunctions.csproj index d8bb2e2e..e765fec6 100644 --- a/source/Firebase/CloudFunctions/CloudFunctions.csproj +++ b/source/Firebase/CloudFunctions/CloudFunctions.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.CloudFunctions Firebase.CloudFunctions - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/CloudMessaging/CloudMessaging.csproj b/source/Firebase/CloudMessaging/CloudMessaging.csproj index fbd46c75..05793568 100644 --- a/source/Firebase/CloudMessaging/CloudMessaging.csproj +++ b/source/Firebase/CloudMessaging/CloudMessaging.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.CloudMessaging Firebase.CloudMessaging - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Core/Core.csproj b/source/Firebase/Core/Core.csproj index 9006b5c4..5d46ca98 100644 --- a/source/Firebase/Core/Core.csproj +++ b/source/Firebase/Core/Core.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Core Firebase.Core - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Crashlytics/Crashlytics.csproj b/source/Firebase/Crashlytics/Crashlytics.csproj index eb624bea..1f7dfae2 100644 --- a/source/Firebase/Crashlytics/Crashlytics.csproj +++ b/source/Firebase/Crashlytics/Crashlytics.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Crashlytics Firebase.Crashlytics - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Crashlytics/Crashlytics.targets b/source/Firebase/Crashlytics/Crashlytics.targets index 6621db5a..5d4bf025 100644 --- a/source/Firebase/Crashlytics/Crashlytics.targets +++ b/source/Firebase/Crashlytics/Crashlytics.targets @@ -1,7 +1,7 @@ - <_FirebaseCrashlyticsAssemblyName>Firebase.Crashlytics, Version=12.19.3, Culture=neutral, PublicKeyToken=null - <_FirebaseCrashlyticsItemsFolder>FCrshlytcs-12.19.3 + <_FirebaseCrashlyticsAssemblyName>Firebase.Crashlytics, Version=12.19.3.1, Culture=neutral, PublicKeyToken=null + <_FirebaseCrashlyticsItemsFolder>FCrshlytcs-12.19.3.1 <_FirebaseCrashlyticsSDKBaseFolder>$(XamarinBuildDownloadDir)$(_FirebaseCrashlyticsItemsFolder)\ <_FirebaseScriptName>upload-symbols.sh diff --git a/source/Firebase/Database/Database.csproj b/source/Firebase/Database/Database.csproj index 38f0d37b..4ad70148 100644 --- a/source/Firebase/Database/Database.csproj +++ b/source/Firebase/Database/Database.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Database Firebase.Database - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/InAppMessaging/InAppMessaging.csproj b/source/Firebase/InAppMessaging/InAppMessaging.csproj index 94fcee16..9d997e5b 100644 --- a/source/Firebase/InAppMessaging/InAppMessaging.csproj +++ b/source/Firebase/InAppMessaging/InAppMessaging.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.InAppMessaging Firebase.InAppMessaging - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Installations/Installations.csproj b/source/Firebase/Installations/Installations.csproj index ffab754e..a163d610 100644 --- a/source/Firebase/Installations/Installations.csproj +++ b/source/Firebase/Installations/Installations.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Installations Firebase.Installations - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/PerformanceMonitoring/PerformanceMonitoring.csproj b/source/Firebase/PerformanceMonitoring/PerformanceMonitoring.csproj index 65bece1e..f60b57fd 100644 --- a/source/Firebase/PerformanceMonitoring/PerformanceMonitoring.csproj +++ b/source/Firebase/PerformanceMonitoring/PerformanceMonitoring.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.PerformanceMonitoring Firebase.PerformanceMonitoring - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/RemoteConfig/RemoteConfig.csproj b/source/Firebase/RemoteConfig/RemoteConfig.csproj index 40064338..0379ee90 100644 --- a/source/Firebase/RemoteConfig/RemoteConfig.csproj +++ b/source/Firebase/RemoteConfig/RemoteConfig.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.RemoteConfig Firebase.RemoteConfig - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Firebase/Storage/Storage.csproj b/source/Firebase/Storage/Storage.csproj index ffc6291d..90296b44 100644 --- a/source/Firebase/Storage/Storage.csproj +++ b/source/Firebase/Storage/Storage.csproj @@ -8,8 +8,8 @@ 15.0 Firebase.Storage Firebase.Storage - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -27,7 +27,7 @@ License.md NUGET_README.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Google/AppCheckCore/AppCheckCore.csproj b/source/Google/AppCheckCore/AppCheckCore.csproj index 46fb1fcc..7496f2dc 100644 --- a/source/Google/AppCheckCore/AppCheckCore.csproj +++ b/source/Google/AppCheckCore/AppCheckCore.csproj @@ -8,8 +8,8 @@ 15.0 Google.AppCheckCore Google.AppCheckCore - 11.3.2 - 11.3.2 + 11.3.2.1 + 11.3.2.1 Resources true true @@ -26,7 +26,7 @@ https://github.com/AdamEssenmacher/GoogleApisForiOSComponents License.md true - 11.3.2 + 11.3.2.1 diff --git a/source/Google/GTMSessionFetcher/GTMSessionFetcher.csproj b/source/Google/GTMSessionFetcher/GTMSessionFetcher.csproj index 48fd2825..d3403557 100644 --- a/source/Google/GTMSessionFetcher/GTMSessionFetcher.csproj +++ b/source/Google/GTMSessionFetcher/GTMSessionFetcher.csproj @@ -8,8 +8,8 @@ 15.0 Google.GTMSessionFetcher Google.GTMSessionFetcher - 3.5.0.8 - 3.5.0.8 + 3.5.0.9 + 3.5.0.9 Resources true true @@ -26,7 +26,7 @@ License.md NUGET_README.md true - 3.5.0.8 + 3.5.0.9 diff --git a/source/Google/GoogleAppMeasurement/GoogleAppMeasurement.csproj b/source/Google/GoogleAppMeasurement/GoogleAppMeasurement.csproj index 9318a46d..46819e0a 100644 --- a/source/Google/GoogleAppMeasurement/GoogleAppMeasurement.csproj +++ b/source/Google/GoogleAppMeasurement/GoogleAppMeasurement.csproj @@ -8,8 +8,8 @@ 15.0 Google.GoogleAppMeasurement Google.GoogleAppMeasurement - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 Resources true true @@ -25,7 +25,7 @@ https://github.com/AdamEssenmacher/GoogleApisForiOSComponents License.md true - 12.19.3 + 12.19.3.1 diff --git a/source/Google/GoogleDataTransport/GoogleDataTransport.csproj b/source/Google/GoogleDataTransport/GoogleDataTransport.csproj index 71ff8481..a865a43e 100644 --- a/source/Google/GoogleDataTransport/GoogleDataTransport.csproj +++ b/source/Google/GoogleDataTransport/GoogleDataTransport.csproj @@ -8,8 +8,8 @@ 15.0 Google.GoogleDataTransport Google.GoogleDataTransport - 10.1.0.6 - 10.1.0.6 + 10.1.0.7 + 10.1.0.7 Resources true true @@ -25,7 +25,7 @@ https://github.com/AdamEssenmacher/GoogleApisForiOSComponents License.md true - 10.1.0.6 + 10.1.0.7 diff --git a/source/Google/GoogleUtilities/GoogleUtilities.csproj b/source/Google/GoogleUtilities/GoogleUtilities.csproj index fd3380ae..e376bbb0 100644 --- a/source/Google/GoogleUtilities/GoogleUtilities.csproj +++ b/source/Google/GoogleUtilities/GoogleUtilities.csproj @@ -8,8 +8,8 @@ 15.0 Google.GoogleUtilities Google.GoogleUtilities - 8.1.5 - 8.1.5 + 8.1.5.1 + 8.1.5.1 Resources true true @@ -26,7 +26,7 @@ License.md NUGET_README.md true - 8.1.5 + 8.1.5.1 diff --git a/source/Google/Nanopb/Nanopb.csproj b/source/Google/Nanopb/Nanopb.csproj index fd98b92d..44d45935 100644 --- a/source/Google/Nanopb/Nanopb.csproj +++ b/source/Google/Nanopb/Nanopb.csproj @@ -8,8 +8,8 @@ 15.0 Google.Nanopb Google.Nanopb - 3.30910.0.6 - 3.30910.0.6 + 3.30910.0.7 + 3.30910.0.7 Resources true true @@ -25,7 +25,7 @@ https://github.com/AdamEssenmacher/GoogleApisForiOSComponents License.md true - 3.30910.0.6 + 3.30910.0.7 diff --git a/source/Google/PromisesObjC/PromisesObjC.csproj b/source/Google/PromisesObjC/PromisesObjC.csproj index 84734584..7b3fd893 100644 --- a/source/Google/PromisesObjC/PromisesObjC.csproj +++ b/source/Google/PromisesObjC/PromisesObjC.csproj @@ -8,8 +8,8 @@ 15.0 Google.PromisesObjC Google.PromisesObjC - 2.4.0.8 - 2.4.0.8 + 2.4.0.9 + 2.4.0.9 Resources true true @@ -26,7 +26,7 @@ License.md NUGET_README.md true - 2.4.0.8 + 2.4.0.9 diff --git a/source/Google/SignIn/SignIn.csproj b/source/Google/SignIn/SignIn.csproj index 3b9f4bd8..132f586b 100644 --- a/source/Google/SignIn/SignIn.csproj +++ b/source/Google/SignIn/SignIn.csproj @@ -8,8 +8,8 @@ 15.0 Google.SignIn Google.SignIn - 10.1.0.0 - 10.1.0.0 + 10.1.0.1 + 10.1.0.1 Resources true @@ -26,7 +26,7 @@ License.md NUGET_README.md true - 10.1.0.0 + 10.1.0.1 diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj index 6f09251d..fb9e70db 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj @@ -21,8 +21,8 @@ - 12.19.3 - 12.19.3 + 12.19.3.1 + 12.19.3.1 $(NoWarn);NU1603 diff --git a/tests/E2E/Firebase.Foundation/binding-surface-coverage.json b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json index 35082c3b..48116a61 100644 --- a/tests/E2E/Firebase.Foundation/binding-surface-coverage.json +++ b/tests/E2E/Firebase.Foundation/binding-surface-coverage.json @@ -53,7 +53,7 @@ "requiredExtraPackages": [ { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -166,7 +166,7 @@ "requiredExtraPackages": [ { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, diff --git a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json index 447bbfd6..3a3812e1 100644 --- a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json +++ b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json @@ -25,7 +25,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.AppCheck", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -36,11 +36,11 @@ "packages": [ { "id": "AdamE.Firebase.iOS.RemoteConfig", - "version": "12.19.3" + "version": "12.19.3.1" }, { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -51,7 +51,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.Database", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -62,7 +62,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.Database", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -73,7 +73,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -84,7 +84,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -95,7 +95,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.ABTesting", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -106,7 +106,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -117,7 +117,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -128,7 +128,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -139,7 +139,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -150,7 +150,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -161,7 +161,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -172,7 +172,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -183,7 +183,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -194,7 +194,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFirestore", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -205,7 +205,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.CloudFunctions", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -216,7 +216,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.Crashlytics", - "version": "12.19.3" + "version": "12.19.3.1" } ] }, @@ -227,7 +227,7 @@ "packages": [ { "id": "AdamE.Firebase.iOS.Crashlytics", - "version": "12.19.3" + "version": "12.19.3.1" } ] } From 4d946da13196ae24dfc819f28127109aad118208 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Thu, 5 Nov 2026 14:37:12 -0500 Subject: [PATCH 2/3] Accept the Mac Catalyst framework plist layout --- common.cake | 5 ++++- .../Fixtures/NativeCacheHarness.cs.txt | 6 ++++-- .../FirebaseBindingAudit.Tests/NativeCacheTests.cs | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/common.cake b/common.cake index 6bde3a4b..b31f62c5 100644 --- a/common.cake +++ b/common.cake @@ -714,7 +714,10 @@ void BuildXcodeXcframework (FilePath xcodeProject, PodSpec [] podSpecs, Platform DeleteDirectory (archiveDirectory, new DeleteDirectorySettings { Recursive = true, Force = true }); XCodeBuild (xcodeBuildSettings); var frameworkPath = archiveFrameworksDirectory.Combine ($"{podSpec.FrameworkName}.framework"); - if (!FileExists (frameworkPath.CombineWithFilePath ("Info.plist")) || + // Catalyst uses a versioned framework with its plist under Resources. + var hasInfoPlist = FileExists (frameworkPath.CombineWithFilePath ("Info.plist")) || + FileExists (frameworkPath.CombineWithFilePath ("Resources/Info.plist")); + if (!hasInfoPlist || !FileExists (frameworkPath.CombineWithFilePath (podSpec.FrameworkName))) throw new Exception ($"Incomplete native archive: {archiveDirectory}"); if (cacheable) diff --git a/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt index b44dc5b6..4c29d586 100644 --- a/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt +++ b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt @@ -25,7 +25,7 @@ class PodSpec { public class Harness { public string Version = "1.0", Key = "key", Hash = "", UrlPrefix = "https://example.test"; public string Settings = "default", Architectures = "arm64", XcodeId = "Xcode 1", CopyFailure = "", BuildFailure = ""; - public bool Local; + public bool Local, Catalyst; public int Downloads, Copies, Builds, Creates; string Root; Dictionary ExternalDownloads = new Dictionary(); @@ -64,6 +64,8 @@ public class Harness { if (BuildFailure == "throw") throw new IOException("Interrupted build"); if (BuildFailure == "partial") return; Framework(path, "Widget"); + if (Catalyst) + File.Move(Path.Combine(path, "Info.plist"), Path.Combine(path, "Resources/Info.plist")); Write(Path.Combine(path, "Widget"), Version); } void DownloadArchive(ExternalDownloadSource source, FilePath path) { Downloads++; Write(MakeAbsolute(path).FullPath, "archive"); } @@ -98,5 +100,5 @@ public class Harness { public void Analytics() { SetDownload("FirebaseAnalytics"); FirebaseAnalyticsDownload(); } public void Build() => BuildXcodeXcframework("Pods/Pods.xcodeproj", new[] { new PodSpec { Version = Version, Source = Local ? PodSpec.PodSource.LocalPath : PodSpec.PodSource.PodVersion } }, - new[] { PlatformV2.Create(Sdk.iPhoneOS, new[] { Architectures == "arm64" ? Arch.Arm64 : Arch.x86_64 }) }, + new[] { PlatformV2.Create(Catalyst ? Sdk.macCatalyst : Sdk.iPhoneOS, new[] { Architectures == "arm64" ? Arch.Arm64 : Arch.x86_64 }) }, new DirectoryPath(Path.Combine(Root, "build")), new Dictionary { ["CUSTOM"] = Settings }); diff --git a/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs index 6e988c25..3551d215 100644 --- a/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs @@ -117,6 +117,19 @@ public void Archive_UnchangedInputReusesButStillCreatesXcframework() Assert.Equal(1, Count("Builds")); Assert.Equal(2, Count("Creates")); } + [Fact] + public void Archive_CatalystResourcesPlistIsRequiredAndCanBeCached() + { + Set("Catalyst", true); + Run("Build"); + Run("Build"); + Assert.Equal(1, Count("Builds")); + + File.Delete(Path.Combine(root, "build/Widget/maccatalyst.xcarchive/Products/Library/Frameworks/Widget.framework/Resources/Info.plist")); + Run("Build"); + Assert.Equal(2, Count("Builds")); + } + [Theory] [InlineData("Version")] [InlineData("Settings")] From 48e60e965e0d9b76745ae9eea614c5741cbcb5d1 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Thu, 5 Nov 2026 15:02:40 -0500 Subject: [PATCH 3/3] Record framework symlinks without following them --- common.cake | 28 ++++++++++++++----- .../Fixtures/NativeCacheHarness.cs.txt | 6 +++- .../NativeCacheTests.cs | 16 +++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/common.cake b/common.cake index b31f62c5..0f4e3a50 100644 --- a/common.cake +++ b/common.cake @@ -572,18 +572,32 @@ class NativeCacheRecord { var entries = new List (); foreach (var root in roots) { - var files = System.IO.Directory.GetFiles (root, "*", System.IO.SearchOption.AllDirectories); - if (files.Length == 0) - throw new System.IO.IOException ($"Native cache output is empty: {root}"); entries.Add ("root"); - foreach (var file in files.OrderBy (p => p, StringComparer.Ordinal)) { - var relative = System.IO.Path.GetRelativePath (root, file).Replace ('\\', '/'); - entries.Add (Convert.ToBase64String (System.Text.Encoding.UTF8.GetBytes (relative)) + ":" + FileHash (file)); - } + var firstEntry = entries.Count; + InventoryDirectory (root, new System.IO.DirectoryInfo (root), entries); + if (entries.Count == firstEntry) + throw new System.IO.IOException ($"Native cache output is empty: {root}"); } return Hash (string.Join ("\n", entries)); } + static void InventoryDirectory (string root, System.IO.DirectoryInfo directory, List entries) + { + foreach (var entry in directory.EnumerateFileSystemInfos ().OrderBy (p => p.Name, StringComparer.Ordinal)) { + var relative = System.IO.Path.GetRelativePath (root, entry.FullName).Replace ('\\', '/'); + var pathKey = Convert.ToBase64String (System.Text.Encoding.UTF8.GetBytes (relative)); + // Frameworks may contain directory aliases or dangling links in nested dependencies. + // Record each link without walking through it or opening its target. + var linkTarget = entry.LinkTarget; + if (linkTarget != null) + entries.Add (pathKey + ":link:" + Hash (linkTarget)); + else if (entry is System.IO.DirectoryInfo child) + InventoryDirectory (root, child, entries); + else + entries.Add (pathKey + ":" + FileHash (entry.FullName)); + } + } + public static bool Matches (string stamp, string identity, string[] roots) { if (!System.IO.File.Exists (stamp)) diff --git a/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt index 4c29d586..b2c718c2 100644 --- a/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt +++ b/scripts/FirebaseBindingAudit.Tests/Fixtures/NativeCacheHarness.cs.txt @@ -25,7 +25,7 @@ class PodSpec { public class Harness { public string Version = "1.0", Key = "key", Hash = "", UrlPrefix = "https://example.test"; public string Settings = "default", Architectures = "arm64", XcodeId = "Xcode 1", CopyFailure = "", BuildFailure = ""; - public bool Local, Catalyst; + public bool Local, Catalyst, FrameworkLinks; public int Downloads, Copies, Builds, Creates; string Root; Dictionary ExternalDownloads = new Dictionary(); @@ -66,6 +66,10 @@ public class Harness { Framework(path, "Widget"); if (Catalyst) File.Move(Path.Combine(path, "Info.plist"), Path.Combine(path, "Resources/Info.plist")); + if (FrameworkLinks) { + System.IO.Directory.CreateSymbolicLink(Path.Combine(path, "ResourceAlias"), "Resources"); + File.CreateSymbolicLink(Path.Combine(path, "Headers"), "MissingHeaders"); + } Write(Path.Combine(path, "Widget"), Version); } void DownloadArchive(ExternalDownloadSource source, FilePath path) { Downloads++; Write(MakeAbsolute(path).FullPath, "archive"); } diff --git a/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs index 3551d215..f38bf27c 100644 --- a/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs +++ b/scripts/FirebaseBindingAudit.Tests/NativeCacheTests.cs @@ -130,6 +130,22 @@ public void Archive_CatalystResourcesPlistIsRequiredAndCanBeCached() Assert.Equal(2, Count("Builds")); } + [Fact] + public void Archive_RecordsFrameworkLinksWithoutFollowingThem() + { + if (OperatingSystem.IsWindows()) return; + Set("FrameworkLinks", true); + Run("Build"); + Run("Build"); + Assert.Equal(1, Count("Builds")); + + var link = Path.Combine(Archive, "Products/Library/Frameworks/Widget.framework/Headers"); + File.Delete(link); + File.CreateSymbolicLink(link, "ChangedHeaders"); + Run("Build"); + Assert.Equal(2, Count("Builds")); + } + [Theory] [InlineData("Version")] [InlineData("Settings")]