Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/src/r8/"
schedule:
interval: "weekly"
- package-ecosystem: "gradle"
directory: "/src/manifestmerger/"
directories:
- "/src/r8"
- "/src/manifestmerger"
- "/src/proguard-android"
- "/tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib"
schedule:
interval: "weekly"
- package-ecosystem: "gitsubmodule"
Expand Down
114 changes: 114 additions & 0 deletions .github/instructions/gradle.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
applyTo: "**/*.gradle,**/*.gradle.kts"
---

# Gradle conventions

All `src/*` Gradle projects share two repo config files: **`eng/gradle/plugin-repositories.gradle`** (for `pluginManagement.repositories`) and **`eng/gradle/dependency-repositories.gradle`** (for `dependencyResolutionManagement.repositories`). Never hard-code Maven URLs (`mavenCentral()`, `google()`, `pkgs.dev.azure.com/...`, etc.) in `build.gradle`/`settings.gradle`.

## 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
}
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

Both files switch on `System.getenv('RUNNINGONCI')`. Azure DevOps exports the
`RunningOnCI` pipeline variable under this normalized environment-variable name.

- **`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. `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 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

pwsh ./eng/gradle/mirror-dependencies.ps1 `
-ProjectDir <path-to-failing-gradle-project> `
-Task <gradle-task-CI-runs> `
-AndroidHome <path-to-Android-SDK> # required for any com.android.* project
```

The mirror must run in the project that actually needs the new package — a sibling project's build won't trigger a mirror for someone else's deps. Typical convergence is 2-5 iterations as the resolver walks the dep graph breadth-first.

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 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: 17 additions & 0 deletions eng/gradle/dependency-repositories.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Shared Maven repository list for project DEPENDENCY resolution
// (dependencyResolutionManagement.repositories) across every settings.gradle
// in this repo. See plugin-repositories.gradle for plugin resolution.
//
// Switches on RUNNINGONCI for the same CFSClean reasons described there.

repositories {
if (System.getenv('RUNNINGONCI') == 'true') {
maven {
Comment thread
jonathanpeppers marked this conversation as resolved.
url = 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-maven/maven/v1'
name = 'dotnet-public-maven'
}
} else {
google()
mavenCentral()
}
}
224 changes: 224 additions & 0 deletions eng/gradle/mirror-dependencies.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
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),
CI fails with 401 errors because the new package(s) haven't been pulled
from upstream into the dnceng feed yet. CI agents only do anonymous reads,
so a developer has to authenticate locally once to seed the feed.

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/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
CI job, since the packages are now anonymous-readable.

.PARAMETER ProjectDir
Path to the gradle project (the one containing the failing dependency).
Mirroring must run in the project that actually requires the package;
a sibling project's build won't trigger a mirror for someone else's deps.

.PARAMETER Task
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.
Use this when a subproject has its own wrapper so Gradle resolves the same
dependency variants that CI requests.

.PARAMETER AndroidHome
Optional path to the Android SDK. Required when the gradle build needs it
(any project using the com.android.* plugins). Defaults to the value of
`$env:ANDROID_HOME` if set.

.PARAMETER MaxIterations
Cap on build/mirror cycles. Default 15. Typical convergence is 2-5
iterations as the resolver walks the dep graph breadth-first.

.EXAMPLE
pwsh ./eng/gradle/mirror-dependencies.ps1 `
-ProjectDir tests/CodeGen-Binding/Xamarin.Android.LibraryProjectZip-LibBinding/java/JavaLib `
-Task assembleDebug `
-AndroidHome D:\android-toolchain\sdk

.EXAMPLE
pwsh ./eng/gradle/mirror-dependencies.ps1 -ProjectDir src/proguard-android -Task extractProguardFiles

.EXAMPLE
pwsh ./eng/gradle/mirror-dependencies.ps1 `
-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(DefaultParameterSetName='Gradle')]
param(
[Parameter(Mandatory=$true, ParameterSetName='Gradle')]
[string] $ProjectDir = '.',

[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
)

$ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '../..') | Select-Object -ExpandProperty Path
$projectDirAbs = Resolve-Path (Join-Path $repoRoot $ProjectDir) -ErrorAction Stop | Select-Object -ExpandProperty Path
$defaultGradleWrapper = if ($IsWindows -or $env:OS -eq 'Windows_NT') {
'build-tools/gradle/gradlew.bat'
} else {
'build-tools/gradle/gradlew'
}
$gradleWrapperPath = if ([string]::IsNullOrEmpty($GradleWrapper)) {
Join-Path $repoRoot $defaultGradleWrapper
} elseif ([IO.Path]::IsPathRooted($GradleWrapper)) {
$GradleWrapper
} else {
Join-Path $repoRoot $GradleWrapper
}
$gradlew = Resolve-Path $gradleWrapperPath -ErrorAction Stop | Select-Object -ExpandProperty Path

# Azure DevOps resource id — same for every AzDO tenant.
$azDevOpsResource = '499b84ac-1321-427f-aa17-267ca6975798'

function Get-AzDevOpsToken {
$token = az account get-access-token --resource $azDevOpsResource --query accessToken -o tsv 2>$null
if ([string]::IsNullOrEmpty($token)) {
throw "Could not get an Azure DevOps access token. Run 'az login' first."
}
return $token
}

function Invoke-Mirror($logPath) {
$urls = Select-String -Path $logPath -Pattern "Could not (?:GET|HEAD) '(https://pkgs\.dev\.azure\.com/dnceng/[^']+)'" -AllMatches |
ForEach-Object { $_.Matches } |
ForEach-Object { $_.Groups[1].Value } |
Sort-Object -Unique
if ($urls.Count -eq 0) { return 0 }
$token = Get-AzDevOpsToken
$basicCredential = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token"))
$headers = @{ Authorization = "Basic $basicCredential" }
$ok = 0; $fail = 0
foreach ($u in $urls) {
try {
$r = Invoke-WebRequest -Uri $u -Headers $headers -SkipHttpErrorCheck -ErrorAction Stop
if ($r.StatusCode -eq 200) { $ok++ } else { $fail++; Write-Host " $($r.StatusCode) $u" -ForegroundColor Yellow }
} catch {
$fail++
Write-Host " ERR $u : $_" -ForegroundColor Yellow
}
}
Write-Host " -> mirrored OK=$ok, not-found=$fail (of $($urls.Count))" -ForegroundColor Cyan
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" }

if ($AndroidHome) { $env:ANDROID_HOME = $AndroidHome }
$env:RUNNINGONCI = '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 --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
}
$count = Invoke-Mirror $log
if ($count -eq 0) {
Write-Host "`nGradle failed but no 401s to mirror — see $log" -ForegroundColor Red
Get-Content $log -Tail 30
exit 1
}
}
Write-Host "`nExhausted $MaxIterations iterations without success. Last log:" -ForegroundColor Red
Get-Content $log -Tail 30
exit 1
}
finally {
Pop-Location
}
Loading