From fc744be5f1dca2fbd9ea7a334cd05e7749fe0018 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 21 Jul 2026 13:59:14 -0500 Subject: [PATCH 01/16] [tests] Isolate Maven resolution on CI Route test Maven and Gradle resolution through dotnet-public-maven when RUNNINGONCI is set while retaining public repositories for local development. Teach generated Gradle projects to use the shared repository configuration and extend the mirror helper for tests that resolve Maven files without Gradle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .github/instructions/gradle.instructions.md | 10 +++ eng/gradle/mirror-dependencies.ps1 | 77 ++++++++++++++++--- .../Extensions/MavenProjectResolver.cs | 10 ++- .../tools/java-source-utils/build.gradle | 6 -- .../tools/java-source-utils/settings.gradle | 18 +++-- .../AndroidGradleProjectTests.cs | 3 + .../BindingBuildTest.cs | 9 ++- .../Tasks/MavenDownloadTests.cs | 44 +++++++---- .../Android/AndroidGradleProject.cs | 32 ++++---- .../Common/DownloadedCache.cs | 2 +- .../Common/TestEnvironment.cs | 28 ++++++- .../Tests/InstallAndRunTests.cs | 2 + 12 files changed, 182 insertions(+), 59 deletions(-) diff --git a/.github/instructions/gradle.instructions.md b/.github/instructions/gradle.instructions.md index 805e8a9649d..8cf1266a884 100644 --- a/.github/instructions/gradle.instructions.md +++ b/.github/instructions/gradle.instructions.md @@ -57,6 +57,16 @@ The mirror must run in the project that actually needs the new package — a sib After it succeeds, just re-run the failed CI job. No PR edits needed — the packages are now anonymous-readable forever. +Tests that resolve Maven files without Gradle can seed coordinates directly: + +```powershell +pwsh ./eng/gradle/mirror-dependencies.ps1 ` + -MavenArtifact 'androidx.core:core:1.12.0' +``` + +This attempts the coordinate's POM, JAR, AAR, and Gradle module metadata. Append +the exact filename as a fourth segment for a nonstandard payload. + ## Don'ts - Don't hard-code Maven repo URLs in `build.gradle` / `settings.gradle`; use the shared file. diff --git a/eng/gradle/mirror-dependencies.ps1 b/eng/gradle/mirror-dependencies.ps1 index 93e586d85ed..4fd5db79c4a 100644 --- a/eng/gradle/mirror-dependencies.ps1 +++ b/eng/gradle/mirror-dependencies.ps1 @@ -1,8 +1,8 @@ #!/usr/bin/env pwsh <# .SYNOPSIS - Mirrors a gradle project's dependencies into the dnceng dotnet-public-maven - Azure Artifacts feed so CI can resolve them anonymously. + Mirrors Gradle dependencies or explicit Maven artifacts into the dnceng + dotnet-public-maven Azure Artifacts feed so CI can resolve them anonymously. .DESCRIPTION When Dependabot bumps a gradle dependency (or its transitive graph changes), @@ -30,6 +30,12 @@ Gradle task(s) to run. Should be one that resolves the new dependency graph (e.g. 'assembleDebug', 'build', 'extractProguardFiles'). +.PARAMETER MavenArtifact + Maven coordinates to mirror directly, for tests that do not use Gradle. + Each value is group:artifact:version, which attempts the POM, JAR, AAR, and + Gradle module metadata files. Append an exact filename as a fourth segment + when a test requests a nonstandard payload. + .PARAMETER GradleWrapper Optional path to the Gradle wrapper used by CI for this project, relative to the repository root or absolute. Defaults to build-tools/gradle/gradlew. @@ -59,19 +65,30 @@ -ProjectDir external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle ` -Task classes ` -GradleWrapper external/Java.Interop/build-tools/gradle/gradlew.bat + +.EXAMPLE + pwsh ./eng/gradle/mirror-dependencies.ps1 ` + -MavenArtifact 'androidx.core:core:1.12.0', ` + 'com.facebook.react:react-android:0.76.1:react-android-0.76.1.module' #> -[CmdletBinding()] +[CmdletBinding(DefaultParameterSetName='Gradle')] param( - [Parameter(Mandatory=$true)] - [string] $ProjectDir, + [Parameter(Mandatory=$true, ParameterSetName='Gradle')] + [string] $ProjectDir = '.', - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$true, ParameterSetName='Gradle')] [string] $Task, + [Parameter(Mandatory=$true, ParameterSetName='MavenArtifact')] + [string[]] $MavenArtifact, + + [Parameter(ParameterSetName='Gradle')] [string] $GradleWrapper, + [Parameter(ParameterSetName='Gradle')] [string] $AndroidHome = $env:ANDROID_HOME, + [Parameter(ParameterSetName='Gradle')] [int] $MaxIterations = 15 ) @@ -125,15 +142,57 @@ function Invoke-Mirror($logPath) { return $urls.Count } +function Get-MavenArtifactUrls($artifacts) { + $feedBaseUrl = 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1' + foreach ($artifact in $artifacts) { + $parts = $artifact.Split(':', 4) + if ($parts.Count -lt 3) { + throw "Invalid Maven artifact '$artifact'. Expected group:artifact:version[:filename]." + } + $group = $parts[0].Replace('.', '/') + $name = $parts[1] + $version = $parts[2] + $filenames = if ($parts.Count -eq 4) { + @($parts[3]) + } else { + @( + "$name-$version.pom" + "$name-$version.jar" + "$name-$version.aar" + "$name-$version.module" + ) + } + foreach ($filename in $filenames) { + "$feedBaseUrl/$group/$name/$version/$filename" + } + } +} + +# Verify az is available and authenticated up front so we fail fast. +Get-AzDevOpsToken | Out-Null + +if ($PSCmdlet.ParameterSetName -eq 'MavenArtifact') { + Write-Host "Mirroring Maven artifacts directly:" + $MavenArtifact | ForEach-Object { Write-Host " $_" } + $log = Join-Path ([IO.Path]::GetTempPath()) 'maven-artifact-mirror.log' + try { + Get-MavenArtifactUrls $MavenArtifact | + ForEach-Object { "Could not GET '$_'" } | + Set-Content $log + Invoke-Mirror $log | Out-Null + } + finally { + Remove-Item $log -ErrorAction SilentlyContinue + } + return +} + Write-Host "Repo root: $repoRoot" Write-Host "Project: $projectDirAbs" Write-Host "Task: $Task" Write-Host "Gradle: $gradlew" if ($AndroidHome) { Write-Host "ANDROID_HOME: $AndroidHome" } -# Verify az is available and authenticated up front so we fail fast. -Get-AzDevOpsToken | Out-Null - if ($AndroidHome) { $env:ANDROID_HOME = $AndroidHome } $env:RUNNINGONCI = 'true' $env:ANDROID_MIRROR_MAVEN_DEPENDENCIES = 'true' diff --git a/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/Extensions/MavenProjectResolver.cs b/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/Extensions/MavenProjectResolver.cs index 1d208c32716..37ca8423a3b 100644 --- a/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/Extensions/MavenProjectResolver.cs +++ b/external/Java.Interop/tests/Java.Interop.Tools.Maven-Tests/Extensions/MavenProjectResolver.cs @@ -8,6 +8,7 @@ namespace Java.Interop.Tools.Maven_Tests.Extensions; class MavenProjectResolver : IProjectResolver { + const string DotNetPublicMaven = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1"; readonly IMavenRepository repository; public MavenProjectResolver (IMavenRepository repository) @@ -19,10 +20,15 @@ static MavenProjectResolver () { var cache_path = Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.LocalApplicationData), "dotnet-android", "MavenCacheDirectory"); - Central = new MavenProjectResolver (new CachedMavenRepository (cache_path, MavenRepository.Central)); - Google = new MavenProjectResolver (new CachedMavenRepository (cache_path, MavenRepository.Google)); + Central = new MavenProjectResolver (new CachedMavenRepository (cache_path, GetRepository (MavenRepository.Central, "central"))); + Google = new MavenProjectResolver (new CachedMavenRepository (cache_path, GetRepository (MavenRepository.Google, "google"))); } + static MavenRepository GetRepository (MavenRepository localRepository, string name) => + string.Equals (Environment.GetEnvironmentVariable ("RUNNINGONCI"), "true", StringComparison.OrdinalIgnoreCase) + ? new MavenRepository (DotNetPublicMaven, name) + : localRepository; + public Project Resolve (Artifact artifact) { if (repository.TryGetFile (artifact, $"{artifact.Id}-{artifact.Version}.pom", out var stream)) { diff --git a/external/Java.Interop/tools/java-source-utils/build.gradle b/external/Java.Interop/tools/java-source-utils/build.gradle index d763f5fe3fe..ad1639e10e7 100644 --- a/external/Java.Interop/tools/java-source-utils/build.gradle +++ b/external/Java.Interop/tools/java-source-utils/build.gradle @@ -22,12 +22,6 @@ java { targetCompatibility = ext.javaTargetVer } -repositories { - // Use maven central for resolving dependencies. - // You can declare any Maven/Ivy/file repository here. - mavenCentral() -} - dependencies { // This dependency is used by the application. implementation 'com.github.javaparser:javaparser-core:3.28.2' diff --git a/external/Java.Interop/tools/java-source-utils/settings.gradle b/external/Java.Interop/tools/java-source-utils/settings.gradle index 8a5eebc5986..2c530fcaa3c 100644 --- a/external/Java.Interop/tools/java-source-utils/settings.gradle +++ b/external/Java.Interop/tools/java-source-utils/settings.gradle @@ -1,10 +1,12 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * The settings file is used to specify which projects to include in your build. - * - * Detailed information about configuring a multi-project build in Gradle can be found - * in the user manual at https://docs.gradle.org/6.3/userguide/multi_project_builds.html - */ +// See: eng/gradle/plugin-repositories.gradle, eng/gradle/dependency-repositories.gradle +pluginManagement { + apply from: "${rootDir}/../../../../eng/gradle/plugin-repositories.gradle", to: pluginManagement +} +if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { + apply from: "${rootDir}/../../../../eng/gradle/credential-provider.gradle" +} +dependencyResolutionManagement { + apply from: "${rootDir}/../../../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement +} rootProject.name = 'java-source-utils' diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index 9fa0060780b..e6ff057c4b1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -598,6 +598,9 @@ static IEnumerable GetAgpGradleVersionTestData () [TestCaseSource (nameof (GetAgpGradleVersionTestData))] public void BindLibraryWithMultipleGradleVersions (string agpVersion, string gradleVersion, int compileSdk) { + if (TestEnvironment.IsRunningOnCI) + Assert.Ignore ("Alternate Gradle distributions are not downloaded from public services in CI."); + var gradleProject = AndroidGradleProject.CreateDefault (GradleTestProjectDir, agpVersion, gradleVersion, compileSdk: compileSdk); var gradleModule = gradleProject.Modules.First (); var moduleName = gradleModule.Name; diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs index b445f7cb60e..a68b12299e9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs @@ -1011,6 +1011,7 @@ public void AndroidMavenLibrary ([Values (AndroidRuntime.CoreCLR, AndroidRuntime // Test that downloads .jar from Maven and successfully binds it var item = new BuildItem ("AndroidMavenLibrary", "com.google.auto.value:auto-value-annotations"); item.Metadata.Add ("Version", "1.10.4"); + item.Metadata.Add ("Repository", TestEnvironment.GetMavenRepository ("Central")); var proj = new XamarinAndroidBindingProject { IsRelease = isRelease, @@ -1039,7 +1040,7 @@ public void AndroidMavenLibrary_FailsDueToUnverifiedDependency ([Values (Android // var item = new BuildItem ("AndroidMavenLibrary", "androidx.core:core"); item.Metadata.Add ("Version", "1.9.0"); - item.Metadata.Add ("Repository", "Google"); + item.Metadata.Add ("Repository", TestEnvironment.GetMavenRepository ("Google")); var proj = new XamarinAndroidBindingProject { IsRelease = isRelease, @@ -1068,7 +1069,7 @@ public void AndroidMavenLibrary_IgnoreDependencyVerification ([Values (AndroidRu // var item = new BuildItem ("AndroidMavenLibrary", "androidx.core:core"); item.Metadata.Add ("Version", "1.9.0"); - item.Metadata.Add ("Repository", "Google"); + item.Metadata.Add ("Repository", TestEnvironment.GetMavenRepository ("Google")); item.Metadata.Add ("VerifyDependencies", "false"); item.Metadata.Add ("Bind", "false"); @@ -1097,7 +1098,7 @@ public void AndroidMavenLibrary_AllDependenciesAreVerified ([Values (AndroidRunt // var item = new BuildItem ("AndroidMavenLibrary", "androidx.core:core"); item.Metadata.Add ("Version", "1.9.0"); - item.Metadata.Add ("Repository", "Google"); + item.Metadata.Add ("Repository", TestEnvironment.GetMavenRepository ("Google")); item.Metadata.Add ("Bind", "false"); // Dependency fulfilled by @@ -1109,7 +1110,7 @@ public void AndroidMavenLibrary_AllDependenciesAreVerified ([Values (AndroidRunt // Dependency fulfilled by var annotations_experimental_androidlib = new BuildItem ("AndroidMavenLibrary", "androidx.annotation:annotation-experimental"); annotations_experimental_androidlib.Metadata.Add ("Version", "1.3.0"); - annotations_experimental_androidlib.Metadata.Add ("Repository", "Google"); + annotations_experimental_androidlib.Metadata.Add ("Repository", TestEnvironment.GetMavenRepository ("Google")); annotations_experimental_androidlib.Metadata.Add ("Bind", "false"); annotations_experimental_androidlib.Metadata.Add ("VerifyDependencies", "false"); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs index 9cd088d44f8..218bf76274c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs @@ -7,14 +7,12 @@ using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks; +using Xamarin.ProjectTools; using Task = System.Threading.Tasks.Task; namespace Xamarin.Android.Build.Tests; public class MavenDownloadTests { - // Internal CI cannot resolve Maven Central directly; the public mirror provides the same artifacts anonymously. - const string DotNetPublicMaven = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1"; - [Test] public async Task MissingVersionMetadata () { @@ -94,7 +92,7 @@ public async Task InsecureHttpRepository_Blocked () public async Task InsecureHttpRepository_AllowedWithOptIn () { var engine = new MockBuildEngine (TestContext.Out, new List ()); - var item = CreateMavenTaskItem ("com.example:dummy", "1.0.0", "http://repo.example.com/maven2/"); + var item = CreateMavenTaskItem ("com.example:dummy", "1.0.0", "http://127.0.0.1:1/maven2/"); item.SetMetadata ("AllowInsecureHttp", "true"); var task = new MavenDownload { @@ -113,6 +111,9 @@ public async Task InsecureHttpRepository_AllowedWithOptIn () [Test] public async Task UnknownArtifact () { + if (TestEnvironment.IsRunningOnCI) + Assert.Ignore ("The CI mirror returns 401 for uncached artifacts instead of Maven Central's 404."); + var engine = new MockBuildEngine (TestContext.Out, new List ()); var task = new MavenDownload { BuildEngine = engine, @@ -129,6 +130,9 @@ public async Task UnknownArtifact () [Test] public async Task UnknownPom () { + if (TestEnvironment.IsRunningOnCI) + Assert.Ignore ("The CI mirror returns 401 for uncached artifacts instead of Maven Central's 404."); + var temp_cache_dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); try { @@ -141,7 +145,10 @@ public async Task UnknownPom () // Create the dummy jar so we bypass that step and try to download the dummy pom var dummy_jar = Path.Combine (temp_cache_dir, "central", "com.example", "dummy", "1.0.0", "dummy-1.0.0.jar"); - Directory.CreateDirectory (Path.GetDirectoryName (dummy_jar)!); + var dummy_jar_directory = Path.GetDirectoryName (dummy_jar); + if (dummy_jar_directory is null) + throw new InvalidOperationException ($"Could not determine the directory for '{dummy_jar}'."); + Directory.CreateDirectory (dummy_jar_directory); using (File.Create (dummy_jar)) { } @@ -164,7 +171,7 @@ public async Task MavenCentralSuccess () var task = new MavenDownload { BuildEngine = engine, MavenCacheDirectory = temp_cache_dir, - AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.auto.value:auto-value-annotations", "1.10.4", DotNetPublicMaven)], + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.auto.value:auto-value-annotations", "1.10.4", TestEnvironment.GetMavenRepository ("Central"))], }; await task.RunTaskAsync (); @@ -172,7 +179,10 @@ public async Task MavenCentralSuccess () Assert.AreEqual (0, engine.Errors.Count); Assert.AreEqual (1, task.ResolvedAndroidMavenLibraries?.Length); - var output_item = task.ResolvedAndroidMavenLibraries! [0]; + var output_items = task.ResolvedAndroidMavenLibraries; + if (output_items is null) + throw new InvalidOperationException ("MavenDownload did not produce resolved libraries."); + var output_item = output_items [0]; Assert.AreEqual ("com.google.auto.value:auto-value-annotations:1.10.4", output_item.GetMetadata ("JavaArtifact")); Assert.That (output_item.GetMetadata ("Manifest"), Does.StartWith (temp_cache_dir)); @@ -192,7 +202,7 @@ public async Task MavenGoogleSuccess () var task = new MavenDownload { BuildEngine = engine, MavenCacheDirectory = temp_cache_dir, - AndroidMavenLibraries = [CreateMavenTaskItem ("androidx.core:core", "1.12.0", "Google")], + AndroidMavenLibraries = [CreateMavenTaskItem ("androidx.core:core", "1.12.0", TestEnvironment.GetMavenRepository ("Google"))], }; await task.RunTaskAsync (); @@ -200,10 +210,14 @@ public async Task MavenGoogleSuccess () Assert.AreEqual (0, engine.Errors.Count); Assert.AreEqual (1, task.ResolvedAndroidMavenLibraries?.Length); - var output_item = task.ResolvedAndroidMavenLibraries! [0]; + var output_items = task.ResolvedAndroidMavenLibraries; + if (output_items is null) + throw new InvalidOperationException ("MavenDownload did not produce resolved libraries."); + var output_item = output_items [0]; Assert.AreEqual ("androidx.core:core:1.12.0", output_item.GetMetadata ("JavaArtifact")); - Assert.AreEqual (Path.Combine (temp_cache_dir, "google", "androidx.core", "core", "1.12.0", "core-1.12.0.pom"), output_item.GetMetadata ("Manifest")); + Assert.That (output_item.GetMetadata ("Manifest"), Does.StartWith (temp_cache_dir)); + Assert.That (output_item.GetMetadata ("Manifest"), Does.EndWith (Path.Combine ("androidx.core", "core", "1.12.0", "core-1.12.0.pom"))); } finally { DeleteTempDirectory (temp_cache_dir); } @@ -221,7 +235,7 @@ public async Task ArtifactFilenameOverride () var task = new MavenDownload { BuildEngine = engine, MavenCacheDirectory = temp_cache_dir, - AndroidMavenLibraries = [CreateMavenTaskItem ("com.facebook.react:react-android", "0.76.1", artifactFilename: "react-android-0.76.1.module")], + AndroidMavenLibraries = [CreateMavenTaskItem ("com.facebook.react:react-android", "0.76.1", TestEnvironment.GetMavenRepository ("Central"), artifactFilename: "react-android-0.76.1.module")], }; await task.RunTaskAsync (); @@ -229,11 +243,15 @@ public async Task ArtifactFilenameOverride () Assert.AreEqual (0, engine.Errors.Count); Assert.AreEqual (1, task.ResolvedAndroidMavenLibraries?.Length); - var output_item = task.ResolvedAndroidMavenLibraries! [0]; + var output_items = task.ResolvedAndroidMavenLibraries; + if (output_items is null) + throw new InvalidOperationException ("MavenDownload did not produce resolved libraries."); + var output_item = output_items [0]; Assert.AreEqual ("com.facebook.react:react-android:0.76.1", output_item.GetMetadata ("JavaArtifact")); Assert.True (output_item.ItemSpec.EndsWith (Path.Combine ("0.76.1", "react-android-0.76.1.module"), StringComparison.OrdinalIgnoreCase)); - Assert.AreEqual (Path.Combine (temp_cache_dir, "central", "com.facebook.react", "react-android", "0.76.1", "react-android-0.76.1.pom"), output_item.GetMetadata ("Manifest")); + Assert.That (output_item.GetMetadata ("Manifest"), Does.StartWith (temp_cache_dir)); + Assert.That (output_item.GetMetadata ("Manifest"), Does.EndWith (Path.Combine ("com.facebook.react", "react-android", "0.76.1", "react-android-0.76.1.pom"))); } finally { DeleteTempDirectory (temp_cache_dir); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index c183570f858..c741176f5d3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -37,7 +37,7 @@ public void Create () Directory.CreateDirectory (ProjectDirectory); gradleCLI.Init (ProjectDirectory); var settingsFile = Path.Combine (ProjectDirectory, "settings.gradle.kts"); - File.WriteAllText (settingsFile, settings_gradle_kts_content); + File.WriteAllText (settingsFile, GetSettingsGradleKtsContent ()); File.WriteAllText (BuildFilePath, GetBuildGradleKtsContent ()); foreach (var module in Modules) { module.Create (); @@ -45,8 +45,9 @@ public void Create () } File.AppendAllText (Path.Combine (ProjectDirectory, "gradle.properties"), "android.useAndroidX=true"); - // Update Gradle wrapper version if specified - if (!string.IsNullOrEmpty (GradleVersion)) { + // The repository wrapper used by Init has already populated its distribution in CI. + // Keep that generated wrapper version there rather than downloading another distribution. + if (!string.IsNullOrEmpty (GradleVersion) && !TestEnvironment.IsRunningOnCI) { var wrapperPropertiesPath = Path.Combine (ProjectDirectory, "gradle", "wrapper", "gradle-wrapper.properties"); if (File.Exists (wrapperPropertiesPath)) { var content = File.ReadAllText (wrapperPropertiesPath); @@ -100,23 +101,24 @@ string GetBuildGradleKtsContent () => id(""com.android.library"") version ""{AgpVersion}"" apply false }} "; - const string settings_gradle_kts_content = -@" + string GetSettingsGradleKtsContent () + { + var gradleConfigurationDirectory = Path.Combine (XABuildPaths.TopDirectory, "eng", "gradle").Replace ('\\', '/'); + + return $$""" +// See: eng/gradle/plugin-repositories.gradle, eng/gradle/dependency-repositories.gradle pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } + apply(from = "{{gradleConfigurationDirectory}}/plugin-repositories.gradle", to = this) +} +if (System.getenv("ANDROID_MIRROR_MAVEN_DEPENDENCIES") == "true") { + apply(from = "{{gradleConfigurationDirectory}}/credential-provider.gradle") } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } + apply(from = "{{gradleConfigurationDirectory}}/dependency-repositories.gradle", to = this) } -"; +"""; + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DownloadedCache.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DownloadedCache.cs index 99158ee487a..bc00ccb8d29 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DownloadedCache.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DownloadedCache.cs @@ -32,6 +32,7 @@ public DownloadedCache (string cacheDirectory) public string GetAsFile (string url, string filename = "") { Directory.CreateDirectory (CacheDirectory); + url = TestEnvironment.GetTestDownloadUrl (url); filename = Path.Combine (CacheDirectory, string.IsNullOrEmpty (filename) ? Path.GetFileName (new Uri (url).LocalPath) : filename); lock (locks.GetOrAdd (filename, _ => new object ())) { @@ -89,4 +90,3 @@ static bool IsTransientError (HttpRequestException ex) } } } - diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs index 4ff3228edae..25284c461e2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs @@ -20,6 +20,14 @@ namespace Xamarin.ProjectTools /// public static class TestEnvironment { + const string DotNetPublicMaven = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1"; + static readonly string [] publicMavenRepositories = { + "https://repo1.maven.org/maven2/", + "https://repo.maven.apache.org/maven2/", + "https://maven.google.com/", + "https://dl.google.com/android/maven2/", + }; + [DllImport ("libc")] static extern int uname (IntPtr buf); @@ -67,6 +75,25 @@ public static bool IsLinux { } } + public static bool IsRunningOnCI => + string.Equals (Environment.GetEnvironmentVariable ("RUNNINGONCI"), "true", StringComparison.OrdinalIgnoreCase); + + public static string GetMavenRepository (string repository) => + IsRunningOnCI ? DotNetPublicMaven : repository; + + public static string GetTestDownloadUrl (string url) + { + if (!IsRunningOnCI) + return url; + + foreach (var repository in publicMavenRepositories) { + if (url.StartsWith (repository, StringComparison.OrdinalIgnoreCase)) + return $"{DotNetPublicMaven}/{url.Substring (repository.Length)}"; + } + + return url; + } + /// /// The MonoAndroid reference assemblies directory within a local build tree, e.g. bin/Debug/lib/packs/Microsoft.Android.Ref.34/34.99.0/ref/net8.0/
/// If a local build tree can not be found, or if it is empty, this will return the system installation location instead:
@@ -196,4 +223,3 @@ static Version ParseVersion (string path) public static bool IsUsingJdk11 => AndroidSdkResolver.GetJavaSdkVersionString ().Contains ("11.0"); } } - diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index e90cacd8120..cc9489ce106 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2542,12 +2542,14 @@ public static void logEvent(String eventName) {{ Metadata = { { "Version", "17.0.2" }, { "Bind", "false" }, + { "Repository", TestEnvironment.GetMavenRepository ("Central") }, }, }, new BuildItem ("AndroidMavenLibrary", "com.facebook.android:facebook-bolts") { Metadata = { { "Version", "17.0.2" }, { "Bind", "false" }, + { "Repository", TestEnvironment.GetMavenRepository ("Central") }, }, }, }, From f38c96b8d7ac2b4147eb2dc4475117e64dfbdbe1 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 21 Jul 2026 14:30:29 -0500 Subject: [PATCH 02/16] [tests] Isolate parallel Kotlin Gradle builds Put each importing test project Kotlin intermediates under its own obj directory so concurrent solution builds cannot remove another compiler classpath snapshot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .../Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle.targets | 3 ++- .../kotlin-gradle/build.gradle.kts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle.targets b/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle.targets index 24994b94442..3a130104faa 100644 --- a/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle.targets +++ b/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle.targets @@ -24,6 +24,7 @@ <_KotlinGradleProjectDir>$(MSBuildThisFileDirectory)kotlin-gradle <_TestKotlinGradleOutputDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)kotlin-gradle\classes')) + <_TestKotlinGradleBuildDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\$(IntermediateOutputPath)kotlin-gradle\build')) + + From 0a0295fb3bc49abf4f2ab87c1e0e9ec0fcfa8bfe Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 21 Jul 2026 17:55:02 -0500 Subject: [PATCH 06/16] [tests] Fix Maven mirror authentication Authenticate Azure Artifacts Maven requests with the Azure DevOps OAuth token as a Basic credential, and disable Gradle configuration caching while the credential provider runs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- eng/gradle/mirror-dependencies.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eng/gradle/mirror-dependencies.ps1 b/eng/gradle/mirror-dependencies.ps1 index 4fd5db79c4a..fe3888006b5 100644 --- a/eng/gradle/mirror-dependencies.ps1 +++ b/eng/gradle/mirror-dependencies.ps1 @@ -127,7 +127,8 @@ function Invoke-Mirror($logPath) { Sort-Object -Unique if ($urls.Count -eq 0) { return 0 } $token = Get-AzDevOpsToken - $headers = @{ Authorization = "Bearer $token" } + $basicCredential = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")) + $headers = @{ Authorization = "Basic $basicCredential" } $ok = 0; $fail = 0 foreach ($u in $urls) { try { @@ -202,7 +203,7 @@ try { for ($i = 1; $i -le $MaxIterations; $i++) { Write-Host "`n=== iteration $i ===" -ForegroundColor Green $log = Join-Path ([IO.Path]::GetTempPath()) "gradle-mirror-iter-$i.log" - & $gradlew $Task --no-daemon --refresh-dependencies *>&1 | Tee-Object -FilePath $log | Out-Null + & $gradlew $Task --no-daemon --no-configuration-cache --refresh-dependencies *>&1 | Tee-Object -FilePath $log | Out-Null if (Select-String -Path $log -Pattern 'BUILD SUCCESSFUL' -SimpleMatch -Quiet) { Write-Host "`nBUILD SUCCESSFUL after $i iteration(s). The feed now has the packages CI needs." -ForegroundColor Green return From bf1058171b1569efd44cfb284107f892cc525a33 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 22 Jul 2026 10:22:31 -0500 Subject: [PATCH 07/16] [tests] Mirror lazy Gradle dependencies Run mirror resolution anonymously with configuration caching enabled so it discovers the same lazy Kotlin and lint classpaths as CI. Seed each 401 through the authenticated HTTP path instead of loading a Gradle credential provider. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .github/instructions/gradle.instructions.md | 11 ++++------- eng/gradle/credential-provider.gradle | 17 ----------------- eng/gradle/mirror-dependencies.ps1 | 10 +++++----- .../kotlin-gradle/settings.gradle.kts | 5 ----- .../tools/java-source-utils/settings.gradle | 3 --- .../Android/AndroidGradleProject.cs | 3 --- src/manifestmerger/settings.gradle | 5 ----- src/proguard-android/settings.gradle | 5 ----- src/r8/settings.gradle | 5 ----- .../java/JavaLib/settings.gradle | 5 ----- 10 files changed, 9 insertions(+), 60 deletions(-) delete mode 100644 eng/gradle/credential-provider.gradle diff --git a/.github/instructions/gradle.instructions.md b/.github/instructions/gradle.instructions.md index 8cf1266a884..b33da144d31 100644 --- a/.github/instructions/gradle.instructions.md +++ b/.github/instructions/gradle.instructions.md @@ -12,9 +12,6 @@ All `src/*` Gradle projects share two repo config files: **`eng/gradle/plugin-re pluginManagement { apply from: "${rootDir}/../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../eng/gradle/credential-provider.gradle" -} dependencyResolutionManagement { apply from: "${rootDir}/../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } @@ -31,9 +28,9 @@ Both files switch on `System.getenv('RUNNINGONCI')`. Azure DevOps exports the - **`RUNNINGONCI=true`** (Azure DevOps, sourced from `RunningOnCI` in `build-tools/automation/yaml-templates/variables.yaml`) → dnceng `dotnet-public-maven` feed (CFSClean isolation, https://aka.ms/1es/netiso/CFS). Anonymous read of cached packages. - **unset** (local, Dependabot, GitHub Actions) → `google()` + `mavenCentral()` + `gradlePluginPortal()` for plugins, `google()` + `mavenCentral()` for deps. No credentials needed. -CI reads cached packages from the mirror anonymously. The Azure Artifacts -credential provider is loaded only when `ANDROID_MIRROR_MAVEN_DEPENDENCIES=true`; -`mirror-dependencies.ps1` sets this while seeding uncached packages. +CI reads cached packages from the mirror anonymously. `mirror-dependencies.ps1` +runs the same anonymous Gradle resolution, then seeds each missing URL with an +authenticated HTTP request until the build succeeds. Test the CI path locally: `$env:RUNNINGONCI='true'` (PowerShell) or `RUNNINGONCI=true ...` (bash). @@ -41,7 +38,7 @@ Test the CI path locally: `$env:RUNNINGONCI='true'` (PowerShell) or `RUNNINGONCI The new package isn't cached in the dnceng `dotnet-public-maven` feed yet. CI agents only do anonymous reads, so someone has to authenticate once locally to make the feed pull the package (and its transitive deps) from upstream. -Use the helper script — it runs the build, parses any 401 URLs out of the log, re-fetches each one with an Azure DevOps bearer token (so the feed mirrors it), and loops until the build succeeds: +Use the helper script — it runs the build, parses any 401 URLs out of the log, re-fetches each one with an Azure DevOps OAuth token using Basic authentication (so the feed mirrors it), and loops until the build succeeds: ```powershell az login # one-time, corp account with MFA satisfied diff --git a/eng/gradle/credential-provider.gradle b/eng/gradle/credential-provider.gradle deleted file mode 100644 index 32f6fa95e8b..00000000000 --- a/eng/gradle/credential-provider.gradle +++ /dev/null @@ -1,17 +0,0 @@ -// The dependency-mirroring helper uses this plugin to authenticate with the -// Maven mirror. Regular local and CI builds do not apply this script. -buildscript { - repositories { - maven { - url = 'https://pkgs.dev.azure.com/artifacts-public/PublicTools/_packaging/AzureArtifacts/maven/v1' - name = 'AzureArtifacts' - } - } - dependencies { - classpath 'com.microsoft.azure:artifacts-gradle-credprovider:1.1.1' - } -} - -// Plugins loaded through an applied script's buildscript classpath cannot be -// resolved by ID; Gradle requires the implementation class in this context. -apply plugin: com.microsoft.azure.artifacts.credprovider.gradle.GradleCredentialProviderPlugin diff --git a/eng/gradle/mirror-dependencies.ps1 b/eng/gradle/mirror-dependencies.ps1 index fe3888006b5..971e3e4f524 100644 --- a/eng/gradle/mirror-dependencies.ps1 +++ b/eng/gradle/mirror-dependencies.ps1 @@ -13,9 +13,10 @@ This script does that by running the requested gradle build in a loop: 1. Run gradle with RUNNINGONCI=true so it points at the dnceng feed. 2. Parse any 'Could not GET' URLs out of the build log. - 3. Re-fetch each failing URL with an Azure DevOps OAuth bearer token - (obtained via `az account get-access-token`). The feed's upstream - connector then pulls the package and caches it for anonymous reads. + 3. Re-fetch each failing URL with an Azure DevOps OAuth token using Basic + authentication (obtained via `az account get-access-token`). The + feed's upstream connector then pulls the package and caches it for + anonymous reads. 4. Repeat until the build succeeds or no more 401s appear. After the loop converges, no PR edits are needed — just re-run the failing @@ -196,14 +197,13 @@ if ($AndroidHome) { Write-Host "ANDROID_HOME: $AndroidHome" } if ($AndroidHome) { $env:ANDROID_HOME = $AndroidHome } $env:RUNNINGONCI = 'true' -$env:ANDROID_MIRROR_MAVEN_DEPENDENCIES = 'true' Push-Location $projectDirAbs try { for ($i = 1; $i -le $MaxIterations; $i++) { Write-Host "`n=== iteration $i ===" -ForegroundColor Green $log = Join-Path ([IO.Path]::GetTempPath()) "gradle-mirror-iter-$i.log" - & $gradlew $Task --no-daemon --no-configuration-cache --refresh-dependencies *>&1 | Tee-Object -FilePath $log | Out-Null + & $gradlew $Task --no-daemon --refresh-dependencies *>&1 | Tee-Object -FilePath $log | Out-Null if (Select-String -Path $log -Pattern 'BUILD SUCCESSFUL' -SimpleMatch -Quiet) { Write-Host "`nBUILD SUCCESSFUL after $i iteration(s). The feed now has the packages CI needs." -ForegroundColor Green return diff --git a/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts b/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts index 5d99098eecd..00f24022787 100644 --- a/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts +++ b/external/Java.Interop/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts @@ -2,11 +2,6 @@ pluginManagement { apply(from = "$rootDir/../../../../../eng/gradle/plugin-repositories.gradle", to = this) } - -if (System.getenv("ANDROID_MIRROR_MAVEN_DEPENDENCIES") == "true") { - apply(from = "$rootDir/../../../../../eng/gradle/credential-provider.gradle") -} - dependencyResolutionManagement { apply(from = "$rootDir/../../../../../eng/gradle/dependency-repositories.gradle", to = this) } diff --git a/external/Java.Interop/tools/java-source-utils/settings.gradle b/external/Java.Interop/tools/java-source-utils/settings.gradle index 2c530fcaa3c..5aa827949f8 100644 --- a/external/Java.Interop/tools/java-source-utils/settings.gradle +++ b/external/Java.Interop/tools/java-source-utils/settings.gradle @@ -2,9 +2,6 @@ pluginManagement { apply from: "${rootDir}/../../../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../../../eng/gradle/credential-provider.gradle" -} dependencyResolutionManagement { apply from: "${rootDir}/../../../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index c741176f5d3..18aaefa10ca 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -110,9 +110,6 @@ string GetSettingsGradleKtsContent () pluginManagement { apply(from = "{{gradleConfigurationDirectory}}/plugin-repositories.gradle", to = this) } -if (System.getenv("ANDROID_MIRROR_MAVEN_DEPENDENCIES") == "true") { - apply(from = "{{gradleConfigurationDirectory}}/credential-provider.gradle") -} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) apply(from = "{{gradleConfigurationDirectory}}/dependency-repositories.gradle", to = this) diff --git a/src/manifestmerger/settings.gradle b/src/manifestmerger/settings.gradle index 47d043fb305..ce3501521db 100644 --- a/src/manifestmerger/settings.gradle +++ b/src/manifestmerger/settings.gradle @@ -2,11 +2,6 @@ pluginManagement { apply from: "${rootDir}/../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } - -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../eng/gradle/credential-provider.gradle" -} - dependencyResolutionManagement { apply from: "${rootDir}/../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } diff --git a/src/proguard-android/settings.gradle b/src/proguard-android/settings.gradle index 8a8d63d070d..820b2c40e49 100644 --- a/src/proguard-android/settings.gradle +++ b/src/proguard-android/settings.gradle @@ -2,11 +2,6 @@ pluginManagement { apply from: "${rootDir}/../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } - -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../eng/gradle/credential-provider.gradle" -} - dependencyResolutionManagement { apply from: "${rootDir}/../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } diff --git a/src/r8/settings.gradle b/src/r8/settings.gradle index 39e8674b5d8..c4f158d7464 100644 --- a/src/r8/settings.gradle +++ b/src/r8/settings.gradle @@ -2,11 +2,6 @@ pluginManagement { apply from: "${rootDir}/../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } - -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../eng/gradle/credential-provider.gradle" -} - dependencyResolutionManagement { apply from: "${rootDir}/../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } diff --git a/tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib/settings.gradle b/tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib/settings.gradle index 0f5b891e40c..d36a773edd5 100644 --- a/tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib/settings.gradle +++ b/tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib/settings.gradle @@ -2,11 +2,6 @@ pluginManagement { apply from: "${rootDir}/../../../../../eng/gradle/plugin-repositories.gradle", to: pluginManagement } - -if (System.getenv('ANDROID_MIRROR_MAVEN_DEPENDENCIES') == 'true') { - apply from: "${rootDir}/../../../../../eng/gradle/credential-provider.gradle" -} - dependencyResolutionManagement { apply from: "${rootDir}/../../../../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement } From 4693d523f81a5df2da69e3d2f710ce4b297f94f7 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 22 Jul 2026 13:50:09 -0500 Subject: [PATCH 08/16] [tests] Seed Facebook Gradle dependencies Pin BindFacebook to the mirrored 18.3.0 release and teach the mirror helper to seed payloads that Gradle probes with HEAD as well as GET. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- eng/gradle/mirror-dependencies.ps1 | 6 +++--- .../AndroidGradleProjectTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/gradle/mirror-dependencies.ps1 b/eng/gradle/mirror-dependencies.ps1 index 971e3e4f524..a1c43f1b547 100644 --- a/eng/gradle/mirror-dependencies.ps1 +++ b/eng/gradle/mirror-dependencies.ps1 @@ -12,7 +12,7 @@ This script does that by running the requested gradle build in a loop: 1. Run gradle with RUNNINGONCI=true so it points at the dnceng feed. - 2. Parse any 'Could not GET' URLs out of the build log. + 2. Parse any 'Could not GET/HEAD' URLs out of the build log. 3. Re-fetch each failing URL with an Azure DevOps OAuth token using Basic authentication (obtained via `az account get-access-token`). The feed's upstream connector then pulls the package and caches it for @@ -122,9 +122,9 @@ function Get-AzDevOpsToken { } function Invoke-Mirror($logPath) { - $urls = Select-String -Path $logPath -Pattern "Could not GET 'https://pkgs\.dev\.azure\.com/dnceng/[^']+'" -AllMatches | + $urls = Select-String -Path $logPath -Pattern "Could not (?:GET|HEAD) '(https://pkgs\.dev\.azure\.com/dnceng/[^']+)'" -AllMatches | ForEach-Object { $_.Matches } | - ForEach-Object { $_.Value -replace "^Could not GET '", "" -replace "'$", "" } | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique if ($urls.Count -eq 0) { return 0 } $token = Get-AzDevOpsToken diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index e6ff057c4b1..d96806c0074 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -510,7 +510,7 @@ namespace = ""{gradleModule.PackageName}"" dependencies {{ implementation(""androidx.appcompat:appcompat:1.6.1"") implementation(""com.google.android.material:material:1.11.0"") - implementation(""com.facebook.android:facebook-android-sdk:latest.release"") + implementation(""com.facebook.android:facebook-android-sdk:18.3.0"") }} "; gradleModule.JavaSources.Add (new AndroidItem.AndroidJavaSource ("FacebookSdk.java") { From bf48f711553b404553d8694f2d41677a95614554 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 10:52:00 -0500 Subject: [PATCH 09/16] [tests] Align Facebook device fixture Use the same mirrored Facebook Android SDK version in GradleFBProj as the host BindFacebook test so the device lane resolves the validated anonymous dependency closure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index cc9489ce106..39658b5b1e4 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2495,7 +2495,7 @@ namespace = ""{gradleModule.PackageName}"" dependencies {{ implementation(""androidx.appcompat:appcompat:1.7.0"") implementation(""com.google.android.material:material:1.11.0"") - implementation(""com.facebook.android:facebook-android-sdk:17.0.2"") + implementation(""com.facebook.android:facebook-android-sdk:18.3.0"") }} "; gradleModule.JavaSources.Add (new AndroidItem.AndroidJavaSource ("FacebookSdk.java") { From 4c3587ed9b544b728883a5ae6385daf62e32afd2 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 13:22:33 -0500 Subject: [PATCH 10/16] [tests] Stabilize generated Gradle projects Copy the checked-in Gradle wrapper instead of relying on gradle init, and keep all Facebook fixture dependencies aligned on the mirrored version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .../AndroidGradleProjectTests.cs | 19 +++++++++ .../Android/AndroidGradleProject.cs | 39 +++++++++++++++---- .../Tests/InstallAndRunTests.cs | 7 ++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index d96806c0074..3b86fee16b1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -40,6 +40,25 @@ public void GradleTestTearDown () } } + [Test] + public void CreateCopiesGradleWrapper () + { + AndroidGradleProject.CreateDefault (GradleTestProjectDir, agpVersion: "9.1.1", gradleVersion: null); + + var sourceDirectory = Path.Combine (XABuildPaths.TopDirectory, "build-tools", "gradle"); + var expectedFiles = new [] { + "gradlew", + "gradlew.bat", + Path.Combine ("gradle", "wrapper", "gradle-wrapper.jar"), + Path.Combine ("gradle", "wrapper", "gradle-wrapper.properties"), + }; + foreach (var relativePath in expectedFiles) { + var source = Path.Combine (sourceDirectory, relativePath); + var destination = Path.Combine (GradleTestProjectDir, relativePath); + Assert.That (File.ReadAllBytes (destination), Is.EqualTo (File.ReadAllBytes (source)), destination); + } + } + [Test] public void BuildApp ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index 18aaefa10ca..bdf9c7ef152 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -21,12 +21,10 @@ public class AndroidGradleProject /// /// Gradle wrapper version to use (e.g., "8.12", "9.0"). - /// Defaults to "9.3.1" (minimum required by AGP 9.1.1). If set to null or empty, the Gradle wrapper version generated by gradle init is used. + /// Defaults to "9.3.1" (minimum required by AGP 9.1.1). If set to null or empty, the repository wrapper version is used. /// public string? GradleVersion { get; set; } = "9.3.1"; - GradleCLI gradleCLI = new GradleCLI (); - public AndroidGradleProject (string directory) { ProjectDirectory = directory; @@ -35,7 +33,7 @@ public AndroidGradleProject (string directory) public void Create () { Directory.CreateDirectory (ProjectDirectory); - gradleCLI.Init (ProjectDirectory); + CopyGradleWrapper (); var settingsFile = Path.Combine (ProjectDirectory, "settings.gradle.kts"); File.WriteAllText (settingsFile, GetSettingsGradleKtsContent ()); File.WriteAllText (BuildFilePath, GetBuildGradleKtsContent ()); @@ -43,10 +41,15 @@ public void Create () module.Create (); File.AppendAllText (settingsFile, $"{Environment.NewLine}include(\":{module.Name}\")"); } - File.AppendAllText (Path.Combine (ProjectDirectory, "gradle.properties"), "android.useAndroidX=true"); + File.WriteAllText (Path.Combine (ProjectDirectory, "gradle.properties"), """ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +"""); - // The repository wrapper used by Init has already populated its distribution in CI. - // Keep that generated wrapper version there rather than downloading another distribution. + // The repository wrapper has already populated its distribution in CI. + // Keep that version there rather than downloading another distribution. if (!string.IsNullOrEmpty (GradleVersion) && !TestEnvironment.IsRunningOnCI) { var wrapperPropertiesPath = Path.Combine (ProjectDirectory, "gradle", "wrapper", "gradle-wrapper.properties"); if (File.Exists (wrapperPropertiesPath)) { @@ -62,6 +65,28 @@ public void Create () } } + void CopyGradleWrapper () + { + var sourceDirectory = Path.Combine (XABuildPaths.TopDirectory, "build-tools", "gradle"); + var destinationWrapperDirectory = Path.Combine (ProjectDirectory, "gradle", "wrapper"); + Directory.CreateDirectory (destinationWrapperDirectory); + + CopyFile ("gradlew", ProjectDirectory); + CopyFile ("gradlew.bat", ProjectDirectory); + CopyFile (Path.Combine ("gradle", "wrapper", "gradle-wrapper.jar"), destinationWrapperDirectory); + CopyFile (Path.Combine ("gradle", "wrapper", "gradle-wrapper.properties"), destinationWrapperDirectory); + + void CopyFile (string relativePath, string destinationDirectory) + { + var source = Path.Combine (sourceDirectory, relativePath); + var destination = Path.Combine (destinationDirectory, Path.GetFileName (relativePath)); + File.Copy (source, destination, overwrite: true); + if (!TestEnvironment.IsWindows) { + File.SetUnixFileMode (destination, File.GetUnixFileMode (source)); + } + } + } + public static AndroidGradleProject CreateDefault (string projectDir, bool isApplication = false) { var proj = new AndroidGradleProject (projectDir) { diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 39658b5b1e4..b3667212ba4 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -2477,6 +2477,7 @@ public void GradleFBProj ([Values] bool isRelease, [Values (AndroidRuntime.CoreC return; } + const string facebookVersion = "18.3.0"; var moduleName = "Library"; var gradleTestProjectDir = Path.Combine (Root, "temp", "gradle", TestName); var gradleModule = new AndroidGradleModule (Path.Combine (gradleTestProjectDir, moduleName)); @@ -2495,7 +2496,7 @@ namespace = ""{gradleModule.PackageName}"" dependencies {{ implementation(""androidx.appcompat:appcompat:1.7.0"") implementation(""com.google.android.material:material:1.11.0"") - implementation(""com.facebook.android:facebook-android-sdk:18.3.0"") + implementation(""com.facebook.android:facebook-android-sdk:{facebookVersion}"") }} "; gradleModule.JavaSources.Add (new AndroidItem.AndroidJavaSource ("FacebookSdk.java") { @@ -2540,14 +2541,14 @@ public static void logEvent(String eventName) {{ }, new BuildItem ("AndroidMavenLibrary", "com.facebook.android:facebook-core") { Metadata = { - { "Version", "17.0.2" }, + { "Version", facebookVersion }, { "Bind", "false" }, { "Repository", TestEnvironment.GetMavenRepository ("Central") }, }, }, new BuildItem ("AndroidMavenLibrary", "com.facebook.android:facebook-bolts") { Metadata = { - { "Version", "17.0.2" }, + { "Version", facebookVersion }, { "Bind", "false" }, { "Repository", TestEnvironment.GetMavenRepository ("Central") }, }, From e68a0cd314936ce2145ff9dae4484f404ae60023 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 13:31:31 -0500 Subject: [PATCH 11/16] [tests] Simplify Gradle wrapper assertion Keep the missing-wrapper regression check in the existing generated app test instead of maintaining a separate test for test infrastructure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .../AndroidGradleProjectTests.cs | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs index 3b86fee16b1..571312d6f43 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/AndroidGradleProjectTests.cs @@ -40,25 +40,6 @@ public void GradleTestTearDown () } } - [Test] - public void CreateCopiesGradleWrapper () - { - AndroidGradleProject.CreateDefault (GradleTestProjectDir, agpVersion: "9.1.1", gradleVersion: null); - - var sourceDirectory = Path.Combine (XABuildPaths.TopDirectory, "build-tools", "gradle"); - var expectedFiles = new [] { - "gradlew", - "gradlew.bat", - Path.Combine ("gradle", "wrapper", "gradle-wrapper.jar"), - Path.Combine ("gradle", "wrapper", "gradle-wrapper.properties"), - }; - foreach (var relativePath in expectedFiles) { - var source = Path.Combine (sourceDirectory, relativePath); - var destination = Path.Combine (GradleTestProjectDir, relativePath); - Assert.That (File.ReadAllBytes (destination), Is.EqualTo (File.ReadAllBytes (source)), destination); - } - } - [Test] public void BuildApp ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) { @@ -68,6 +49,7 @@ public void BuildApp ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT) } var gradleProject = AndroidGradleProject.CreateDefault (GradleTestProjectDir, isApplication: true); + FileAssert.Exists (Path.Combine (GradleTestProjectDir, TestEnvironment.IsWindows ? "gradlew.bat" : "gradlew")); var moduleName = gradleProject.Modules.First ().Name; var proj = new XamarinAndroidApplicationProject { From 0244f6c36c0d13903d4596792d7ba0a76c1ae261 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 13:36:54 -0500 Subject: [PATCH 12/16] [build] Simplify out-of-process Prepare builds Build the BootstrapTasks and workloads projects entirely out of process and capture timestamped binlogs alongside the other Prepare logs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- build-tools/scripts/Prepare.proj | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/build-tools/scripts/Prepare.proj b/build-tools/scripts/Prepare.proj index 628a26d1439..8c51b180699 100644 --- a/build-tools/scripts/Prepare.proj +++ b/build-tools/scripts/Prepare.proj @@ -19,25 +19,15 @@ Text="The specified `%24(AndroidToolchainDirectory)` '$(AndroidToolchainDirectory)' contains a space. Android NDK commands do not support this. Please create a Configuration.Override.props file that sets the AndroidToolchainDirectory property to a different path." Condition=" '$(HostOS)' == 'Windows' And $(AndroidToolchainDirectory.Contains (' '))" /> - + - - Date: Thu, 23 Jul 2026 13:38:29 -0500 Subject: [PATCH 13/16] [tests] Document generated Gradle settings Briefly explain why each generated gradle.properties option is enabled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .../Xamarin.ProjectTools/Android/AndroidGradleProject.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index bdf9c7ef152..642cd47e78a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -42,9 +42,13 @@ public void Create () File.AppendAllText (settingsFile, $"{Environment.NewLine}include(\":{module.Name}\")"); } File.WriteAllText (Path.Combine (ProjectDirectory, "gradle.properties"), """ +# Exercise Gradle configuration-cache compatibility. org.gradle.configuration-cache=true +# Build independent modules concurrently. org.gradle.parallel=true +# Reuse task outputs across test builds. org.gradle.caching=true +# Required by the AndroidX dependencies used by generated modules. android.useAndroidX=true """); From 00aa6feeb53ee5bdf50530ea446f5755f3ef1920 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 13:39:24 -0500 Subject: [PATCH 14/16] [tests] Explain Gradle wrapper copy Document why generated Gradle projects use the checked-in wrapper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d21fe48a-552e-44ca-8c0b-739e7c2b7129 --- .../Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index 642cd47e78a..9c3d25c32ce 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -69,6 +69,9 @@ public void Create () } } + /// + /// Copies the repository wrapper so generated projects do not depend on gradle init or a CI distribution download. + /// void CopyGradleWrapper () { var sourceDirectory = Path.Combine (XABuildPaths.TopDirectory, "build-tools", "gradle"); From 05ca3d7f779f822b88d692fef25893b278ec5fda Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 24 Jul 2026 13:05:54 -0500 Subject: [PATCH 15/16] [tests] Fix CA1416 in Gradle wrapper copy CA1416 does not understand the `TestEnvironment.IsWindows` guard, so the `File.GetUnixFileMode`/`File.SetUnixFileMode` calls broke the build. Use `OperatingSystem.IsWindows ()` which the analyzer recognizes as a platform guard. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79b5be27-8ca9-42a6-ac4f-10d69c200075 --- .../Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index 9c3d25c32ce..df50dab85a4 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -88,7 +88,7 @@ void CopyFile (string relativePath, string destinationDirectory) var source = Path.Combine (sourceDirectory, relativePath); var destination = Path.Combine (destinationDirectory, Path.GetFileName (relativePath)); File.Copy (source, destination, overwrite: true); - if (!TestEnvironment.IsWindows) { + if (!OperatingSystem.IsWindows ()) { File.SetUnixFileMode (destination, File.GetUnixFileMode (source)); } } From c312f925de99d0f8c053106dd5978c04736c6980 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 24 Jul 2026 13:11:14 -0500 Subject: [PATCH 16/16] [tests] Teach CA1416 about TestEnvironment platform checks Revert the `OperatingSystem.IsWindows ()` workaround and instead annotate `TestEnvironment.IsWindows`/`IsMacOS`/`IsLinux` with `[SupportedOSPlatformGuard]`. The properties always worked at runtime; only the CA1416 analyzer could not see through them, and it only recognizes `OperatingSystem.Is*()`, `RuntimeInformation.IsOSPlatform` or members carrying a platform-guard attribute. Annotating them fixes every current and future caller instead of just this one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79b5be27-8ca9-42a6-ac4f-10d69c200075 --- .../Xamarin.ProjectTools/Android/AndroidGradleProject.cs | 2 +- .../Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs index df50dab85a4..9c3d25c32ce 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Android/AndroidGradleProject.cs @@ -88,7 +88,7 @@ void CopyFile (string relativePath, string destinationDirectory) var source = Path.Combine (sourceDirectory, relativePath); var destination = Path.Combine (destinationDirectory, Path.GetFileName (relativePath)); File.Copy (source, destination, overwrite: true); - if (!OperatingSystem.IsWindows ()) { + if (!TestEnvironment.IsWindows) { File.SetUnixFileMode (destination, File.GetUnixFileMode (source)); } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs index 25284c461e2..f8d4108560c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/TestEnvironment.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using Xamarin.Android.Tools; namespace Xamarin.ProjectTools @@ -51,6 +52,12 @@ static bool IsDarwin () /// /// Gets a value indicating whether the current platform is Windows. /// + /// + /// teaches the platform-compatibility + /// analyzer (CA1416) that this property is a windows guard, so callers can use it + /// instead of . + /// + [SupportedOSPlatformGuard ("windows")] public static bool IsWindows { get { return Environment.OSVersion.Platform == PlatformID.Win32NT; @@ -60,6 +67,7 @@ public static bool IsWindows { /// /// Gets a value indicating whether the current platform is macOS. /// + [SupportedOSPlatformGuard ("macos")] public static bool IsMacOS { get { return IsDarwin (); @@ -69,6 +77,7 @@ public static bool IsMacOS { /// /// Gets a value indicating whether the current platform is Linux. /// + [SupportedOSPlatformGuard ("linux")] public static bool IsLinux { get { return !IsWindows && !IsMacOS;