diff --git a/.github/verify-bytecode-version.sh b/.github/verify-bytecode-version.sh new file mode 100755 index 0000000..a7a3954 --- /dev/null +++ b/.github/verify-bytecode-version.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# 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 [--allow ]... ... +# --max-major 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 repeatable, optional. Glob matched against ":", 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. +# ... 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 [--allow ]... ..." >&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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92c82ab..61d6d91 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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' diff --git a/CLAUDE.md b/CLAUDE.md index 2e3c76f..4b6361d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ':']... ... +``` + +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 +`:` 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