Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ procedure or a steer. Why, and the gate's exact contract: [`docs/ci.md`](../../d
| `claude.yml` | Answers `@claude` comments, and reviews when asked to - the only route that can raise inline review threads. |
| `copyright.yml` | Checks source headers against the fork policy on every push and PR. |
| `dependency-audit.yml` | Scans the **whole** resolved dependency tree for CVEs - the only place OSS Index is switched on. Per-PR, on demand, and weekly, because a new advisory needs no push to arrive. |
| `maven.yml` | The main build: unit, integration and performance suites, SpotBugs, duplicate detection, PIT, dependency scanning. |
| `maven.yml` | The main build: unit, integration and performance suites, SpotBugs, duplicate detection, PIT, Java API-compatibility, dependency scanning. |
| `mutation-full-sweep.yml` | The whole-repo PIT mutation sweep, on demand - too slow for a PR. Self-hosted high-CPU lane. |
| `pr-checklist.yml` | Makes the PR template binding: the checklist must be present and every box resolved. |
| `pr-highcpu-fast-feedback.yml` | Fast PR feedback on the self-hosted high-CPU runners, including the per-PR chaos tripwire. |
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,49 @@ jobs:
max_increase: 10

# SpotBugs static analysis - finds null derefs, concurrency issues, resource leaks.
# Java API-compatibility gate for the four published modules - the sibling of `proto: breaking`,
# which does the same job for the proxy protocol schema. Reports whether this branch changes the
# surface a downstream caller compiles against, measured against the snapshot publish.yml deploys
# to Maven Central on every green push to master.
#
# NOT a required status check yet, and deliberately: a required context that no run has produced
# blocks every PR whose base predates it (docs/ci.md, and `tooling: package rename` is sitting
# un-armed for exactly this reason). Arming is a follow-up once this job exists on master.
api-compat:
if: github.event_name == 'pull_request'
needs: prepare-deps
name: "api: breaking"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
# Explicit cache/restore with the rotating key from prepare-deps - never
# `setup-java cache: 'maven'`, per docs/ci.md.
- name: Restore Maven cache
uses: actions/cache/restore@v4
with:
path: ~/.m2/repository
key: setup-java-Linux-x64-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
setup-java-Linux-x64-maven-
- name: Build the published modules
run: |
./mvnw --batch-mode -Papi-compat -am \
-pl parallel-consumer-core,parallel-consumer-vertx,parallel-consumer-reactor,parallel-consumer-mutiny \
-Dmaven.test.skip=true -Dmaven.javadoc.skip=true package
# Self-test FIRST, the same order as the proto, docs-data and copyright scanners. This gate's
# ability to say no is not theoretical: three separate misconfigurations while building it each
# produced a green "No changes." while comparing an artifact against itself or a stale jar.
# Those cases are pinned here, where a regression is visible.
- name: Self-test the gate (prove it can still say no)
run: bash bin/test-check-api-breaking.sh
- name: Check API compatibility against the published baseline
run: bin/check-api-breaking.sh

# Uses a baseline from the base branch so only NEW bugs introduced by the PR are
# reported. The baseline is generated by the push build and cached.
spotbugs:
Expand Down
155 changes: 155 additions & 0 deletions bin/check-api-breaking.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
#
# Copyright (C) 2026 Antony Stubbs and contributors
#

# Java API-compatibility gate for the published modules - the sibling of bin/check-proto-breaking.sh,
# which does the same job for the proxy protocol's schema. Same problem, different schema language:
# what a downstream caller compiled against must keep working, and any break must be deliberate.
#
# Compares each published module's built jar against the SAME module last published to Maven Central.
# The baseline is master's snapshot (publish.yml redeploys it on every green push to master), so the
# question this answers today - before any release exists - is "does this branch change the published
# surface?". Point JAPICMP_BASELINE_VERSION at a release once one exists and the question becomes
# "does this release break users?"; both are the same comparison against a different left-hand side.
#
# BEFORE THE FIRST PUBLISH THERE IS NOTHING TO COMPARE AGAINST. The check says so and passes, arming
# itself the moment a baseline exists on the remote - the same grace branch bin/check-proto-breaking.sh
# uses, and for the same reason: a gate that cannot exist yet must not block every PR until it can.
#
# WHY BOTH SIDES ARE RESOLVED TO EXPLICIT FILES, AND WHY THE HASHES ARE COMPARED.
# The published baseline carries the SAME Maven version string as the working tree (both are
# 0.6.0.0-SNAPSHOT). A coordinate-based comparison therefore resolves BOTH sides to the local
# reactor/.m2 artifact, and japicmp cheerfully compares the new jar against itself and prints
# "No changes." - green, forever, whatever you break. Three separate ways of getting this wrong were
# hit while building this script, and every one of them was GREEN:
# 1. <oldVersion> as a coordinate -> compared the new jar to itself
# 2. the rebuild failed (missing -am) -> compared a STALE jar, break invisible
# 3. the rebuild failed (test compile error) -> compared a STALE jar, break invisible
# So this script (a) resolves both sides to paths, (b) refuses to run if they are the same file or
# the same bytes, and (c) refuses to run if the "new" jar is older than its own sources. Each exits 2
# - "cannot run" - which is NOT a pass. The repo's name for this failure class:
# docs/solutions/workflow-issues/a-check-that-reports-success-without-having-run.md
#
# The self-test that proves the gate can still say no is bin/test-check-api-breaking.sh, and CI runs
# it BEFORE this script, the same order as the proto, docs-data and copyright scanners.
#
# Env overrides (the self-test uses them):
# JAPICMP_BASELINE_VERSION baseline Maven version (default: the project's own, i.e. master's snapshot)
# JAPICMP_BASELINE_JAR a jar to use as the baseline, replacing the download entirely
# JAPICMP_MODULES space-separated module list to check
#
# Usage: bin/check-api-breaking.sh
# Exit codes: 0 = compatible (or no baseline published yet), 1 = breaking change,
# 2 = cannot run (no network, jar missing, or both sides resolved to the same artifact).

set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

SNAPSHOT_REPO=https://central.sonatype.com/repository/maven-snapshots
GROUP_PATH=bz/stub/parallelconsumer

# The published modules. parallel-consumer-examples and its children are deploy-skipped (see the
# examples pom's skipPublishing), so they have no published surface and are deliberately absent.
DEFAULT_MODULES="parallel-consumer-core parallel-consumer-vertx parallel-consumer-reactor parallel-consumer-mutiny"
MODULES=${JAPICMP_MODULES:-$DEFAULT_MODULES}

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

die_cannot_run() {
echo "::error::api-compat gate CANNOT RUN: $1" >&2
echo "A gate that cannot compare what it guards has not run, and must not report success." >&2
exit 2
}

project_version=$(./mvnw -q help:evaluate -Dexpression=project.version -DforceStdout 2>/dev/null | tail -1)
[ -n "$project_version" ] || die_cannot_run "could not read project.version"
BASELINE_VERSION=${JAPICMP_BASELINE_VERSION:-$project_version}

echo "Project version: $project_version"
echo "Baseline version: $BASELINE_VERSION"
echo "Modules: $MODULES"
echo

# Resolve the newest published jar for a module, or print nothing when none is published.
resolve_baseline_url() {
local module=$1 meta ts
meta="$SNAPSHOT_REPO/$GROUP_PATH/$module/$BASELINE_VERSION/maven-metadata.xml"
ts=$(curl -sS --fail "$meta" 2>/dev/null | sed -n 's|.*<value>\(.*\)</value>.*|\1|p' | head -1) || true
[ -n "$ts" ] || return 0
echo "$SNAPSHOT_REPO/$GROUP_PATH/$module/$BASELINE_VERSION/$module-$ts.jar"
}

overall=0
checked=0
ungated=0

for module in $MODULES; do
new_jar="$module/target/$module-$project_version.jar"
if [ ! -f "$new_jar" ]; then
die_cannot_run "$module: built jar not found at $new_jar - run a package build first"
fi

# (c) the built jar must not predate its own sources: a stale jar is how a failed rebuild
# turns into a green gate (failure 2 and 3 in the header).
newest_src=$(find "$module/src/main" -name '*.java' -newer "$new_jar" -print -quit 2>/dev/null || true)
if [ -n "$newest_src" ]; then
die_cannot_run "$module: $new_jar is older than $newest_src - the rebuild did not happen"
fi

if [ -n "${JAPICMP_BASELINE_JAR:-}" ]; then
base_jar=$JAPICMP_BASELINE_JAR
else
url=$(resolve_baseline_url "$module")
if [ -z "$url" ]; then
echo " $module: nothing published at $BASELINE_VERSION yet - nothing to compare against."
ungated=$((ungated + 1))
continue
fi
base_jar="$WORK/$module-baseline.jar"
curl -sS --fail -o "$base_jar" "$url" || die_cannot_run "$module: could not download baseline $url"
fi

[ -f "$base_jar" ] || die_cannot_run "$module: baseline jar $base_jar does not exist"

# (a)/(b) the two sides must be genuinely different artifacts.
if [ "$(realpath "$base_jar")" = "$(realpath "$new_jar")" ]; then
die_cannot_run "$module: baseline and new resolved to THE SAME FILE ($new_jar)"
fi

report="$module/target/japicmp/api-breaking.diff"
set +e
./mvnw --batch-mode -Papi-compat -pl "$module" -Dmaven.test.skip=true -Dcopyright.skip=true \
-Djapicmp.baseline.jar="$(realpath "$base_jar")" \
-Djapicmp.new.jar="$(realpath "$new_jar")" \
japicmp:cmp >"$WORK/$module.log" 2>&1
mvn_rc=$?
set -e
[ $mvn_rc -eq 0 ] || { cat "$WORK/$module.log" >&2; die_cannot_run "$module: japicmp itself failed"; }

generated="$module/target/japicmp/default-cli.diff"
[ -f "$generated" ] || die_cannot_run "$module: japicmp produced no report at $generated"
mkdir -p "$(dirname "$report")" && cp "$generated" "$report"

checked=$((checked + 1))

# japicmp marks binary/source-incompatible entries with a leading '!'. Lines starting '---!' are
# removals, '***!' modified classes. Presence of any '!' marker is a break.
if grep -qE '^\s*(---|\+\+\+|\*\*\*)!' "$report"; then
echo " $module: BREAKING CHANGES against $BASELINE_VERSION"
grep -E '^\s*(---|\+\+\+|\*\*\*)!|^Semantic versioning' "$report" | sed 's/^/ /'
overall=1
else
echo " $module: compatible"
fi
done

echo
if [ "$checked" -eq 0 ]; then
echo "No module had a published baseline ($ungated skipped). The gate arms itself once one exists."
exit 0
fi
[ "$overall" -eq 0 ] && echo "All $checked checked module(s) are API-compatible with $BASELINE_VERSION."
exit "$overall"
109 changes: 109 additions & 0 deletions bin/test-check-api-breaking.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
#
# Copyright (C) 2026 Antony Stubbs and contributors
#

# Self-test for bin/check-api-breaking.sh - proves the gate can still SAY NO, and still refuses to
# run when it cannot actually compare. Modelled on bin/test-check-proto-breaking.sh, and here for a
# sharper reason than symmetry: while building the gate, THREE different mistakes each produced a
# confident green "No changes." while comparing nothing at all. Those three are cases 3-5 below.
#
# Each case mutates one thing, runs the gate, asserts the exit code, and restores the tree. CI runs
# this BEFORE the gate itself, so the tree the real check sees is untouched.
#
# Usage: bash bin/test-check-api-breaking.sh
# Exit codes: 0 = the gate behaves; 1 = the gate has regressed.

set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

MODULE=parallel-consumer-core
SRC=$MODULE/src/main/java/bz/stub/parallelconsumer/PollContext.java
WORK=$(mktemp -d)
failures=0

cleanup() {
[ -f "$WORK/PollContext.orig" ] && cp "$WORK/PollContext.orig" "$SRC"
rm -rf "$WORK"
}
trap cleanup EXIT

cp "$SRC" "$WORK/PollContext.orig"

# The exact artifact, not a glob: `$MODULE-*.jar` also matches the -sources and -javadoc jars.
VERSION=$(./mvnw -q help:evaluate -Dexpression=project.version -DforceStdout 2>/dev/null | tail -1)
JAR=$MODULE/target/$MODULE-$VERSION.jar

build() {
./mvnw --batch-mode -Papi-compat -pl "$MODULE" -am -Dmaven.test.skip=true -Dcopyright.skip=true \
-Dmaven.javadoc.skip=true package >"$WORK/build.log" 2>&1 \
|| { echo " build failed - see $WORK/build.log"; return 1; }
}

run_gate() {
set +e
JAPICMP_MODULES=$MODULE JAPICMP_BASELINE_JAR="$1" bash bin/check-api-breaking.sh >"$WORK/gate.log" 2>&1
local rc=$?
set -e
echo "$rc"
}

expect() {
local name=$1 want=$2 got=$3
if [ "$got" = "$want" ]; then
echo " PASS $name (exit $got)"
else
echo " FAIL $name - expected exit $want, got $got"
sed 's/^/ /' "$WORK/gate.log" | tail -15
failures=$((failures + 1))
fi
}

echo "Building the pristine jar to use as this test's baseline..."
build || { echo "cannot self-test: the pristine build does not compile"; exit 1; }
cp "$JAR" "$WORK/baseline.jar"

echo
echo "Case 1: an unchanged tree is compatible"
build && expect "unchanged tree passes" 0 "$(run_gate "$WORK/baseline.jar")"

echo
echo "Case 2: a renamed public method is a break"
sed -i 's/public V value() {/public V valueRenamed() {/' "$SRC"
build && expect "renamed public method is caught" 1 "$(run_gate "$WORK/baseline.jar")"
cp "$WORK/PollContext.orig" "$SRC"

echo
echo "Case 3: a removed public method is a break"
python3 - "$SRC" <<'PY'
import re, sys
p = sys.argv[1]
s = open(p, encoding='utf-8').read()
# drop the whole `public K key() { ... }` accessor
s = re.sub(r'\n public K key\(\) \{.*?\n \}\n', '\n', s, count=1, flags=re.S)
open(p, 'w', encoding='utf-8').write(s)
PY
build && expect "removed public method is caught" 1 "$(run_gate "$WORK/baseline.jar")"
cp "$WORK/PollContext.orig" "$SRC"

echo
echo "Case 4: comparing an artifact against ITSELF must not pass"
# The original bug: baseline and new resolve to the same file, japicmp says "No changes." green.
build
expect "self-comparison refuses to run" 2 "$(run_gate "$(realpath "$JAR")")"

echo
echo "Case 5: a stale jar (a rebuild that never happened) must not pass"
# Touch a source file so it is newer than the jar, WITHOUT rebuilding - exactly the state a failed
# rebuild leaves behind, which twice produced a green gate over an unchanged artifact.
touch "$SRC"
expect "stale jar refuses to run" 2 "$(run_gate "$WORK/baseline.jar")"

echo
if [ "$failures" -eq 0 ]; then
echo "All cases passed - the gate can still say no, and still refuses to run when blind."
exit 0
fi
echo "$failures case(s) FAILED - the gate has regressed."
exit 1
5 changes: 4 additions & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ document. This section is the detail behind it.
the pom's default Kafka version (`bin/ci-unit-test.sh`, `bin/ci-integration-test.sh`,
`bin/performance-test.sh`) for fast feedback, and an experimental Kafka 4.x compatibility check
(`bin/ci-build.sh`). Also carries the seconds-fast Quarantine Audit job, SpotBugs, duplicate
detection, PR-scoped mutation testing (PIT), and dependency vulnerability scanning. Push to
detection, PR-scoped mutation testing (PIT), the `api: breaking` Java API-compatibility gate
(`bin/check-api-breaking.sh`, self-tested by `bin/test-check-api-breaking.sh` - **not** a required
check yet, see [`docs/inflight/ci-api-compatibility-gate.md`](inflight/ci-api-compatibility-gate.md)),
and dependency vulnerability scanning. Push to
master runs a single full `bin/ci-build.sh` on the default Kafka version to gate SNAPSHOT
publishing. All jobs use explicit `cache/restore` with rotating keys from the `prepare-deps`
job - never `setup-java cache: 'maven'`.
Expand Down
33 changes: 33 additions & 0 deletions docs/inflight/ci-api-compatibility-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# `api: breaking` - the Java API-compatibility gate

The gate exists and runs; the **policy around it is still open**. Plan and evidence:
[`docs/plans/2026-08-18-001-ci-java-api-compatibility-gate-plan.md`](../plans/2026-08-18-001-ci-java-api-compatibility-gate-plan.md).

## What is decided

japicmp, wired behind an opt-in `api-compat` Maven profile, driven by `bin/check-api-breaking.sh`
and pinned by `bin/test-check-api-breaking.sh`. Baseline is the `0.6.0.0-SNAPSHOT` on Maven Central
that `publish.yml` redeploys on every green master push. Design copied from
`bin/check-proto-breaking.sh` (astubbs#242): self-arming grace branch, self-test first, exit 2 for
cannot-run.

## What is open, and blocks nothing yet

- **It is not a required status check**, deliberately - a required context no run has produced blocks
every PR whose base predates it. Arming is a follow-up **after** the job is on master. This is the
same state `tooling: package rename` is in.
- **What should happen on red** is undecided: hard block, advisory, or block-unless-recorded. Today
the job fails on a break but gates nothing, so the practical answer is "advisory" by accident
rather than by choice.
- **The public/internal boundary is inherited, not chosen.** Everything `public` outside
`**.internal.**` counts, which sweeps in `.state`, `.offsets` and `.metrics`. Nobody has decided
those are public API; the gate currently asserts they are.

## Trap worth knowing before touching this

The published baseline carries the **same version string** as the working tree, so a coordinate-based
comparison resolves both sides to the local artifact and reports `No changes.` green forever. Two
further variants (a failed rebuild leaving a stale jar) do the same. All three were hit while building
this and all three exited 0. The script refuses to run rather than pass when it cannot tell the two
sides apart; do not "simplify" that away. Class:
[`a-check-that-reports-success-without-having-run.md`](../solutions/workflow-issues/a-check-that-reports-success-without-having-run.md).
Loading
Loading