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); + } +}