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
79 changes: 79 additions & 0 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,47 @@ jobs:
setup-java-Linux-x64-maven-
- name: Download all dependencies
run: ./mvnw --batch-mode -Pci dependency:go-offline -DincludeScope=test -U
# First of two plugin-resolves-for-itself misses, same class, different plugin. go-offline does
# not fetch the platform protoc / protoc-gen-grpc-java EXECUTABLES that protobuf-maven-plugin
# resolves at generate time, so left cold every downstream job fetches them from Central and
# inherits the Azure West US CDN route lottery in
# docs/solutions/build-errors/maven-central-timeout-azure-west-regions-2026-04-21.md - a 240s
# hang, then a hard NullPointerException out of Aether rather than a retry. Measured on
# astubbs/parallel-consumer#293: in one run "Unit Tests" fetched protoc in 272ms while
# "Integration Tests" hung the full 240s on the identical URL, and it failed 2 of 6 consecutive
# runs. The classifier is linux-x86_64 because this job declares ubuntu-latest. (astubbs#242)
- name: Warm plugin-resolved protoc binaries
run: |
set -euo pipefail
evaluate() {
./mvnw -q --batch-mode -pl parallel-consumer-proxy-protocol \
-Dexpression="$1" -DforceStdout \
org.apache.maven.plugins:maven-help-plugin:3.5.2:evaluate 2>/dev/null | tail -1
}
# A renamed property makes help:evaluate print "null object or invalid expression", which
# dependency:get would happily turn into a warm of the wrong coordinate - warming nothing is
# indistinguishable from a cache hit until it fails four jobs downstream, so require a
# version-shaped answer rather than merely a non-empty one.
require_version() {
case "$2" in
[0-9]*) : ;;
*) echo "::error::$1 did not resolve to a version from parallel-consumer-proxy-protocol/pom.xml (got '$2') - the protoc cache warm would be a silent no-op"; exit 1 ;;
esac
}
protoc_version=$(evaluate proxy.protoc)
grpc_version=$(evaluate grpc.version)
require_version proxy.protoc "$protoc_version"
require_version grpc.version "$grpc_version"
get() {
./mvnw --batch-mode -ntp \
org.apache.maven.plugins:maven-dependency-plugin:3.11.0:get -Dartifact="$1"
}
get "com.google.protobuf:protoc:${protoc_version}:exe:linux-x86_64"
get "io.grpc:protoc-gen-grpc-java:${grpc_version}:exe:linux-x86_64"
# Assert the binaries are on disk: the exit code says the goal ran, only the file says the
# cache this job exists to populate actually got populated.
test -s ~/.m2/repository/com/google/protobuf/protoc/"${protoc_version}"/protoc-"${protoc_version}"-linux-x86_64.exe
test -s ~/.m2/repository/io/grpc/protoc-gen-grpc-java/"${grpc_version}"/protoc-gen-grpc-java-"${grpc_version}"-linux-x86_64.exe
# go-offline resolves declared dependencies and plugin JARs, but NOT what a plugin resolves
# FOR ITSELF at execution time - and scala-maven-plugin fetches the Scala compiler and the
# zinc compiler-bridge sources jar that way, so neither is declared anywhere and both stay
Expand Down Expand Up @@ -270,6 +311,44 @@ jobs:
- name: Validate release-documentation data structure
run: bin/check-docs-data.sh

# Protocol freeze gate: the proxy wire schema may only GAIN, never change (the language-proxy
# plan's freeze, astubbs#242). `buf breaking` in the FILE category - configured in
# parallel-consumer-proxy-protocol/buf.yaml, FILE because WIRE would tolerate source-breaking
# renames the compatibility promise forbids - compares the PR's schema against the frozen one on
# origin/master. bin/check-proto-breaking.sh passes with a notice while master has no frozen
# schema yet, so the job arms itself the moment the freeze lands.
#
# It also carries the schema's STATIC ANALYSIS - `buf lint`, via bin/check-proto-lint.sh - because
# the two share a toolchain install and a checkout, and splitting them would buy a second job for
# one binary. The lint runs FIRST: a schema that does not lint is a schema whose breaking-change
# comparison is answering a question nobody asked yet. The protocol earns its own analysis because
# it is the artifact all eleven clients will be generated from - one defect there arrives in every
# language at once.
proto-breaking:
if: github.event_name == 'pull_request'
name: "proto: breaking"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # the baseline is origin/master, which a shallow clone lacks
- name: Install buf
uses: bufbuild/buf-action@v1
with:
setup_only: true
- name: Lint the schema (buf lint)
run: bin/check-proto-lint.sh
# Self-test first, the same order as the docs-data and copyright scanners: this gate's
# ability to say no was demonstrated once, by hand, and never since - so the cases that pin
# it (a deletion, a renumber, a rename, and a renamed module that must not re-enter the
# "nothing frozen yet" branch) run here, where a regression is visible. It mutates the schema
# one way at a time and restores it, so the gate below still sees an untouched tree.
- name: Self-test the gate (prove it can still say no)
run: bash bin/test-check-proto-breaking.sh
- name: Enforce the frozen schema (additions only)
run: bin/check-proto-breaking.sh

# Experimental Kafka 4.x compatibility check. Non-blocking.
# DISABLED pending the Kafka 4.x migration work: the experimental 4.x build currently fails and just adds
# a red X of noise to every PR (it is continue-on-error, so it never gated merges). Re-enable by restoring
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ jobs:
- name: Confirm shellcheck runs, so a shell-lint exit 2 means a real defect
run: shellcheck --version

# Same environment gap, third instance, and the one this lane most needs to close:
# bin/test-check-proto-breaking.sh exits 2 "cannot run" without buf, and the gate it protects
# is the only one in bin/ that reads its inputs into ARRAYS - which is where the bash-3.2
# divergence this whole job exists for actually lives. It shipped with a `mapfile` (bash 4;
# exit 127 on 3.2, a gate reporting neither verdict) and every ubuntu runner passed it. The
# setup action rather than `brew install buf`: it is already pinned in maven.yml's
# `proto: breaking` job, so the two lanes cannot end up on different buf versions.
- name: Install the self-tests' buf dependency
uses: bufbuild/buf-action@v1
with:
setup_only: true

- name: Confirm buf runs, so a proto-breaking exit 2 means a real defect
run: buf --version

- name: Pin bash to the system 3.2
run: |
mkdir -p "$RUNNER_TEMP/bash32"
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ bin/performance-test.sh # performance tests (substantial hardware)
| `parallel-consumer-vertx` | Vert.x integration for async HTTP |
| `parallel-consumer-reactor` | Project Reactor integration |
| `parallel-consumer-mutiny` | SmallRye Mutiny integration (Quarkus) |
| `parallel-consumer-proxy-protocol` | The **frozen** v1 wire contract for the language proxy: `proxy.proto`, its specification and client-authoring guide, and the tests and `buf` gates that keep it frozen. The wire may only gain - `bin/check-proto-breaking.sh` enforces it, and that module's `README.md` owns the detail |
| `parallel-consumer-proxy-clients` | Eleven language client modules. **Build scaffolding only today** - toolchain, compile, run, one fixture line; no Kafka and no PC semantics. The eight non-JVM ones are outside the reactor unless `-Dpc.foreignClients` is passed, and an absent toolchain is reported and skipped rather than failing. That directory's `README.md` owns the detail |
| `parallel-consumer-examples` | Example implementations for each module |

Expand Down
134 changes: 134 additions & 0 deletions bin/check-proto-breaking.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# Copyright (C) 2026 Antony Stubbs and contributors
#

# Breaking-change gate for the frozen proxy protocol schema (the language-proxy plan's freeze unit,
# astubbs#242). The plan travels with the work it plans, on astubbs#293 -
# `git show origin/feats/proxy-requirements:docs/plans/2026-08-14-001-feat-language-proxy-plan.md`.
# What the freeze MEANS is owned by the banner at the top of the schema and by
# parallel-consumer-proxy-protocol/docs/protocol-specification.md, both of which are on master with
# this gate.
#
# After the freeze the wire may only GAIN, never change: `buf breaking` in the FILE category
# forbids deleting, renumbering, retyping or renaming anything in proxy.proto. FILE rather than
# WIRE, deliberately: WIRE tolerates source-breaking changes (a renamed field is wire-identical),
# and the compatibility promise here covers the generated APIs ten client languages sit on, not
# only the bytes. The category is configured in parallel-consumer-proxy-protocol/buf.yaml; this
# script only points buf at the right before/after pair.
#
# Compares the working tree's schema against the frozen one on origin/master. Before the freeze
# commit reaches master there is nothing frozen to compare against; the check says so and passes,
# arming itself the moment the frozen schema lands on master.
#
# BOTH SIDES ARE FOUND BY SEARCHING, NOT BY A HARDCODED PATH. The first version looked for the
# proto at one path on master and took its absence as "not frozen yet" - so a post-freeze PR that
# renamed the module, updated the constant and deleted a frozen field re-entered the grace branch
# and passed green. Searching the baseline for the file wherever it lives means a rename cannot
# disarm the gate: only the file being genuinely absent from the baseline can, which is the one
# case the grace branch is for. Anything else - two copies, or a layout the module root cannot be
# derived from - exits 2 rather than passing, because a gate that cannot locate what it guards has
# not run. (The repo's green-without-having-run class:
# docs/solutions/workflow-issues/a-check-that-reports-success-without-having-run.md.)
#
# Overrides, both for the self-test (bin/test-check-proto-breaking.sh):
# PROTO_BREAKING_AGAINST a complete buf input to compare against, replacing the discovery
# below. NOT a bare git ref: buf reads a bare "master" as a PATH and
# fails with 'had no .proto files'. The working forms are a directory
# holding a copy of the module, or the git form
# '.git#ref=<ref>,subdir=parallel-consumer-proxy-protocol'.
# PROTO_BREAKING_BASELINE_REF the ref to search for the frozen schema (default origin/master).
#
# Requires buf (https://buf.build). CI installs it via bufbuild/buf-action; locally it is a
# managed toolchain - this script never downloads anything.
#
# Usage: bin/check-proto-breaking.sh
# Exit codes: 0 = compatible (or nothing frozen to compare against), 1 = breaking change,
# 2 = cannot run (buf missing, no usable baseline ref, or the schema cannot be located).

set -euo pipefail

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

# The schema's path INSIDE its module - the layout, not the module's own name, so the search below
# survives the module being renamed but reports loudly if the layout itself moves.
PROTO_IN_MODULE=src/main/proto/parallelconsumer/proxy/v1/proxy.proto
BASELINE_REF=${PROTO_BREAKING_BASELINE_REF:-origin/master}

# Module roots holding the schema, one per line. `sed -n s///p` rather than `grep -q`: the guard in
# bin/check-shell-sigpipe.sh, and a reader that exits early would take EPIPE under `pipefail`.
module_roots_in_worktree() {
git ls-files | sed -n "s|^\(.*\)/${PROTO_IN_MODULE}\$|\1|p"
}
module_roots_in_ref() {
git ls-tree -r --name-only "$1" | sed -n "s|^\(.*\)/${PROTO_IN_MODULE}\$|\1|p"
}

if ! command -v buf >/dev/null 2>&1; then
echo "check-proto-breaking: buf is not installed, so the frozen schema cannot be verified." >&2
echo "CI installs it via bufbuild/buf-action; locally it is an Ansible-managed toolchain." >&2
exit 2
fi

# NO `mapfile`, and both array reads here used to be one. mapfile is a bash 4 builtin; macOS ships
# bash 3.2, where the line dies with "mapfile: command not found" and this gate exits 127 - a gate
# that has NOT run, reported as neither verdict, which is the exact class its own header argues
# about. ShellCheck passes it clean, so the class now has a bin/check-shell-hazards.sh row rather
# than only this comment. `while read` over a process substitution is portable to 3.2 and reads the
# same.
here=()
while IFS= read -r module_root; do here+=("$module_root"); done < <(module_roots_in_worktree)
if [[ ${#here[@]} -ne 1 ]]; then
echo "check-proto-breaking: expected exactly one tracked ${PROTO_IN_MODULE}, found ${#here[@]}" \
"(${here[*]:-none}); cannot tell which module to check, so this gate has NOT run." >&2
exit 2
fi
MODULE=${here[0]}

AGAINST=${PROTO_BREAKING_AGAINST:-}
if [[ -z "$AGAINST" ]]; then
if ! git rev-parse --verify --quiet "$BASELINE_REF" >/dev/null; then
echo "check-proto-breaking: $BASELINE_REF is not available (shallow clone?); cannot pick a baseline." >&2
exit 2
fi
frozen=()
while IFS= read -r module_root; do frozen+=("$module_root"); done < <(module_roots_in_ref "$BASELINE_REF")
if [[ ${#frozen[@]} -eq 0 ]]; then
echo "check-proto-breaking: $BASELINE_REF carries no ${PROTO_IN_MODULE} at any path -" \
"nothing frozen to compare against; passing. The gate arms when the freeze lands there."
exit 0
fi
if [[ ${#frozen[@]} -gt 1 ]]; then
echo "check-proto-breaking: $BASELINE_REF holds ${#frozen[@]} copies of the schema" \
"(${frozen[*]}); the baseline is ambiguous, so this gate has NOT run." >&2
exit 2
fi
if [[ ${frozen[0]} != "$MODULE" ]]; then
echo "check-proto-breaking: the module moved since $BASELINE_REF (${frozen[0]} -> $MODULE);" \
"comparing against it where it lives - a rename does not disarm the freeze."
fi
AGAINST=".git#ref=${BASELINE_REF},subdir=${frozen[0]}"
fi

echo "check-proto-breaking: buf breaking $MODULE --against $AGAINST"
# The status is captured from buf ITSELF, not read out of `$?` after an `if`: a compound `if` whose
# condition fails and which has no `else` returns 0, so the obvious spelling reports every breaking
# schema as "buf exited 0" and lands in the cannot-run branch below.
status=0
buf breaking "$MODULE" --against "$AGAINST" || status=$?

if [[ $status -eq 0 ]]; then
echo "check-proto-breaking: OK - the schema only gained; nothing frozen was changed."
exit 0
fi

# buf's own codes, mapped onto this gate's contract: 100 is "breaking changes found", and anything
# else is buf refusing to answer the question at all (a baseline it cannot read is the live case -
# a bare ref in PROTO_BREAKING_AGAINST exits 1 with "had no .proto files"). Those are not a verdict
# of compatible OR breaking, and reporting one as the other is how a gate lies in both directions.
if [[ $status -eq 100 ]]; then
exit 1
fi
echo "check-proto-breaking: buf could not complete the comparison (exit $status), so this gate has" \
"NOT run - the schema is neither confirmed compatible nor found breaking." >&2
exit 2
58 changes: 58 additions & 0 deletions bin/check-proto-lint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# Copyright (C) 2026 Antony Stubbs and contributors
#

# Static analysis for the proxy protocol schema itself (astubbs#242). The per-language static-analysis
# policy for the generated clients is a separate document that arrives with the clients it governs, on
# astubbs#293 - `git show origin/feats/proxy-requirements:docs/client-static-analysis.md`.
#
# WHY THE .PROTO IS A STATIC-ANALYSIS TARGET IN ITS OWN RIGHT: it is the one artifact every client
# is generated FROM. A defect here is not one language's bug, it is eleven - and it arrives in each
# of them as generated code nobody reads, wearing whatever shape protoc chose. `buf lint` catches
# the classes no downstream analyser can: an enum whose zero value is not the UNSPECIFIED default
# (so "unset" and "the first real value" become indistinguishable on the wire), a package that
# disagrees with its directory (so generated import paths differ per language), RPC request and
# response types shared between methods, field-presence conventions.
#
# THE RULE SET AND ITS TWO EXCEPTIONS LIVE IN parallel-consumer-proxy-protocol/buf.yaml, with the
# justification written beside them - STANDARD, less RPC_REQUEST_STANDARD_NAME and
# RPC_RESPONSE_STANDARD_NAME, because the single bidirectional stream reuses the session envelopes
# rather than wrapping them. This script does not restate that list; it points buf at the module
# and lets the config be the single authority. Do not add exceptions here.
#
# The schema lints clean today, and that is exactly why this gate exists: the freeze wave achieved
# it and nothing held it. A clean state nobody checks is a clean state with a date on it.
#
# Severity policy: any lint finding fails. There is no warn tier, deliberately - a warn tier on the one
# artifact eleven languages are generated from is a finding nobody reads until it is in eleven languages.
#
# Requires buf (https://buf.build). CI installs it via bufbuild/buf-action; locally it is a managed
# toolchain (mise). This script never downloads anything: the module has no BSR dependencies, so
# the lint is entirely local.
#
# Usage: bin/check-proto-lint.sh
# Exit codes: 0 = clean, 1 = lint findings, 2 = cannot run (buf missing).

set -euo pipefail

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

MODULE=parallel-consumer-proxy-protocol

if ! command -v buf >/dev/null 2>&1; then
echo "check-proto-lint: buf is not installed, so the schema cannot be linted." >&2
echo "CI installs it via bufbuild/buf-action; locally it is a managed toolchain (mise use -g buf)." >&2
exit 2
fi

echo "check-proto-lint: buf lint $MODULE (rules and exceptions from $MODULE/buf.yaml)"
# Normalised to 1 rather than propagated: buf exits 100 on findings, and this script's caller reads
# 2 as "could not run". Letting 100 through would be harmless today and wrong the day someone tests
# for a specific code. Findings go to stderr/stdout uncaptured, so nothing is swallowed.
if ! buf lint "$MODULE"; then
echo "check-proto-lint: FAILED - fix the schema, or justify an exception in $MODULE/buf.yaml" \
"beside the two that are already there. Do not add exceptions to this script." >&2
exit 1
fi
echo "check-proto-lint: OK - the schema every client is generated from lints clean."
Loading
Loading