Skip to content

Commit fe8c9df

Browse files
VinayGuthalrlazo
andauthored
Semver check for firebase sdks (#4826)
* add semver check * added api diff * added api diff * update according to comments * update according to comments * fix bugs * small fix * fix * update copyright * address comments * nit * Address comment * Update buildSrc/src/main/java/com/google/firebase/gradle/plugins/semver/ApiDiffer.kt Co-authored-by: Rodrigo Lazo <[email protected]> * address comments * update * address comments * move functions within classes --------- Co-authored-by: Rodrigo Lazo <[email protected]>
1 parent ca6216e commit fe8c9df

File tree

11 files changed

+1177
-4
lines changed

11 files changed

+1177
-4
lines changed

buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseJavaLibraryPlugin.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatExtension
1818
import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatPlugin
1919
import com.google.common.collect.ImmutableList
2020
import com.google.firebase.gradle.plugins.LibraryType.JAVA
21+
import com.google.firebase.gradle.plugins.semver.ApiDiffer
22+
import com.google.firebase.gradle.plugins.semver.GmavenCopier
2123
import org.gradle.api.Project
2224
import org.gradle.api.attributes.Attribute
2325
import org.gradle.api.plugins.JavaLibraryPlugin
@@ -48,9 +50,38 @@ class FirebaseJavaLibraryPlugin : BaseFirebaseLibraryPlugin() {
4850
setupStaticAnalysis(project, firebaseLibrary)
4951
setupApiInformationAnalysis(project)
5052
getIsPomValidTask(project, firebaseLibrary)
53+
getSemverTaskJar(project, firebaseLibrary)
5154
configurePublishing(project, firebaseLibrary)
5255
}
5356

57+
private fun getSemverTaskJar(project: Project, firebaseLibrary: FirebaseLibraryExtension) {
58+
project.mkdir("semver")
59+
project.tasks.register<GmavenCopier>("copyPreviousArtifacts") {
60+
dependsOn("jar")
61+
project.file("semver/previous.jar").delete()
62+
groupId.value(firebaseLibrary.groupId.get())
63+
artifactId.value(firebaseLibrary.artifactId.get())
64+
aarAndroidFile.value(false)
65+
filePath.value(project.file("semver/previous.jar").absolutePath)
66+
}
67+
val currentJarFile =
68+
project
69+
.file("build/libs/${firebaseLibrary.artifactId.get()}-${firebaseLibrary.version}.jar")
70+
.absolutePath
71+
val previousJarFile = project.file("semver/previous.jar").absolutePath
72+
project.tasks.register<ApiDiffer>("semverCheck") {
73+
currentJar.value(currentJarFile)
74+
previousJar.value(previousJarFile)
75+
version.value(firebaseLibrary.version)
76+
previousVersionString.value(
77+
GmavenHelper(firebaseLibrary.groupId.get(), firebaseLibrary.artifactId.get())
78+
.getLatestReleasedVersion()
79+
)
80+
81+
dependsOn("copyPreviousArtifacts")
82+
}
83+
}
84+
5485
private fun setupApiInformationAnalysis(project: Project) {
5586
val srcDirs =
5687
project.convention.getPlugin<JavaPluginConvention>().sourceSets.getByName("main").java.srcDirs

buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseLibraryPlugin.kt

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ import com.github.sherter.googlejavaformatgradleplugin.GoogleJavaFormatPlugin
2121
import com.google.firebase.gradle.plugins.LibraryType.ANDROID
2222
import com.google.firebase.gradle.plugins.ci.device.FirebaseTestServer
2323
import com.google.firebase.gradle.plugins.license.LicenseResolverPlugin
24+
import com.google.firebase.gradle.plugins.semver.ApiDiffer
25+
import com.google.firebase.gradle.plugins.semver.GmavenCopier
2426
import java.io.File
2527
import org.gradle.api.JavaVersion
2628
import org.gradle.api.Project
2729
import org.gradle.api.attributes.Attribute
2830
import org.gradle.api.publish.tasks.GenerateModuleMetadata
29-
import org.gradle.kotlin.dsl.apply
30-
import org.gradle.kotlin.dsl.create
31-
import org.gradle.kotlin.dsl.getByType
32-
import org.gradle.kotlin.dsl.withType
31+
import org.gradle.api.tasks.Copy
32+
import org.gradle.kotlin.dsl.*
3333
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3434

3535
class FirebaseLibraryPlugin : BaseFirebaseLibraryPlugin() {
@@ -81,9 +81,51 @@ class FirebaseLibraryPlugin : BaseFirebaseLibraryPlugin() {
8181
android.testServer(FirebaseTestServer(project, firebaseLibrary.testLab, android))
8282
setupStaticAnalysis(project, firebaseLibrary)
8383
getIsPomValidTask(project, firebaseLibrary)
84+
getSemverTaskAar(project, firebaseLibrary)
8485
configurePublishing(project, firebaseLibrary, android)
8586
}
8687

88+
private fun getSemverTaskAar(project: Project, firebaseLibrary: FirebaseLibraryExtension) {
89+
project.mkdir("semver")
90+
project.tasks.register<GmavenCopier>("copyPreviousArtifacts") {
91+
dependsOn("bundleReleaseAar")
92+
project.file("semver/previous.aar").delete()
93+
94+
groupId.value(firebaseLibrary.groupId.get())
95+
artifactId.value(firebaseLibrary.artifactId.get())
96+
aarAndroidFile.value(true)
97+
filePath.value(project.file("semver/previous.aar").absolutePath)
98+
}
99+
100+
project.tasks.register<Copy>("extractCurrentClasses") {
101+
dependsOn("bundleReleaseAar")
102+
103+
from(project.zipTree("build/outputs/aar/${firebaseLibrary.artifactId.get()}-release.aar"))
104+
into(project.file("semver/current-version"))
105+
}
106+
project.tasks.register<Copy>("extractPreviousClasses") {
107+
dependsOn("copyPreviousArtifacts")
108+
109+
from(project.zipTree("semver/previous.aar"))
110+
into(project.file("semver/previous-version"))
111+
}
112+
113+
val currentJarFile = project.file("semver/current-version/classes.jar").absolutePath
114+
115+
val previousJarFile = project.file("semver/previous-version/classes.jar").absolutePath
116+
project.tasks.register<ApiDiffer>("semverCheck") {
117+
currentJar.value(currentJarFile)
118+
previousJar.value(previousJarFile)
119+
version.value(firebaseLibrary.version)
120+
previousVersionString.value(
121+
GmavenHelper(firebaseLibrary.groupId.get(), firebaseLibrary.artifactId.get())
122+
.getLatestReleasedVersion()
123+
)
124+
dependsOn("extractCurrentClasses")
125+
dependsOn("extractPreviousClasses")
126+
}
127+
}
128+
87129
private fun setupApiInformationAnalysis(project: Project, android: LibraryExtension) {
88130
val srcDirs = android.sourceSets.getByName("main").java.srcDirs
89131

buildSrc/src/main/java/com/google/firebase/gradle/plugins/GmavenHelper.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ class GmavenHelper(val groupId: String, val artifactId: String) {
2929
return "${GMAVEN_ROOT}/${groupIdAsPath}/${artifactId}/${version}/${pomFileName}"
3030
}
3131

32+
fun getArtifactForVersion(version: String, isJar: Boolean): String {
33+
val fileName =
34+
if (isJar == true) "${artifactId}-${version}.jar" else "${artifactId}-${version}.aar"
35+
val groupIdAsPath = groupId.replace(".", "/")
36+
return "${GMAVEN_ROOT}/${groupIdAsPath}/${artifactId}/${version}/${fileName}"
37+
}
38+
3239
fun getLatestReleasedVersion(): String {
3340
try {
3441
val groupIdAsPath = groupId.replace(".", "/")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.semver
16+
17+
import org.objectweb.asm.Opcodes
18+
19+
/**
20+
* Convenience class that helps avoid confusing (if more performant) bitwise checks against {@link
21+
* Opcodes}
22+
*/
23+
class AccessDescriptor(private val access: Int) {
24+
fun isProtected(): Boolean = accessIs(Opcodes.ACC_PROTECTED)
25+
26+
fun isPublic(): Boolean = accessIs(Opcodes.ACC_PUBLIC)
27+
28+
fun isStatic(): Boolean = accessIs(Opcodes.ACC_STATIC)
29+
30+
fun isSynthetic(): Boolean = accessIs(Opcodes.ACC_SYNTHETIC)
31+
32+
fun isBridge(): Boolean = accessIs(Opcodes.ACC_BRIDGE)
33+
34+
fun isAbstract(): Boolean = accessIs(Opcodes.ACC_ABSTRACT)
35+
36+
fun isFinal(): Boolean = accessIs(Opcodes.ACC_FINAL)
37+
38+
fun isPrivate(): Boolean = !this.isProtected() && !this.isPublic()
39+
40+
fun getVerboseDescription(): String {
41+
val outputStringList = mutableListOf<String>()
42+
if (this.isPublic()) {
43+
outputStringList.add("public")
44+
}
45+
if (this.isPrivate()) {
46+
outputStringList.add("private")
47+
}
48+
if (this.isProtected()) {
49+
outputStringList.add("protected")
50+
}
51+
if (this.isStatic()) {
52+
outputStringList.add("static")
53+
}
54+
if (this.isFinal()) {
55+
outputStringList.add("final")
56+
}
57+
if (this.isAbstract()) {
58+
outputStringList.add("abstract")
59+
}
60+
return outputStringList.joinToString(" ")
61+
}
62+
/** Returns true if the given access modifier matches the given opcode. */
63+
fun accessIs(opcode: Int): Boolean = (access and opcode) != 0
64+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.semver
16+
17+
import java.nio.file.Files
18+
import java.nio.file.Path
19+
import java.nio.file.Paths
20+
import java.util.jar.JarEntry
21+
import java.util.jar.JarInputStream
22+
import org.gradle.api.DefaultTask
23+
import org.gradle.api.GradleException
24+
import org.gradle.api.provider.Property
25+
import org.gradle.api.tasks.Input
26+
import org.gradle.api.tasks.TaskAction
27+
import org.objectweb.asm.ClassReader
28+
import org.objectweb.asm.tree.ClassNode
29+
30+
abstract class ApiDiffer : DefaultTask() {
31+
@get:Input abstract val currentJar: Property<String>
32+
33+
@get:Input abstract val previousJar: Property<String>
34+
35+
@get:Input abstract val version: Property<String>
36+
37+
@get:Input abstract val previousVersionString: Property<String>
38+
39+
private val CLASS_EXTENSION = ".class"
40+
41+
@TaskAction
42+
fun run() {
43+
val (pMajor, pMinor, _) = previousVersionString.get().split(".")
44+
val (major, minor, _) = version.get().split(".")
45+
val curVersionDelta: VersionDelta =
46+
if (major > pMajor) VersionDelta.MAJOR
47+
else if (minor > pMinor) VersionDelta.MINOR else VersionDelta.PATCH
48+
val afterJar = readApi(currentJar.get())
49+
val beforeJar = readApi(previousJar.get())
50+
val classKeys = afterJar.keys union beforeJar.keys
51+
val apiDeltas =
52+
classKeys
53+
.map { className -> Pair(beforeJar.get(className), afterJar.get(className)) }
54+
.flatMap { (before, after) ->
55+
DeltaType.values().flatMap { it.getViolations(before, after) }
56+
}
57+
val deltaViolations: List<Delta> =
58+
if (curVersionDelta == VersionDelta.MINOR)
59+
apiDeltas.filter { it.versionDelta == VersionDelta.MAJOR }
60+
else if (curVersionDelta == VersionDelta.PATCH) apiDeltas else mutableListOf()
61+
if (!apiDeltas.isEmpty()) {
62+
val printString =
63+
apiDeltas.joinToString(
64+
prefix =
65+
"Here is a list of all the minor/major version bump changes which are made since the last release.\n",
66+
separator = "\n"
67+
) {
68+
"[${it.versionDelta}] ${it.description}"
69+
}
70+
println(printString)
71+
}
72+
if (!deltaViolations.isEmpty()) {
73+
val outputString =
74+
deltaViolations.joinToString(
75+
prefix =
76+
"Here is a list of all the violations which needs to be fixed before we could release.\n",
77+
separator = "\n"
78+
) {
79+
"[${it.versionDelta}] ${it.description}"
80+
}
81+
throw GradleException(outputString)
82+
}
83+
}
84+
85+
private fun readApi(jarPath: String): Map<String, ClassInfo> {
86+
val classes: Map<String, ClassNode> = readClassNodes(Paths.get(jarPath))
87+
return classes.entries.associate { (key, value) -> key to ClassInfo(value, classes) }
88+
}
89+
90+
/** Returns true if the class is local or anonymous. */
91+
private fun isLocalOrAnonymous(classNode: ClassNode): Boolean {
92+
// JVMS 4.7.7 says a class has an EnclosingMethod attribute iff it is local or anonymous.
93+
// ASM sets the "enclosing class" only if an EnclosingMethod attribute is present, so this
94+
// this test will not include named inner classes even though they have enclosing classes.
95+
return classNode.outerClass != null
96+
}
97+
98+
private fun getUnqualifiedClassname(classNodeName: String): String {
99+
// Class names may be "/" or "." separated depending on context. Normalize to "/"
100+
val normalizedPath = classNodeName.replace(".", "/")
101+
val withoutPackage = normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1)
102+
return withoutPackage.substring(withoutPackage.lastIndexOf('$') + 1)
103+
}
104+
105+
fun readClassNodes(jar: Path): Map<String, ClassNode> {
106+
val classes: MutableMap<String, ClassNode> = LinkedHashMap()
107+
val inputStream = Files.newInputStream(jar)
108+
val jis = JarInputStream(inputStream)
109+
var je: JarEntry? = null
110+
while (true) {
111+
je = jis.nextJarEntry
112+
if (je == null) {
113+
break
114+
}
115+
if (!je.name.endsWith(CLASS_EXTENSION)) {
116+
continue
117+
}
118+
val expectedName = je.name.substring(0, je.name.length - CLASS_EXTENSION.length)
119+
val classNode: ClassNode = ClassNode()
120+
121+
ClassReader(jis).accept(classNode, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
122+
123+
if (!classNode.name.equals(expectedName)) {
124+
logger.error(
125+
"Classnode doesn't match expected name ${classNode.name}. This is not a valid jar."
126+
)
127+
continue
128+
}
129+
// Skip classes that appear to be obfuscated.
130+
if (UtilityClass.isObfuscatedSymbol(getUnqualifiedClassname(classNode.name))) {
131+
continue
132+
}
133+
// Skip local and anonymous classes.
134+
if (isLocalOrAnonymous(classNode)) {
135+
continue
136+
}
137+
// Skip private nested classes
138+
if (!classes.containsKey(classNode.name)) {
139+
classes.put(classNode.name, classNode)
140+
} else {
141+
project.logger.info("Duplicate class seen: ${classNode.name}")
142+
}
143+
}
144+
return classes
145+
}
146+
}

0 commit comments

Comments
 (0)