diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/BomGeneratorTask.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/BomGeneratorTask.java deleted file mode 100644 index 533c3af06f8..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/BomGeneratorTask.java +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator; - -import static java.util.stream.Collectors.toList; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Sets; -import com.google.firebase.gradle.bomgenerator.model.Dependency; -import com.google.firebase.gradle.bomgenerator.model.VersionBump; -import com.google.firebase.gradle.bomgenerator.tagging.GitClient; -import com.google.firebase.gradle.bomgenerator.tagging.ShellExecutor; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import org.eclipse.aether.resolution.VersionRangeResolutionException; -import org.gradle.api.DefaultTask; -import org.gradle.api.file.DirectoryProperty; -import org.gradle.api.logging.Logger; -import org.gradle.api.tasks.OutputDirectory; -import org.gradle.api.tasks.TaskAction; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -// TODO: add tests, and then start migrating to maybe a task or a new refined testable plugin -public abstract class BomGeneratorTask extends DefaultTask { - private static final List BOM_ARTIFACTS = - List.of( - "com.google.firebase:firebase-analytics", - "com.google.firebase:firebase-analytics-ktx", - "com.google.firebase:firebase-appcheck-debug", - "com.google.firebase:firebase-appcheck-debug-testing", - "com.google.firebase:firebase-appcheck-ktx", - "com.google.firebase:firebase-appcheck-playintegrity", - "com.google.firebase:firebase-appcheck", - "com.google.firebase:firebase-auth", - "com.google.firebase:firebase-auth-ktx", - "com.google.firebase:firebase-common", - "com.google.firebase:firebase-common-ktx", - "com.google.firebase:firebase-config", - "com.google.firebase:firebase-config-ktx", - "com.google.firebase:firebase-crashlytics", - "com.google.firebase:firebase-crashlytics-ktx", - "com.google.firebase:firebase-crashlytics-ndk", - "com.google.firebase:firebase-database", - "com.google.firebase:firebase-database-ktx", - "com.google.firebase:firebase-dynamic-links", - "com.google.firebase:firebase-dynamic-links-ktx", - "com.google.firebase:firebase-encoders", - "com.google.firebase:firebase-firestore", - "com.google.firebase:firebase-firestore-ktx", - "com.google.firebase:firebase-functions", - "com.google.firebase:firebase-functions-ktx", - "com.google.firebase:firebase-inappmessaging", - "com.google.firebase:firebase-inappmessaging-display", - "com.google.firebase:firebase-inappmessaging-display-ktx", - "com.google.firebase:firebase-inappmessaging-ktx", - "com.google.firebase:firebase-installations", - "com.google.firebase:firebase-installations-ktx", - "com.google.firebase:firebase-messaging", - "com.google.firebase:firebase-messaging-directboot", - "com.google.firebase:firebase-messaging-ktx", - "com.google.firebase:firebase-ml-modeldownloader", - "com.google.firebase:firebase-ml-modeldownloader-ktx", - "com.google.firebase:firebase-perf", - "com.google.firebase:firebase-perf-ktx", - "com.google.firebase:firebase-storage", - "com.google.firebase:firebase-storage-ktx", - "com.google.firebase:firebase-vertexai"); - private static final List IGNORED_ARTIFACTS = - List.of( - "crash-plugin", - "firebase-ml-vision", - "crashlytics", - "firebase-ads", - "firebase-ads-lite", - "firebase-abt", - "firebase-analytics-impl", - "firebase-analytics-impl-license", - "firebase-analytics-license", - "firebase-annotations", - "firebase-appcheck-interop", - "firebase-appcheck-safetynet", - "firebase-appdistribution-gradle", - "firebase-appindexing-license", - "firebase-appindexing", - "firebase-iid", - "firebase-core", - "firebase-auth-common", - "firebase-auth-impl", - "firebase-auth-interop", - "firebase-auth-license", - "firebase-encoders-json", - "firebase-encoders-proto", - "firebase-auth-module", - "firebase-bom", - "firebase-common-license", - "firebase-components", - "firebase-config-license", - "firebase-config-interop", - "firebase-crash", - "firebase-crash-license", - "firebase-crashlytics-buildtools", - "firebase-crashlytics-gradle", - "firebase-database-collection", - "firebase-database-connection", - "firebase-database-connection-license", - "firebase-database-license", - "firebase-dataconnect", - "firebase-datatransport", - "firebase-appdistribution-ktx", - "firebase-appdistribution", - "firebase-appdistribution-api", - "firebase-appdistribution-api-ktx", - "firebase-dynamic-module-support", - "firebase-dynamic-links-license", - "firebase-functions-license", - "firebase-iid-interop", - "firebase-iid-license", - "firebase-invites", - "firebase-measurement-connector", - "firebase-measurement-connector-impl", - "firebase-messaging-license", - "firebase-ml-common", - "firebase-ml-vision-internal-vkp", - "firebase-ml-model-interpreter", - "firebase-perf-license", - "firebase-plugins", - "firebase-sessions", - "firebase-storage-common", - "firebase-storage-common-license", - "firebase-storage-license", - "perf-plugin", - "play-services-ads", - "protolite-well-known-types", - "testlab-instr-lib", - "firebase-installations-interop", - "google-services", - "gradle", - "firebase-ml-vision-automl", - "firebase-ml-vision-barcode-model", - "firebase-ml-vision-face-model", - "firebase-ml-vision-image-label-model", - "firebase-ml-vision-object-detection-model", - "firebase-ml-natural-language", - "firebase-ml-natural-language-language-id-model", - "firebase-ml-natural-language-smart-reply", - "firebase-ml-natural-language-smart-reply-model", - "firebase-ml-natural-language-translate", - "firebase-ml-natural-language-translate-model"); - private static final List IMPORTANT_NON_FIREBASE_LIBRARIES = - List.of( - "com.google.android.gms:play-services-ads", - "com.google.gms:google-services", - "com.android.tools.build:gradle", - "com.google.firebase:perf-plugin", - "com.google.firebase:firebase-crashlytics-gradle", - "com.google.firebase:firebase-appdistribution-gradle"); - - private Set ignoredFirebaseArtifacts; - private Set bomArtifacts; - private Set allFirebaseArtifacts; - - public Map versionOverrides = new HashMap<>(); - - @OutputDirectory - public abstract DirectoryProperty getBomDirectory(); - - /** - * This task generates a current Bill of Materials (BoM) based on the latest versions of - * everything in gMaven. This is meant to be a post-release task so that the BoM contains the most - * recent versions of all artifacts. - * - *

This task also tags the release candidate commit with the BoM version, the new version of - * releasing products, and the M version of the current release. - * - *

Version overrides may be given to this task in a map like so: versionOverrides = - * ["com.google.firebase:firebase-firestore": "17.0.1"] - */ - @TaskAction - // TODO(yifany): needs a more accurate name - public void generateBom() throws Exception { - // Repo Access Setup - RepositoryClient depPopulator = new RepositoryClient(); - - // Prepare script by pulling the state of the world (checking configuration files and gMaven - // artifacts) - bomArtifacts = new HashSet(BOM_ARTIFACTS); - ignoredFirebaseArtifacts = new HashSet(IGNORED_ARTIFACTS); - allFirebaseArtifacts = depPopulator.getAllFirebaseArtifacts(); - allFirebaseArtifacts.addAll(IMPORTANT_NON_FIREBASE_LIBRARIES); - - // Find version for BoM artifact. First version released should be 15.0.0 - String currentVersion = - depPopulator - .getLastPublishedVersion(Dependency.create("com.google.firebase", "firebase-bom")) - .orElse("15.0.0"); - - // We need to get the content of the current BoM to compute version bumps. - Map previousBomVersions = getBomMap(currentVersion); - - // Generate list of firebase libraries, ping gmaven for current versions, and override as needed - // from local settings - List allFirebaseDependencies = - buildVersionedDependencyList(depPopulator, previousBomVersions); - - List bomDependencies = - allFirebaseDependencies.stream() - .filter(dep -> bomArtifacts.contains(dep.fullArtifactId())) - .collect(toList()); - - // Sanity check that there are no unaccounted for artifacts that we might want in the BoM - Set bomArtifactIds = - bomArtifacts.stream().map(x -> x.split(":")[1]).collect(Collectors.toSet()); - Set allFirebaseArtifactIds = - allFirebaseArtifacts.stream().map(x -> x.split(":")[1]).collect(Collectors.toSet()); - Set invalidArtifacts = - Sets.difference( - Sets.difference(allFirebaseArtifactIds, bomArtifactIds), ignoredFirebaseArtifacts); - - if (!invalidArtifacts.isEmpty()) { - throw new RuntimeException( - "Some dependencies are unaccounted for, add to BomGeneratorTask#IGNORED_ARTIFACTS or " - + "BomGeneratorTask#BOM_ARTIFACTS. Unaccounted for dependencies: " - + invalidArtifacts.toString()); - } - String version = findArtifactVersion(bomDependencies, currentVersion, previousBomVersions); - - // Surface generated pom for sanity checking and testing, and then write it. - Path bomDir = getBomDirectory().getAsFile().get().toPath(); - PomXmlWriter xmlWriter = new PomXmlWriter(bomDependencies, version, bomDir); - MarkdownDocumentationWriter documentationWriter = - new MarkdownDocumentationWriter( - bomDependencies, version, previousBomVersions, currentVersion); - RecipeVersionWriter recipeWriter = new RecipeVersionWriter(allFirebaseDependencies); - Document outputXmlDoc = xmlWriter.generatePomXml(); - String outputDocumentation = documentationWriter.generateDocumentation(); - String outputRecipe = recipeWriter.generateVersionUpdate(); - xmlWriter.writeXmlDocument(outputXmlDoc); - documentationWriter.writeDocumentation(outputDocumentation); - recipeWriter.writeVersionUpdate(outputRecipe); - - tagVersions(version, bomDependencies); - } - - // Finds the version for the BoM artifact. - private String findArtifactVersion( - List firebaseDependencies, - String currentVersion, - Map previousBomVersions) - throws VersionRangeResolutionException { - Optional bump = - firebaseDependencies.stream().map(Dependency::versionBump).distinct().sorted().findFirst(); - - if (firebaseDependencies.size() < previousBomVersions.size()) { - bump = Optional.of(VersionBump.MAJOR); - } - - return bump.map(x -> VersionBump.bumpVersionBy(currentVersion, x)) - .orElseThrow(() -> new RuntimeException("Could not figure out how to bump version")); - } - - private Dependency overrideVersion(Dependency dep) { - if (versionOverrides.containsKey(dep.fullArtifactId())) { - return Dependency.create( - dep.groupId(), - dep.artifactId(), - versionOverrides.get(dep.fullArtifactId()), - VersionBump.PATCH); - } else { - return dep; - } - } - - private List buildVersionedDependencyList( - RepositoryClient depPopulator, Map previousBomVersions) { - return allFirebaseArtifacts.stream() - .map( - dep -> { - String[] splitDep = dep.split(":"); - return Dependency.create(splitDep[0], splitDep[1]); - }) - .map(dep -> depPopulator.populateDependencyVersion(dep, previousBomVersions)) - .map(this::overrideVersion) - .collect(toList()); - } - - private Map getBomMap(String bomVersion) { - String bomUrl = - "https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-bom/" - + bomVersion - + "/firebase-bom-" - + bomVersion - + ".pom"; - try (InputStream index = new URL(bomUrl).openStream()) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setValidating(true); - factory.setIgnoringElementContentWhitespace(true); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = builder.parse(index); - NodeList dependencyList = doc.getElementsByTagName("dependency"); - ImmutableMap.Builder outputBuilder = ImmutableMap.builder(); - for (int i = 0; i < dependencyList.getLength(); i++) { - Element artifact = (Element) dependencyList.item(i); - String groupId = artifact.getElementsByTagName("groupId").item(0).getTextContent(); - String artifactId = artifact.getElementsByTagName("artifactId").item(0).getTextContent(); - String version = artifact.getElementsByTagName("version").item(0).getTextContent(); - outputBuilder.put(groupId + ":" + artifactId, version); - } - return outputBuilder.build(); - } catch (SAXException | IOException | ParserConfigurationException e) { - throw new RuntimeException("Failed to get contents of BoM version " + bomVersion, e); - } - } - - private void tagVersions(String bomVersion, List firebaseDependencies) { - Logger logger = this.getProject().getLogger(); - if (!System.getenv().containsKey("FIREBASE_CI")) { - logger.warn("Tagging versions is skipped for non-CI environments."); - return; - } - - String mRelease = System.getenv("PULL_BASE_REF"); - String rcCommit = System.getenv("PULL_BASE_SHA"); - ShellExecutor executor = new ShellExecutor(Paths.get(".").toFile(), logger::lifecycle); - GitClient git = new GitClient(mRelease, rcCommit, executor, logger::lifecycle); - git.tagReleaseVersion(); - git.tagBomVersion(bomVersion); - firebaseDependencies.stream() - .filter(d -> d.versionBump() != VersionBump.NONE) - .forEach(d -> git.tagProductVersion(d.artifactId(), d.version())); - git.pushCreatedTags(); - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomReleaseNotesTask.kt b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomReleaseNotesTask.kt new file mode 100644 index 00000000000..72fe51a8017 --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomReleaseNotesTask.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.bomgenerator + +import com.google.firebase.gradle.plugins.createIfAbsent +import com.google.firebase.gradle.plugins.datamodels.ArtifactDependency +import com.google.firebase.gradle.plugins.datamodels.PomElement +import com.google.firebase.gradle.plugins.datamodels.fullArtifactName +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Generates the release notes for a bom. + * + * @see GenerateBomTask + */ +abstract class GenerateBomReleaseNotesTask : DefaultTask() { + @get:InputFile abstract val currentBom: RegularFileProperty + + @get:Input abstract val previousBom: Property + + @get:OutputFile abstract val releaseNotesFile: RegularFileProperty + + @get:Internal abstract val previousBomVersions: MapProperty + + @TaskAction + fun generate() { + val bom = PomElement.fromFile(currentBom.asFile.get()) + val currentDeps = bom.dependencyManagement?.dependencies.orEmpty() + val previousDeps = previousBom.get().dependencyManagement?.dependencies.orEmpty() + previousBomVersions.set(previousDeps.associate { it.fullArtifactName to it.version }) + + val sortedDependencies = currentDeps.sortedBy { it.version } + + val headingId = "{: #bom_v${bom.version.replace(".", "-")}}" + + releaseNotesFile.asFile + .get() + .createIfAbsent() + .writeText( + """ + |### {{firebase_bom_long}} ({{bill_of_materials}}) version ${bom.version} $headingId + |{% comment %} + |These library versions must be flat-typed, do not use variables. + |The release note for this BoM version is a library-version snapshot. + |{% endcomment %} + | + |

+ | + """ + .trimMargin() + ) + } + + private fun artifactToListEntry(artifact: ArtifactDependency): String { + val previousVersion = previousBomVersions.get()[artifact.fullArtifactName] ?: "N/A" + val artifactName = "${artifact.groupId}:${artifact.artifactId}" + + return if (artifact.version != previousVersion) { + """ + | + | ${artifactName} + | $previousVersion + | ${artifact.version} + | + """ + .trimMargin() + } else { + """ + | + | ${artifactName} + | $previousVersion + | ${artifact.version} + | + """ + .trimMargin() + } + } +} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomTask.kt b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomTask.kt new file mode 100644 index 00000000000..8e5599a530a --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateBomTask.kt @@ -0,0 +1,221 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.bomgenerator + +import com.google.firebase.gradle.plugins.ModuleVersion +import com.google.firebase.gradle.plugins.VersionType +import com.google.firebase.gradle.plugins.createIfAbsent +import com.google.firebase.gradle.plugins.datamodels.ArtifactDependency +import com.google.firebase.gradle.plugins.datamodels.DependencyManagementElement +import com.google.firebase.gradle.plugins.datamodels.LicenseElement +import com.google.firebase.gradle.plugins.datamodels.PomElement +import com.google.firebase.gradle.plugins.datamodels.fullArtifactName +import com.google.firebase.gradle.plugins.datamodels.moduleVersion +import com.google.firebase.gradle.plugins.diff +import com.google.firebase.gradle.plugins.orEmpty +import com.google.firebase.gradle.plugins.partitionNotNull +import com.google.firebase.gradle.plugins.services.GMavenService +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.services.ServiceReference +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Generates the firebase bom, using gmaven as a source of truth for artifacts and versions. + * + * @see validateArtifacts + * @see GenerateBomReleaseNotesTask + * @see GenerateTutorialBundleTask + */ +abstract class GenerateBomTask : DefaultTask() { + /** + * Artifacts to include in the bom. + * + * ``` + * bomArtifacts.set(listOf( + * "com.google.firebase:firebase-firestore", + * "com.google.firebase:firebase-storage" + * )) + * ``` + */ + @get:Input abstract val bomArtifacts: ListProperty + + /** + * Artifacts to exclude from the bom. + * + * These are artifacts that are under the `com.google.firebase` namespace, but are intentionally + * not included in the bom. + * + * ``` + * bomArtifacts.set(listOf( + * "com.google.firebase:crashlytics", + * "com.google.firebase:crash-plugin" + * )) + * ``` + */ + @get:Input abstract val ignoredArtifacts: ListProperty + + /** + * Optional map of versions to use instead of the versions on gmaven. + * + * ``` + * versionOverrides.set(mapOf( + * "com.google.firebase:firebase-firestore" to "10.0.0" + * )) + * ``` + */ + @get:Input abstract val versionOverrides: MapProperty + + /** Directory to save the bom under. */ + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @get:ServiceReference("gmaven") abstract val gmaven: Property + + @TaskAction + fun generate() { + val versionOverrides = versionOverrides.getOrElse(emptyMap()) + + val validatedArtifactsToPublish = validateArtifacts() + val artifactsToPublish = + validatedArtifactsToPublish.map { + val version = versionOverrides[it.fullArtifactName] ?: it.version + logger.debug("Using ${it.fullArtifactName} with version $version") + + it.copy(version = version) + } + + val newVersion = determineNewBomVersion(artifactsToPublish) + + val pom = + PomElement( + namespace = "http://maven.apache.org/POM/4.0.0", + schema = "http://www.w3.org/2001/XMLSchema-instance", + schemaLocation = + "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd", + modelVersion = "4.0.0", + groupId = "com.google.firebase", + artifactId = "firebase-bom", + version = newVersion.toString(), + packaging = "pom", + licenses = + listOf( + LicenseElement( + name = "The Apache Software License, Version 2.0", + url = "http://www.apache.org/licenses/LICENSE-2.0.txt", + distribution = "repo", + ) + ), + dependencyManagement = DependencyManagementElement(artifactsToPublish), + ) + + val bomFile = + outputDirectory.file( + "com/google/firebase/firebase-bom/$newVersion/firebase-bom-$newVersion.pom" + ) + + pom.toFile(bomFile.get().asFile.createIfAbsent()) + } + + private fun determineNewBomVersion( + releasingDependencies: List + ): ModuleVersion { + logger.info("Determining the new bom version") + + val oldBom = gmaven.get().latestPom("com.google.firebase", "firebase-bom") + val oldBomVersion = ModuleVersion.fromString(oldBom.artifactId, oldBom.version) + + val oldBomDependencies = oldBom.dependencyManagement?.dependencies.orEmpty() + val changedDependencies = oldBomDependencies.diff(releasingDependencies) + + val versionBumps = + changedDependencies.mapNotNull { (old, new) -> + if (old == null) { + logger.warn("Dependency was added: ${new?.fullArtifactName}") + + VersionType.MINOR + } else if (new === null) { + logger.warn("Dependency was removed: ${old.fullArtifactName}") + + VersionType.MAJOR + } else { + old.moduleVersion.bumpFrom(new.moduleVersion) + } + } + + val finalBump = versionBumps.minOrNull() + return oldBomVersion.bump(finalBump) + } + + /** + * Validates that the provided bom artifacts satisfy the following constraints: + * - All are released and live on gmaven. + * - They include _all_ of the firebase artifacts on gmaven, unless they're specified in + * [ignoredArtifacts].+ + * + * @return The validated artifacts to release. + * @throws RuntimeException If any of the validations fail. + */ + private fun validateArtifacts(): List { + logger.info("Validating bom artifacts") + + val firebaseArtifacts = bomArtifacts.get().toSet() + val ignoredArtifacts = ignoredArtifacts.orEmpty().toSet() + + val allFirebaseArtifacts = + gmaven + .get() + .groupIndex("com.google.firebase") + .map { "${it.groupId}:${it.artifactId}" } + .toSet() + + val (released, unreleased) = + firebaseArtifacts + .associateWith { gmaven.get().groupIndexArtifactOrNull(it) } + .partitionNotNull() + + if (unreleased.isNotEmpty()) { + throw RuntimeException( + """ + |Some artifacts required for bom generation are not live on gmaven yet: + |${unreleased.joinToString("\n")} + """ + .trimMargin() + ) + } + + val requiredArtifacts = allFirebaseArtifacts - ignoredArtifacts + val missingArtifacts = requiredArtifacts - firebaseArtifacts + if (missingArtifacts.isNotEmpty()) { + throw RuntimeException( + """ + |There are Firebase artifacts missing from the provided bom artifacts. + |Add the artifacts to the ignoredArtifacts property to ignore them or to the bomArtifacts property to include them in the bom. + |Dependencies missing: + |${missingArtifacts.joinToString("\n")} + """ + .trimMargin() + ) + } + + return released.values.map { it.toArtifactDependency() } + } +} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateTutorialBundleTask.kt b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateTutorialBundleTask.kt new file mode 100644 index 00000000000..b315e7188aa --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/GenerateTutorialBundleTask.kt @@ -0,0 +1,312 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.bomgenerator + +import com.google.firebase.gradle.plugins.createIfAbsent +import com.google.firebase.gradle.plugins.multiLine +import com.google.firebase.gradle.plugins.orEmpty +import com.google.firebase.gradle.plugins.services.GMavenService +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.services.ServiceReference +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Generates the tutorial bundle recipe for a release. + * + * This task uses gmaven as a source of truth, and as such, should be ran _after_ the artifacts are + * live on gmaven. + * + * @see GenerateBomTask + */ +abstract class GenerateTutorialBundleTask : DefaultTask() { + /** + * Firebase library artifacts. + * + * ``` + * firebaseArtifacts.set(listOf( + * "com.google.firebase:firebase-analytics", + * "com.google.firebase:firebase-crashlytics" + * )) + * ``` + */ + @get:Input abstract val firebaseArtifacts: ListProperty + + /** + * Common artifacts and dependencies whose versions also need to be tracked during releases. + * + * ``` + * commonArtifacts.set(listOf( + * "com.google.gms:google-services", + * )) + * ``` + */ + @get:Input abstract val commonArtifacts: ListProperty + + /** + * Firebase gradle plugins. + * + * ``` + * gradlePlugins.set(listOf( + * "com.google.firebase:firebase-appdistribution-gradle", + * "com.google.firebase:firebase-crashlytics-gradle" + * )) + * ``` + */ + @get:Input abstract val gradlePlugins: ListProperty + + /** + * Performance monitoring related artifacts. + * + * ``` + * firebaseArtifacts.set(listOf( + * "com.google.firebase:perf-plugin" + * )) + * ``` + */ + @get:Input abstract val perfArtifacts: ListProperty + + /** + * All artifacts that are expected to be present. + * + * You can use this to verify that the input doesn't exclude any artifacts. + * + * ``` + * requiredArtifacts.set(listOf( + * "com.google.firebase:firebase-analytics", + * "com.google.firebase:perf-plugin" + * )) + * ``` + */ + @get:Input abstract val requiredArtifacts: ListProperty + + /** + * Optional map of versions to use instead of the versions on gmaven. + * + * ``` + * versionOverrides.set(mapOf( + * "com.google.firebase:firebase-firestore" to "10.0.0" + * )) + * ``` + */ + @get:Input abstract val versionOverrides: MapProperty + + /** The file to save the generated tutorial to. */ + @get:OutputFile abstract val tutorialFile: RegularFileProperty + + @get:ServiceReference("gmaven") abstract val gmaven: Property + + @TaskAction + fun generate() { + val firebaseArtifacts = firebaseArtifacts.orEmpty() + val commonArtifacts = commonArtifacts.orEmpty() + val gradlePlugins = gradlePlugins.orEmpty() + val perfArtifacts = perfArtifacts.orEmpty() + val requiredArtifacts = requiredArtifacts.orEmpty() + + val allArtifacts = firebaseArtifacts + commonArtifacts + gradlePlugins + perfArtifacts + + val missingArtifacts = requiredArtifacts - allArtifacts.toSet() + if (missingArtifacts.isNotEmpty()) { + throw RuntimeException( + multiLine( + "Artifacts required for the tutorial bundle are missing from the provided input:", + missingArtifacts, + ) + ) + } + + val sections = + listOfNotNull( + generateSection("Common Firebase dependencies", commonArtifacts), + generateSection("Firebase SDK libraries", firebaseArtifacts), + generateSection("Firebase Gradle plugins", gradlePlugins), + generateSection("Performance Monitoring", perfArtifacts), + ) + + tutorialFile + .get() + .asFile + .createIfAbsent() + .writeText( + """ + | + | + """ + .trimMargin() + ) + } + + private fun generateSection(name: String, artifacts: List): String? { + if (artifacts.isEmpty()) { + logger.warn("Skipping section, since no data was provided: $name") + return null + } else { + logger.info("Using artifacts for section ($name): ${artifacts.joinToString()}") + } + + val mappingKeys = mappings.keys + + val (supported, unsupported) = artifacts.partition { mappingKeys.contains(it) } + if (unsupported.isNotEmpty()) { + logger.info( + multiLine( + "The following artifacts are missing mapping keys.", + "This is likely intentional, but the artifacts will be listed for debugging purposes:", + unsupported, + ) + ) + } + + val sortedArtifacts = supported.sortedBy { mappingKeys.indexOf(it) } + val artifactSection = sortedArtifacts.map { artifactVariableString(it) } + + return multiLine("", artifactSection).prependIndent(" ") + } + + private fun versionString(fullArtifactName: String): String { + val overrideVersion = versionOverrides.orEmpty()[fullArtifactName] + + if (overrideVersion != null) { + logger.info("Using a version override for an artifact ($fullArtifactName): $overrideVersion") + + return overrideVersion + } else { + logger.info("Fetching the latest version for an artifact: $fullArtifactName") + + return gmaven.get().latestVersionOrNull(fullArtifactName) + ?: throw RuntimeException( + "An artifact required for the tutorial bundle is missing from gmaven: $fullArtifactName" + ) + } + } + + private fun artifactVariableString(fullArtifactName: String): String { + val (name, alias, extra) = mappings[fullArtifactName]!! + + return multiLine( + "", + "", + extra, + ) + } + + companion object { + /** + * A linked mapping for artifact ids to their respective [ArtifactTutorialMapping] metadata. + * + * Since this is a _linked_ map, the order is preserved in the tutorial output. + */ + private val mappings = + linkedMapOf( + "com.google.gms:google-services" to + ArtifactTutorialMapping( + "Google Services Plugin", + "google-services-plugin-class", + listOf( + "", + "", + ), + ), + "com.google.firebase:firebase-analytics" to + ArtifactTutorialMapping("Analytics", "analytics-dependency"), + "com.google.firebase:firebase-crashlytics" to + ArtifactTutorialMapping("Crashlytics", "crashlytics-dependency"), + "com.google.firebase:firebase-perf" to + ArtifactTutorialMapping("Performance Monitoring", "perf-dependency"), + "com.google.firebase:firebase-vertexai" to + ArtifactTutorialMapping("Vertex AI in Firebase", "vertex-dependency"), + "com.google.firebase:firebase-messaging" to + ArtifactTutorialMapping("Cloud Messaging", "messaging-dependency"), + "com.google.firebase:firebase-auth" to + ArtifactTutorialMapping("Authentication", "auth-dependency"), + "com.google.firebase:firebase-database" to + ArtifactTutorialMapping("Realtime Database", "database-dependency"), + "com.google.firebase:firebase-storage" to + ArtifactTutorialMapping("Cloud Storage", "storage-dependency"), + "com.google.firebase:firebase-config" to + ArtifactTutorialMapping("Remote Config", "remote-config-dependency"), + "com.google.android.gms:play-services-ads" to + ArtifactTutorialMapping("Admob", "ads-dependency"), + "com.google.firebase:firebase-firestore" to + ArtifactTutorialMapping("Cloud Firestore", "firestore-dependency"), + "com.google.firebase:firebase-functions" to + ArtifactTutorialMapping("Firebase Functions", "functions-dependency"), + "com.google.firebase:firebase-inappmessaging-display" to + ArtifactTutorialMapping("FIAM Display", "fiamd-dependency"), + "com.google.firebase:firebase-ml-vision" to + ArtifactTutorialMapping("Firebase MLKit Vision", "ml-vision-dependency"), + "com.google.firebase:firebase-appdistribution-gradle" to + ArtifactTutorialMapping( + "App Distribution", + "appdistribution-plugin-class", + listOf(""), + ), + "com.google.firebase:firebase-crashlytics-gradle" to + ArtifactTutorialMapping( + "Crashlytics", + "crashlytics-plugin-class", + listOf(""), + ), + "com.google.firebase:perf-plugin" to + ArtifactTutorialMapping( + "Perf Plugin", + "perf-plugin-class", + listOf(""), + ), + ) + } +} + +/** + * Metadata for an artifact to use in generation of the tutorial. + * + * For example, given the following: + * ``` + * ArtifactTutorialMapping( + * "Perf Plugin", + * "perf-plugin-class", + * listOf("") + * ) + * ``` + * + * The tutorial will generate the following output: + * ```html + * + * + * + * ``` + * + * _Assuming the latest version on gmaven is `1.2.3`._ + * + * @property name The space separated, capitalized, full name of the artifact. + * @property alias The internal alias of the artifact. + * @property extra Optional additional data to add after the metadata entry in the tutorial. + * @see GenerateTutorialBundleTask.mappings + */ +private data class ArtifactTutorialMapping( + val name: String, + val alias: String, + val extra: List = emptyList(), +) diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/MarkdownDocumentationWriter.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/MarkdownDocumentationWriter.java deleted file mode 100644 index 530ecf9c977..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/MarkdownDocumentationWriter.java +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator; - -import com.google.firebase.gradle.bomgenerator.model.Dependency; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; - -public class MarkdownDocumentationWriter { - private final List firebaseDependencies; - private final Map previousBomVersions; - private final String version; - private final String previousVersion; - - public MarkdownDocumentationWriter( - List firebaseDependencies, - String version, - Map previousBomVersions, - String previousVersion) { - this.firebaseDependencies = firebaseDependencies; - this.previousBomVersions = previousBomVersions; - this.version = version; - this.previousVersion = previousVersion; - } - - public String generateDocumentation() { - StringBuilder docBuilder = new StringBuilder(); - docBuilder.append(generateHeader(version)); - firebaseDependencies.stream() - .sorted(Comparator.comparing(Dependency::toGradleString)) - .map(this::generateListEntry) - .forEach(docBuilder::append); - docBuilder.append(generateFooter()); - return docBuilder.toString(); - } - - public void writeDocumentation(String document) throws IOException { - Files.write( - new File("bomReleaseNotes.md").toPath(), - Collections.singleton(document), - StandardCharsets.UTF_8); - } - - public String getVersion() { - return version; - } - - private String generateHeader(String version) { - return "### {{firebase_bom_long}} ({{bill_of_materials}}) version " - + version - + " " - + headingId() - + "\n" - + "{% comment %}\n" - + "These library versions must be flat-typed, do not use variables.\n" - + "The release note for this BoM version is a library-version snapshot.\n" - + "{% endcomment %}\n" - + "\n" - + "
\n" - + "

\n" - + " Firebase Android SDKs mapped to this {{bom}} version

\n" - + "

Libraries that were versioned with this release are in highlighted rows.\n" - + "
Refer to a library's release notes (on this page) for details about its\n" - + " changes.\n" - + "

\n" - + " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " \n"; - } - - private String generateListEntry(Dependency dep) { - String previousDepVersion = - previousBomVersions.containsKey(dep.fullArtifactId()) - ? previousBomVersions.get(dep.fullArtifactId()) - : "N/A"; - boolean depChanged = !dep.version().equals(previousDepVersion); - String boldOpenTag = depChanged ? "" : ""; - String boldClosedTag = depChanged ? "" : ""; - String tableStyle = depChanged ? " class=\"alt\"" : ""; - return " \n" - + " \n" - + " \n" - + " \n" - + " \n"; - } - - private String generateFooter() { - return " \n
Artifact nameVersion mapped
to previous {{bom}} v" - + previousVersion - + "
Version mapped
to this {{bom}} v" - + version - + "
" - + boldOpenTag - + dep.fullArtifactId() - + boldClosedTag - + "" - + previousDepVersion - + "" - + boldOpenTag - + dep.version() - + boldClosedTag - + "
\n
\n"; - } - - private String headingId() { - return "{: #bom_v" + version.replaceAll("\\.", "-") + "}"; - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/PomXmlWriter.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/PomXmlWriter.java deleted file mode 100644 index bac4b8d9c85..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/PomXmlWriter.java +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator; - -import com.google.firebase.gradle.bomgenerator.model.Dependency; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerException; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import org.eclipse.aether.resolution.VersionRangeResolutionException; -import org.w3c.dom.Document; -import org.w3c.dom.Element; - -public class PomXmlWriter { - private static final String ARTIFACT_GROUP_ID = "com.google.firebase"; - private static final String ARTIFACT_ARTIFACT_ID = "firebase-bom"; - private final List firebaseDependencies; - private final String version; - private final Path rootDir; - - public PomXmlWriter(List firebaseDependencies, String version, Path rootDir) { - this.firebaseDependencies = firebaseDependencies; - this.version = version; - this.rootDir = rootDir; - } - - public Document generatePomXml() - throws ParserConfigurationException, VersionRangeResolutionException { - Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); - - Element project = doc.createElement("project"); - project.setAttribute("xmlns", "http://maven.apache.org/POM/4.0.0"); - project.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); - project.setAttribute( - "xsi:schemaLocation", - "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"); - doc.appendChild(project); - - createAndAppendSimpleElement("modelVersion", "4.0.0", project, doc); - createAndAppendSimpleElement("groupId", ARTIFACT_GROUP_ID, project, doc); - createAndAppendSimpleElement("artifactId", ARTIFACT_ARTIFACT_ID, project, doc); - createAndAppendSimpleElement("version", getVersion(), project, doc); - createAndAppendSimpleElement("packaging", "pom", project, doc); - - Element licenses = createLicense(doc); - project.appendChild(licenses); - - Element dependencyManagement = doc.createElement("dependencyManagement"); - project.appendChild(dependencyManagement); - - Element dependencies = doc.createElement("dependencies"); - dependencyManagement.appendChild(dependencies); - - for (Dependency dep : firebaseDependencies) { - Element depXml = dependencyToMavenXmlElement(dep, doc); - dependencies.appendChild(depXml); - } - - return doc; - } - - public void writeXmlDocument(Document document) throws IOException, TransformerException { - - Transformer transformer = TransformerFactory.newInstance().newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); - - DOMSource source = new DOMSource(document); - Path outputDir = rootDir.resolve("com/google/firebase/firebase-bom/" + version + "/"); - Files.createDirectories(outputDir); - Path pom = outputDir.resolve("firebase-bom-" + version + ".pom"); - StreamResult file = new StreamResult(pom.toFile()); - transformer.transform(source, file); - } - - public String getVersion() { - return version; - } - - private static void createAndAppendSimpleElement( - String key, String value, Element toAppendTo, Document doc) { - Element element = doc.createElement(key); - element.appendChild(doc.createTextNode(value)); - toAppendTo.appendChild(element); - } - - private static Element createLicense(Document doc) { - Element licenses = doc.createElement("licenses"); - Element license = doc.createElement("license"); - createAndAppendSimpleElement("name", "The Apache Software License, Version 2.0", license, doc); - createAndAppendSimpleElement( - "url", "http://www.apache.org/licenses/LICENSE-2.0.txt", license, doc); - createAndAppendSimpleElement("distribution", "repo", license, doc); - licenses.appendChild(license); - return licenses; - } - - public Element dependencyToMavenXmlElement(Dependency dep, Document doc) { - Element dependencyElement = doc.createElement("dependency"); - - Element groupId = doc.createElement("groupId"); - groupId.appendChild(doc.createTextNode(dep.groupId())); - - Element artifactId = doc.createElement("artifactId"); - artifactId.appendChild(doc.createTextNode(dep.artifactId())); - - Element version = doc.createElement("version"); - version.appendChild(doc.createTextNode(dep.version())); - - dependencyElement.appendChild(groupId); - dependencyElement.appendChild(artifactId); - dependencyElement.appendChild(version); - - return dependencyElement; - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RecipeVersionWriter.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RecipeVersionWriter.java deleted file mode 100644 index 5ef8c9207fe..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RecipeVersionWriter.java +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator; - -import com.google.firebase.gradle.bomgenerator.model.Dependency; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class RecipeVersionWriter { - private final List firebaseDependencies; - - public RecipeVersionWriter(List firebaseDependencies) { - this.firebaseDependencies = firebaseDependencies; - } - - public String generateVersionUpdate() { - Map depsByArtifactId = - firebaseDependencies.stream().collect(Collectors.toMap(Dependency::fullArtifactId, x -> x)); - StringBuilder outputBuilder = new StringBuilder(); - outputBuilder.append("\n"); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Google Services Plugin", - "google-services-plugin-class", - "com.google.gms:google-services")); - outputBuilder.append( - " \n" - + " \n"); - outputBuilder.append("\n"); - outputBuilder.append(" \n"); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Analytics", - "analytics-dependency", - "com.google.firebase:firebase-analytics")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Crashlytics", - "crashlytics-dependency", - "com.google.firebase:firebase-crashlytics")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Performance Monitoring", - "perf-dependency", - "com.google.firebase:firebase-perf")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Vertex AI in Firebase", - "vertex-dependency", - "com.google.firebase:firebase-vertexai")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Cloud Messaging", - "messaging-dependency", - "com.google.firebase:firebase-messaging")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Authentication", - "auth-dependency", - "com.google.firebase:firebase-auth")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Realtime Database", - "database-dependency", - "com.google.firebase:firebase-database")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Cloud Storage", - "storage-dependency", - "com.google.firebase:firebase-storage")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Remote Config", - "remote-config-dependency", - "com.google.firebase:firebase-config")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Admob", - "ads-dependency", - "com.google.android.gms:play-services-ads")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Cloud Firestore", - "firestore-dependency", - "com.google.firebase:firebase-firestore")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Firebase Functions", - "functions-dependency", - "com.google.firebase:firebase-functions")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "FIAM Display", - "fiamd-dependency", - "com.google.firebase:firebase-inappmessaging-display")); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Firebase MLKit Vision", - "ml-vision-dependency", - "com.google.firebase:firebase-ml-vision")); - outputBuilder.append("\n"); - outputBuilder.append(" \n"); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "App Distribution", - "appdistribution-plugin-class", - "com.google.firebase:firebase-appdistribution-gradle")); - outputBuilder.append( - " \n\n"); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Crashlytics", - "crashlytics-plugin-class", - "com.google.firebase:firebase-crashlytics-gradle")); - outputBuilder.append(" \n\n"); - outputBuilder.append(" \n"); - outputBuilder.append( - generateVersionVariable( - depsByArtifactId, - "Perf Plugin", - "perf-plugin-class", - "com.google.firebase:perf-plugin")); - outputBuilder.append(" \n"); - outputBuilder.append("]>\n"); - return outputBuilder.toString(); - } - - private static String generateVersionVariable( - Map depsByArtifactId, String comment, String alias, String artifactId) { - if (!depsByArtifactId.containsKey(artifactId)) { - throw new RuntimeException("Error fetching newest version for " + artifactId); - } - return " \n" - + " \n"; - } - - public void writeVersionUpdate(String document) throws IOException { - Files.write( - new File("recipeVersionUpdate.txt").toPath(), - Collections.singleton(document), - StandardCharsets.UTF_8); - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RepositoryClient.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RepositoryClient.java deleted file mode 100644 index 5fbfd83ec3f..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/RepositoryClient.java +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator; - -import com.google.firebase.gradle.bomgenerator.model.Dependency; -import com.google.firebase.gradle.bomgenerator.model.VersionBump; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import org.apache.maven.repository.internal.MavenRepositorySystemUtils; -import org.eclipse.aether.DefaultRepositorySystemSession; -import org.eclipse.aether.RepositorySystem; -import org.eclipse.aether.RepositorySystemSession; -import org.eclipse.aether.artifact.Artifact; -import org.eclipse.aether.artifact.DefaultArtifact; -import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; -import org.eclipse.aether.impl.DefaultServiceLocator; -import org.eclipse.aether.repository.LocalRepository; -import org.eclipse.aether.repository.RemoteRepository; -import org.eclipse.aether.resolution.VersionRangeRequest; -import org.eclipse.aether.resolution.VersionRangeResolutionException; -import org.eclipse.aether.resolution.VersionRangeResult; -import org.eclipse.aether.spi.connector.RepositoryConnectorFactory; -import org.eclipse.aether.spi.connector.transport.TransporterFactory; -import org.eclipse.aether.transport.file.FileTransporterFactory; -import org.eclipse.aether.transport.http.HttpTransporterFactory; -import org.eclipse.aether.version.Version; -import org.gradle.api.GradleException; -import org.w3c.dom.Document; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -public class RepositoryClient { - private static final RemoteRepository GMAVEN = - new RemoteRepository.Builder("central", "default", "https://maven.google.com").build(); - - private final RepositorySystem system; - private final RepositorySystemSession session; - - public RepositoryClient() { - system = newRepositorySystem(); - session = newRepositorySystemSession(system); - } - - public Dependency populateDependencyVersion( - Dependency firebaseDep, Map versionsFromPreviousBomByArtifact) { - try { - List rangeResult = getVersionsForDependency(firebaseDep).getVersions(); - String version = rangeResult.get(rangeResult.size() - 1).toString(); - String versionFromPreviousBom = - versionsFromPreviousBomByArtifact.get(firebaseDep.fullArtifactId()); - - VersionBump versionBump = - versionFromPreviousBom == null - ? VersionBump.MINOR - : VersionBump.getBumpBetweenVersion(version, versionFromPreviousBom); - return Dependency.create( - firebaseDep.groupId(), firebaseDep.artifactId(), version, versionBump); - } catch (VersionRangeResolutionException e) { - throw new GradleException("Failed to resolve dependency: " + firebaseDep.toGradleString(), e); - } - } - - public Optional getLastPublishedVersion(Dependency dependency) - throws VersionRangeResolutionException { - Version version = getVersionsForDependency(dependency).getHighestVersion(); - return Optional.ofNullable(version).map(Version::toString); - } - - public Set getAllFirebaseArtifacts() { - try (InputStream index = - new URL("https://dl.google.com/dl/android/maven2/com/google/firebase/group-index.xml") - .openStream()) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setValidating(true); - factory.setIgnoringElementContentWhitespace(true); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = builder.parse(index); - NodeList artifactList = doc.getFirstChild().getChildNodes(); - Set outputArtifactIds = new HashSet<>(); - for (int i = 0; i < artifactList.getLength(); i++) { - Node artifact = artifactList.item(i); - if (artifact.getNodeName().contains("#")) { - continue; - } - outputArtifactIds.add("com.google.firebase:" + artifact.getNodeName()); - } - return outputArtifactIds; - } catch (SAXException | IOException | ParserConfigurationException e) { - throw new RuntimeException("Failed to get Firebase Artifact Ids", e); - } - } - - // Dependency string must be in the format : - // for example: "com.google.firebase:firebase-bom" - private VersionRangeResult getVersionsForDependency(Dependency dep) - throws VersionRangeResolutionException { - Artifact requestArtifact = new DefaultArtifact(dep.fullArtifactId() + ":[0,)"); - - VersionRangeRequest rangeRequest = new VersionRangeRequest(); - rangeRequest.setArtifact(requestArtifact); - rangeRequest.setRepositories(Arrays.asList(GMAVEN)); - - return system.resolveVersionRange(session, rangeRequest); - } - - private static RepositorySystem newRepositorySystem() { - /* - * Aether's components implement org.eclipse.aether.spi.locator.Service to ease - * manual wiring and using the prepopulated DefaultServiceLocator, we only need - * to register the repository connector and transporter factories. - */ - DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator(); - locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class); - locator.addService(TransporterFactory.class, FileTransporterFactory.class); - locator.addService(TransporterFactory.class, HttpTransporterFactory.class); - - locator.setErrorHandler( - new DefaultServiceLocator.ErrorHandler() { - @Override - public void serviceCreationFailed(Class type, Class impl, Throwable exception) { - exception.printStackTrace(); - } - }); - - return locator.getService(RepositorySystem.class); - } - - private static DefaultRepositorySystemSession newRepositorySystemSession( - RepositorySystem system) { - DefaultRepositorySystemSession session = MavenRepositorySystemUtils.newSession(); - - LocalRepository localRepo = new LocalRepository("target/local-repo"); - session.setLocalRepositoryManager(system.newLocalRepositoryManager(session, localRepo)); - - return session; - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/Dependency.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/Dependency.java deleted file mode 100644 index 3dd36e95158..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/Dependency.java +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator.model; - -import com.google.auto.value.AutoValue; - -@AutoValue -public abstract class Dependency { - - public abstract String groupId(); - - public abstract String artifactId(); - - public abstract String version(); - - public abstract VersionBump versionBump(); - - public static Dependency create( - String groupId, String artifactId, String version, VersionBump versionBump) { - return new AutoValue_Dependency(groupId, artifactId, version, versionBump); - } - - // Null safe default constructor. Represents dependencies that have not yet been looked up in - // repos. - public static Dependency create(String groupId, String artifactId) { - return new AutoValue_Dependency(groupId, artifactId, "0.0.0", VersionBump.NONE); - } - - public String fullArtifactId() { - return groupId() + ":" + artifactId(); - } - - public String toGradleString() { - return groupId() + ":" + artifactId() + (version() == null ? "" : (":" + version())); - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/VersionBump.java b/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/VersionBump.java deleted file mode 100644 index e8345fae2bb..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/bomgenerator/model/VersionBump.java +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2021 Google LLC -// -// 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. - -package com.google.firebase.gradle.bomgenerator.model; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public enum VersionBump { - MAJOR, - MINOR, - PATCH, - NONE; - - private static final Pattern SEMVER_PATTERN = Pattern.compile("(\\d+)\\.(\\d+)\\.(\\d+).*"); - - // Assumes list of versions passed in is sorted, newest versions last. - public static VersionBump getBumpBetweenVersion( - String newestVersion, String secondNewestVersion) { - Matcher newestVersionMatcher = SEMVER_PATTERN.matcher(newestVersion); - Matcher secondNewestVersionMatcher = SEMVER_PATTERN.matcher(secondNewestVersion); - if (!(newestVersionMatcher.matches() && secondNewestVersionMatcher.matches())) { - throw new RuntimeException( - "Could not figure out version bump between " - + secondNewestVersion - + " and " - + newestVersion - + "."); - } - if (Integer.parseInt(newestVersionMatcher.group(1)) - > Integer.parseInt(secondNewestVersionMatcher.group(1))) { - return MAJOR; - } - if (Integer.parseInt(newestVersionMatcher.group(2)) - > Integer.parseInt(secondNewestVersionMatcher.group(2))) { - return MINOR; - } - if (Integer.parseInt(newestVersionMatcher.group(3)) - > Integer.parseInt(secondNewestVersionMatcher.group(3))) { - return PATCH; - } - return NONE; - } - - public static String bumpVersionBy(String version, VersionBump bump) { - Matcher versionMatcher = SEMVER_PATTERN.matcher(version); - if (!versionMatcher.matches()) { - throw new RuntimeException("Could not bump " + version + " as it is not a valid version."); - } - switch (bump) { - case NONE: - return version; - case MAJOR: - return Integer.toString(Integer.parseInt(versionMatcher.group(1)) + 1).toString() + ".0.0"; - case MINOR: - return versionMatcher.group(1) - + "." - + Integer.toString(Integer.parseInt(versionMatcher.group(2)) + 1) - + ".0"; - case PATCH: - return versionMatcher.group(1) - + "." - + versionMatcher.group(2) - + "." - + Integer.toString(Integer.parseInt(versionMatcher.group(3)) + 1); - default: - throw new RuntimeException("Should be impossible"); - } - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/GradleExtensions.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/GradleExtensions.kt index d454f8b5b3b..88cd9927045 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/GradleExtensions.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/GradleExtensions.kt @@ -28,6 +28,7 @@ import org.gradle.api.Task import org.gradle.api.artifacts.Dependency import org.gradle.api.attributes.Attribute import org.gradle.api.attributes.AttributeContainer +import org.gradle.api.file.Directory import org.gradle.api.plugins.PluginManager import org.gradle.api.provider.Provider import org.gradle.api.services.BuildService @@ -263,3 +264,46 @@ fun LibraryAndroidComponentsExtension.onReleaseVariants( inline fun , reified P : BuildServiceParameters> BuildServiceRegistry .registerIfAbsent(name: String, noinline config: BuildServiceSpec

.() -> Unit = {}) = registerIfAbsent(name, T::class.java, config) + +/** + * The value of this provider if present, or an empty list if it's not present. + * + * @return The value of this provider or an empty list. + */ +fun > Provider.orEmpty() = orNull.orEmpty() + +/** + * The value of this provider if present, or an empty map if it's not present. + * + * @return The value of this provider or an map list. + */ +fun > Provider.orEmpty() = orNull.orEmpty() + +/** + * Maps to the single file (non directory) within this directory, or throws an exception if it can't + * find a file or if there's more than one file. + * + * Helper wrapper around [Directory.nestedFile] for providers. + */ +val Provider.nestedFile: Provider + get() = map { it.nestedFile } + +/** + * Maps to the single file (non directory) within this directory, or throws an exception if it can't + * find a file or if there's more than one file. + * + * Useful in situations where a directory merely acts as a container for a nested file whose name + * isn't known at compile time. + * + * For example, given the following directory structure: + * ``` + * com/ + * google/ + * firebase/ + * firebase-bom-34.8.0.pom + * ``` + * + * This will result in the `firebase-bom-34.8.0.pom` file being returned. + */ +val Directory.nestedFile: File + get() = asFileTree.single { it.isFile } diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/KotlinExtensions.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/KotlinExtensions.kt index 61718a1c1a0..8b7634f4500 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/KotlinExtensions.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/KotlinExtensions.kt @@ -29,14 +29,48 @@ fun String.remove(regex: Regex) = replace(regex, "") fun String.remove(str: String) = replace(str, "") /** - * Joins a variable amount of [strings][Any.toString] to a single [String] split by newlines (`\n`). + * Joins a variable amount of [objects][Any.toString] to a single [String] split by newlines (`\n`). * * For example: * ```kotlin - * println(multiLine("Hello", "World", "!")) // "Hello\nWorld\n!" + * multiLine("Hello", "World", "!") shouldBeText + * """ + * Hello + * World + * ! + * """.trimIndent() * ``` + * + * If any of the elements are collections, their elements will be recursively joined instead. + * + * ```kotlin + * multiLine( + * "Hello", + * listOf("World"), + * listOf("Goodbye", listOf("World", "!"), + * emptyList() + * ) shouldBeText + * """ + * Hello + * World + * Goodbye + * World + * ! + * """.trimIndent() + * ``` + * + * _Note:_ Empty collections will not be rendered. */ -fun multiLine(vararg strings: Any?) = strings.joinToString("\n") +fun multiLine(vararg strings: Any?): String = + strings + .filter { it !is Collection<*> || it.isNotEmpty() } + .joinToString("\n") { + if (it is Collection<*>) { + multiLine(*it.toTypedArray()) + } else { + it.toString() + } + } /** * Converts an [Element] to an Artifact string. @@ -289,6 +323,19 @@ fun File.writeStream(stream: InputStream): File { return this } +/** + * Creates the the path to a file if it doesn't already exist. + * + * This includes creating the directories for this file. + * + * @return This [File] instance for chaining. + */ +fun File.createIfAbsent(): File { + parentFile?.mkdirs() + createNewFile() + return this +} + /** * The [path][File.path] represented as a qualified unix path. * @@ -308,3 +355,45 @@ val File.unixPath: String */ val File.absoluteUnixPath: String get() = absolutePath.replace("\\", "/") + +/** + * Partitions a map with nullable values into a map of non null values and a list of keys with null + * values. + * + * For example: + * ``` + * val weekdays = mapOf( + * "Monday" to 0, + * "Tuesday" to 1, + * "Wednesday" to null, + * "Thursday" to 3, + * "Friday" to null, + * ) + * + * val (validDays, invalidDays) = weekdays.partitionNotNull() + * + * validDays shouldEqual mapOf( + * "Monday" to 0, + * "Tuesday" to 1, + * "Thursday" to 3, + * ) + * invalidDays shouldContainExactly listOf("Wednesday", "Friday") + * ``` + * + * @return A pair where the first component is a map of all the non null values and the second + * component is a list of the keys with null values. + */ +fun Map.partitionNotNull(): Pair, List> { + val nonNullEntries = mutableMapOf() + val nullEntries = mutableListOf() + + for ((key, value) in this) { + if (value !== null) { + nonNullEntries[key] = value + } else { + nullEntries.add(key) + } + } + + return nonNullEntries to nullEntries +} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/ModuleVersion.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/ModuleVersion.kt index c2ebc602493..c07b382aac6 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/ModuleVersion.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/ModuleVersion.kt @@ -57,7 +57,7 @@ enum class PreReleaseVersionType { * Where `Type` is a case insensitive string of any [PreReleaseVersionType], and `Build` is a two * digit number (single digits should have a leading zero). * - * Note that `build` will always be present as starting at one by default. That is, the following + * Note that `build` will always be present as starting at one by defalt. That is, the following * transform occurs: * ``` * "12.13.1-beta" // 12.13.1-beta01 @@ -234,6 +234,30 @@ data class ModuleVersion( ) } + /** + * Determine the [VersionType] representing the bump that would be required to reach [other], if + * any. + * + * ``` + * ModuleVersion(1,0,0).bumpFrom(ModuleVersion(2,1,3)).shouldBeEqual(VersionType.MAJOR) + * ModuleVersion(1,0,0).bumpFrom(ModuleVersion(1,1,3)).shouldBeEqual(VersionType.MINOR) + * ModuleVersion(1,0,0).bumpFrom(ModuleVersion(1,0,3)).shouldBeEqual(VersionType.PATCH) + * ModuleVersion(1,0,0).bumpFrom(ModuleVersion(1,0,0)).shouldBeNull() + * ``` + * + * @param other The target version to get the bump for. + * @return A [VersionType] representing the bump that this version would need to reach [other], or + * null if they're the same version. + */ + fun bumpFrom(other: ModuleVersion): VersionType? { + if (other.major != this.major) return VersionType.MAJOR + if (other.minor != this.minor) return VersionType.MINOR + if (other.patch != this.patch) return VersionType.PATCH + if (other.pre != this.pre) return VersionType.PRE + + return null + } + /** * Returns a copy of this [ModuleVersion], with the given [VersionType] increased by one. * diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/PostReleasePlugin.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/PostReleasePlugin.kt index 4b34c9f737f..7bc3d81be20 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/PostReleasePlugin.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/PostReleasePlugin.kt @@ -43,6 +43,7 @@ class PostReleasePlugin : Plugin { val updatePinnedDependencies = registerUpdatePinnedDependenciesTask(project) project.tasks.register("postReleaseCleanup") { + // TODO(b/394606626): Add task for tagging releases dependsOn(versionBump, moveUnreleasedChanges, updatePinnedDependencies) } } diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/PublishingPlugin.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/PublishingPlugin.kt index da1c9e2b724..b698dc08ad8 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/PublishingPlugin.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/PublishingPlugin.kt @@ -16,7 +16,9 @@ package com.google.firebase.gradle.plugins -import com.google.firebase.gradle.bomgenerator.BomGeneratorTask +import com.google.firebase.gradle.bomgenerator.GenerateBomReleaseNotesTask +import com.google.firebase.gradle.bomgenerator.GenerateBomTask +import com.google.firebase.gradle.bomgenerator.GenerateTutorialBundleTask import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.BUILD_BOM_ZIP_TASK import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.BUILD_KOTLINDOC_ZIP_TASK import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.BUILD_MAVEN_ZIP_TASK @@ -25,7 +27,7 @@ import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.FIREBASE_PU import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.GENERATE_BOM_TASK import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.GENERATE_KOTLINDOC_FOR_RELEASE_TASK import com.google.firebase.gradle.plugins.PublishingPlugin.Companion.PUBLISH_RELEASING_LIBS_TO_BUILD_TASK -import com.google.firebase.gradle.plugins.semver.ApiDiffer +import com.google.firebase.gradle.plugins.services.gmavenService import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project @@ -40,7 +42,7 @@ import org.gradle.kotlin.dsl.register /** * Plugin for providing tasks to release [FirebaseLibrary][FirebaseAndroidLibraryPlugin] projects. * - * Projects to release are computed via [computeReleasingLibraries]. A multitude of tasks are then + * Projects to release are computed via [computeReleaseMetadata]. A multitude of tasks are then * registered at the root project. * * The following pertain specifically to a release: @@ -59,7 +61,9 @@ import org.gradle.kotlin.dsl.register * outside of the standard [FIREBASE_PUBLISH_TASK] workflow (possibly at a later time in the release * cycle): * - [BUILD_BOM_ZIP_TASK] -> Creates a zip file of the contents of [GENERATE_BOM_TASK] - * [registerGenerateBomTask] + * [registerGenerateBomTask], + * [GENERATE_BOM_RELEASE_NOTES_TASK][registerGenerateBomReleaseNotesTask] and + * [GENERATE_TUTORIAL_BUNDLE_TASK][registerGenerateTutorialBundleTask] * - [RELEASE_GENEATOR_TASK][registerGenerateReleaseConfigFilesTask] * - [RELEASE_REPORT_GENERATOR_TASK][registerGenerateReleaseReportFilesTask] * - [PUBLISH_RELEASING_LIBS_TO_LOCAL_TASK][registerPublishReleasingLibrariesToMavenLocalTask] @@ -83,6 +87,8 @@ abstract class PublishingPlugin : Plugin { val releasingProjects = releasingFirebaseLibraries.map { it.project } val generateBom = registerGenerateBomTask(project) + val generateBomReleaseNotes = registerGenerateBomReleaseNotesTask(project, generateBom) + val generateTutorialBundle = registerGenerateTutorialBundleTask(project) val validatePomForRelease = registerValidatePomForReleaseTask(project, releasingProjects) val checkHeadDependencies = registerCheckHeadDependenciesTask(project, releasingFirebaseLibraries) @@ -135,7 +141,7 @@ abstract class PublishingPlugin : Plugin { } project.tasks.register(BUILD_BOM_ZIP_TASK) { - from(generateBom) + from(generateBom, generateBomReleaseNotes, generateTutorialBundle) archiveFileName.set("bom.zip") destinationDirectory.set(project.layout.projectDirectory) } @@ -180,19 +186,19 @@ abstract class PublishingPlugin : Plugin { * Metadata can be provided either via the project properties or a [ReleaseConfig] file. * * The expected project properties can be defined as such: - * - `projectsToPublish` -> A comma seperated list of the publishing project(s) `artifactId`. + * - `projectsToPublish` -> A comma separated list of the publishing project(s) `artifactId`. * - `releaseName` -> The name of the release (such as `m123`) * - * When using project properties, this method will take into account [librariesToRelease] - * [FirebaseLibraryExtension.getLibrariesToRelease] -> so there's no need to specify multiple of - * the same co-releasing libs. + * When using project properties, this method will take into account the sibling libraries per + * [libraryGroup][FirebaseLibraryExtension.libraryGroup] -> so there's no need to specify multiple + * of the same co-releasing libs. * - * The [ReleaseConfig] is a pre-defined set of data for a release. It expects a valid [file] - * [ReleaseConfig.fromFile], which provides a list of libraries via their [path] - * [FirebaseLibraryExtension.getPath]. Additionally, it does __NOT__ take into account - * co-releasing libraries-> meaning libraries that should be releasing alongside one another will - * need to be individually specified in the [ReleaseConfig], otherwise it will likely cause an - * error during the release process. + * The [ReleaseConfig] is a pre-defined set of data for a release. It expects a valid + * `release.json` [file][ReleaseConfig.fromFile], which provides a list of libraries via their + * respective project [paths][FirebaseLibraryExtension.path]. Additionally, it does __NOT__ take + * into account co-releasing libraries-> meaning libraries that should be releasing alongside one + * another will need to be individually specified in the [ReleaseConfig], otherwise it will likely + * cause an error during the release process. * * **Project properties take priority over a [ReleaseConfig].** * @@ -270,9 +276,11 @@ abstract class PublishingPlugin : Plugin { computeMissingLibrariesToRelease(librariesToRelease, libraryGroups) if (missingLibrariesToRelease.isNotEmpty()) { throw GradleException( - "Invalid release configuration. " + - "It's should include the following libraries due to library groups: \n" + - "${missingLibrariesToRelease.joinToString("\n"){ it.artifactName }}" + multiLine( + "Invalid release configuration.", + "It should include the following libraries due to library groups:", + missingLibrariesToRelease, + ) ) } @@ -286,11 +294,160 @@ abstract class PublishingPlugin : Plugin { * Generates a BOM for a release, although it relies on gmaven to be updated- so it should be * invoked manually later on in the release process. * - * @see BomGeneratorTask + * @see GenerateBomTask */ private fun registerGenerateBomTask(project: Project) = - project.tasks.register(GENERATE_BOM_TASK) { - bomDirectory.convention(project.layout.projectDirectory.dir(BOM_DIR_NAME)) + project.tasks.register("generateBom") { + bomArtifacts.set(BOM_ARTIFACTS) + ignoredArtifacts.set( + listOf( + "com.google.firebase:crash-plugin", + "com.google.firebase:firebase-ml-vision", + "com.google.firebase:firebase-ads", + "com.google.firebase:firebase-ads-lite", + "com.google.firebase:firebase-abt", + "com.google.firebase:firebase-analytics-impl", + "com.google.firebase:firebase-analytics-impl-license", + "com.google.firebase:firebase-analytics-license", + "com.google.firebase:firebase-annotations", + "com.google.firebase:firebase-appcheck-interop", + "com.google.firebase:firebase-appcheck-safetynet", + "com.google.firebase:firebase-appdistribution-gradle", + "com.google.firebase:firebase-appindexing-license", + "com.google.firebase:firebase-appindexing", + "com.google.firebase:firebase-iid", + "com.google.firebase:firebase-core", + "com.google.firebase:firebase-auth-common", + "com.google.firebase:firebase-auth-impl", + "com.google.firebase:firebase-auth-interop", + "com.google.firebase:firebase-auth-license", + "com.google.firebase:firebase-encoders-json", + "com.google.firebase:firebase-encoders-proto", + "com.google.firebase:firebase-auth-module", + "com.google.firebase:firebase-bom", + "com.google.firebase:firebase-common-license", + "com.google.firebase:firebase-components", + "com.google.firebase:firebase-config-license", + "com.google.firebase:firebase-config-interop", + "com.google.firebase:firebase-crash", + "com.google.firebase:firebase-crash-license", + "com.google.firebase:firebase-crashlytics-buildtools", + "com.google.firebase:firebase-crashlytics-gradle", + "com.google.firebase:firebase-database-collection", + "com.google.firebase:firebase-database-connection", + "com.google.firebase:firebase-database-connection-license", + "com.google.firebase:firebase-database-license", + "com.google.firebase:firebase-dataconnect", + "com.google.firebase:firebase-datatransport", + "com.google.firebase:firebase-appdistribution-ktx", + "com.google.firebase:firebase-appdistribution", + "com.google.firebase:firebase-appdistribution-api", + "com.google.firebase:firebase-appdistribution-api-ktx", + "com.google.firebase:firebase-dynamic-module-support", + "com.google.firebase:firebase-dynamic-links-license", + "com.google.firebase:firebase-functions-license", + "com.google.firebase:firebase-iid-interop", + "com.google.firebase:firebase-iid-license", + "com.google.firebase:firebase-invites", + "com.google.firebase:firebase-measurement-connector", + "com.google.firebase:firebase-measurement-connector-impl", + "com.google.firebase:firebase-messaging-license", + "com.google.firebase:firebase-ml-common", + "com.google.firebase:firebase-ml-vision-internal-vkp", + "com.google.firebase:firebase-ml-model-interpreter", + "com.google.firebase:firebase-perf-license", + "com.google.firebase:firebase-plugins", + "com.google.firebase:firebase-sessions", + "com.google.firebase:firebase-storage-common", + "com.google.firebase:firebase-storage-common-license", + "com.google.firebase:firebase-storage-license", + "com.google.firebase:perf-plugin", + "com.google.firebase:protolite-well-known-types", + "com.google.firebase:testlab-instr-lib", + "com.google.firebase:firebase-installations-interop", + "com.google.firebase:firebase-ml-vision-automl", + "com.google.firebase:firebase-ml-vision-barcode-model", + "com.google.firebase:firebase-ml-vision-face-model", + "com.google.firebase:firebase-ml-vision-image-label-model", + "com.google.firebase:firebase-ml-vision-object-detection-model", + "com.google.firebase:firebase-ml-natural-language", + "com.google.firebase:firebase-ml-natural-language-language-id-model", + "com.google.firebase:firebase-ml-natural-language-smart-reply", + "com.google.firebase:firebase-ml-natural-language-smart-reply-model", + "com.google.firebase:firebase-ml-natural-language-translate", + "com.google.firebase:firebase-ml-natural-language-translate-model", + "com.crashlytics.sdk.android:crashlytics:crashlytics", + "com.google.android.gms:play-services-ads", + "com.google.gms:google-services", + "com.android.tools.build:gradle", + ) + ) + + outputDirectory.set(project.layout.buildDirectory.dir(BOM_DIR_NAME)) + } + + /** + * Registers the [GENERATE_TUTORIAL_BUNDLE_TASK] task. + * + * Generates a tutorial bundle for a release. + * + * @see GenerateTutorialBundleTask + */ + private fun registerGenerateTutorialBundleTask(project: Project) = + project.tasks.register(GENERATE_TUTORIAL_BUNDLE_TASK) { + commonArtifacts.set(listOf("com.google.gms:google-services")) + gradlePlugins.set( + listOf( + "com.google.firebase:firebase-appdistribution-gradle", + "com.google.firebase:firebase-crashlytics-gradle", + ) + ) + perfArtifacts.set(listOf("com.google.firebase:perf-plugin")) + requiredArtifacts.set( + listOf( + "com.google.gms:google-services", + "com.google.firebase:firebase-analytics", + "com.google.firebase:firebase-crashlytics", + "com.google.firebase:firebase-perf", + "com.google.firebase:firebase-vertexai", + "com.google.firebase:firebase-messaging", + "com.google.firebase:firebase-auth", + "com.google.firebase:firebase-database", + "com.google.firebase:firebase-storage", + "com.google.firebase:firebase-config", + "com.google.android.gms:play-services-ads", + "com.google.firebase:firebase-firestore", + "com.google.firebase:firebase-functions", + "com.google.firebase:firebase-inappmessaging-display", + "com.google.firebase:firebase-ml-vision", + "com.google.firebase:firebase-appdistribution-gradle", + "com.google.firebase:firebase-crashlytics-gradle", + "com.google.firebase:perf-plugin", + ) + ) + firebaseArtifacts.set(BOM_ARTIFACTS + EXTRA_TUTORIAL_ARTIFACTS) + + tutorialFile.set(project.layout.buildDirectory.file("recipeVersionUpdate.txt")) + } + + /** + * Registers the [GENERATE_BOM_RELEASE_NOTES_TASK] task. + * + * Generates the release notes for a bom during a release. + * + * @see GenerateBomReleaseNotesTask + */ + private fun registerGenerateBomReleaseNotesTask( + project: Project, + generateBomTask: TaskProvider, + ) = + project.tasks.register(GENERATE_BOM_RELEASE_NOTES_TASK) { + currentBom.set(project.layout.file(generateBomTask.flatMap { it.outputDirectory.nestedFile })) + previousBom.set( + project.gmavenService.map { it.latestPom("com.google.firebase", "firebase-bom") } + ) + + releaseNotesFile.set(project.layout.buildDirectory.file("bomReleaseNotes.md")) } /** @@ -377,9 +534,11 @@ abstract class PublishingPlugin : Plugin { computeMissingLibrariesToRelease(librariesToRelease, libraryGroups) if (missingLibrariesToRelease.isNotEmpty()) { throw GradleException( - "Invalid release configuration. " + - "It's should include the following libraries due to library groups: \n" + - "${missingLibrariesToRelease.joinToString("\n")}" + multiLine( + "Invalid release configuration.", + "It should include the following libraries due to library groups:", + missingLibrariesToRelease, + ) ) } } @@ -580,6 +739,8 @@ abstract class PublishingPlugin : Plugin { const val BOM_DIR_NAME = "bom" const val GENERATE_BOM_TASK = "generateBom" + const val GENERATE_BOM_RELEASE_NOTES_TASK = "generateBomReleaseNotes" + const val GENERATE_TUTORIAL_BUNDLE_TASK = "generateTutorialBundle" const val VALIDATE_PROJECTS_TO_PUBLISH_TASK = "validateProjectsToPublish" const val VALIDATE_LIBRARY_GROUPS_TO_PUBLISH_TASK = "validateLibraryGroupsToPublish" const val SEMVER_CHECK_TASK = "semverCheckForRelease" @@ -600,6 +761,56 @@ abstract class PublishingPlugin : Plugin { const val PUBLISH_ALL_TO_BUILD_TASK = "publishAllToBuildDir" const val BUILD_DIR_REPOSITORY_DIR = "m2repository" + + /** Artifacts included in the bom. */ + val BOM_ARTIFACTS = + listOf( + "com.google.firebase:firebase-analytics", + "com.google.firebase:firebase-analytics-ktx", + "com.google.firebase:firebase-appcheck-debug", + "com.google.firebase:firebase-appcheck-debug-testing", + "com.google.firebase:firebase-appcheck-ktx", + "com.google.firebase:firebase-appcheck-playintegrity", + "com.google.firebase:firebase-appcheck", + "com.google.firebase:firebase-auth", + "com.google.firebase:firebase-auth-ktx", + "com.google.firebase:firebase-common", + "com.google.firebase:firebase-common-ktx", + "com.google.firebase:firebase-config", + "com.google.firebase:firebase-config-ktx", + "com.google.firebase:firebase-crashlytics", + "com.google.firebase:firebase-crashlytics-ktx", + "com.google.firebase:firebase-crashlytics-ndk", + "com.google.firebase:firebase-database", + "com.google.firebase:firebase-database-ktx", + "com.google.firebase:firebase-dynamic-links", + "com.google.firebase:firebase-dynamic-links-ktx", + "com.google.firebase:firebase-encoders", + "com.google.firebase:firebase-firestore", + "com.google.firebase:firebase-firestore-ktx", + "com.google.firebase:firebase-functions", + "com.google.firebase:firebase-functions-ktx", + "com.google.firebase:firebase-inappmessaging", + "com.google.firebase:firebase-inappmessaging-display", + "com.google.firebase:firebase-inappmessaging-display-ktx", + "com.google.firebase:firebase-inappmessaging-ktx", + "com.google.firebase:firebase-installations", + "com.google.firebase:firebase-installations-ktx", + "com.google.firebase:firebase-messaging", + "com.google.firebase:firebase-messaging-directboot", + "com.google.firebase:firebase-messaging-ktx", + "com.google.firebase:firebase-ml-modeldownloader", + "com.google.firebase:firebase-ml-modeldownloader-ktx", + "com.google.firebase:firebase-perf", + "com.google.firebase:firebase-perf-ktx", + "com.google.firebase:firebase-storage", + "com.google.firebase:firebase-storage-ktx", + "com.google.firebase:firebase-vertexai", + ) + + /** Artifacts that we use in the tutorial bundle, but _not_ in the bom. */ + val EXTRA_TUTORIAL_ARTIFACTS = + listOf("com.google.android.gms:play-services-ads", "com.google.firebase:firebase-ml-vision") } } diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/datamodels/PomElement.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/datamodels/PomElement.kt index 07d18cb2a91..e3fccfb66e9 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/datamodels/PomElement.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/datamodels/PomElement.kt @@ -41,7 +41,7 @@ data class LicenseElement( @XmlElement val name: String, @XmlElement val url: String? = null, @XmlElement val distribution: String? = null, -) +) : java.io.Serializable /** * Representation of an `` element in a a pom file. @@ -50,7 +50,10 @@ data class LicenseElement( */ @Serializable @XmlSerialName("scm") -data class SourceControlManagement(@XmlElement val connection: String, @XmlElement val url: String) +data class SourceControlManagement( + @XmlElement val connection: String, + @XmlElement val url: String, +) : java.io.Serializable /** * Representation of a `` element in a pom file. @@ -66,7 +69,7 @@ data class ArtifactDependency( @XmlElement val version: String? = null, @XmlElement val type: String? = null, @XmlElement val scope: String? = null, -) { +) : java.io.Serializable { /** * Returns the artifact dependency as a a gradle dependency string. * @@ -141,7 +144,7 @@ val ArtifactDependency.configuration: String @XmlSerialName("dependencyManagement") data class DependencyManagementElement( @XmlChildrenName("dependency") val dependencies: List? = null -) +) : java.io.Serializable /** Representation of a `` element within a `pom.xml` file. */ @Serializable @@ -159,7 +162,7 @@ data class PomElement( @XmlElement val scm: SourceControlManagement? = null, @XmlElement val dependencyManagement: DependencyManagementElement? = null, @XmlChildrenName("dependency") val dependencies: List? = null, -) { +) : java.io.Serializable { /** * Serializes this pom element into a valid XML element and saves it to the specified [file]. * diff --git a/plugins/src/test/kotlin/com/google/firebase/gradle/TestUtil.kt b/plugins/src/test/kotlin/com/google/firebase/gradle/TestUtil.kt index 548fd29d8d3..9988e05a373 100644 --- a/plugins/src/test/kotlin/com/google/firebase/gradle/TestUtil.kt +++ b/plugins/src/test/kotlin/com/google/firebase/gradle/TestUtil.kt @@ -16,8 +16,12 @@ package com.google.firebase.gradle +import io.kotest.assertions.throwables.shouldThrowAny +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.string.shouldContain import io.mockk.MockKMatcherScope import java.io.File +import kotlin.test.assertEquals import org.gradle.testkit.runner.BuildResult import org.gradle.testkit.runner.GradleRunner @@ -51,3 +55,35 @@ fun createGradleRunner(directory: File, vararg arguments: String): GradleRunner /** Match arguments that end with the specified [str]. */ fun MockKMatcherScope.endsWith(str: String) = match { it.endsWith(str) } + +/** + * Asserts that an exception is thrown with a message that contains the provided [substrings]. + * + * ``` + * shouldThrowSubstring("fetching", "firebase-common") { + * throw RuntimeException("We ran into a problem while fetching the firebase-common artifact") + * } + * ``` + * + * @param substrings A variable amount of strings that the exception message should contain. + * @param block The callback that should throw the exception. + */ +inline fun shouldThrowSubstring(vararg substrings: String, block: () -> Unit) { + val exception = shouldThrowAny { block() } + + for (str in substrings) { + exception.message.shouldContain(str) + } +} + +/** + * Asserts that this string is equal to the [expected] string. + * + * Should be used in place of [shouldBeEqual] when working with multi-line strings, as this method + * will provide a proper diff in the console _and_ IDE of which parts of the string were different. + * + * Works around [kotest/issues/1084](https://github.com/kotest/kotest/issues/1084). + * + * @param expected The string that this string should have the same contents of. + */ +infix fun String.shouldBeText(expected: String) = assertEquals(expected, this) diff --git a/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomReleaseNotesTests.kt b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomReleaseNotesTests.kt new file mode 100644 index 00000000000..3d914df29d8 --- /dev/null +++ b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomReleaseNotesTests.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.plugins + +import com.google.firebase.gradle.bomgenerator.GenerateBomReleaseNotesTask +import com.google.firebase.gradle.plugins.datamodels.ArtifactDependency +import com.google.firebase.gradle.plugins.datamodels.DependencyManagementElement +import com.google.firebase.gradle.plugins.datamodels.PomElement +import com.google.firebase.gradle.shouldBeText +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.file.shouldExist +import java.io.File +import org.gradle.api.tasks.TaskProvider +import org.gradle.kotlin.dsl.register +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GenerateBomReleaseNotesTests : FunSpec() { + @Rule @JvmField val testProjectDir = TemporaryFolder() + + @Test + fun `generates the release notes`() { + val dependencies = + listOf( + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-auth", + version = "10.0.0", + ), + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-firestore", + version = "10.0.0", + ), + ) + val bom = makeBom("1.0.0", dependencies) + val file = makeReleaseNotes(bom, bom) + + file.readText().trim() shouldBeText + """ + ### {{firebase_bom_long}} ({{bill_of_materials}}) version 1.0.0 {: #bom_v1-0-0} + {% comment %} + These library versions must be flat-typed, do not use variables. + The release note for this BoM version is a library-version snapshot. + {% endcomment %} + +

+ """ + .trimIndent() + } + + @Test + fun `correctly formats changed dependencies`() { + val oldDependencies = + listOf( + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-auth", + version = "10.0.0", + ), + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-analytics", + version = "10.0.0", + ), + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-vertexai", + version = "10.0.0", + ), + ) + val newDependencies = + listOf( + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-auth", + version = "10.0.0", + ), + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-firestore", + version = "10.0.0", + ), + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-vertexai", + version = "11.0.0", + ), + ) + val oldBom = makeBom("1.0.0", oldDependencies) + val newBom = makeBom("2.0.0", newDependencies) + val file = makeReleaseNotes(oldBom, newBom) + + file.readText().trim() shouldBeText + """ + ### {{firebase_bom_long}} ({{bill_of_materials}}) version 2.0.0 {: #bom_v2-0-0} + {% comment %} + These library versions must be flat-typed, do not use variables. + The release note for this BoM version is a library-version snapshot. + {% endcomment %} + + + """ + .trimIndent() + } + + private fun makeTask( + configure: GenerateBomReleaseNotesTask.() -> Unit + ): TaskProvider { + val project = ProjectBuilder.builder().withProjectDir(testProjectDir.root).build() + + return project.tasks.register("generateBomReleaseNotes") { + releaseNotesFile.set(project.layout.buildDirectory.file("bomReleaseNotes.md")) + + configure() + } + } + + private fun makeReleaseNotes(previousBom: PomElement, currentBom: PomElement): File { + val currentBomFile = testProjectDir.newFile("current.bom") + currentBom.toFile(currentBomFile) + + val task = makeTask { + this.currentBom.set(currentBomFile) + this.previousBom.set(previousBom) + } + + val file = + task.get().let { + it.generate() + it.releaseNotesFile.asFile.get() + } + + file.shouldExist() + + return file + } + + private fun makeBom(version: String, dependencies: List): PomElement { + return PomElement.fromFile(emptyBom) + .copy(version = version, dependencyManagement = DependencyManagementElement(dependencies)) + } + + companion object { + private val resourcesDirectory = File("src/test/resources") + private val testResources = resourcesDirectory.childFile("Bom") + private val emptyBom = testResources.childFile("empty-bom.pom") + } +} diff --git a/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomTests.kt b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomTests.kt new file mode 100644 index 00000000000..0298a4584f3 --- /dev/null +++ b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateBomTests.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.plugins + +import com.google.firebase.gradle.bomgenerator.GenerateBomTask +import com.google.firebase.gradle.plugins.datamodels.ArtifactDependency +import com.google.firebase.gradle.plugins.datamodels.DependencyManagementElement +import com.google.firebase.gradle.plugins.datamodels.PomElement +import com.google.firebase.gradle.plugins.services.GMavenService +import com.google.firebase.gradle.plugins.services.GroupIndexArtifact +import com.google.firebase.gradle.shouldBeText +import com.google.firebase.gradle.shouldThrowSubstring +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.file.shouldExist +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.io.File +import org.gradle.api.tasks.TaskProvider +import org.gradle.kotlin.dsl.register +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GenerateBomTests : FunSpec() { + @Rule @JvmField val testProjectDir = TemporaryFolder() + + private val service = mockk() + + @Before + fun setup() { + clearMocks(service) + } + + @Test + fun `ignores the configured ignored dependencies`() { + val ignoredDependency = + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("1.0.0"), + ) + + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ) + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies + ignoredDependency) + + val file = + makeNewBom( + bomArtifacts = listOf("com.google.firebase:firebase-common"), + ignoredArtifacts = listOf("com.google.firebase:firebase-functions"), + ) + + val newPom = PomElement.fromFile(file) + val deps = newPom.dependencyManagement?.dependencies.shouldNotBeEmpty() + deps.shouldNotContain(ignoredDependency.toArtifactDependency()) + } + + @Test + fun `major bumps the bom version when artifacts are removed`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ), + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("1.0.0", "1.0.1"), + ), + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + val file = + makeNewBom( + bomArtifacts = listOf("com.google.firebase:firebase-common"), + ignoredArtifacts = listOf("com.google.firebase:firebase-functions"), + ) + + val newPom = PomElement.fromFile(file) + val deps = newPom.dependencyManagement?.dependencies.shouldNotBeEmpty().map { it.artifactId } + deps.shouldNotContain("firebase-functions") + + newPom.version shouldBeEqual "2.0.0" + } + + @Test + fun `minor bumps the bom version when artifacts are added`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ) + ) + + val newArtifact = + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("1.0.0"), + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies + newArtifact) + + val file = + makeNewBom( + bomArtifacts = + listOf("com.google.firebase:firebase-common", "com.google.firebase:firebase-functions") + ) + + val newPom = PomElement.fromFile(file) + val deps = newPom.dependencyManagement?.dependencies.shouldNotBeEmpty().map { it.artifactId } + deps.shouldContain("firebase-functions") + + newPom.version shouldBeEqual "1.1.0" + } + + @Test + fun `bumps the bom version per the biggest artifact bump`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ), + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("10.1.2", "11.0.0"), + ), + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + val file = + makeNewBom( + bomArtifacts = + listOf("com.google.firebase:firebase-common", "com.google.firebase:firebase-functions") + ) + + val newPom = PomElement.fromFile(file) + newPom.version shouldBeEqual "2.0.0" + } + + @Test + fun `allows versions to be overridden`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ) + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + val file = + makeNewBom(bomArtifacts = listOf("com.google.firebase:firebase-common")) { + versionOverrides.set(mapOf("com.google.firebase:firebase-common" to "22.0.0")) + } + + val newPom = PomElement.fromFile(file) + val deps = newPom.dependencyManagement?.dependencies.shouldNotBeEmpty() + deps.shouldContain( + ArtifactDependency( + groupId = "com.google.firebase", + artifactId = "firebase-common", + version = "22.0.0", + ) + ) + } + + @Test + fun `generates in the expected format`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ), + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("1.0.0", "1.0.1"), + ), + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + val file = + makeNewBom( + bomArtifacts = + listOf("com.google.firebase:firebase-common", "com.google.firebase:firebase-functions") + ) + + file.readText().trim() shouldBeText + """ + + 4.0.0 + com.google.firebase + firebase-bom + 1.0.1 + pom + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + com.google.firebase + firebase-common + 21.0.1 + + + com.google.firebase + firebase-functions + 1.0.1 + + + + + """ + .trimIndent() + } + + @Test + fun `throws an error if artifacts are not live on gmaven yet`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ) + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + shouldThrowSubstring("not live on gmaven yet", "com.google.firebase:firebase-functions") { + makeNewBom( + bomArtifacts = + listOf("com.google.firebase:firebase-common", "com.google.firebase:firebase-functions") + ) + } + } + + @Test + fun `throws an error if there are firebase artifacts missing`() { + val dependencies = + listOf( + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-common", + versions = listOf("21.0.0", "21.0.1"), + ), + GroupIndexArtifact( + groupId = "com.google.firebase", + artifactId = "firebase-functions", + versions = listOf("1.0.0", "1.0.1"), + ), + ) + + makeOldBom(dependencies) + linkGroupIndex(dependencies) + + shouldThrowSubstring("artifacts missing", "com.google.firebase:firebase-functions") { + makeNewBom(bomArtifacts = listOf("com.google.firebase:firebase-common")) + } + } + + private fun makeTask(configure: GenerateBomTask.() -> Unit): TaskProvider { + val project = ProjectBuilder.builder().withProjectDir(testProjectDir.root).build() + + return project.tasks.register("generateBom") { + outputDirectory.set(project.layout.buildDirectory.dir("bom")) + gmaven.set(service) + + configure() + } + } + + private fun makeNewBom( + bomArtifacts: List = emptyList(), + ignoredArtifacts: List = emptyList(), + configure: GenerateBomTask.() -> Unit = {}, + ): File { + val task = makeTask { + this.bomArtifacts.set(bomArtifacts) + this.ignoredArtifacts.set(ignoredArtifacts) + + configure() + } + + val file = + task.get().let { + it.generate() + it.outputDirectory.get().nestedFile + } + + file.shouldExist() + + return file + } + + private fun linkGroupIndex(dependencies: List) { + every { service.groupIndex("com.google.firebase") } answers { dependencies } + every { service.groupIndexArtifactOrNull(any()) } answers { null } + + for (artifact in dependencies) { + every { + service.groupIndexArtifactOrNull("${artifact.groupId}:${artifact.artifactId}") + } answers { artifact } + } + } + + private fun makeOldBom(dependencies: List): PomElement { + val artifacts = + dependencies.map { it.toArtifactDependency().copy(version = it.versions.first()) } + val bom = + PomElement.fromFile(emptyBom) + .copy(dependencyManagement = DependencyManagementElement(artifacts)) + + every { service.latestPom("com.google.firebase", "firebase-bom") } answers { bom } + + return bom + } + + companion object { + private val resourcesDirectory = File("src/test/resources") + private val testResources = resourcesDirectory.childFile("Bom") + private val emptyBom = testResources.childFile("empty-bom.pom") + } +} diff --git a/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateTutorialBundleTests.kt b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateTutorialBundleTests.kt new file mode 100644 index 00000000000..11f1ae0bc72 --- /dev/null +++ b/plugins/src/test/kotlin/com/google/firebase/gradle/plugins/GenerateTutorialBundleTests.kt @@ -0,0 +1,330 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + */ + +package com.google.firebase.gradle.plugins + +import com.google.firebase.gradle.bomgenerator.GenerateTutorialBundleTask +import com.google.firebase.gradle.plugins.services.GMavenService +import com.google.firebase.gradle.shouldBeText +import com.google.firebase.gradle.shouldThrowSubstring +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.file.shouldExist +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.io.File +import org.gradle.api.tasks.TaskProvider +import org.gradle.kotlin.dsl.register +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GenerateTutorialBundleTests : FunSpec() { + @Rule @JvmField val testProjectDir = TemporaryFolder() + + private val service = mockk() + + @Before + fun setup() { + clearMocks(service) + } + + @Test + fun `generates the tutorial bundle`() { + val tutorialFile = + makeTutorial( + commonArtifacts = listOf("com.google.gms:google-services:1.2.3"), + firebaseArtifacts = + listOf( + "com.google.firebase:firebase-analytics:1.2.4", + "com.google.firebase:firebase-crashlytics:12.0.0", + "com.google.firebase:firebase-perf:10.2.3", + "com.google.firebase:firebase-vertexai:9.2.3", + ), + gradlePlugins = + listOf( + "com.google.firebase:firebase-appdistribution-gradle:1.21.3", + "com.google.firebase:firebase-crashlytics-gradle:15.1.0", + "com.google.firebase:perf-plugin:20.5.6", + ), + ) { + requiredArtifacts.set(listOf("com.google.firebase:firebase-crashlytics")) + } + + tutorialFile.readText().trim() shouldBeText + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + ]> + """ + .trimIndent() + } + + @Test + fun `does not include empty sections`() { + val tutorialFile = makeTutorial() + + tutorialFile.readText().trim() shouldBeText + """ + + """ + .trimIndent() + } + + @Test + fun `allows versions to be overridden`() { + val tutorialFile = + makeTutorial( + commonArtifacts = + listOf( + "com.google.gms:google-services:1.2.3", + "com.google.firebase:firebase-perf:10.2.3", + ), + firebaseArtifacts = + listOf( + "com.google.firebase:firebase-analytics:1.2.4", + "com.google.firebase:firebase-crashlytics:12.0.0", + ), + gradlePlugins = + listOf( + "com.google.firebase:firebase-appdistribution-gradle:1.21.3", + "com.google.firebase:firebase-crashlytics-gradle:15.1.0", + ), + ) { + versionOverrides.set( + mapOf( + "com.google.gms:google-services" to "3.2.1", + "com.google.firebase:firebase-crashlytics" to "1.2.12", + "com.google.firebase:firebase-crashlytics-gradle" to "1.15.0", + ) + ) + } + + tutorialFile.readText().trim() shouldBeText + """ + + + + + + + + + + + + + + + + + + + + + + ]> + """ + .trimIndent() + } + + @Test + fun `enforces the predefined order of artifacts`() { + val tutorialFile = + makeTutorial( + commonArtifacts = + listOf( + "com.google.firebase:firebase-perf:10.2.3", + "com.google.gms:google-services:1.2.3", + ), + firebaseArtifacts = + listOf( + "com.google.firebase:firebase-crashlytics:12.0.0", + "com.google.firebase:firebase-analytics:1.2.4", + ), + gradlePlugins = + listOf( + "com.google.firebase:firebase-crashlytics-gradle:15.1.0", + "com.google.firebase:firebase-appdistribution-gradle:1.21.3", + ), + ) + + tutorialFile.readText().trim() shouldBeText + """ + + + + + + + + + + + + + + + + + + + + + + ]> + """ + .trimIndent() + } + + @Test + fun `throws an error if required artifacts are missing`() { + shouldThrowSubstring( + "Artifacts required for the tutorial bundle are missing from the provided input", + "com.google.firebase:firebase-auth", + ) { + makeTutorial( + firebaseArtifacts = + listOf( + "com.google.firebase:firebase-crashlytics:12.0.0", + "com.google.firebase:firebase-analytics:1.2.4", + ) + ) { + requiredArtifacts.add("com.google.firebase:firebase-auth") + } + } + } + + @Test + fun `throws an error if an unreleased artifact is used`() { + shouldThrowSubstring("missing from gmaven", "com.google.firebase:firebase-auth") { + every { service.latestVersionOrNull(any()) } answers { null } + + val task = makeTask { firebaseArtifacts.set(listOf("com.google.firebase:firebase-auth")) } + + task.get().generate() + } + } + + @Test + fun `allows unreleased artifacts to be used if the version is provided`() { + val task = makeTask { + firebaseArtifacts.set(listOf("com.google.firebase:firebase-auth")) + versionOverrides.set(mapOf("com.google.firebase:firebase-auth" to "10.0.0")) + } + + val file = + task.get().let { + it.generate() + it.tutorialFile.get().asFile + } + + file.shouldExist() + file.readText().trim() shouldBeText + """ + + + + ]> + """ + .trimIndent() + } + + private fun makeTask( + configure: GenerateTutorialBundleTask.() -> Unit + ): TaskProvider { + val project = ProjectBuilder.builder().withProjectDir(testProjectDir.root).build() + return project.tasks.register("generateTutorialBundle") { + tutorialFile.set(project.layout.buildDirectory.file("tutorial.txt")) + gmaven.set(service) + + configure() + } + } + + private fun artifactsToVersionMap(artifacts: List): Map { + return artifacts + .associate { + val (groupId, artifactId, version) = it.split(":") + "$groupId:$artifactId" to version + } + .onEach { entry -> every { service.latestVersionOrNull(entry.key) } answers { entry.value } } + } + + private fun makeTutorial( + firebaseArtifacts: List = emptyList(), + commonArtifacts: List = emptyList(), + gradlePlugins: List = emptyList(), + perfArtifacts: List = emptyList(), + configure: GenerateTutorialBundleTask.() -> Unit = {}, + ): File { + + val mappedFirebaseArtifacts = artifactsToVersionMap(firebaseArtifacts) + val mappedCommonArtifacts = artifactsToVersionMap(commonArtifacts) + val mappedGradlePlugins = artifactsToVersionMap(gradlePlugins) + val mappedPerfArtifacts = artifactsToVersionMap(perfArtifacts) + + val task = makeTask { + this.firebaseArtifacts.set(mappedFirebaseArtifacts.keys) + this.commonArtifacts.set(mappedCommonArtifacts.keys) + this.gradlePlugins.set(mappedGradlePlugins.keys) + this.perfArtifacts.set(mappedPerfArtifacts.keys) + configure() + } + + val file = + task.get().let { + it.generate() + it.tutorialFile.get().asFile + } + + file.shouldExist() + + return file + } +} diff --git a/plugins/src/test/resources/Bom/empty-bom.pom b/plugins/src/test/resources/Bom/empty-bom.pom new file mode 100644 index 00000000000..206e6413059 --- /dev/null +++ b/plugins/src/test/resources/Bom/empty-bom.pom @@ -0,0 +1,18 @@ + + 4.0.0 + com.google.firebase + firebase-bom + 1.0.0 + pom + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + + \ No newline at end of file diff --git a/plugins/src/test/resources/Bom/filled-bom.pom b/plugins/src/test/resources/Bom/filled-bom.pom new file mode 100644 index 00000000000..66bd6420081 --- /dev/null +++ b/plugins/src/test/resources/Bom/filled-bom.pom @@ -0,0 +1,223 @@ + + 4.0.0 + com.google.firebase + firebase-bom + 33.7.0 + pom + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + com.google.firebase + firebase-ml-modeldownloader-ktx + 25.0.1 + + + com.google.firebase + firebase-perf + 21.0.3 + + + com.google.firebase + firebase-crashlytics-ndk + 19.3.0 + + + com.google.firebase + firebase-appcheck + 18.0.0 + + + com.google.firebase + firebase-encoders + 17.0.0 + + + com.google.firebase + firebase-functions-ktx + 21.1.0 + + + com.google.firebase + firebase-storage-ktx + 21.0.1 + + + com.google.firebase + firebase-functions + 21.1.0 + + + com.google.firebase + firebase-appcheck-debug + 18.0.0 + + + com.google.firebase + firebase-perf-ktx + 21.0.3 + + + com.google.firebase + firebase-analytics + 22.1.2 + + + com.google.firebase + firebase-dynamic-links-ktx + 22.1.0 + + + com.google.firebase + firebase-appcheck-debug-testing + 18.0.0 + + + com.google.firebase + firebase-auth + 23.1.0 + + + com.google.firebase + firebase-config-ktx + 22.0.1 + + + com.google.firebase + firebase-inappmessaging-display + 21.0.1 + + + com.google.firebase + firebase-installations-ktx + 18.0.0 + + + com.google.firebase + firebase-crashlytics-ktx + 19.3.0 + + + com.google.firebase + firebase-vertexai + 16.0.2 + + + com.google.firebase + firebase-inappmessaging + 21.0.1 + + + com.google.firebase + firebase-analytics-ktx + 22.1.2 + + + com.google.firebase + firebase-firestore + 25.1.1 + + + com.google.firebase + firebase-database-ktx + 21.0.0 + + + com.google.firebase + firebase-inappmessaging-display-ktx + 21.0.1 + + + com.google.firebase + firebase-ml-modeldownloader + 25.0.1 + + + com.google.firebase + firebase-config + 22.0.1 + + + com.google.firebase + firebase-common-ktx + 21.0.0 + + + com.google.firebase + firebase-firestore-ktx + 25.1.1 + + + com.google.firebase + firebase-messaging-directboot + 24.1.0 + + + com.google.firebase + firebase-appcheck-playintegrity + 18.0.0 + + + com.google.firebase + firebase-appcheck-ktx + 18.0.0 + + + com.google.firebase + firebase-messaging + 24.1.0 + + + com.google.firebase + firebase-auth-ktx + 23.1.0 + + + com.google.firebase + firebase-messaging-ktx + 24.1.0 + + + com.google.firebase + firebase-crashlytics + 19.3.0 + + + com.google.firebase + firebase-dynamic-links + 22.1.0 + + + com.google.firebase + firebase-storage + 21.0.1 + + + com.google.firebase + firebase-common + 21.0.0 + + + com.google.firebase + firebase-installations + 18.0.0 + + + com.google.firebase + firebase-inappmessaging-ktx + 21.0.1 + + + com.google.firebase + firebase-database + 21.0.0 + + + + \ No newline at end of file