Skip to content
Merged
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
159 changes: 159 additions & 0 deletions .github/verify-bytecode-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env bash

# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
#
# SPDX-License-Identifier: MIT OR Apache-2.0

# Cross-repo shared script — kept BYTE-IDENTICAL in java-llama.cpp, srcmorph,
# BitcoinAddressFinder and streambuffer (sync any edit to all four, and to the checksum table in
# workspace/crossrepostatus.md). Fails when a built jar contains a class file newer than the Java
# release the artifact claims to support.
#
# Why: `maven.compiler.release` governs only the code WE compile. A dependency compiled for a newer
# Java lands in the jar untouched, and nothing in a normal build objects. The failure surfaces at a
# consumer's JVM as UnsupportedClassVersionError, which is the worst possible place to find it.
# This has happened twice here: checker-qual 4.x (Java 11 bytecode, and its annotations are
# @Retention(RUNTIME), so anything reflecting over an annotated element loads them), and
# logback-classic from 1.4.0 on, whose LogbackServiceProvider SLF4J's ServiceLoader loads at
# startup — a guaranteed crash rather than a latent one.
#
# Scan the BUILT ARTIFACT, not a resolved classpath: an uber jar is what a user actually runs, and
# `dependency:build-classpath` answers a different question (and answers it with an empty file when
# it fails, which reads as a pass).
#
# Usage: verify-bytecode-version.sh --max-major <N> [--allow <pattern>]... <path>...
# --max-major <N> highest class-file major version a consumer JVM may be asked to load.
# 52 = Java 8, 55 = Java 11, 61 = Java 17, 65 = Java 21.
# Pass it from the pipeline so the value lives next to the release it belongs
# to, instead of being duplicated here per repo.
# --allow <pattern> repeatable, optional. Glob matched against "<jar-basename>:<entry-path>", so
# it can waive a whole jar (`--allow 'foo-*.jar:*'`) or a single entry
# (`--allow '*:com/example/Legacy.class'`). Use sparingly and say why in the
# workflow: every entry here is a hole in the guarantee, and a hole nobody
# revisits is how a gate stops gating.
# <path>... jars, and/or directories searched recursively for *.jar.
#
# ALWAYS skipped, not configurable — a classpath JVM never loads these, so a high version in them
# is not a defect and waiving them per-repo would only invite blanket exceptions:
# * module-info.class (any directory) — read only in module mode, and Java 8 has none
# * META-INF/versions/** — multi-release overlays, invisible below their own release
#
# Exit codes: 0 clean · 1 violations found · 2 nothing to scan / bad usage. 2 matters as much as 1:
# a run that scanned no jars must never be reported as a pass.

set -euo pipefail

MAX_MAJOR=""
ALLOW=()
PATHS=()

fail_usage() {
echo "::error::$*" >&2
echo "usage: verify-bytecode-version.sh --max-major <N> [--allow <pattern>]... <path>..." >&2
exit 2
}

while [ $# -gt 0 ]; do
case "$1" in
--max-major) [ $# -ge 2 ] || fail_usage "--max-major needs a value"; MAX_MAJOR="$2"; shift 2 ;;
--allow) [ $# -ge 2 ] || fail_usage "--allow needs a value"; ALLOW+=("$2"); shift 2 ;;
--) shift; while [ $# -gt 0 ]; do PATHS+=("$1"); shift; done ;;
-*) fail_usage "unknown option '$1'" ;;
*) PATHS+=("$1"); shift ;;
esac
done

[ -n "$MAX_MAJOR" ] || fail_usage "--max-major is required"
case "$MAX_MAJOR" in ''|*[!0-9]*) fail_usage "--max-major must be a number, got '$MAX_MAJOR'" ;; esac
[ "${#PATHS[@]}" -gt 0 ] || fail_usage "at least one jar or directory is required"

for p in "${PATHS[@]}"; do
[ -e "$p" ] || fail_usage "path '$p' does not exist"
done

command -v python3 >/dev/null 2>&1 || fail_usage "python3 is required to read class-file headers"

# The scan itself: one pass per jar, reading the 8-byte class-file header of every entry. Kept in
# python because the alternative (unzip -p per entry) spawns a process per class — thousands for a
# fat jar — and because a zip reader must not be reimplemented in shell.
python3 - "$MAX_MAJOR" "${#ALLOW[@]}" "${ALLOW[@]}" "${PATHS[@]}" <<'PYTHON'
import fnmatch, os, sys, zipfile

max_major = int(sys.argv[1])
n_allow = int(sys.argv[2])
allow = sys.argv[3:3 + n_allow]
paths = sys.argv[3 + n_allow:]

jars = []
for p in paths:
if os.path.isdir(p):
for root, _dirs, files in os.walk(p):
jars.extend(os.path.join(root, f) for f in files if f.endswith(".jar"))
elif p.endswith(".jar"):
jars.append(p)
jars = sorted(set(jars))

# An empty scan is a broken measurement, never a pass. A glob that matched nothing, a download step
# that silently produced no artifact, a renamed output directory: all of them yield "0 violations"
# from a scanner that just shrugs, and that is indistinguishable from a clean run.
if not jars:
print(f"::error::no jars found under: {', '.join(paths)} -- refusing to report a pass", file=sys.stderr)
sys.exit(2)

def skipped_always(entry):
# A plain classpath JVM never loads either of these, at any Java level.
return (entry == "module-info.class"
or entry.endswith("/module-info.class")
or entry.startswith("META-INF/versions/"))

violations = []
waived = 0
scanned = 0

for jar in jars:
base = os.path.basename(jar)
try:
zf = zipfile.ZipFile(jar)
except Exception as exc: # noqa: BLE001 - report, do not crash
print(f"::error::cannot read '{jar}': {exc}", file=sys.stderr)
sys.exit(2)
with zf:
for entry in zf.namelist():
if not entry.endswith(".class") or skipped_always(entry):
continue
try:
with zf.open(entry) as handle:
head = handle.read(8)
except Exception: # noqa: BLE001 - unreadable entry
continue
if len(head) < 8 or head[:4] != b"\xca\xfe\xba\xbe":
continue
scanned += 1
major = int.from_bytes(head[6:8], "big")
if major <= max_major:
continue
key = f"{base}:{entry}"
if any(fnmatch.fnmatch(key, pattern) for pattern in allow):
waived += 1
continue
violations.append((base, entry, major))

# One line per offending jar, naming an example entry: a full listing of a fat jar's thousands of
# classes buries the answer, and the jar is the unit somebody acts on.
by_jar = {}
for base, entry, major in violations:
prev = by_jar.get(base)
if prev is None or major > prev[1]:
by_jar[base] = (entry, major)

for base in sorted(by_jar):
entry, major = by_jar[base]
print(f"::error::{base}: class-file major {major} (Java {major - 44}) exceeds the "
f"allowed {max_major} (Java {max_major - 44}) -- e.g. {entry}")

print(f"scanned {scanned} class file(s) in {len(jars)} jar(s); "
f"{len(by_jar)} jar(s) over major {max_major}"
+ (f"; {waived} entr(y/ies) waived by --allow" if waived else ""))

sys.exit(1 if violations else 0)
PYTHON
9 changes: 9 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,15 @@ jobs:
distribution: temurin
- uses: actions/download-artifact@v8
with: { name: jars, path: jars/ }
# Cross-repo shared gate (workspace/crossrepostatus.md checksum table). maven.compiler.release
# governs only the code we compile; a dependency built for a newer Java lands in the jar
# untouched and surfaces as UnsupportedClassVersionError on a consumer's JVM. This repo's
# deliverable is the plain library jar, not a fat jar, so the whole downloaded artifact is
# scanned rather than one uber jar. module-info.class and META-INF/versions/** are skipped
# unconditionally because a classpath JVM never loads them. Runs before the smoke test so an
# unloadable class is reported as such instead of as a mysterious smoke failure.
- name: Verify Java 8 bytecode (no class newer than major 52)
run: .github/verify-bytecode-version.sh --max-major 52 jars
- name: Run packaged-jar smoke test
run: .github/smoke-jar.sh jars 'streambuffer-*.jar'

Expand Down
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ See [`../workspace/policies/ci-test-diagnostics.md`](../workspace/policies/ci-te
See [`../workspace/policies/pit-mutation-testing.md`](../workspace/policies/pit-mutation-testing.md).
Run PIT with the lifecycle prefix — `mvn test-compile org.pitest:pitest-maven:mutationCoverage`.

## Java 8 bytecode floor — what may ship

This artifact targets **Java 8**, so **every class a consumer's JVM can load must be class-file
major 52 or lower**. `maven.compiler.release` governs only the code *we* compile; a dependency
built for a newer Java lands in the jar untouched and surfaces as `UnsupportedClassVersionError`
on a consumer's JVM. This repo is the least exposed of the four siblings — it has no logger and
its deliverable is the plain library jar, not a fat jar — but it runs the same gate so a future
dependency cannot change that quietly.

**The gate: `.github/verify-bytecode-version.sh`.** Kept **byte-identical** across java-llama.cpp /
BitcoinAddressFinder / streambuffer / srcmorph (checksum table in `workspace/crossrepostatus.md`).
It opens every `.class` in every jar it is given and fails on any whose class-file major version
exceeds `--max-major`:

```bash
.github/verify-bytecode-version.sh --max-major 52 [--allow '<jar>:<entry>']... <jar-or-dir>...
```

Paths may be jars or directories (searched recursively for `*.jar`). `module-info.class` and
`META-INF/versions/**` are skipped unconditionally: a classpath JVM never loads either, which is
why the `release 9` `module-info` here is fine. `--allow` is a repeatable glob matched against
`<jar-basename>:<entry-path>` for anything else that must be tolerated. Exit codes: 0 clean,
1 violations, **2 nothing to scan** (an empty input is a failure, never a pass). Wired into the
`smoke-jar` job, ahead of the smoke test itself.

## JPMS Module Descriptor

This repo ships a `module-info.java` compiled in a separate `release 9` execution. Javadoc
Expand Down
Loading