From dcae21e88fd277cefa2e6d168801afaa7d6067cc Mon Sep 17 00:00:00 2001 From: Ruchir Jain <122954065+jainruchir@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:14:11 +0530 Subject: [PATCH] build: check binary compatibility against published releases Compare core and provider APIs with the latest stable Maven Central release during check. Add TestKit coverage, including the published 0.8.14 to 0.8.15 builder regression, and document baseline overrides and intentional changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ruchir Jain <122954065+jainruchir@users.noreply.github.com> --- CHANGELOG.md | 12 + README.md | 73 ++++++ build.gradle | 28 ++ gradle/binary-compatibility.gradle | 65 +++++ java-spiffe-core/build.gradle | 5 + .../BinaryCompatibilityTest.java | 241 ++++++++++++++++++ 6 files changed, 424 insertions(+) create mode 100644 gradle/binary-compatibility.gradle create mode 100644 src/binaryCompatibilityTest/java/io/spiffe/compatibility/BinaryCompatibilityTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 95be1b1a..c35887e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Breaking changes + +* `RetryHandler.scheduleRetry(Runnable)` now returns `boolean` instead of `void`. + Consumers compiled against the previous signature must recompile before upgrading. + This existing unreleased change is explicitly excluded from the binary compatibility check. + +### Build + +* Check core and provider binary compatibility against the latest stable Maven Central release. + ## [0.8.17] - 2026-04-20 ### Fixed diff --git a/README.md b/README.md index 75d0d9c5..9b805daa 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,79 @@ On Linux or MacOS, run: All `jar` files are placed in `build/libs` folder. +### Binary compatibility + +`./gradlew check` (and `./gradlew build`) runs japicmp for `java-spiffe-core` and +`java-spiffe-provider`. It compares each module's regular JAR against its latest +stable Maven Central release, selecting versions of the form `major.minor.patch` +and rejecting prereleases. Helper, native transport, test-fixture and shaded JARs +are not compared. Dependencies are resolved separately on each side to look up +referenced types, not treated as APIs belonging to this project. + +The gate checks public and protected APIs, including synthetic bridge methods, +and excludes the `internal` and generated `grpc` packages excluded from Javadoc. +Binary-incompatible changes fail the build; compatible additions and source-only +incompatibilities do not. Text and HTML reports are written to +`/build/reports/japicmp/`, including when an incompatibility fails the task. + +Run just the compatibility checks locally (no SPIRE agent is required): + +```sh +./gradlew :java-spiffe-core:japicmp :java-spiffe-provider:japicmp +``` + +For a maintenance branch, pin the release from which that branch was developed: + +```sh +./gradlew check -PbaselineVersion=0.8.17 +``` + +The default baseline refreshes Maven metadata on each invocation. Gradle can reuse +a successful comparison when its API inputs are unchanged; changes to either +JAR, its dependencies or the comparison configuration invalidate that result. +Use a pinned baseline and `--offline` when the required artifacts are already +cached. Resolution errors fail the build rather than silently skipping the check. +For a first-ever release with no published baseline, explicitly use +`-PbaselineVersion=none`; this skips the comparison with a warning. Do not use +that opt-out to accept breaking changes in an existing library. + +#### Intentional breaking changes + +Add the narrowest possible exclusion to the affected module's `japicmp` task, +with a rationale and a **Breaking changes** entry in `CHANGELOG.md`. For example: + +```groovy +tasks.named('japicmp') { + methodExcludes.add('io.spiffe.example.Example#method(java.lang.String)') +} +``` + +Use `fieldExcludes` for individual fields. Reserve `classExcludes` for deliberate +removal of an entire type; avoid package-wide exclusions or disabling failure. +Method signatures include parameter types but not return types, so an exclusion +also hides future changes to that method. Remove exclusions once the baseline +contains the accepted change. Document whether consumers need to recompile or +migrate; deprecation alone does not make an ABI break compatible. + +#### Testing the gate + +```sh +./gradlew binaryCompatibilityTest +``` + +These Gradle TestKit tests compile small Java APIs and publish them to temporary +local Maven repositories. They exercise compatible additions, removed public and +protected methods, builder return-type changes, stable baseline selection, +version pinning, missing baselines, narrow exclusions and up-to-date invalidation. +They also compare the published 0.8.14 and 0.8.15 core JARs and assert that the +`X509SourceOptionsBuilder` regression is detected. This historical test downloads +those releases from Maven Central; all tests use the project's Gradle version +and the JDK running the build. To run only the historical regression: + +```sh +./gradlew binaryCompatibilityTest --tests '*detectsPublishedBuilderRegressionFrom0814To0815' +``` + #### Jars that include all dependencies For the module [java-spiffe-provider](java-spiffe-provider), a fat jar is generated with the classifier `-all-[os-classifier]`. diff --git a/build.gradle b/build.gradle index 567dfe95..a94141c4 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'com.google.osdetector' version '1.7.3' id 'jvm-test-suite' id 'com.vanniktech.maven.publish' version '0.37.0' apply false + id 'me.champeau.gradle.japicmp' version '0.4.6' apply false } allprojects { @@ -124,6 +125,12 @@ subprojects { } } +configure([project(':java-spiffe-core'), project(':java-spiffe-provider')]) { + apply plugin: 'me.champeau.gradle.japicmp' + tasks.register('japicmp', me.champeau.gradle.japicmp.JapicmpTask) + apply from: rootProject.file('gradle/binary-compatibility.gradle') +} + tasks.register('jacocoTestReport', JacocoReport) { dependsOn(subprojects.collect { it.tasks.named('test') }) @@ -166,3 +173,24 @@ def copyJars = tasks.register('copyJars', Copy) { tasks.named('assemble') { finalizedBy(copyJars) } + +testing { + suites { + binaryCompatibilityTest(JvmTestSuite) { + useJUnitJupiter('5.13.4') + dependencies { + implementation gradleTestKit() + } + targets.all { + testTask.configure { + inputs.file(layout.projectDirectory.file('gradle/binary-compatibility.gradle')) + systemProperty 'compatibilityScript', file('gradle/binary-compatibility.gradle').absolutePath + } + } + } + } +} + +tasks.named('check') { + dependsOn(testing.suites.binaryCompatibilityTest) +} diff --git a/gradle/binary-compatibility.gradle b/gradle/binary-compatibility.gradle new file mode 100644 index 00000000..6f58d638 --- /dev/null +++ b/gradle/binary-compatibility.gradle @@ -0,0 +1,65 @@ +import org.gradle.api.artifacts.component.ModuleComponentIdentifier + +def baselineVersion = providers.gradleProperty('baselineVersion').getOrElse('+') +def firstRelease = baselineVersion == 'none' +def moduleGroup = project.group.toString() +def moduleName = project.name + +// A detached configuration avoids resolving this module to the project being built. +def baseline = configurations.detachedConfiguration() +baseline.with { + resolutionStrategy { + useGlobalDependencySubstitutionRules = false + cacheDynamicVersionsFor 0, 'seconds' + componentSelection { + all { selection -> + if (selection.candidate.group == moduleGroup + && selection.candidate.module == moduleName + && !(selection.candidate.version ==~ /\d+\.\d+\.\d+/)) { + selection.reject('The binary compatibility baseline must be a stable release') + } + } + } + } +} + +if (!firstRelease) { + baseline.dependencies.add(dependencies.create("${moduleGroup}:${moduleName}:${baselineVersion}")) +} + +def japicmp = tasks.named('japicmp') { + group = 'verification' + description = 'Checks binary compatibility against the latest published stable release.' + onlyIf { + if (firstRelease) { + logger.warn("No binary compatibility baseline for ${project.path}: first release explicitly requested.") + } + !firstRelease + } + + // Compare only this module's JAR; dependencies are used to resolve referenced types. + oldArchives.from(baseline.incoming.artifactView { + componentFilter { id -> + id instanceof ModuleComponentIdentifier && id.group == moduleGroup && id.module == moduleName + } + }.files) + oldClasspath.from(baseline) + newArchives.from(tasks.named('jar')) + newClasspath.from(sourceSets.main.compileClasspath) + + accessModifier = 'protected' + includeSynthetic = true + packageExcludes = ['io.spiffe.internal', 'io.spiffe.*.internal', 'io.spiffe.*.grpc'] + onlyModified = true + // In this plugin, failOnModification rejects binary breaks, not compatible additions. + failOnModification = true + failOnSourceIncompatibility = false + ignoreMissingClasses = false + + txtOutputFile = layout.buildDirectory.file('reports/japicmp/japicmp.txt') + htmlOutputFile = layout.buildDirectory.file('reports/japicmp/japicmp.html') +} + +tasks.named('check') { + dependsOn(japicmp) +} \ No newline at end of file diff --git a/java-spiffe-core/build.gradle b/java-spiffe-core/build.gradle index d1c337a2..d6bf9244 100644 --- a/java-spiffe-core/build.gradle +++ b/java-spiffe-core/build.gradle @@ -6,6 +6,11 @@ plugins { description = "Core functionality to fetch, process and validate X.509 and JWT SVIDs and Bundles from the Workload API." +tasks.named('japicmp') { + // Existing unreleased return-type change; see CHANGELOG.md (Unreleased). + methodExcludes.add('io.spiffe.workloadapi.retry.RetryHandler#scheduleRetry(java.lang.Runnable)') +} + sourceSets { main { java { diff --git a/src/binaryCompatibilityTest/java/io/spiffe/compatibility/BinaryCompatibilityTest.java b/src/binaryCompatibilityTest/java/io/spiffe/compatibility/BinaryCompatibilityTest.java new file mode 100644 index 00000000..28fb5f4f --- /dev/null +++ b/src/binaryCompatibilityTest/java/io/spiffe/compatibility/BinaryCompatibilityTest.java @@ -0,0 +1,241 @@ +package io.spiffe.compatibility; + +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.ToolProvider; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class BinaryCompatibilityTest { + @TempDir + Path projectDir; + + private final List versions = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + write("settings.gradle", "rootProject.name = 'api'\n"); + Files.copy(Paths.get(System.getProperty("compatibilityScript")), + projectDir.resolve("binary-compatibility.gradle")); + write("build.gradle", + "plugins {\n" + + " id 'java-library'\n" + + " id 'me.champeau.gradle.japicmp' version '0.4.6'\n" + + "}\n" + + "group = 'io.spiffe'\n" + + "version = '2.0.0'\n" + + "repositories {\n" + + " maven { url = uri('repo'); content { includeGroup 'io.spiffe' } }\n" + + " mavenCentral { content { excludeGroup 'io.spiffe' } }\n" + + "}\n" + + "tasks.register('japicmp', me.champeau.gradle.japicmp.JapicmpTask)\n" + + "apply from: 'binary-compatibility.gradle'\n"); + write("gradle.properties", "org.gradle.workers.max=2\norg.gradle.jvmargs=-Xmx256m\n"); + } + + @Test + void checkRejectsRemovedPublicMethod() throws IOException { + publish("1.0.0", "public void removed() {}"); + current(""); + + BuildResult result = runner("check").buildAndFail(); + + assertEquals(TaskOutcome.FAILED, result.task(":japicmp").getOutcome()); + assertTrue(report().contains("removed()"), report()); + } + + @Test + void compatibleAdditionPassesAndIsUpToDateUntilApiChanges() throws IOException { + publish("1.0.0", "public void retained() {}"); + current("public void retained() {} public void added() {}"); + assertEquals(TaskOutcome.SUCCESS, runner("check").build().task(":japicmp").getOutcome()); + assertEquals(TaskOutcome.UP_TO_DATE, runner("check").build().task(":japicmp").getOutcome()); + + current("public void added() {}"); + assertEquals(TaskOutcome.FAILED, runner("check").buildAndFail().task(":japicmp").getOutcome()); + assertTrue(report().contains("retained()"), report()); + } + + @Test + void selectsLatestStableReleaseAndAllowsPinnedBaseline() throws IOException { + publish("1.0.0", ""); + publish("1.1.0", "public void stable() {}"); + publish("2.0.0-rc1", "public void prerelease() {}"); + current(""); + + runner("check").buildAndFail(); + assertTrue(report().contains("stable()"), report()); + assertFalse(report().contains("prerelease()"), report()); + assertEquals(TaskOutcome.SUCCESS, + runner("check", "-PbaselineVersion=1.0.0").build().task(":japicmp").getOutcome()); + } + + @Test + void newlyPublishedBaselineInvalidatesPreviousResult() throws IOException { + publish("1.0.0", ""); + current(""); + runner("check").build(); + + publish("1.1.0", "public void newlyPublished() {}"); + + assertEquals(TaskOutcome.FAILED, runner("check").buildAndFail().task(":japicmp").getOutcome()); + assertTrue(report().contains("newlyPublished()"), report()); + } + + @Test + void missingBaselineFailsUnlessFirstReleaseIsExplicit() throws IOException { + current(""); + assertTrue(runner("check").buildAndFail().getOutput().contains("io.spiffe:api")); + assertTrue(runner("check", "-PbaselineVersion=1.2.3").buildAndFail() + .getOutput().contains("io.spiffe:api:1.2.3")); + + BuildResult firstRelease = runner("check", "-PbaselineVersion=none").build(); + assertEquals(TaskOutcome.SKIPPED, firstRelease.task(":japicmp").getOutcome()); + assertTrue(firstRelease.getOutput().contains("No binary compatibility baseline")); + } + + @Test + void memberExclusionDoesNotHideOtherBreaksInTheClass() throws IOException { + publish("1.0.0", "public void accepted() {} public void retained() {}"); + Files.writeString(projectDir.resolve("build.gradle"), + "\ntasks.named('japicmp') { methodExcludes.add('io.spiffe.Api#accepted()') }\n", + java.nio.file.StandardOpenOption.APPEND); + current("public void retained() {}"); + runner("check").build(); + + current(""); + runner("check").buildAndFail(); + assertTrue(report().contains("retained()"), report()); + assertFalse(report().contains("accepted()"), report()); + } + + @Test + void detectsSourceCompatibleBuilderReturnTypeChange() throws IOException { + publish("1.0.0", "public static OldBuilder builder() { return new OldBuilder(); } " + + "public static class OldBuilder { public Api build() { return new Api(); } }"); + current("public static Builder builder() { return new Builder(); } " + + "public static class Builder { public Api build() { return new Api(); } }"); + + runner("check").buildAndFail(); + assertTrue(report().contains("builder()"), report()); + assertTrue(report().contains("io.spiffe.Api$OldBuilder"), report()); + } + + @Test + void detectsPublishedBuilderRegressionFrom0814To0815() throws IOException { + write("settings.gradle", "rootProject.name = 'java-spiffe-core'\n"); + Files.writeString(projectDir.resolve("build.gradle"), + "\nrepositories { mavenCentral() }\n" + + "def released = configurations.detachedConfiguration(" + + "dependencies.create('io.spiffe:java-spiffe-core:0.8.15'))\n" + + "released.resolutionStrategy.useGlobalDependencySubstitutionRules = false\n" + + "tasks.named('japicmp') {\n" + + " newArchives.setFrom(released.incoming.artifactView {\n" + + " componentFilter { id -> id instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier" + + " && id.group == 'io.spiffe' && id.module == 'java-spiffe-core' }\n" + + " }.files)\n" + + " newClasspath.setFrom(released)\n" + + "}\n", + java.nio.file.StandardOpenOption.APPEND); + + BuildResult result = runner("check", "-PbaselineVersion=0.8.14").buildAndFail(); + + assertEquals(TaskOutcome.FAILED, result.task(":japicmp").getOutcome()); + assertTrue(report().contains("DefaultX509Source$X509SourceOptions$X509SourceOptionsBuilder"), report()); + assertTrue(report().contains("builder()"), report()); + } + + @Test + void checksProtectedMembersButExcludesInternalAndGeneratedPackages() throws IOException { + publish("1.0.0", "protected void retained() {}", + "io/spiffe/workloadapi/internal/Hidden.java", "package io.spiffe.workloadapi.internal; " + + "public class Hidden {}", + "io/spiffe/workloadapi/grpc/Generated.java", "package io.spiffe.workloadapi.grpc; " + + "public class Generated {}"); + current("protected void retained() {}"); + runner("check").build(); + + current(""); + runner("check").buildAndFail(); + assertTrue(report().contains("retained()"), report()); + assertFalse(report().contains("Hidden"), report()); + assertFalse(report().contains("Generated"), report()); + } + + private void current(String members) throws IOException { + write("src/main/java/io/spiffe/Api.java", "package io.spiffe; public class Api { " + members + " }"); + } + + private void publish(String version, String members, String... extraSources) throws IOException { + Path staging = projectDir.resolve("published-" + version); + Path classes = staging.resolve("classes"); + Files.createDirectories(classes); + List compilerArgs = new ArrayList<>(Arrays.asList("--release", "8", "-d", classes.toString())); + Path source = staging.resolve("io/spiffe/Api.java"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "package io.spiffe; public class Api { " + members + " }"); + compilerArgs.add(source.toString()); + for (int i = 0; i < extraSources.length; i += 2) { + Path extra = staging.resolve(extraSources[i]); + Files.createDirectories(extra.getParent()); + Files.writeString(extra, extraSources[i + 1]); + compilerArgs.add(extra.toString()); + } + assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, + compilerArgs.toArray(new String[0]))); + + Path artifactDir = projectDir.resolve("repo/io/spiffe/api/" + version); + Files.createDirectories(artifactDir); + try (JarOutputStream jar = new JarOutputStream( + Files.newOutputStream(artifactDir.resolve("api-" + version + ".jar"))); + Stream files = Files.walk(classes)) { + for (Path file : (Iterable) files.filter(Files::isRegularFile)::iterator) { + jar.putNextEntry(new JarEntry(classes.relativize(file).toString().replace('\\', '/'))); + Files.copy(file, jar); + jar.closeEntry(); + } + } + Files.writeString(artifactDir.resolve("api-" + version + ".pom"), + "4.0.0io.spiffe" + + "api" + version + ""); + versions.add(version); + write("repo/io/spiffe/api/maven-metadata.xml", + "io.spiffeapi" + + "" + version + "" + version + "" + + versions.stream().map(v -> "" + v + "") + .collect(java.util.stream.Collectors.joining()) + + ""); + } + + private GradleRunner runner(String... arguments) { + List args = new ArrayList<>(Arrays.asList(arguments)); + args.add("--stacktrace"); + args.add("--console=plain"); + return GradleRunner.create().withProjectDir(projectDir.toFile()).withArguments(args); + } + + private String report() throws IOException { + return Files.readString(projectDir.resolve("build/reports/japicmp/japicmp.txt")); + } + + private void write(String path, String contents) throws IOException { + Path file = projectDir.resolve(path); + Files.createDirectories(file.getParent()); + Files.writeString(file, contents); + } +}