Skip to content

Semver check for firebase sdks #4826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatExtension
import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatPlugin
import com.google.common.collect.ImmutableList
import com.google.firebase.gradle.plugins.LibraryType.JAVA
import com.google.firebase.gradle.plugins.semver.ApiDiffer
import com.google.firebase.gradle.plugins.semver.GmavenCopier
import org.gradle.api.Project
import org.gradle.api.attributes.Attribute
import org.gradle.api.plugins.JavaLibraryPlugin
Expand Down Expand Up @@ -48,9 +50,38 @@ class FirebaseJavaLibraryPlugin : BaseFirebaseLibraryPlugin() {
setupStaticAnalysis(project, firebaseLibrary)
setupApiInformationAnalysis(project)
getIsPomValidTask(project, firebaseLibrary)
getSemverTaskJar(project, firebaseLibrary)
configurePublishing(project, firebaseLibrary)
}

private fun getSemverTaskJar(project: Project, firebaseLibrary: FirebaseLibraryExtension) {
project.mkdir("semver")
project.tasks.register<GmavenCopier>("copyPreviousArtifacts") {
dependsOn("jar")
project.file("semver/previous.jar").delete()
groupId.value(firebaseLibrary.groupId.get())
artifactId.value(firebaseLibrary.artifactId.get())
aarAndroidFile.value(false)
filePath.value(project.file("semver/previous.jar").absolutePath)
}
val currentJarFile =
project
.file("build/libs/${firebaseLibrary.artifactId.get()}-${firebaseLibrary.version}.jar")
.absolutePath
val previousJarFile = project.file("semver/previous.jar").absolutePath
project.tasks.register<ApiDiffer>("semverCheck") {
currentJar.value(currentJarFile)
previousJar.value(previousJarFile)
version.value(firebaseLibrary.version)
previousVersionString.value(
GmavenHelper(firebaseLibrary.groupId.get(), firebaseLibrary.artifactId.get())
.getLatestReleasedVersion()
)

dependsOn("copyPreviousArtifacts")
}
}

private fun setupApiInformationAnalysis(project: Project) {
val srcDirs =
project.convention.getPlugin<JavaPluginConvention>().sourceSets.getByName("main").java.srcDirs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatPlugin
import com.google.firebase.gradle.plugins.LibraryType.ANDROID
import com.google.firebase.gradle.plugins.ci.device.FirebaseTestServer
import com.google.firebase.gradle.plugins.license.LicenseResolverPlugin
import com.google.firebase.gradle.plugins.semver.ApiDiffer
import com.google.firebase.gradle.plugins.semver.GmavenCopier
import java.io.File
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.attributes.Attribute
import org.gradle.api.publish.tasks.GenerateModuleMetadata
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.create
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.withType
import org.gradle.api.tasks.Copy
import org.gradle.kotlin.dsl.*
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

class FirebaseLibraryPlugin : BaseFirebaseLibraryPlugin() {
Expand Down Expand Up @@ -81,9 +81,51 @@ class FirebaseLibraryPlugin : BaseFirebaseLibraryPlugin() {
android.testServer(FirebaseTestServer(project, firebaseLibrary.testLab, android))
setupStaticAnalysis(project, firebaseLibrary)
getIsPomValidTask(project, firebaseLibrary)
getSemverTaskAar(project, firebaseLibrary)
configurePublishing(project, firebaseLibrary, android)
}

private fun getSemverTaskAar(project: Project, firebaseLibrary: FirebaseLibraryExtension) {
project.mkdir("semver")
project.tasks.register<GmavenCopier>("copyPreviousArtifacts") {
dependsOn("bundleReleaseAar")
project.file("semver/previous.aar").delete()

groupId.value(firebaseLibrary.groupId.get())
artifactId.value(firebaseLibrary.artifactId.get())
aarAndroidFile.value(true)
filePath.value(project.file("semver/previous.aar").absolutePath)
}

project.tasks.register<Copy>("extractCurrentClasses") {
dependsOn("bundleReleaseAar")

from(project.zipTree("build/outputs/aar/${firebaseLibrary.artifactId.get()}-release.aar"))
into(project.file("semver/current-version"))
}
project.tasks.register<Copy>("extractPreviousClasses") {
dependsOn("copyPreviousArtifacts")

from(project.zipTree("semver/previous.aar"))
into(project.file("semver/previous-version"))
}

val currentJarFile = project.file("semver/current-version/classes.jar").absolutePath

val previousJarFile = project.file("semver/previous-version/classes.jar").absolutePath
project.tasks.register<ApiDiffer>("semverCheck") {
currentJar.value(currentJarFile)
previousJar.value(previousJarFile)
version.value(firebaseLibrary.version)
previousVersionString.value(
GmavenHelper(firebaseLibrary.groupId.get(), firebaseLibrary.artifactId.get())
.getLatestReleasedVersion()
)
dependsOn("extractCurrentClasses")
dependsOn("extractPreviousClasses")
}
}

private fun setupApiInformationAnalysis(project: Project, android: LibraryExtension) {
val srcDirs = android.sourceSets.getByName("main").java.srcDirs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class GmavenHelper(val groupId: String, val artifactId: String) {
return "${GMAVEN_ROOT}/${groupIdAsPath}/${artifactId}/${version}/${pomFileName}"
}

fun getArtifactForVersion(version: String, isJar: Boolean): String {
val fileName =
if (isJar == true) "${artifactId}-${version}.jar" else "${artifactId}-${version}.aar"
val groupIdAsPath = groupId.replace(".", "/")
return "${GMAVEN_ROOT}/${groupIdAsPath}/${artifactId}/${version}/${fileName}"
}

fun getLatestReleasedVersion(): String {
try {
val groupIdAsPath = groupId.replace(".", "/")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2023 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.semver

import org.objectweb.asm.Opcodes

/**
* Convenience class that helps avoid confusing (if more performant) bitwise checks against {@link
* Opcodes}
*/
class AccessDescriptor(private val access: Int) {
fun isProtected(): Boolean = accessIs(Opcodes.ACC_PROTECTED)

fun isPublic(): Boolean = accessIs(Opcodes.ACC_PUBLIC)

fun isStatic(): Boolean = accessIs(Opcodes.ACC_STATIC)

fun isSynthetic(): Boolean = accessIs(Opcodes.ACC_SYNTHETIC)

fun isBridge(): Boolean = accessIs(Opcodes.ACC_BRIDGE)

fun isAbstract(): Boolean = accessIs(Opcodes.ACC_ABSTRACT)

fun isFinal(): Boolean = accessIs(Opcodes.ACC_FINAL)

fun isPrivate(): Boolean = !this.isProtected() && !this.isPublic()

fun getVerboseDescription(): String {
val outputStringList = mutableListOf<String>()
if (this.isPublic()) {
outputStringList.add("public")
}
if (this.isPrivate()) {
outputStringList.add("private")
}
if (this.isProtected()) {
outputStringList.add("protected")
}
if (this.isStatic()) {
outputStringList.add("static")
}
if (this.isFinal()) {
outputStringList.add("final")
}
if (this.isAbstract()) {
outputStringList.add("abstract")
}
return outputStringList.joinToString(" ")
}
/** Returns true if the given access modifier matches the given opcode. */
fun accessIs(opcode: Int): Boolean = (access and opcode) != 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright 2023 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.semver

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.jar.JarEntry
import java.util.jar.JarInputStream
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
import org.objectweb.asm.ClassReader
import org.objectweb.asm.tree.ClassNode

abstract class ApiDiffer : DefaultTask() {
@get:Input abstract val currentJar: Property<String>

@get:Input abstract val previousJar: Property<String>

@get:Input abstract val version: Property<String>

@get:Input abstract val previousVersionString: Property<String>

private val CLASS_EXTENSION = ".class"

@TaskAction
fun run() {
val (pMajor, pMinor, _) = previousVersionString.get().split(".")
val (major, minor, _) = version.get().split(".")
val curVersionDelta: VersionDelta =
if (major > pMajor) VersionDelta.MAJOR
else if (minor > pMinor) VersionDelta.MINOR else VersionDelta.PATCH
val afterJar = readApi(currentJar.get())
val beforeJar = readApi(previousJar.get())
val classKeys = afterJar.keys union beforeJar.keys
val apiDeltas =
classKeys
.map { className -> Pair(beforeJar.get(className), afterJar.get(className)) }
.flatMap { (before, after) ->
DeltaType.values().flatMap { it.getViolations(before, after) }
}
val deltaViolations: List<Delta> =
if (curVersionDelta == VersionDelta.MINOR)
apiDeltas.filter { it.versionDelta == VersionDelta.MAJOR }
else if (curVersionDelta == VersionDelta.PATCH) apiDeltas else mutableListOf()
if (!apiDeltas.isEmpty()) {
val printString =
apiDeltas.joinToString(
prefix =
"Here is a list of all the minor/major version bump changes which are made since the last release.\n",
separator = "\n"
) {
"[${it.versionDelta}] ${it.description}"
}
println(printString)
}
if (!deltaViolations.isEmpty()) {
val outputString =
deltaViolations.joinToString(
prefix =
"Here is a list of all the violations which needs to be fixed before we could release.\n",
separator = "\n"
) {
"[${it.versionDelta}] ${it.description}"
}
throw GradleException(outputString)
}
}

private fun readApi(jarPath: String): Map<String, ClassInfo> {
val classes: Map<String, ClassNode> = readClassNodes(Paths.get(jarPath))
return classes.entries.associate { (key, value) -> key to ClassInfo(value, classes) }
}

/** Returns true if the class is local or anonymous. */
private fun isLocalOrAnonymous(classNode: ClassNode): Boolean {
// JVMS 4.7.7 says a class has an EnclosingMethod attribute iff it is local or anonymous.
// ASM sets the "enclosing class" only if an EnclosingMethod attribute is present, so this
// this test will not include named inner classes even though they have enclosing classes.
return classNode.outerClass != null
}

private fun getUnqualifiedClassname(classNodeName: String): String {
// Class names may be "/" or "." separated depending on context. Normalize to "/"
val normalizedPath = classNodeName.replace(".", "/")
val withoutPackage = normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1)
return withoutPackage.substring(withoutPackage.lastIndexOf('$') + 1)
}

fun readClassNodes(jar: Path): Map<String, ClassNode> {
val classes: MutableMap<String, ClassNode> = LinkedHashMap()
val inputStream = Files.newInputStream(jar)
val jis = JarInputStream(inputStream)
var je: JarEntry? = null
while (true) {
je = jis.nextJarEntry
if (je == null) {
break
}
if (!je.name.endsWith(CLASS_EXTENSION)) {
continue
}
val expectedName = je.name.substring(0, je.name.length - CLASS_EXTENSION.length)
val classNode: ClassNode = ClassNode()

ClassReader(jis).accept(classNode, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)

if (!classNode.name.equals(expectedName)) {
logger.error(
"Classnode doesn't match expected name ${classNode.name}. This is not a valid jar."
)
continue
}
// Skip classes that appear to be obfuscated.
if (UtilityClass.isObfuscatedSymbol(getUnqualifiedClassname(classNode.name))) {
continue
}
// Skip local and anonymous classes.
if (isLocalOrAnonymous(classNode)) {
continue
}
// Skip private nested classes
if (!classes.containsKey(classNode.name)) {
classes.put(classNode.name, classNode)
} else {
project.logger.info("Duplicate class seen: ${classNode.name}")
}
}
return classes
}
}
Loading