Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<module>/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]`.
Expand Down
28 changes: 28 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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') })

Expand Down Expand Up @@ -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)
}
65 changes: 65 additions & 0 deletions gradle/binary-compatibility.gradle
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions java-spiffe-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading