-
Notifications
You must be signed in to change notification settings - Fork 4
fix(build)!: unbreak main's compile and gate every jar on the Java 8 class-file floor #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
|
Comment on lines
+129
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct class file format parsing:
Major version mapping: 52=Java 8, 55=Java 11, 61=Java 17, 65=Java 21. The calculation This efficient approach reads only the 8-byte header per class rather than spawning unzip per entry—important for fat jars with thousands of classes. |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1633,12 +1633,39 @@ easy to undo by accident: | |
| only in 1.5.x/1.6.x with no backport, so it is not an option either. `slf4j-simple` is six classes | ||
| from the same release train as `slf4j-api`, with no configuration or socket layer for a CVE to | ||
| live in. Configure it with a classpath `simplelogger.properties` or `-Dorg.slf4j.simpleLogger.*`. | ||
| - **`checker.qual.version` (3.55.1) is a separate property from `checker.version` (the build-time | ||
| processor).** checker-qual 4.x is major 55, its annotations are `@Retention(RUNTIME)`, and anything | ||
| reflecting over an annotated element (Jackson does) loads them. `<optional>true</optional>` keeps | ||
| it out of consumers' transitive graph but **not** out of the fat jar — `jar-with-dependencies` | ||
| filters on scope only — so the version pin is what protects the shipped artifact. Never collapse | ||
| the two properties back into one: the processor runs on the CI JDK and must stay current. | ||
| - **`checker-qual` is `provided` scope, not a pinned old version.** Its annotations are major 55 | ||
| from 4.0.0 on and `@Retention(RUNTIME)`, so anything reflecting over an annotated element (Jackson | ||
| does) loads them and a Java 8 JVM throws `UnsupportedClassVersionError`. **Pinning the shipped copy | ||
| to the last Java 8 line (3.55.1) does not work** — that was shipped in #411 and broke `main` | ||
| outright: the Nullness Checker resolves its own qualifiers through javac's symbol table, i.e. the | ||
| *compile classpath*, so a 3.x checker-qual under the 4.x processor fails every build with | ||
| `Could not load type: org.checkerframework.framework.qual.DoesNotUnrefineReceiver`. Processor and | ||
| qualifiers must share a major version. `provided` satisfies both constraints: 4.2.2 on the compile | ||
| classpath where the checker needs it, and excluded from consumers' graph **and** from the fat jar | ||
| (`jar-with-dependencies` takes scope `runtime`), so no checker-qual class of any version ships. | ||
| `<optional>true</optional>` would not have been enough on its own — that descriptor filters on | ||
| scope only. Safe because no source imports `org.checkerframework`. | ||
|
|
||
| **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>... | ||
|
Comment on lines
+1640
to
+1655
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documentation is excellent: This section clearly explains:
This is exactly the kind of documentation that helps prevent the next person from undoing the fix by trying the obvious (but incorrect) alternative. |
||
| ``` | ||
|
|
||
| Paths may be jars or directories (searched recursively for `*.jar`), so one invocation covers a whole | ||
| artifact set — here all 16 classifier jars plus every `all-<os>-<arch>` fat jar. `module-info.class` | ||
| and `META-INF/versions/**` are skipped unconditionally: a classpath JVM never loads either, which is | ||
| why a `release 9` `module-info` 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 — the first version of | ||
| this check reported a clean pass over a directory a failed build had left empty). | ||
|
|
||
| It runs twice: in the `package` job over `llama/target` (every classifier jar plus the default fat | ||
| jar, as early as they exist), and again in `smoke-fatjar-linux` over the downloaded `fatjars/` — | ||
| `package-fatjars` rewrites those zips, and they are the artifacts users actually download. | ||
|
|
||
| **Surefire excludes `org.slf4j:slf4j-simple` from the test classpath** (`classpathDependencyExcludes`). | ||
| Runtime scope is on the test classpath too, and LogCaptor (test scope) requires logback specifically — | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,14 +59,10 @@ SPDX-License-Identifier: MIT | |
| <lombok.version>1.18.46</lombok.version> | ||
| <errorprone.version>2.50.0</errorprone.version> | ||
| <nullaway.version>0.14.0</nullaway.version> | ||
| <!-- Build-time Checker Framework processor. Runs on the CI JDK, never ships, so it | ||
| tracks the newest release. Deliberately NOT the same property as the annotations | ||
| below: those are shipped and must stay Java 8 bytecode. --> | ||
| <!-- Checker Framework: the processor AND the checker-qual qualifiers it resolves. | ||
| Both run on the build JDK and neither ships (checker-qual is provided scope), so | ||
| this tracks the newest release. --> | ||
| <checker.version>4.2.2</checker.version> | ||
| <!-- Shipped checker-qual annotations. Last release whose classes are class-file | ||
| major 52; 4.0.0 moved the line to Java 11. See the dependency for why the pin, | ||
| not just the optional flag, is what protects the artifact. --> | ||
| <checker.qual.version>3.55.1</checker.qual.version> | ||
| <jackson.version>2.22.2</jackson.version> | ||
| <reactor.version>3.8.7</reactor.version> | ||
| <slf4j.version>2.0.18</slf4j.version> | ||
|
|
@@ -190,20 +186,31 @@ SPDX-License-Identifier: MIT | |
| <artifactId>jspecify</artifactId> | ||
| <version>${jspecify.version}</version> | ||
| </dependency> | ||
| <!-- Pinned to the newest Java 8 line on purpose. checker-qual 4.x is Java 11 | ||
| bytecode (class-file major 55) and this artifact targets Java 8; its | ||
| annotations are @Retention(RUNTIME), so anything reflecting over an | ||
| annotated element (Jackson does) loads them and a Java 8 JVM then throws | ||
| UnsatisfiedClassVersionError. Marking it optional keeps it out of | ||
| consumers' transitive graph but NOT out of the fat jar; the | ||
| jar-with-dependencies descriptor filters on scope only, so the version | ||
| pin is the part that actually protects the shipped artifact. | ||
| 3.55.1 is the last release whose classes are major 52; the break is at 4.0.0. --> | ||
| <!-- COMPILE-ONLY, and that is the whole point. checker-qual 4.x is Java 11 | ||
| bytecode (class-file major 55) while this artifact targets Java 8, and its | ||
| annotations are @Retention(RUNTIME), so anything reflecting over an annotated | ||
| element (Jackson does) loads them and a Java 8 JVM then throws | ||
| UnsupportedClassVersionError. | ||
|
|
||
| The obvious fix (pinning the shipped copy to the last Java 8 line, 3.55.1) does | ||
| not work and was reverted: the Nullness Checker resolves its own qualifiers | ||
| through javac's symbol table, i.e. the COMPILE CLASSPATH, so a 3.x checker-qual | ||
| under a 4.x processor fails the build outright with "Could not load type: | ||
| org.checkerframework.framework.qual.DoesNotUnrefineReceiver". Processor and | ||
| qualifiers must share a major version. | ||
|
|
||
| provided scope resolves both constraints at once: 4.2.2 is on the compile | ||
| classpath where the checker needs it, and provided is excluded from consumers' | ||
| transitive graph AND from the fat jar (jar-with-dependencies takes scope | ||
| runtime), so no checker-qual class of any version reaches a consumer's JVM. | ||
| Safe because no source in this module imports org.checkerframework: the | ||
| annotations exist for the processor, not for our code. | ||
| .github/verify-bytecode-version.sh enforces the resulting class-file floor. --> | ||
| <dependency> | ||
| <groupId>org.checkerframework</groupId> | ||
| <artifactId>checker-qual</artifactId> | ||
| <version>${checker.qual.version}</version> | ||
| <optional>true</optional> | ||
| <version>${checker.version}</version> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope change is key:
This is strictly better than shipping an old 3.55.1 copy, and it's the only way to keep both processor and qualifiers on the same major version without breaking consumers. |
||
| <scope>provided</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>com.fasterxml.jackson.core</groupId> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent defensive design: Exit code 2 on empty scan prevents a critical failure mode. A failed build step that silently produces no artifacts would otherwise read as a pass. This catches glob-match failures, renamed output directories, and download step regressions.
Per the PR description: the first version of this check reported a clean pass over a directory a failed build had left empty — this guard prevents that exact bug."