diff --git a/buildSrc-fork/androidxSnapshotRepos.gradle b/buildSrc-fork/androidxSnapshotRepos.gradle new file mode 100644 index 0000000000000..da605d0b97994 --- /dev/null +++ b/buildSrc-fork/androidxSnapshotRepos.gradle @@ -0,0 +1,170 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import groovy.transform.Field +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.tomlj.Toml + +import java.util.regex.Pattern + +buildscript { + // The settings buildscript classpath is not visible to script plugins applied from it, so this + // script carries its own. tomlj is already trusted in gradle/verification-metadata.xml. + repositories { + mavenCentral() + } + dependencies { + classpath("org.tomlj:tomlj:1.0.0") + } +} + +/* + * Declares the androidx.dev repositories serving the artifact redirects that `redirectversions.toml` + * pins to a `-SNAPSHOT`, i.e. to an androidx build Google has cut but not yet published to Google + * Maven. Each `[[snapshots]]` entry becomes one repository, filtered down to exactly the group + * prefixes and versions recorded for it. + * + * Kept out of `repos.gradle` — that file is shared byte-identically with AOSP and also configures + * buildscript classpaths, which never resolve redirect coordinates. + * + * The registry is validated in `ArtifactRedirection.kt`; this reader reports only what it must read. + */ + +@Field final String TOML_FILE_NAME = "redirectversions.toml" + +@Field List snapshotBuildsCache = null + +ext.androidxSnapshots = new Properties() +ext.androidxSnapshots.addRepositories = this.&addRepositories +ext.androidxSnapshots.disableChangingModuleCache = this.&disableChangingModuleCache + +String repositoryUrl(String flavour, String buildId) { + switch (flavour) { + case "kmp": + return "https://androidx.dev/kmp/builds/$buildId/artifacts/snapshots/repository" + case "androidx": + return "https://androidx.dev/snapshots/builds/$buildId/artifacts/repository" + default: + throw new GradleException("$TOML_FILE_NAME: [[snapshots]] buildId \"$buildId\" has " + + "repo = \"$flavour\"; known flavours are \"kmp\" and \"androidx\".") + } +} + +/** + * Parses `[[snapshots]]` into one entry per androidx.dev build: + * `[buildId: String, url: String, versionsByGroup: Map]`. + * Empty when every redirect is on a released version. + */ +List snapshotBuilds(File rootDir) { + if (snapshotBuildsCache != null) return snapshotBuildsCache + + def tomlFile = new File(rootDir, TOML_FILE_NAME) + if (!tomlFile.exists()) { + snapshotBuildsCache = [] + return snapshotBuildsCache + } + def parsed = Toml.parse(tomlFile.toPath()) + if (parsed.hasErrors()) { + def issues = parsed.errors().collect { "$TOML_FILE_NAME:${it.position()}: ${it.message}" } + throw new GradleException("$TOML_FILE_NAME has issues.\n${issues.join("\n")}") + } + def entries = parsed.getArray("snapshots") + if (entries == null) { + snapshotBuildsCache = [] + return snapshotBuildsCache + } + def versions = parsed.getTable("versions") + if (versions == null) { + throw new GradleException("$TOML_FILE_NAME is missing the [versions] table") + } + + def builds = [] + for (int i = 0; i < entries.size(); i++) { + def entry = entries.getTable(i) + def buildId = entry.getString("buildId") + if (buildId == null) { + throw new GradleException("$TOML_FILE_NAME: [[snapshots]] entry #${i + 1} must declare " + + "a string \"buildId\" naming the androidx.dev build") + } + def groups = entry.getArray("groups") + if (groups == null || !groups.containsStrings()) { + throw new GradleException("$TOML_FILE_NAME: [[snapshots]] buildId \"$buildId\" must " + + "declare a \"groups\" array of redirect group prefixes") + } + def versionsByGroup = [:] + for (int g = 0; g < groups.size(); g++) { + def group = groups.getString(g) + // tomlj treats a dotted String key as a path lookup, so a dotted group key must be read + // via the literal single-segment List overload. + def version = versions.getString([group]) + if (version == null) { + throw new GradleException("$TOML_FILE_NAME: [[snapshots]] build \"$buildId\" lists " + + "group \"$group\", which has no entry in the [versions] table.") + } + versionsByGroup[group] = version + } + builds << [ + buildId : buildId, + url : repositoryUrl(entry.getString("repo") ?: "kmp", buildId), + versionsByGroup: versionsByGroup, + ] + } + snapshotBuildsCache = builds + return snapshotBuildsCache +} + +/** + * Adds one filtered repository per registered androidx.dev build to [handler]. No-op when the + * registry is empty, which is the state of a released branch. + */ +def addRepositories(RepositoryHandler handler, File rootDir) { + def added = snapshotBuilds(rootDir).collect { build -> + handler.maven { repo -> + repo.name = "androidxDevBuild${build.buildId}" + repo.url = build.url + repo.content { content -> + // Group prefix AND exact version. The version half is what keeps overlapping + // prefixes (androidx.compose vs androidx.compose.material3) and two groups pinned to + // two different builds apart: a repository answers only for what it was recorded for. + build.versionsByGroup.each { group, version -> + content.includeVersionByRegex( + Pattern.quote(group) + "(\\..*)?", + ".*", + Pattern.quote(version)) + } + } + } + } + if (!added.isEmpty()) { + // Ahead of `mavenLocal()` and the unfiltered Sonatype snapshots repo, both of which would + // otherwise be asked for these coordinates first. + handler.removeAll(added) + handler.addAll(0, added) + } +} + +/** + * Opts [project] out of Gradle's 24h changing-module cache while any redirect is on a snapshot. + * Google reuses the version string `X.Y.Z-SNAPSHOT` across builds, so moving the registry to a new + * buildId would otherwise keep serving the previous build's artifacts, silently. No-op on a released + * state; `--refresh-dependencies` remains the manual escape hatch. + */ +def disableChangingModuleCache(Project project) { + if (snapshotBuilds(project.rootDir).isEmpty()) return + project.configurations.configureEach { configuration -> + configuration.resolutionStrategy.cacheChangingModulesFor(0, "seconds") + } +} diff --git a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/ArtifactRedirection.kt b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/ArtifactRedirection.kt index 8d64b6803c2ba..9e15e85a29148 100644 --- a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/ArtifactRedirection.kt +++ b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/ArtifactRedirection.kt @@ -28,10 +28,49 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.tomlj.Toml import org.tomlj.TomlTable +/** + * The androidx.dev repository flavour an [AndroidxSnapshotBuild] was published to, selected by the + * `repo` field of a `[[snapshots]]` entry. + */ +enum class AndroidxSnapshotRepoFlavour(val id: String) { + /** The KMP build repository, carrying the multiplatform artifacts. */ + KMP("kmp"), + /** The Android-only build repository, for groups the KMP build does not carry. */ + ANDROIDX("androidx"); + + fun repositoryUrl(buildId: String): String = + when (this) { + KMP -> "https://androidx.dev/kmp/builds/$buildId/artifacts/snapshots/repository" + ANDROIDX -> "https://androidx.dev/snapshots/builds/$buildId/artifacts/repository" + } + + companion object { + const val DEFAULT_ID = "kmp" + + fun fromId(id: String): AndroidxSnapshotRepoFlavour? = values().firstOrNull { it.id == id } + } +} + +/** + * One `[[snapshots]]` entry of `redirectversions.toml`: the androidx.dev build a set of redirect + * group prefixes take their `-SNAPSHOT` version from. One build normally covers several groups, + * which is how Google cuts them. + */ +data class AndroidxSnapshotBuild( + val buildId: String, + val flavour: AndroidxSnapshotRepoFlavour, + val groups: List, +) { + val repositoryUrl: String + get() = flavour.repositoryUrl(buildId) +} + /** * Loads the artifact-redirection version registry from `redirectversions.toml` (repo root) once per * build. The `[versions]` table maps a redirect-coordinate group prefix (e.g. `androidx.compose`) to - * the `androidx.*` version the redirect points at. + * the `androidx.*` version the redirect points at. The optional `[[snapshots]]` array records which + * androidx.dev build backs each group whose version is a `-SNAPSHOT`, so those coordinates stay + * resolvable before Google publishes them to Google Maven. */ abstract class RedirectVersionsService : BuildService { interface Parameters : BuildServiceParameters { @@ -39,30 +78,128 @@ abstract class RedirectVersionsService : BuildService } + private data class Registry( + val versions: Map, + val snapshots: List, + ) + + private val registry: Registry by lazy { parseRegistry() } + /** Group prefix (e.g. `androidx.compose`) -> redirect version. */ - val versions: Map by lazy { + val versions: Map + get() = registry.versions + + /** `[[snapshots]]` entries in file order. Empty when every redirect is on a released version. */ + val snapshots: List + get() = registry.snapshots + + /** Group prefix -> the androidx.dev build its `-SNAPSHOT` version comes from. */ + val snapshotBuildsByGroup: Map by lazy { + registry.snapshots.flatMap { build -> build.groups.map { it to build } }.toMap() + } + + private fun parseRegistry(): Registry { + val fileName = parameters.tomlFileName val parsed = Toml.parse(parameters.tomlFileContents.get()) if (parsed.hasErrors()) { - val issues = - parsed.errors().joinToString("\n") { - "${parameters.tomlFileName}:${it.position()}: ${it.message}" - } - throw GradleException("${parameters.tomlFileName} has issues.\n$issues") + val issues = parsed.errors().joinToString("\n") { "$fileName:${it.position()}: ${it.message}" } + throw GradleException("$fileName has issues.\n$issues") } val table: TomlTable = parsed.getTable("versions") - ?: throw GradleException("${parameters.tomlFileName} is missing the [versions] table") + ?: throw GradleException("$fileName is missing the [versions] table") // tomlj treats a dotted String key as a path lookup, so the dotted group keys must be read // via the literal single-segment List overload (getString(listOf(key))), not getString(key). - table.keySet().associateWith { key -> - table.getString(listOf(key)) - ?: throw GradleException( - "${parameters.tomlFileName}: [versions] \"$key\" must be a string", + val versions = + table.keySet().associateWith { key -> + table.getString(listOf(key)) + ?: throw GradleException("$fileName: [versions] \"$key\" must be a string") + } + + val entries = parsed.getArray("snapshots") + val snapshots = + (0 until (entries?.size() ?: 0)).map { index -> + val entry = + entries!!.getTable(index) + ?: throw GradleException( + "$fileName: [[snapshots]] entry #${index + 1} must be a table", + ) + val buildId = + entry.getString("buildId") + ?: throw GradleException( + "$fileName: [[snapshots]] entry #${index + 1} must declare a string " + + "\"buildId\" naming the androidx.dev build", + ) + val flavourId = entry.getString("repo") ?: AndroidxSnapshotRepoFlavour.DEFAULT_ID + val flavour = + AndroidxSnapshotRepoFlavour.fromId(flavourId) + ?: throw GradleException( + "$fileName: [[snapshots]] buildId \"$buildId\" has repo = \"$flavourId\"; " + + "known flavours are " + + AndroidxSnapshotRepoFlavour.values().joinToString { "\"${it.id}\"" }, + ) + val groups = + entry.getArray("groups")?.takeIf { it.containsStrings() } + ?: throw GradleException( + "$fileName: [[snapshots]] buildId \"$buildId\" must declare a \"groups\" " + + "array of redirect group prefixes", + ) + AndroidxSnapshotBuild( + buildId = buildId, + flavour = flavour, + groups = (0 until groups.size()).map { groups.getString(it) }, ) + } + validate(fileName, versions, snapshots) + return Registry(versions, snapshots) + } + + private fun validate( + fileName: String, + versions: Map, + snapshots: List, + ) { + val buildIdByGroup = mutableMapOf() + snapshots.forEach { build -> + build.groups.forEach { group -> + buildIdByGroup.put(group, build.buildId)?.let { owner -> + throw GradleException( + "$fileName: group \"$group\" is listed in the [[snapshots]] entries of both " + + "build \"$owner\" and build \"${build.buildId}\". A group takes its " + + "version from exactly one androidx.dev build.", + ) + } + val version = + versions[group] + ?: throw GradleException( + "$fileName: [[snapshots]] build \"${build.buildId}\" lists group " + + "\"$group\", which has no entry in the [versions] table.", + ) + if (!version.endsWith(SNAPSHOT_SUFFIX)) { + throw GradleException( + "$fileName: group \"$group\" is pinned to androidx.dev build " + + "\"${build.buildId}\" but its [versions] entry is \"$version\". Only " + + "$SNAPSHOT_SUFFIX versions are served by androidx.dev; a released " + + "version must not carry a buildId.", + ) + } + } + } + versions.forEach { (group, version) -> + if (version.endsWith(SNAPSHOT_SUFFIX) && group !in buildIdByGroup) { + throw GradleException( + "$fileName: [versions] \"$group\" is \"$version\" but no [[snapshots]] entry " + + "lists it. A snapshot version without an androidx.dev buildId is not " + + "resolvable — add the group to the [[snapshots]] entry for the build it " + + "was merged from.", + ) + } } } companion object { + private const val SNAPSHOT_SUFFIX = "-SNAPSHOT" + private const val TOML_FILE_NAME = "redirectversions.toml" internal fun registerOrGet(project: Project): Provider { @@ -121,9 +258,56 @@ internal fun Project.registerRedirectVersionsExtension() { */ fun Project.findArtifactRedirectionVersion(groupId: String): String? { val versions = RedirectVersionsService.registerOrGet(this).get().versions - val parts = groupId.split(".") + name - val variations = (parts.size downTo 1).map { i -> parts.take(i).joinToString(".") } - return variations.firstNotNullOfOrNull { versions[it] } + return groupPrefixes(groupId, name).firstNotNullOfOrNull { versions[it] } +} + +/** + * Look up the androidx.dev build the redirect version of [groupId] comes from. Resolves the same + * prefix [findArtifactRedirectionVersion] does and asks only that one: a more specific prefix on a + * released version (`androidx.compose.material3`) must not inherit the snapshot of a less specific + * one (`androidx.compose`). Null when the redirect is on a version already on Google Maven. + */ +fun Project.findArtifactRedirectionSnapshot(groupId: String): AndroidxSnapshotBuild? { + val redirects = RedirectVersionsService.registerOrGet(this).get() + val prefix = groupPrefixes(groupId, name).firstOrNull { it in redirects.versions } ?: return null + return redirects.snapshotBuildsByGroup[prefix] +} + +/** + * Group prefixes of [groupId] from the most specific (`.`) down to the least + * specific (the leading segment), the lookup order of the redirect registry. + */ +private fun groupPrefixes(groupId: String, projectName: String): List { + val parts = groupId.split(".") + projectName + return (parts.size downTo 1).map { i -> parts.take(i).joinToString(".") } +} + +/** + * Registers `printAndroidxSnapshots`, which prints the registered androidx.dev builds as a flat + * comma-separated list of `:::`, empty when no redirect is on a + * snapshot. Consumed by the Gradle plugin build of `compose-multiplatform`, which bakes the ids into + * the published plugin so external consumers of a dev build can resolve the same coordinates. + */ +internal fun Project.registerPrintAndroidxSnapshotsTask() { + val service = RedirectVersionsService.registerOrGet(this) + val registry = + service.map { versions -> + versions.snapshots + .flatMap { build -> + build.groups.map { group -> + "$group:${versions.versions.getValue(group)}:${build.buildId}:${build.flavour.id}" + } + } + .joinToString(",") + } + tasks.register("printAndroidxSnapshots") { task -> + task.group = "Compose Multiplatform" + task.description = + "Prints the androidx.dev builds backing the -SNAPSHOT artifact redirects, as " + + "comma-separated ::: records." + task.usesService(service) + task.doLast { println(registry.get()) } + } } /** @@ -247,9 +431,16 @@ internal fun Project.applyParallelRedirectGraph( if (redirectJavaTasks == null || jc.name in redirectJavaTasks) jc.setSource(files()) } + // Name the androidx.dev build when the redirect is on a snapshot: the version string alone + // ("1.13.0-SNAPSHOT") is reused across builds and does not say what is being compiled against. + val snapshotBuild = findArtifactRedirectionSnapshot(redirectCoord.substringBefore(':')) logger.lifecycle( - "[artifactRedirection] {} -> {} (parallel graph: {} redirect target(s), forkBuilt={})", - path, redirectCoord, redirectTargetNames.size, forkBuiltExists, + "[artifactRedirection] {} -> {}{} (parallel graph: {} redirect target(s), forkBuilt={})", + path, + redirectCoord, + snapshotBuild?.let { " from androidx.dev build ${it.buildId}" } ?: "", + redirectTargetNames.size, + forkBuiltExists, ) } } diff --git a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsAndroidXRootImplPlugin.kt b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsAndroidXRootImplPlugin.kt index 901374486e0d8..982b4bcbe79cf 100644 --- a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsAndroidXRootImplPlugin.kt +++ b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsAndroidXRootImplPlugin.kt @@ -34,6 +34,8 @@ class JetBrainsAndroidXRootImplPlugin @Inject constructor( val componentFactory: SoftwareComponentFactory ) : Plugin { override fun apply(project: Project) { + project.registerPrintAndroidxSnapshotsTask() + project.allprojects { subproject -> // Apply capability rule to resolve conflicts between org.jetbrains.androidx.* and androidx.* subproject.configureJetBrainsCapabilityResolution() diff --git a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsVerifyDependencyVersionsTask.kt b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsVerifyDependencyVersionsTask.kt index ea655ace6c28b..f9ee328e25509 100644 --- a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsVerifyDependencyVersionsTask.kt +++ b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/JetBrainsVerifyDependencyVersionsTask.kt @@ -23,6 +23,7 @@ import androidx.build.uptodatedness.cacheEvenIfNoOutputs import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Project +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask @@ -48,6 +49,14 @@ abstract class JetBrainsVerifyDependencyVersionsTask : DefaultTask() { @get:Input val androidXDependencySet: SetProperty = project.objects.setProperty() + /** + * Group prefix -> redirect version, for the prefixes the `[[snapshots]]` table of + * `redirectversions.toml` pins to an androidx.dev build. Empty when every redirect is on a + * released version. + */ + @get:Input + abstract val snapshotRedirectVersions: MapProperty + /** * Iterate through the dependencies of the project and ensure none of them are of an inferior * release. This means that a beta project should not have any alpha dependencies, an rc project @@ -75,6 +84,7 @@ abstract class JetBrainsVerifyDependencyVersionsTask : DefaultTask() { ) } if (dependencyReleasePhase < projectReleasePhase) { + if (isRegisteredSnapshotRedirect(projectVersion, dependency)) return throw GradleException( "Project with version ${version.get()} may " + "not take a dependency on less-stable artifact ${dependency.group}:" + @@ -85,6 +95,27 @@ abstract class JetBrainsVerifyDependencyVersionsTask : DefaultTask() { } } + /** + * True when [dependency] is an artifact redirect this repository deliberately pinned to an + * androidx.dev build **and** the project is being published to a non-release channel. Semver + * build metadata is the discriminator: a public release version has none, `+dev…` and + * `+snapshot.…` do. Requiring the dependency to match a registered prefix and its exact + * `[versions]` entry keeps this from becoming a blanket "dev builds may depend on anything". + */ + private fun isRegisteredSnapshotRedirect( + projectVersion: String, + dependency: AndroidXDependency, + ): Boolean { + if (Version(projectVersion).buildMetadata == null) return false + val registry = snapshotRedirectVersions.get() + val parts = dependency.group.split(".") + val prefix = + (parts.size downTo 1) + .map { i -> parts.take(i).joinToString(".") } + .firstOrNull { it in registry } ?: return false + return registry[prefix] == dependency.version + } + private fun releasePhase(versionString: String): Int { val version = Version(versionString) return when { @@ -107,6 +138,12 @@ internal fun Project.configureDependencyVerification() { JetBrainsVerifyDependencyVersionsTask::class.java ) { task -> task.version.set(project.provider { project.version.toString() }) + task.snapshotRedirectVersions.set( + project.provider { + val redirects = RedirectVersionsService.registerOrGet(project).get() + redirects.snapshotBuildsByGroup.keys.associateWith { redirects.versions.getValue(it) } + } + ) task.androidXDependencySet.set( project.provider { multiplatformExtension!! diff --git a/redirectversions.toml b/redirectversions.toml index 7f3d9499575bd..b0be2b2e48e75 100644 --- a/redirectversions.toml +++ b/redirectversions.toml @@ -24,3 +24,22 @@ "androidx.performance" = "1.0.0-alpha01" "androidx.savedstate" = "1.5.0-alpha01" "androidx.window" = "1.5.0" + +# androidx.dev builds backing the `-SNAPSHOT` entries above, for library states merged from a commit +# Google has cut but not yet published to Google Maven. One build normally covers several groups, +# which is why the build id is the key. Absent entirely on a released state, and it must be absent +# again before a release branch is cut. +# +# `groups` must name group prefixes whose `[versions]` value ends in `-SNAPSHOT`, and every +# `-SNAPSHOT` above must appear in exactly one entry — a snapshot version with no build id is not +# resolvable. `repo` is optional and defaults to "kmp" +# (https://androidx.dev/kmp/builds//artifacts/snapshots/repository); "androidx" +# (https://androidx.dev/snapshots/builds//artifacts/repository) serves the Android-only +# groups the KMP build does not carry. +# +# After changing a build id, run once with `--refresh-dependencies`: Google reuses the version string +# across builds, so Gradle's changing-module cache would keep serving the previous build. +# +# [[snapshots]] +# buildId = "15316886" +# groups = ["androidx.compose", "androidx.compose.material3"] diff --git a/settings-fork.gradle b/settings-fork.gradle index ab471d0a1ec06..5fdd028f734ac 100644 --- a/settings-fork.gradle +++ b/settings-fork.gradle @@ -35,12 +35,14 @@ def prebuiltsRoot = new File( def rootProjectRepositories apply from: "buildSrc-fork/settingsScripts/out-setup.groovy" +apply from: "buildSrc-fork/androidxSnapshotRepos.gradle" getGradle().beforeProject { project -> // Migrate to dependencyResolutionManagement.repositories when // https://github.com/gradle/gradle/issues/17295 is fixed if (project.path == ":") { repos.addMavenRepositories(project.repositories) + androidxSnapshots.addRepositories(project.repositories, supportRootFolder) rootProjectRepositories = project.repositories } else { // Performance optimization because it is more efficient to reuse @@ -48,6 +50,7 @@ getGradle().beforeProject { project -> // on each project project.repositories.addAll(rootProjectRepositories) } + androidxSnapshots.disableChangingModuleCache(project) project.ext.supportRootFolder = supportRootFolder project.ext.prebuiltsRoot = prebuiltsRoot def checkoutRoot = new File("${buildscript.sourceFile.parent}")