Skip to content

Commit 4bb8501

Browse files
Enforce semantic versioning at publication with japicmp (#119)
2 parents 1e05896 + 81eb59f commit 4bb8501

13 files changed

Lines changed: 155 additions & 0 deletions

File tree

.github/workflows/publication.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ jobs:
2828
run: ./gradlew check -x integrationTest
2929
- name: 'Integration test'
3030
run: ./gradlew :pushiko-fcm:integrationTest
31+
- name: 'Check semantic versioning'
32+
if: github.event_name == 'release' && github.event.action == 'published'
33+
run: ./gradlew checkSemver
34+
- name: 'Archive semver reports'
35+
if: failure()
36+
uses: actions/upload-artifact@v4
37+
with:
38+
name: semver-reports
39+
path: '**/build/reports/japicmp/'
40+
if-no-files-found: ignore
3141
- name: 'Publish release to Maven Central'
3242
if: github.event_name == 'release' && github.event.action == 'published'
3343
env:

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ We believe Pushiko is both mature and generic enough to represent a useful contr
4545

4646
Please refer to the main documentation: For APNs see [Getting started > APNs](https://bloomberg.github.io/pushiko/getting_started_apns/) and for FCM see [Getting started > FCM](https://bloomberg.github.io/pushiko/getting_started_fcm/).
4747

48+
## Versioning
49+
50+
Pushiko follows [Semantic Versioning](https://semver.org/). Breaking changes to the public API are released only in major versions and are documented in the [change log](https://bloomberg.github.io/pushiko/changelog/).
51+
4852
## Acknowledgements
4953

5054
The original implementation of the Netty channel pool in Pushiko was heavily influenced by Jon Chambers' own APNs Java channel pool in Pushy :bow:. Taking this as our template for how to begin designing our pool, any mistakes introduced in subsequent work are entirely our own.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
plugins {
2+
`kotlin-dsl`
3+
}
4+
5+
dependencies {
6+
implementation(libs.japicmp.gradle.plugin)
7+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import me.champeau.gradle.japicmp.JapicmpTask
2+
import me.champeau.gradle.japicmp.JApiCmpWorkerAction
3+
import org.gradle.api.tasks.bundling.Jar
4+
import org.gradle.kotlin.dsl.named
5+
import org.gradle.kotlin.dsl.register
6+
import java.io.FileNotFoundException
7+
import java.net.URI
8+
9+
plugins {
10+
java
11+
}
12+
13+
val groupPath = project.group.toString().replace('.', '/')
14+
15+
val baselineVersion = project.findProperty("japicmpBaseline")?.toString() ?: run {
16+
val currentVersion = project.version.toString()
17+
val currentMajor = currentVersion.substringBefore('.')
18+
val metadataUrl = "https://repo1.maven.org/maven2/$groupPath/${project.name}/maven-metadata.xml"
19+
val metadata = try {
20+
URI.create(metadataUrl).toURL().openStream().bufferedReader().use { it.readText() }
21+
} catch (_: FileNotFoundException) {
22+
return@run ""
23+
}
24+
val allVersions = """<version>([^<]+)</version>""".toRegex().findAll(metadata)
25+
.map { it.groupValues[1] }
26+
.filter { it.startsWith("$currentMajor.") }
27+
.toList()
28+
29+
allVersions.maxWithOrNull { first, second ->
30+
val firstComponents = first.drop(currentMajor.length + 1).split('.')
31+
val secondComponents = second.drop(currentMajor.length + 1).split('.')
32+
for (i in 0 until minOf(firstComponents.size, secondComponents.size)) {
33+
val firstVersion = firstComponents[i].toInt()
34+
val secondVersion = secondComponents[i].toInt()
35+
firstVersion.compareTo(secondVersion).let {
36+
if (it != 0) {
37+
return@maxWithOrNull it
38+
}
39+
}
40+
}
41+
firstComponents.size.compareTo(secondComponents.size)
42+
} ?: ""
43+
}
44+
45+
val baselineJar = layout.buildDirectory.file(project.provider {
46+
"japicmp/${project.name}-$baselineVersion.jar"
47+
})
48+
49+
val downloadJapicmpBaseline = tasks.register("downloadJapicmpBaseline") {
50+
group = "verification"
51+
description = "Download baseline JAR from Maven Central for japicmp comparison."
52+
onlyIf { baselineVersion.isNotEmpty() }
53+
outputs.file(baselineJar)
54+
notCompatibleWithConfigurationCache("downloads from Maven Central")
55+
56+
doLast {
57+
val downloadUrl = "https://repo1.maven.org/maven2/$groupPath/${project.name}/$baselineVersion/${project.name}-$baselineVersion.jar"
58+
val dest = baselineJar.get().asFile
59+
if (!dest.exists()) {
60+
dest.parentFile.mkdirs()
61+
runCatching {
62+
logger.info("Downloading baseline JAR from {}", downloadUrl)
63+
URI.create(downloadUrl).toURL().openStream().use {
64+
dest.outputStream().use(it::copyTo)
65+
}
66+
logger.info("Baseline JAR downloaded: {}", dest.absolutePath)
67+
}.onFailure {
68+
logger.info("Failed to download baseline JAR ({}), skipping check", it.message)
69+
dest.writeText("")
70+
}
71+
}
72+
}
73+
}
74+
75+
val jarTask = tasks.named<Jar>("jar")
76+
77+
val checkSemver = tasks.register<JapicmpTask>("checkSemver") {
78+
dependsOn(downloadJapicmpBaseline, jarTask)
79+
group = "verification"
80+
description = "Fails on binary-incompatible changes vs $baselineVersion."
81+
onlyIf { baselineJar.get().asFile.exists() && baselineJar.get().asFile.length() > 0 }
82+
83+
oldArchiveList.add(project.provider {
84+
JApiCmpWorkerAction.Archive(baselineJar.get().asFile, baselineVersion)
85+
})
86+
newArchiveList.add(project.provider {
87+
JApiCmpWorkerAction.Archive(jarTask.get().archiveFile.get().asFile, project.version.toString())
88+
})
89+
90+
oldClasspath.from(configurations.runtimeClasspath)
91+
newClasspath.from(configurations.runtimeClasspath)
92+
93+
onlyBinaryIncompatibleModified = true
94+
failOnModification = true
95+
ignoreMissingClasses = true
96+
97+
// Filter Kotlin bytecode idioms that are JVM-public but not part of the API.
98+
methodExcludes = listOf("*#*\$*(*)")
99+
fieldExcludes = listOf("*#*\$*")
100+
classExcludes = listOf("*\$WhenMappings")
101+
102+
txtOutputFile = layout.buildDirectory.file("reports/japicmp/${project.name}.txt")
103+
htmlOutputFile = layout.buildDirectory.file("reports/japicmp/${project.name}.html")
104+
}
105+
106+
rootProject.tasks.named("checkSemver") { dependsOn(checkSemver) }

build-logic/settings.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
rootProject.name = "build-logic"
2+
3+
include(":semver")
4+
5+
dependencyResolutionManagement {
6+
repositories {
7+
mavenCentral()
8+
gradlePluginPortal()
9+
}
10+
versionCatalogs {
11+
register("libs") {
12+
from(files("../gradle/libs.versions.toml"))
13+
}
14+
}
15+
}

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ tasks.named("check") {
226226
dependsOn("koverVerify")
227227
}
228228

229+
tasks.register("checkSemver") {
230+
group = "verification"
231+
description = "Binary-compatibility check across all published modules."
232+
}
233+
229234
idea.project.settings {
230235
copyright {
231236
useDefault = "Bloomberg"

buildSrc/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ dependencies {
3232
compileOnly(gradleKotlinDsl())
3333
implementation(platform(libs.kotlin.bom))
3434
implementation(kotlin("gradle-plugin", version=libs.versions.kotlin.get()))
35+
implementation(libs.japicmp.gradle.plugin)
3536
}

buildSrc/settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ dependencyResolutionManagement {
2727
@Suppress("UnstableApiUsage")
2828
repositories {
2929
mavenCentral()
30+
gradlePluginPortal()
3031
}
3132
}

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ atomicfu = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version =
99
findbugs-jsr305 = { module = "com.google.code.findbugs:jsr305", version = "3.0.2" }
1010
google-auth = { module = "com.google.auth:google-auth-library-oauth2-http", version = "1.43.0" }
1111
gson = { module = "com.google.code.gson:gson", version = "2.10.1" }
12+
japicmp-gradle-plugin = { module = "me.champeau.gradle:japicmp-gradle-plugin", version = "0.4.6" }
1213
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
1314
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
1415
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }

pushiko-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ description = "API commonly exposed by multiple subprojects of Pushiko"
1818

1919
plugins {
2020
kotlin("jvm")
21+
id("semver")
2122
alias(libs.plugins.dokka)
2223
`library-conventions`
2324
alias(libs.plugins.kover)

0 commit comments

Comments
 (0)