Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc744be
[tests] Isolate Maven resolution on CI
jonathanpeppers Jul 21, 2026
f38c96b
[tests] Isolate parallel Kotlin Gradle builds
jonathanpeppers Jul 21, 2026
3ae2b2c
[tests] Use hosted Gradle in Java.Interop CI
jonathanpeppers Jul 21, 2026
11f7886
[ci] Restore Java.Interop Gradle wrapper
jonathanpeppers Jul 21, 2026
ec47eeb
[build] Reevaluate projects after restore
jonathanpeppers Jul 21, 2026
0a0295f
[tests] Fix Maven mirror authentication
jonathanpeppers Jul 21, 2026
bf10581
[tests] Mirror lazy Gradle dependencies
jonathanpeppers Jul 22, 2026
4693d52
[tests] Seed Facebook Gradle dependencies
jonathanpeppers Jul 22, 2026
bf48f71
[tests] Align Facebook device fixture
jonathanpeppers Jul 23, 2026
4c3587e
[tests] Stabilize generated Gradle projects
jonathanpeppers Jul 23, 2026
e68a0cd
[tests] Simplify Gradle wrapper assertion
jonathanpeppers Jul 23, 2026
0244f6c
[build] Simplify out-of-process Prepare builds
jonathanpeppers Jul 23, 2026
aafeb5d
[tests] Document generated Gradle settings
jonathanpeppers Jul 23, 2026
00aa6fe
[tests] Explain Gradle wrapper copy
jonathanpeppers Jul 23, 2026
05ca3d7
[tests] Fix CA1416 in Gradle wrapper copy
jonathanpeppers Jul 24, 2026
c312f92
[tests] Teach CA1416 about TestEnvironment platform checks
jonathanpeppers Jul 24, 2026
f63cd5b
Merge remote-tracking branch 'origin/main' into jonathanpeppers-fix-c…
jonathanpeppers Jul 24, 2026
4f53d0a
[docs] Document Gradle mirroring for tests
jonathanpeppers Jul 27, 2026
3f46ff3
Merge remote-tracking branch 'origin/main' into jonathanpeppers-fix-c…
jonathanpeppers Jul 27, 2026
b98ecf0
[tests] Match InstallReferrer to Facebook SDK 18.3.0
jonathanpeppers Jul 27, 2026
1e71df2
[tests] Always download Maven artifacts through the mirror
jonathanpeppers Jul 28, 2026
6f7ac99
[tests] Resolve Java.Interop Maven tests through the mirror
jonathanpeppers Jul 28, 2026
902f0f5
[tests] Shorten mirror comment
jonathanpeppers Jul 28, 2026
85007aa
[tests] Reword mirror comment
jonathanpeppers Jul 28, 2026
2fd7106
[tests] Restore DownloadedCache trailing newline
jonathanpeppers Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions .github/instructions/gradle.instructions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
applyTo: "**/*.gradle"
applyTo: "**/*.gradle,**/*.gradle.kts"
---

# Gradle conventions
Expand All @@ -9,18 +9,34 @@ All `src/*` Gradle projects share two repo config files: **`eng/gradle/plugin-re
## settings.gradle template

```groovy
// 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 = '<project>'
```

Adjust the `../..` depth to reach the repo root from that project; it is not
always two levels (e.g. `external/Java.Interop/tools/java-source-utils` uses
four).

Kotlin DSL (`settings.gradle.kts`) applies the same two Groovy files, but passes
the receiver as `to = this`:

```kotlin
// See: eng/gradle/plugin-repositories.gradle, eng/gradle/dependency-repositories.gradle
pluginManagement {
apply(from = "$rootDir/../../eng/gradle/plugin-repositories.gradle", to = this)
}
dependencyResolutionManagement {
apply(from = "$rootDir/../../eng/gradle/dependency-repositories.gradle", to = this)
}
rootProject.name = "<project>"
```

`build.gradle` files must not declare their own `repositories { ... }`.

## CI vs local
Expand All @@ -31,17 +47,17 @@ 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).

## When CI fails 401 on a Dependabot bump

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
Expand All @@ -57,7 +73,43 @@ 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.

## Tests

Tests must not reach the public internet on CI; everything routes through the
mirror. Two mechanisms in `Xamarin.ProjectTools` handle this, and both apply
unconditionally — local runs hit the same URLs as CI, so a package the mirror
lacks fails everywhere instead of only on CI:

- **Generated Gradle projects** — `AndroidGradleProject` writes a
`settings.gradle.kts` that applies the same two shared config files by
absolute path, and copies the repository wrapper from `build-tools/gradle`
instead of running `gradle init`. Don't reintroduce `google()` /
`mavenCentral()` into generated projects, and don't let a generated project
download its own Gradle distribution on CI.
- **Non-Gradle Maven downloads** — use `TestEnvironment.DotNetPublicMaven` as
the base URL, both for `WebContent` on a `BuildItem` and for `Repository`
metadata on an `<AndroidMavenLibrary>`. Don't write a `repo1.maven.org` or
`maven.google.com` URL into a test, and don't use the `"Central"` / `"Google"`
shorthands there — those are covered without network by
`MavenDownloadTests.KnownRepositoryShorthand`.

When a test needs a coordinate the feed hasn't cached, seed it with
`-MavenArtifact` above rather than pointing the test at a public repository.

## Don'ts

- Don't hard-code Maven repo URLs in `build.gradle` / `settings.gradle`; use the shared file.
- Don't use modern `plugins { id 'com.android.application' version '...' }` DSL without confirming the plugin is in `dotnet-public-maven`; prefer `buildscript { ... } / apply plugin: '...'` when in doubt.
- Don't use modern `plugins { id 'com.android.application' version '...' }` DSL without confirming the plugin is in `dotnet-public-maven`; prefer `buildscript { ... } / apply plugin: '...'` when in doubt.
- Don't add a Gradle credential provider or any authenticated repository to a
build. CI resolves anonymously; authentication belongs only in
`mirror-dependencies.ps1`, which seeds the feed over plain HTTP.
17 changes: 0 additions & 17 deletions eng/gradle/credential-provider.gradle

This file was deleted.

94 changes: 77 additions & 17 deletions eng/gradle/mirror-dependencies.ps1
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -12,10 +12,11 @@

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.
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
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
Expand All @@ -30,6 +31,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.
Expand Down Expand Up @@ -59,19 +66,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
)

Expand Down Expand Up @@ -104,13 +122,14 @@ 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
$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 {
Expand All @@ -125,18 +144,59 @@ 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'

Push-Location $projectDirAbs
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Java.Interop.Tools.Maven_Tests.Extensions;

class MavenProjectResolver : IProjectResolver
{
// Used locally too, so local runs download from the same feed as CI.
const string DotNetPublicMaven = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1";
readonly IMavenRepository repository;

public MavenProjectResolver (IMavenRepository repository)
Expand All @@ -19,8 +21,8 @@ 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, new MavenRepository (DotNetPublicMaven, "central")));
Google = new MavenProjectResolver (new CachedMavenRepository (cache_path, new MavenRepository (DotNetPublicMaven, "google")));
}

public Project Resolve (Artifact artifact)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

<!--
Shared MSBuild target for compiling the real Kotlin/Gradle fixture in
tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/ using the shared
Gradle wrapper from build-tools/gradle. The wrapper downloads Gradle +
Kotlin on first run, so the resulting .class files do not need to be
committed to source control.
tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/ using the Gradle
executable configured by GradleWPath. Gradle downloads Kotlin on first run,
so the resulting .class files do not need to be committed to source control.

This file is imported by any test project that embeds the resulting
.class files as resources (Bytecode-Tests and generator-Tests). Each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 0 additions & 6 deletions external/Java.Interop/tools/java-source-utils/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
15 changes: 7 additions & 8 deletions external/Java.Interop/tools/java-source-utils/settings.gradle
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/*
* 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
}
dependencyResolutionManagement {
apply from: "${rootDir}/../../../../eng/gradle/dependency-repositories.gradle", to: dependencyResolutionManagement
}

rootProject.name = 'java-source-utils'
18 changes: 13 additions & 5 deletions src/Xamarin.Android.Build.Tasks/Tasks/MavenDownload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,24 @@ public async override System.Threading.Tasks.Task RunTaskAsync ()
return result;
}

CachedMavenRepository? GetRepository (ITaskItem item)
{
var type = item.GetMetadataOrDefault ("Repository", "Central");

var repo = type.ToLowerInvariant () switch {
/// <summary>
/// Maps the well-known <c>Repository</c> metadata shorthands to their repositories.
/// Returns <see langword="null"/> if <paramref name="type"/> is not a known shorthand,
/// in which case it is treated as a repository URL.
/// </summary>
internal static MavenRepository? GetKnownRepository (string type) =>
type.ToLowerInvariant () switch {
"central" => MavenRepository.Central,
"google" => MavenRepository.Google,
_ => null
};

CachedMavenRepository? GetRepository (ITaskItem item)
{
var type = item.GetMetadataOrDefault ("Repository", "Central");

var repo = GetKnownRepository (type);

if (repo is null && Uri.TryCreate (type, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) {
if (uri.Scheme == Uri.UriSchemeHttp &&
Expand Down
Loading
Loading