diff --git a/.github/actions-config.json b/.github/actions-config.json index 2a56c928f..2fdad1a2a 100644 --- a/.github/actions-config.json +++ b/.github/actions-config.json @@ -29,5 +29,27 @@ "ignore-groups": [ "net.ladenthin" ] + }, + "post-release": { + "$comment": "bernardladenthin/actions java/audit-publication (.github/workflows/post-release.yml): the release key's primary fingerprint, and the class-file floor of each artifact (CLAUDE.md, \"Java 8 bytecode floor\"; first match wins).", + "signing-key": "EA6E1AC27DB005538E971E85ED0D9440BF148ED2", + "bytecode": [ + { + "files": "llama-ffm-*.jar", + "major": 66 + }, + { + "files": "llama-langchain4j-*.jar", + "major": 61 + }, + { + "files": "llama-atmosphere-agent-*.jar", + "major": 65 + }, + { + "files": "*", + "major": 52 + } + ] } } diff --git a/.github/buildcheck/__init__.py b/.github/buildcheck/__init__.py index 7f4e880ef..04a223be5 100644 --- a/.github/buildcheck/__init__.py +++ b/.github/buildcheck/__init__.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT OR Apache-2.0 """This repository's own build checks: the natives list, the patches, the native dependencies, the -HIP offload bundles, the WebUI license texts. Standard library only. +HIP offload bundles, the WebUI license texts, the AAR poms. Standard library only. The checks it shares with its sibling repositories live in bernardladenthin/actions (actionlib), and some of the modules here build on it (natives.py reads the job graph with its workflow parser). diff --git a/.github/buildcheck/aarpoms.py b/.github/buildcheck/aarpoms.py new file mode 100644 index 000000000..a6577fa8a --- /dev/null +++ b/.github/buildcheck/aarpoms.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""Fail when a published Android AAR pom does not mirror the core's compile-scope dependencies. + +The AAR's classes.jar is the core's classes jar, so an Android consumer needs exactly what a Maven +consumer of net.ladenthin:llama gets: the core's compile-scope, non-optional dependencies, at the +same versions -- no more, no less. The AAR poms are written by llama-android/build.gradle.kts, a +Gradle build of its own, so nothing ties them to llama/pom.xml but this check and that build's +parsing. The 6.0.0 poms shipped checker-qual at compile scope although the core keeps it provided +(major 55 classes, CLAUDE.md "Java 8 bytecode floor"): the versions were derived from the core pom, +the scopes came from a hand-kept list. + +Usage (the CLI is .github/verify-aar-poms.py): verify-aar-poms.py ... + +Each AAR pom may carry, besides the mirrored set, the CPU AAR itself (net.ladenthin:llama-android, +type aar), which the OpenCL AAR depends on. Exits non-zero when a pom lacks a mirrored dependency, +carries another one, or carries one at a different version or a scope other than compile. Only the +standard library, so it runs on any runner. +""" + +import os +import re +import sys +import xml.etree.ElementTree as ET + +# The one dependency an AAR pom may add to the mirrored set: the OpenCL AAR plugs into the CPU AAR. +CPU_AAR = ("net.ladenthin", "llama-android") +PROPERTY = re.compile(r"\$\{([^}]+)\}") + + +def _children(element, tag): + """Direct children by local name, so a pom with or without the Maven namespace reads the same.""" + return [c for c in element if c.tag.rsplit("}", 1)[-1] == tag] + + +def _text(element, tag): + found = _children(element, tag) + return found[0].text.strip() if found and found[0].text else None + + +def _properties(root): + props = _children(root, "properties") + return {c.tag.rsplit("}", 1)[-1]: (c.text or "").strip() for c in props[0]} if props else {} + + +def _dependencies(root): + deps = _children(root, "dependencies") + return _children(deps[0], "dependency") if deps else [] + + +def core_compile_dependencies(core_pom, root_pom): + """{(groupId, artifactId): version} of the core's compile-scope, non-optional dependencies. + + A ${property} version is resolved the way llama-android/build.gradle.kts does it: from the core + pom's properties, then from the parent's. + """ + core = ET.parse(core_pom).getroot() + props = _properties(ET.parse(root_pom).getroot()) + props.update(_properties(core)) + result = {} + for dep in _dependencies(core): + if (_text(dep, "scope") or "compile") != "compile" or _text(dep, "optional") == "true": + continue + key = (_text(dep, "groupId"), _text(dep, "artifactId")) + version = _text(dep, "version") + if version is None: + raise ValueError("%s:%s has no in %s" % (key + (core_pom,))) + match = PROPERTY.fullmatch(version) + if match: + if match.group(1) not in props: + raise ValueError("property <%s> of %s:%s is not defined" % ((match.group(1),) + key)) + version = props[match.group(1)] + result[key] = version + return result + + +def published_dependencies(aar_pom): + """[(groupId, artifactId, version, scope, type)] in pom order; scope and type as written.""" + root = ET.parse(aar_pom).getroot() + return [(_text(d, "groupId"), _text(d, "artifactId"), _text(d, "version"), _text(d, "scope"), + _text(d, "type")) for d in _dependencies(root)] + + +def violations(expected, published): + """What is wrong with one AAR pom's dependencies against the core's compile-scope set.""" + problems = [] + seen = set() + for group, artifact, version, scope, kind in published: + key = (group, artifact) + name = "%s:%s" % key + if key in seen: + problems.append("%s is listed twice" % name) + seen.add(key) + if key == CPU_AAR: + if kind != "aar": + problems.append("%s must be aar, not %s" % (name, kind or "jar")) + continue + if key not in expected: + problems.append("%s (scope %s) is not a compile-scope dependency of the core" % (name, scope or "compile")) + continue + if version != expected[key]: + problems.append("%s is %s, the core has %s" % (name, version, expected[key])) + if (scope or "compile") != "compile": + problems.append("%s has scope %s, the core has compile" % (name, scope)) + for key in sorted(set(expected) - seen): + problems.append("%s:%s %s is missing (a compile-scope dependency of the core)" % (key + (expected[key],))) + return problems + + +def main(argv): + if len(argv) < 3: + print("usage: verify-aar-poms.py ...", file=sys.stderr) + return 2 + repo = argv[1] + expected = core_compile_dependencies(os.path.join(repo, "llama", "pom.xml"), os.path.join(repo, "pom.xml")) + print("core compile-scope dependencies: %s" % ", ".join( + "%s:%s:%s" % (k + (v,)) for k, v in sorted(expected.items()))) + failed = 0 + for pom in argv[2:]: + problems = violations(expected, published_dependencies(pom)) + for p in problems: + print("::error file=%s::%s" % (pom, p)) + print("%s: %s" % (pom, "%d problem(s)" % len(problems) if problems else "mirrors the core")) + failed += bool(problems) + return 1 if failed else 0 diff --git a/.github/buildcheck/nativedeps.py b/.github/buildcheck/nativedeps.py index c72b689d8..90075d1ed 100755 --- a/.github/buildcheck/nativedeps.py +++ b/.github/buildcheck/nativedeps.py @@ -32,8 +32,9 @@ or is a module (whose libggml-base lies next to it only after extraction), must carry the run path `$ORIGIN` and nothing else (a build-tree path would point at the CI runner) -- except on Android, where a library carries no run path at all and only a build-tree path fails (see violations()) -- -and the directories in GLIBC_CEILING, built in a manylinux image, may not reference a glibc symbol -version above the floor they promise. Android libraries must also have every LOAD segment 16 KB +and the directories in GLIBC_CEILING, built in a manylinux image or against its sysroot, may not +reference a glibc symbol version above the floor they promise, nor a libstdc++ one (GLIBCXX_*, +CXXABI_*) above LIBSTDCXX_CEILING. Android libraries must also have every LOAD segment 16 KB aligned. That every listed build arrived is merge-native-artifacts.sh's check. @@ -41,6 +42,7 @@ """ import os +import re import struct import sys @@ -81,7 +83,9 @@ def ucrt_forwarder(name): # What each CPU library needed when this check was introduced (5.1.0 plus the RPC backend, # which adds nothing: its sockets are libc/libSystem/WS2_32, all already present). The manylinux_2_28 -# builds (glibc 2.28, before the libpthread/libdl/librt merge of 2.34) name those three separately. +# builds (glibc 2.28, before the libpthread/libdl/librt merge of 2.34) name those three separately; +# s390x is one of them since its build moved to the manylinux_2_28 sysroot (2026-10-25), and its +# single static library needs libpthread and libdl, not librt (measured). # libgomp.so.1 is deliberately absent from the three Linux cpu entries: llama/CMakeLists.txt forces # GGML_OPENMP=OFF there so the jar loads in slim and distroless images, which do not ship it, and # this list is what keeps it that way -- a build that regains the dependency fails `package` instead @@ -92,7 +96,8 @@ def ucrt_forwarder(name): "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld-linux-x86-64.so.2"}, "Linux/aarch64/cpu": {"libdl.so.2", "libpthread.so.0", "librt.so.1", "libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld-linux-aarch64.so.1"}, - "Linux/s390x/cpu": {"libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld64.so.1"}, + "Linux/s390x/cpu": {"libdl.so.2", "libpthread.so.0", "libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", + "libc.so.6", "ld64.so.1"}, "Linux-Android/aarch64/cpu": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, "Linux-Android/x86_64/cpu": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, # Exactly WINDOWS_OS, plus any api-ms-win-crt-* forwarder through ucrt_forwarder() -- see @@ -122,12 +127,27 @@ def ucrt_forwarder(name): # The glibc floor a directory promises (README, "Runtime requirement"): the highest GLIBC_x.y symbol # version any of its libraries references may not exceed it. These are the builds made in a # manylinux_2_28 image (publish.yml: crosscompile-linux-x86_64, crosscompile-linux-aarch64, -# crosscompile-linux-x86_64-cuda); the other Linux builds inherit the floor of the ubuntu runner. +# crosscompile-linux-x86_64-cuda) or cross-compiled against one unpacked as the sysroot +# (build-linux-s390x, .github/manylinux/s390x-toolchain.cmake); the other Linux builds inherit the +# floor of the ubuntu runner. s390x joined on 2026-10-25: its cross build against the ubuntu +# runner's own sysroot had shipped GLIBC_2.38 without any check noticing (CLAUDE.md, "Linux s390x"). GLIBC_CEILING = { "Linux/x86_64/cpu": (2, 28), "Linux/aarch64/cpu": (2, 28), "Linux/x86_64/cuda13": (2, 28), + "Linux/s390x/cpu": (2, 28), } +# The libstdc++ half of the same floor, for the same directories: the system libstdc++.so.6 of +# manylinux_2_28 is GCC 8's (GLIBCXX_3.4.25, CXXABI_1.3.11), and gcc-toolset-14 links whatever its +# GCC 14 headers need beyond that from libstdc++_nonshared.a. A version above it means the library +# was linked against a newer libstdc++ and fails to load on RHEL 8 even when its glibc symbols fit +# (the s390x jar of 6.0.0 needed GLIBCXX_3.4.32 and CXXABI_1.3.13). Measured on the CI artifacts of +# 2026-10-25: x86-64 and aarch64 reference 3.4.25 / 1.3.11 exactly, the CUDA module 3.4.21 / 1.3.9. +LIBSTDCXX_CEILING = {"GLIBCXX": (3, 4, 25), "CXXABI": (1, 3, 11)} +# The symbol versions elf_info() reports, by family. libstdc++ on s390x (and ppc64) also defines +# GLIBCXX_LDBL_x / CXXABI_LDBL_x for its second long double ABI; they count as their family. +# Anything else (GLIBC_PRIVATE, CXXABI_TM_1, ...) carries no floor and is skipped. +SYMBOL_VERSION = re.compile(r"(GLIBC|GLIBCXX|CXXABI)_(?:LDBL_)?(\d+(?:\.\d+)*)") # Google Play's 16 KB page-size requirement (Android 15+ targets): every LOAD segment of an # Android library must be aligned to a multiple of it. CMake pins -Wl,-z,max-page-size=16384. @@ -172,10 +192,12 @@ def _cstring(data, start): def elf_info(data): - """DT_NEEDED, the run path (DT_RUNPATH, else DT_RPATH, else None) and the highest GLIBC_x.y - symbol version the library references (a tuple, or None when it references none).""" + """DT_NEEDED, the run path (DT_RUNPATH, else DT_RPATH, else None) and the highest GLIBC_x.y, + GLIBCXX_x.y.z and CXXABI_x.y.z symbol version the library references (`glibc`, `glibcxx`, + `cxxabi`: a tuple each, or None when it references none of that family).""" is64, end, sections = _elf_sections(data) - needed, runpath, rpath, glibc = [], None, None, [] + needed, runpath, rpath = [], None, None + versions = {"GLIBC": [], "GLIBCXX": [], "CXXABI": []} for sh_type, sh_offset, sh_size, sh_link, sh_info in sections: strtab = sections[sh_link][1] if sh_link < len(sections) else 0 if sh_type == SHT_DYNAMIC: @@ -197,15 +219,16 @@ def elf_info(data): aux = off + vn_aux for _ in range(vn_cnt): _, _, _, vna_name, vna_next = struct.unpack_from(end + "IHHII", data, aux) - name = _cstring(data, strtab + vna_name) - if name.startswith("GLIBC_"): - glibc.append(tuple(int(p) for p in name[len("GLIBC_"):].split("."))) + match = SYMBOL_VERSION.fullmatch(_cstring(data, strtab + vna_name)) + if match: + versions[match.group(1)].append(tuple(int(p) for p in match.group(2).split("."))) aux += vna_next if vn_next == 0: break off += vn_next + top = {family: max(found) if found else None for family, found in versions.items()} return {"needed": needed, "runpath": runpath if runpath is not None else rpath, - "glibc": max(glibc) if glibc else None} + "glibc": top["GLIBC"], "glibcxx": top["GLIBCXX"], "cxxabi": top["CXXABI"]} def elf_needed(data): @@ -482,15 +505,16 @@ def denied(deps): return [d for d in deps if any(bad in d.lower() for bad in DENIED)] -def glibc_name(version): - return "GLIBC_" + ".".join(str(p) for p in version) +def glibc_name(version, family="GLIBC"): + return family + "_" + ".".join(str(p) for p in version) -def violations(rel, deps, alignments=(), siblings=(), runpath=None, glibc=None): +def violations(rel, deps, alignments=(), siblings=(), runpath=None, glibc=None, glibcxx=None, cxxabi=None): """The violations of one library, `rel` being its path below the natives root (...////); `alignments` are its LOAD segment alignments (checked for Android libraries), `siblings` the names of the other files in its directory, `runpath` - its ELF run path and `glibc` the highest glibc symbol version it references.""" + its ELF run path, `glibc` the highest glibc symbol version it references and `glibcxx` / + `cxxabi` the highest libstdc++ ones.""" failures = [f"{rel} needs {d}, which no consumer can be expected to have" for d in denied(deps)] parts = rel.split("/") key = "/".join(parts[-4:-1]) if len(parts) >= 4 else "" @@ -553,7 +577,14 @@ def violations(rel, deps, alignments=(), siblings=(), runpath=None, glibc=None): ceiling = GLIBC_CEILING.get(key) if ceiling and glibc and glibc > ceiling: failures.append(f"{rel} references {glibc_name(glibc)}, above the {glibc_name(ceiling)} floor its " - f"directory promises -- was it built in the manylinux image?") + f"directory promises -- was it built in (or against) the manylinux image?") + if ceiling: + for family, found in (("GLIBCXX", glibcxx), ("CXXABI", cxxabi)): + top = LIBSTDCXX_CEILING[family] + if found and found > top: + failures.append(f"{rel} references {glibc_name(found, family)}, above the {glibc_name(top, family)} " + f"of manylinux_2_28's libstdc++ -- was it linked against gcc-toolset's libstdc++ " + f"(the system libstdc++.so.6 plus libstdc++_nonshared.a)?") allowed = ALLOWED.get(key) if allowed is None: if key.rsplit("/", 1)[-1] not in CPU_BACKENDS: @@ -579,23 +610,26 @@ def main(argv): checked += 1 rel = os.path.relpath(path, root).replace(os.sep, "/") siblings = {n for n in os.listdir(os.path.dirname(path)) if n != os.path.basename(path)} - alignments, runpath, glibc, note = (), None, None, "" + alignments, runpath, glibc, glibcxx, cxxabi, note = (), None, None, None, None, "" try: if path.endswith(".so"): with open(path, "rb") as f: data = f.read() info = elf_info(data) deps, runpath, glibc = info["needed"], info["runpath"], info["glibc"] + glibcxx, cxxabi = info["glibcxx"], info["cxxabi"] if "/Linux-Android/" in "/" + rel: alignments = elf_load_alignments(data) - note = (f" [runpath={runpath}]" if runpath else "") + (f" [{glibc_name(glibc)}]" if glibc else "") + note = (f" [runpath={runpath}]" if runpath else "") + "".join( + f" [{glibc_name(v, family)}]" for family, v in (("GLIBC", glibc), ("GLIBCXX", glibcxx), + ("CXXABI", cxxabi)) if v) else: deps = dependencies(path) except (ValueError, struct.error) as e: failures.append(f"{rel}: not a readable native library ({e})") continue print(f"{rel}: {' '.join(deps)}{note}") - failures += violations(rel, deps, alignments, siblings, runpath, glibc) + failures += violations(rel, deps, alignments, siblings, runpath, glibc, glibcxx, cxxabi) if os.path.basename(path) in JLLAMA_LIBRARIES: try: names = exports(path) diff --git a/.github/buildcheck/natives.py b/.github/buildcheck/natives.py index 5e563ecac..6e37134a4 100644 --- a/.github/buildcheck/natives.py +++ b/.github/buildcheck/natives.py @@ -43,7 +43,8 @@ KINDS = ("library", "module") # Platforms that get no smoke set: `java -cp` does not apply on Android (the AAR and the emulator -# job do), and no GitHub runner exists for s390x (its C++ suite runs under qemu instead). +# job do), and no GitHub runner exists for s390x (its C++ suite runs under qemu instead, and +# smoke-glibc-floor loads its jar under qemu on RHEL 8 -- a load, not the model-backed server smoke). SMOKE_EXCLUDED_OSES = ("Linux-Android",) # The job that re-uploads the Windows natives artifacts Authenticode-signed (CLAUDE.md, "Code signing"). SIGN_JOB = "sign-windows-natives" @@ -134,6 +135,54 @@ def smoke_set(natives, smoke_target): return [r for r in natives if target(r) == smoke_target] +# The glibc-floor smoke (publish.yml smoke-glibc-floor): every library jar whose directory promises a +# glibc floor (nativedeps.GLIBC_CEILING) is loaded on the oldest distribution of that floor, RHEL 8 +# (glibc 2.28 and GCC 8's libstdc++.so.6), on its own architecture -- s390x under qemu. verify-native- +# deps.py reads the symbol versions; only a load on the old runtime proves the jar starts there, and +# none ran before the s390x jar of 6.0.0 shipped needing glibc 2.38 (CLAUDE.md, "Linux s390x"). `package` +# uploads one set (the classes jar, its dependencies and these jars) as FLOOR_ARTIFACT, and the job has +# one matrix row per floor target, which check_floor holds to the list. +FLOOR_ARTIFACT = "llama-glibc-floor" +FLOOR_JOB = "smoke-glibc-floor" + + +def floor_set(natives): + """The library rows whose directory promises a glibc floor (nativedeps.GLIBC_CEILING), in list order. + Module rows are left out: a GPU module needs its vendor runtime, which an RHEL 8 image does not have.""" + return [r for r in libraries(natives) if r["directory"] in nativedeps.GLIBC_CEILING] + + +def floor_targets(natives): + """The - of every floor_set row, sorted: one smoke-glibc-floor matrix row each.""" + return sorted(target(r) for r in floor_set(natives)) + + +def check_floor(natives, jobs): + """`package` uploads the floor set as FLOOR_ARTIFACT, and FLOOR_JOB downloads it and launches one + matrix row per floor target: a library jar that promises a glibc floor does not ship unloaded on it.""" + targets = floor_targets(natives) + if not targets: + return [] + failures = [] + package = jobs.get("package") + if package is not None and FLOOR_ARTIFACT not in package.uploads(): + failures.append(f"publish.yml: package does not upload {FLOOR_ARTIFACT}, the set {FLOOR_JOB} loads " + f"(package-smoke-sets.sh writes it)") + job = jobs.get(FLOOR_JOB) + if job is None: + return failures + [f"publish.yml has no `{FLOOR_JOB}` job, which loads {', '.join(targets)} on RHEL 8"] + downloads = any(any("actions/download-artifact@" in line for line in step) + and any(line.strip() == f"name: {FLOOR_ARTIFACT}" for line in step) for step in job.steps()) + if not downloads: + failures.append(f"publish.yml {FLOOR_JOB} does not download {FLOOR_ARTIFACT}") + launched = {m.group(1) for m in map(MATRIX_ROW.match, job.lines) if m} + failures += [f"publish.yml {FLOOR_JOB}: no matrix row for {t}, whose jar promises the glibc floor of " + f"nativedeps.GLIBC_CEILING" for t in sorted(set(targets) - launched)] + failures += [f"publish.yml {FLOOR_JOB}: the matrix row {t} is no library jar with a glibc floor " + f"(nativedeps.GLIBC_CEILING)" for t in sorted(launched - set(targets))] + return failures + + def compare(what, expected, actual): return ([f"{what}: missing {name}" for name in sorted(set(expected) - set(actual))] + [f"{what}: {name} is not in .github/natives.csv" for name in sorted(set(actual) - set(expected))]) @@ -514,6 +563,7 @@ def check(root): + check_pom(natives, read(root, "llama/pom.xml")) + check_platform(natives, read(root, "llama-platform/pom.xml")) + check_workflow(natives, workflow.parse(read(root, ".github/workflows/publish.yml"))) + + check_floor(natives, workflow.parse(read(root, ".github/workflows/publish.yml"))) + check_loader(natives, read(root, "llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java")) + check_cmake(natives, read(root, "llama/CMakeLists.txt")) + check_dependency_allowlist(natives, nativedeps.ALLOWED) diff --git a/.github/buildcheck/postrelease.py b/.github/buildcheck/postrelease.py new file mode 100644 index 000000000..dcbaf1288 --- /dev/null +++ b/.github/buildcheck/postrelease.py @@ -0,0 +1,411 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""The post-release checks of this repository's own artifacts, on the published release files. + +docs/post-release-check.md lists what to verify once a release exists. The checks every repository +of bernardladenthin/actions shares (the run and its jobs, the files against the contract, Maven +Central, the signatures, the provenance attestation, the bytecode floors, the next version) are its +`java/audit-publication` action; this module holds the ones only this repository has. It reads the +files that action downloaded and the checkout of the tag, and takes the artifacts' word for nothing +it can recompute: + + natives (check 8) every natives jar is the one natives.csv declares: one directory, its library, + jllama-build.txt with every provenance key and the row's classifier and kind, + jllama-sbom.json, the Automatic-Module-Name; a module jar holds its module, + the two lists and the BOM only + provenance (check 9) one llama.cpp tag and commit across all jars, the release version, the tag's + commit without -dirty, and patches / patches.sha256 RECOMPUTED from the + patches at the tag -- the one check that ties the binaries to the reviewed + patch set rather than to themselves + source (check 13) the source archive's stamp names the jars' llama.cpp commit and the tag's + patches line for line, an added line of every patch is in the unpacked tree, + and the pack is reproducible (one mtime, owner 0/0, no owner names) + android (check 14) the AAR invariants: minSdk, the CPU AAR's classes.jar is the classes jar minus + module-info and its jni/ libraries are the Android natives jars' bytes, the + OpenCL AAR holds its one module and an empty classes.jar, both carry consumer + rules, both poms mirror the core (aarpoms.py) and the OpenCL one names the + CPU AAR of this version, and every Android ELF is 16 KB aligned + cache (check 3) the tag build ran without the shared compiler cache: no job log shows + USE_CACHE true or a build script reporting sccache ON, and some show it off + +Usage (the CLI is .github/post-release-check.py): post-release-check.py --tag v +--assets [--run ] [--checks natives,provenance,...]. Standard library only. +""" + +import argparse +import hashlib +import io +import os +import re +import subprocess +import sys +import tarfile +import zipfile + +from . import aarpoms, natives, nativedeps + +CHECKS = ("natives", "provenance", "source", "android", "cache") +BUILD_KEYS = ("llama.cpp", "llama.cpp.commit", "backend", "kind", "classifier", "jllama.version", + "jllama.commit", "patches", "patches.sha256", "compiler", "toolkit", "signed") +MODULE_FILES = {"jllama-build.txt", "jllama-files.txt", "jllama-sbom.json"} +ANDROID_ABIS = {"arm64-v8a": "cpu-android-aarch64", "x86_64": "cpu-android-x86-64"} +MIN_SDK = "28" + + +class Result: + def __init__(self, name): + self.name, self.problems, self.notes = name, [], [] + + def need(self, condition, message): + if not condition: + self.problems.append(message) + return condition + + +def read_rows(root): + with open(os.path.join(root, ".github", "natives.csv"), encoding="utf-8") as stream: + return natives.rows(stream.read()) + + +def _files(archive): + return [n for n in archive.namelist() if not n.endswith("/")] + + +def build_info(text): + """The key=value lines of a jllama-build.txt (comments skipped).""" + out = {} + for line in text.replace("\r", "").splitlines(): + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + out[key] = value + return out + + +def manifest_attribute(text, name): + """One main-section attribute of a MANIFEST.MF, its 72-byte continuation lines unfolded.""" + unfolded = re.sub(r"\r?\n ", "", text) + for line in unfolded.splitlines(): + if line.startswith(name + ":"): + return line[len(name) + 1:].strip() + return None + + +# --- natives ------------------------------------------------------------------------------------ + +def check_natives(assets, rows, version): + """(result, builds): builds maps each classifier to its jllama-build.txt keys.""" + result = Result("natives") + builds = {} + for row in rows: + classifier, directory, library, kind = row["classifier"], row["directory"], row["library"], row["kind"] + path = os.path.join(assets, f"llama-{version}-{classifier}.jar") + if not result.need(os.path.isfile(path), f"{classifier}: llama-{version}-{classifier}.jar is not in the release"): + continue + with zipfile.ZipFile(path) as jar: + prefix = f"net/ladenthin/llama/{directory}/" + files = _files(jar) + stray = [f for f in files if not f.startswith(prefix) and not f.startswith("META-INF/")] + result.need(not stray, f"{classifier}: files outside {prefix}: {stray[:3]}") + inside = {f[len(prefix):] for f in files if f.startswith(prefix)} + result.need(library in inside, f"{classifier}: {prefix}{library} is missing") + result.need("jllama-sbom.json" in inside, f"{classifier}: no jllama-sbom.json") + if kind == "module": + result.need(inside == MODULE_FILES | {library}, + f"{classifier}: a module jar holds {library} and the lists only, found {sorted(inside)}") + if not result.need("jllama-build.txt" in inside, f"{classifier}: no jllama-build.txt"): + continue + build = build_info(jar.read(prefix + "jllama-build.txt").decode("utf-8")) + builds[classifier] = build + missing = [k for k in BUILD_KEYS if not build.get(k)] + result.need(not missing, f"{classifier}: jllama-build.txt lacks {missing}") + for key, expected in (("classifier", classifier), ("kind", kind), ("jllama.version", version)): + result.need(build.get(key) == expected, f"{classifier}: jllama-build.txt says {key}={build.get(key)}, " + f"expected {expected}") + name = manifest_attribute(jar.read("META-INF/MANIFEST.MF").decode("utf-8"), "Automatic-Module-Name") + result.need(name == natives.module_name(classifier), + f"{classifier}: Automatic-Module-Name {name}, expected {natives.module_name(classifier)}") + result.notes.append(f"{classifier}: {len(inside)} files in {directory}") + result.notes.insert(0, f"{len(builds)} of {len(rows)} natives jars read") + return result, builds + + +# --- provenance --------------------------------------------------------------------------------- + +def git(root, *args): + return subprocess.run(["git", "-C", root, *args], capture_output=True, check=True).stdout + + +def patch_set(root, revision="HEAD"): + """[(name, sha256)] of llama/patches/*.patch|*.diff at revision, in name order -- the blobs, not + the working tree, so a checkout's line-end conversion cannot change a hash.""" + names = git(root, "ls-tree", "--name-only", f"{revision}:llama/patches").decode().split() + names = sorted(n for n in names if n.endswith((".patch", ".diff"))) + return [(n, hashlib.sha256(git(root, "show", f"{revision}:llama/patches/{n}")).hexdigest()) for n in names] + + +def patches_sha256(patches): + """jllama-build-info.cmake's hash: SHA-256 over " \\n" per patch.""" + return hashlib.sha256("".join(f"{n} {h}\n" for n, h in patches).encode()).hexdigest() + + +def patch_ids(patches): + return ",".join(re.sub(r"^([0-9]+)-.*", r"\1", n) for n, _ in patches) + + +def check_provenance(builds, version, commit, patches): + result = Result("provenance") + if not result.need(bool(builds), "no jllama-build.txt was read (see natives)"): + return result + for key in ("llama.cpp", "llama.cpp.commit"): + values = {b.get(key) for b in builds.values()} + result.need(len(values) == 1, f"{key} differs between the natives jars: {sorted(map(str, values))}") + expected_hash, expected_ids = patches_sha256(patches), patch_ids(patches) + for classifier, build in sorted(builds.items()): + result.need(build.get("jllama.version") == version, f"{classifier}: jllama.version {build.get('jllama.version')}") + result.need(build.get("jllama.commit") == commit, + f"{classifier}: jllama.commit {build.get('jllama.commit')}, the tag is {commit}") + result.need(build.get("patches") == expected_ids, f"{classifier}: patches={build.get('patches')}, " + f"the tag has {expected_ids}") + result.need(build.get("patches.sha256") == expected_hash, + f"{classifier}: patches.sha256 {build.get('patches.sha256')}, recomputed from the tag {expected_hash}") + first = next(iter(builds.values())) + result.notes.append(f"llama.cpp {first.get('llama.cpp')} ({first.get('llama.cpp.commit')}), {len(patches)} patches, " + f"patches.sha256 {expected_hash[:16]}… recomputed from the tag") + toolkits = sorted({f"{c}: {b.get('toolkit')}" for c, b in builds.items() if b.get("kind") == "module"}) + if toolkits: + result.notes.append("toolkits: " + "; ".join(toolkits)) + signed = sorted({b.get("signed") for b in builds.values()}) + result.notes.append(f"signed={','.join(map(str, signed))} (docs/post-release-check.md, check 16)") + return result + + +# --- the source archive ------------------------------------------------------------------------- + +def added_lines(patch_text): + """{path: [added lines]} of a unified diff, new files and modified files alike.""" + out, current = {}, None + for line in patch_text.splitlines(): + if line.startswith("+++ "): + target = line[4:].split("\t")[0] + current = None if target == "/dev/null" else re.sub(r"^b/", "", target) + if current: + out.setdefault(current, []) + elif current and line.startswith("+") and not line.startswith("+++"): + out[current].append(line[1:]) + return out + + +def check_source(assets, version, builds, patches, patch_text): + """patch_text(name) -> the patch at the tag, as text.""" + result = Result("source") + archives = [n for n in os.listdir(assets) if re.fullmatch(rf"llama\.cpp-.+-jllama-{re.escape(version)}-src\.tar\.gz", n)] + if not result.need(len(archives) == 1, f"expected one source archive, found {archives or 'none'}"): + return result + with tarfile.open(os.path.join(assets, archives[0])) as tar: + members = tar.getmembers() + top = archives[0][:-len(".tar.gz")] + result.need(all(m.name == top or m.name.startswith(top + "/") for m in members), + f"not everything is below {top}/") + mtimes = {m.mtime for m in members} + result.need(len(mtimes) == 1, f"{len(mtimes)} different mtimes (a reproducible pack has one)") + owners = {(m.uid, m.gid, m.uname, m.gname) for m in members} + result.need(owners == {(0, 0, "", "")}, f"owners {sorted(owners)[:3]}, expected numeric 0/0 without names") + files = {m.name[len(top) + 1:]: m for m in members if m.isfile()} + if not result.need(".jllama-patches-applied" in files, "no .jllama-patches-applied stamp"): + return result + stamp = tar.extractfile(files[".jllama-patches-applied"]).read().decode().splitlines() + head = next((line[5:] for line in stamp if line.startswith("head ")), None) + commits = {b.get("llama.cpp.commit") for b in builds.values()} + result.need(commits == {head}, f"the stamp's head {head}, the natives jars say {sorted(map(str, commits))}") + stamped = [tuple(line.split(" ", 1)) for line in stamp if re.match(r"\S+\.(patch|diff) [0-9a-f]{64}$", line)] + result.need(stamped == patches, "the stamp's patches differ from the tag's: " + f"only stamped {sorted(set(stamped) - set(patches))[:3]}, only at the tag {sorted(set(patches) - set(stamped))[:3]}") + # A later patch may rewrite what an earlier one added (0006/0007 rewrite 0001's lines in + # server.cpp), so a file is held to the LAST patch touching it; a patch whose every file is + # touched again later needs one of its added lines in one of its files. + added = [(name, added_lines(patch_text(name))) for name, _ in patches] + last = {path: name for name, paths in added for path in paths} + texts = {} + + def text(path): + if path not in texts: + member = files.get(path) + texts[path] = tar.extractfile(member).read().decode("utf-8", "replace") if member else None + return texts[path] + + for name, paths in added: + wanted = {p: [line.strip() for line in lines if len(line.strip()) >= 12] for p, lines in paths.items()} + wanted = {p: lines for p, lines in wanted.items() if lines} + if not wanted: + continue + for path in wanted: + result.need(text(path) is not None, f"{name}: {path} is not in the archive") + own = [p for p in wanted if last[p] == name and text(p) is not None] + for path in own: + result.need(any(line in text(path) for line in wanted[path]), f"{name}: none of its added lines is in {path}") + if not own: + result.need(any(text(p) and any(line in text(p) for line in lines) for p, lines in wanted.items()), + f"{name}: none of its added lines survives in {sorted(wanted)}") + result.notes.append(f"{archives[0]}: {len(members)} entries, head {head}, {len(stamped)} patches stamped") + return result + + +# --- Android ------------------------------------------------------------------------------------ + +def zip_entries(data): + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return {i.filename: i.CRC for i in archive.infolist() if not i.filename.endswith("/")} + + +def check_android(assets, version, root): + result = Result("android") + cpu_path = os.path.join(assets, f"llama-android-{version}.aar") + opencl_path = os.path.join(assets, f"llama-android-opencl-{version}.aar") + core_path = os.path.join(assets, f"llama-{version}.jar") + for path in (cpu_path, opencl_path, core_path): + if not result.need(os.path.isfile(path), f"{os.path.basename(path)} is not in the release"): + return result + elves = [] + with zipfile.ZipFile(cpu_path) as cpu, zipfile.ZipFile(opencl_path) as opencl: + for name, aar in (("llama-android", cpu), ("llama-android-opencl", opencl)): + manifest = aar.read("AndroidManifest.xml").decode("utf-8") + result.need(f'android:minSdkVersion="{MIN_SDK}"' in manifest, f"{name}: minSdkVersion is not {MIN_SDK}") + result.need("proguard.txt" in aar.namelist() and aar.read("proguard.txt").strip(), + f"{name}: no consumer rules (proguard.txt)") + elves += [(f"{name}.aar!{n}", aar.read(n)) for n in _files(aar) if n.endswith(".so")] + with open(core_path, "rb") as stream: + core = {n: c for n, c in zip_entries(stream.read()).items() if not n.endswith("module-info.class")} + classes = zip_entries(cpu.read("classes.jar")) + core_classes = {n: c for n, c in core.items() if n.endswith(".class")} + aar_classes = {n: c for n, c in classes.items() if n.endswith(".class")} + result.need(aar_classes == core_classes, "llama-android: classes.jar is not the classes jar minus module-info " + f"({len(aar_classes)} classes, the classes jar has {len(core_classes)}; " + f"differing {sorted(set(aar_classes.items()) ^ set(core_classes.items()))[:2]})") + rows = read_rows(root) + for abi, classifier in ANDROID_ABIS.items(): + row = next(r for r in rows if r["classifier"] == classifier) + with zipfile.ZipFile(os.path.join(assets, f"llama-{version}-{classifier}.jar")) as jar: + prefix = f"net/ladenthin/llama/{row['directory']}/" + expected = {n[len(prefix):]: jar.getinfo(n).CRC for n in _files(jar) + if n.startswith(prefix) and n.endswith(".so")} + elves += [(f"{classifier}!{n}", jar.read(prefix + n)) for n in expected] + actual = {n[len(f"jni/{abi}/"):]: cpu.getinfo(n).CRC for n in _files(cpu) if n.startswith(f"jni/{abi}/")} + result.need(actual == expected, f"llama-android jni/{abi}/ differs from {classifier}: " + f"{sorted(set(actual.items()) ^ set(expected.items()))[:3]}") + jni = sorted(n for n in _files(opencl) if n.startswith("jni/")) + result.need(jni == ["jni/arm64-v8a/libggml-opencl.so"], f"llama-android-opencl: jni/ holds {jni}") + result.need(not any(n.endswith(".class") for n in zip_entries(opencl.read("classes.jar"))), + "llama-android-opencl: classes.jar is not empty (duplicating the API breaks D8)") + for name, data in elves: + bad = [a for a in nativedeps.elf_load_alignments(data) if a % nativedeps.ANDROID_PAGE_ALIGNMENT] + result.need(not bad, f"{name}: LOAD alignment {bad} (Google Play needs 16 KB)") + expected = aarpoms.core_compile_dependencies(os.path.join(root, "llama", "pom.xml"), os.path.join(root, "pom.xml")) + for artifact in ("llama-android", "llama-android-opencl"): + pom = os.path.join(assets, f"{artifact}-{version}.pom") + if not result.need(os.path.isfile(pom), f"{artifact}-{version}.pom is not in the release"): + continue + published = aarpoms.published_dependencies(pom) + result.problems += [f"{artifact} pom: {p}" for p in aarpoms.violations(expected, published)] + if artifact == "llama-android-opencl": + cpu_dep = [d for d in published if (d[0], d[1]) == aarpoms.CPU_AAR] + result.need([d[2] for d in cpu_dep] == [version], + f"llama-android-opencl pom: the CPU AAR dependency is {cpu_dep}, expected version {version}") + result.notes.append(f"{len(elves)} Android ELF files 16 KB aligned; {len(core_classes)} classes in the CPU AAR") + return result + + +# --- the cache-free tag build ------------------------------------------------------------------- + +CACHE_ON = re.compile(r"USE_CACHE: true|build\.sh: sccache ON|build\.bat: sccache probe OK") +CACHE_OFF = re.compile(r"USE_CACHE: false|build\.sh: sccache OFF") + + +def check_cache(logs): + """logs: {job name: log text} of the publishing run.""" + result = Result("cache") + off = 0 + for name, text in sorted(logs.items()): + hits = sorted({m.group(0) for m in CACHE_ON.finditer(text)}) + result.need(not hits, f"{name}: {', '.join(hits)}") + off += bool(CACHE_OFF.search(text)) + result.need(off > 0, "no job log shows the cache off -- the evidence is missing, not the cache") + result.notes.append(f"{len(logs)} job logs read, {off} show the cache off, none on") + return result + + +def run_logs(repository, run_id): + import json + jobs, page = [], 1 + while True: + out = subprocess.run(["gh", "api", f"repos/{repository}/actions/runs/{run_id}/jobs?filter=latest&per_page=50&page={page}"], + capture_output=True, text=True, check=True).stdout + batch = json.loads(out)["jobs"] + jobs += batch + if len(batch) < 50: + break + page += 1 + logs = {} + for job in jobs: + if job["conclusion"] == "skipped": + continue + out = subprocess.run(["gh", "api", f"repos/{repository}/actions/jobs/{job['id']}/logs"], + capture_output=True, check=True).stdout + logs[job["name"]] = out.decode("utf-8", "replace") + return logs + + +# --- the command -------------------------------------------------------------------------------- + +def find_run(repository, tag): + import json + out = subprocess.run(["gh", "api", f"repos/{repository}/actions/workflows/publish.yml/runs?branch={tag}&per_page=50"], + capture_output=True, text=True, check=True).stdout + good = [r["id"] for r in json.loads(out)["workflow_runs"] if r["conclusion"] == "success"] + if len(good) != 1: + raise ValueError(f"expected one successful Publish run on {tag}, found {good}") + return good[0] + + +def main(argv): + parser = argparse.ArgumentParser(prog="post-release-check.py", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--root", default=".", help="a checkout of the tag (default: the current directory)") + parser.add_argument("--tag", required=True) + parser.add_argument("--assets", required=True, help="the GitHub release files, downloaded") + parser.add_argument("--run", help="the publishing run (default: the one successful Publish run on the tag)") + parser.add_argument("--checks", default=",".join(CHECKS)) + args = parser.parse_args(argv[1:]) + checks = [c for c in args.checks.split(",") if c] + if set(checks) - set(CHECKS): + parser.error(f"unknown check(s) {sorted(set(checks) - set(CHECKS))}; known: {', '.join(CHECKS)}") + root = os.path.abspath(args.root) + rows = read_rows(root) + version = args.tag[1:] + commit = git(root, "rev-parse", "HEAD").decode().strip() + patches = patch_set(root) + results = [] + natives_result, builds = check_natives(args.assets, rows, version) + if "natives" in checks: + results.append(natives_result) + if "provenance" in checks: + results.append(check_provenance(builds, version, commit, patches)) + if "source" in checks: + results.append(check_source(args.assets, version, builds, patches, + lambda n: git(root, "show", f"HEAD:llama/patches/{n}").decode("utf-8", "replace"))) + if "android" in checks: + results.append(check_android(args.assets, version, root)) + if "cache" in checks: + repository = os.environ.get("GITHUB_REPOSITORY", "bernardladenthin/java-llama.cpp") + results.append(check_cache(run_logs(repository, args.run or find_run(repository, args.tag)))) + failed = 0 + for result in results: + print(f"== {result.name}: {'FAIL' if result.problems else 'ok'}") + for note in result.notes: + print(f" {note}") + for problem in result.problems: + print(f"::error::{result.name}: {problem}") + failed += bool(result.problems) + print(f"post-release checks of {args.tag}: {len(results)} checks, {failed} failed") + return 1 if failed else 0 diff --git a/.github/buildcheck/tests/test_aarpoms.py b/.github/buildcheck/tests/test_aarpoms.py new file mode 100644 index 000000000..10ed2b979 --- /dev/null +++ b/.github/buildcheck/tests/test_aarpoms.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +import os +import tempfile +import unittest + +from buildcheck import aarpoms +from buildcheck.tests.helpers import REPO + +NS = 'xmlns="http://maven.apache.org/POM/4.0.0"' + + +def dep(group, artifact, version=None, scope=None, optional=None, kind=None): + parts = ["%s" % group, "%s" % artifact] + for tag, value in (("version", version), ("type", kind), ("scope", scope), ("optional", optional)): + if value is not None: + parts.append("<%s>%s" % (tag, value, tag)) + return "%s" % "".join(parts) + + +def pom(deps, properties="", ns=NS): + return "%s%s" % ( + ns, properties, "".join(deps)) + + +# The 6.0.0 llama-android-opencl pom's dependencies, as published. +PUBLISHED_600 = [ + ("com.fasterxml.jackson.core", "jackson-databind", "2.22.4", "compile", None), + ("org.slf4j", "slf4j-api", "2.0.20", "compile", None), + ("org.jspecify", "jspecify", "1.0.1", "compile", None), + ("org.checkerframework", "checker-qual", "4.3.1", "compile", None), + ("net.ladenthin", "llama-android", "6.0.0", "compile", "aar"), +] +EXPECTED_600 = { + ("com.fasterxml.jackson.core", "jackson-databind"): "2.22.4", + ("org.slf4j", "slf4j-api"): "2.0.20", + ("org.jspecify", "jspecify"): "1.0.1", +} + + +class AarPomsTest(unittest.TestCase): + + def write(self, folder, name, text): + path = os.path.join(folder, name) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + return path + + def test_core_set_takes_compile_scope_only_and_resolves_properties_core_first(self): + with tempfile.TemporaryDirectory() as d: + root = self.write(d, "root.xml", pom([], "1root")) + core = self.write(d, "core.xml", pom([ + dep("g", "implicit", "${a.version}"), + dep("g", "explicit", "${b.version}", scope="compile"), + dep("g", "literal", "3.0"), + dep("g", "provided", "1", scope="provided"), + dep("g", "runtime", "1", scope="runtime"), + dep("g", "test", "1", scope="test"), + dep("g", "optional", "1", optional="true"), + ], "core")) + self.assertEqual(aarpoms.core_compile_dependencies(core, root), { + ("g", "implicit"): "1", ("g", "explicit"): "core", ("g", "literal"): "3.0"}) + + def test_core_set_fails_loud_on_an_undefined_property_or_a_missing_version(self): + with tempfile.TemporaryDirectory() as d: + root = self.write(d, "root.xml", pom([])) + core = self.write(d, "core.xml", pom([dep("g", "a", "${nowhere}")])) + with self.assertRaisesRegex(ValueError, "nowhere"): + aarpoms.core_compile_dependencies(core, root) + core = self.write(d, "core.xml", pom([dep("g", "a")])) + with self.assertRaisesRegex(ValueError, "no "): + aarpoms.core_compile_dependencies(core, root) + + def test_reads_a_pom_without_the_maven_namespace(self): + with tempfile.TemporaryDirectory() as d: + path = self.write(d, "a.pom", pom([dep("g", "a", "1", scope="compile")], ns="")) + self.assertEqual(aarpoms.published_dependencies(path), [("g", "a", "1", "compile", None)]) + + def test_the_600_pom_fails_on_checker_qual_and_nothing_else(self): + self.assertEqual(aarpoms.violations(EXPECTED_600, PUBLISHED_600), [ + "org.checkerframework:checker-qual (scope compile) is not a compile-scope dependency of the core"]) + + def test_the_fixed_pom_passes_and_the_cpu_aar_dependency_is_allowed(self): + fixed = [d for d in PUBLISHED_600 if d[1] != "checker-qual"] + self.assertEqual(aarpoms.violations(EXPECTED_600, fixed), []) + self.assertEqual(aarpoms.violations(EXPECTED_600, fixed[:3]), []) # the CPU AAR's own pom + + def test_missing_wrong_version_wrong_scope_duplicate_and_a_jar_typed_cpu_aar(self): + published = [ + ("com.fasterxml.jackson.core", "jackson-databind", "2.0", "compile", None), + ("org.slf4j", "slf4j-api", "2.0.20", "runtime", None), + ("org.slf4j", "slf4j-api", "2.0.20", None, None), + ("net.ladenthin", "llama-android", "6.0.0", "compile", None), + ] + self.assertEqual(aarpoms.violations(EXPECTED_600, published), [ + "com.fasterxml.jackson.core:jackson-databind is 2.0, the core has 2.22.4", + "org.slf4j:slf4j-api has scope runtime, the core has compile", + "org.slf4j:slf4j-api is listed twice", + "net.ladenthin:llama-android must be aar, not jar", + "org.jspecify:jspecify 1.0.1 is missing (a compile-scope dependency of the core)", + ]) + + def test_this_repository_core_set(self): + expected = aarpoms.core_compile_dependencies( + os.path.join(REPO, "llama", "pom.xml"), os.path.join(REPO, "pom.xml")) + for key in (("com.fasterxml.jackson.core", "jackson-databind"), ("org.slf4j", "slf4j-api"), + ("org.jspecify", "jspecify")): + self.assertIn(key, expected) + # provided (major 55 classes) and the JVM-only runtime binding stay out of an Android consumer's graph + self.assertNotIn(("org.checkerframework", "checker-qual"), expected) + self.assertNotIn(("org.slf4j", "slf4j-simple"), expected) + for version in expected.values(): + self.assertNotIn("${", version) + + def test_exit_codes(self): + with tempfile.TemporaryDirectory() as d: + os.makedirs(os.path.join(d, "llama")) + self.write(d, "pom.xml", pom([], "1")) + self.write(os.path.join(d, "llama"), "pom.xml", pom([ + dep("g", "a", "${j.version}"), dep("g", "q", "4", scope="provided")])) + good = self.write(d, "good.pom", pom([dep("g", "a", "1", scope="compile")])) + bad = self.write(d, "bad.pom", pom([dep("g", "a", "1", scope="compile"), + dep("g", "q", "4", scope="compile")])) + self.assertEqual(aarpoms.main(["x", d, good]), 0) + self.assertEqual(aarpoms.main(["x", d, good, bad]), 1) + self.assertEqual(aarpoms.main(["x", d]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/buildcheck/tests/test_nativedeps.py b/.github/buildcheck/tests/test_nativedeps.py index cdaa412a8..05991af3d 100644 --- a/.github/buildcheck/tests/test_nativedeps.py +++ b/.github/buildcheck/tests/test_nativedeps.py @@ -155,7 +155,16 @@ def test_elf_run_path_and_glibc_versions(self): self.assertEqual((plain["runpath"], plain["glibc"]), (None, None)) info = nativedeps.elf_info(elf64(["libggml.so", "libc.so.6"], runpath="$ORIGIN", glibc=["GLIBC_2.28", "GLIBC_2.2.5", "GLIBC_2.17"])) - self.assertEqual(info, {"needed": ["libggml.so", "libc.so.6"], "runpath": "$ORIGIN", "glibc": (2, 28)}) + self.assertEqual(info, {"needed": ["libggml.so", "libc.so.6"], "runpath": "$ORIGIN", "glibc": (2, 28), + "glibcxx": None, "cxxabi": None}) + + def test_elf_libstdcxx_versions(self): + # the highest of each family; s390x's long double twins count as their family, and the + # versions without a number carry no floor (a crash on int("PRIVATE") before) + info = nativedeps.elf_info(elf64(["libstdc++.so.6", "libc.so.6"], glibc=[ + "GLIBC_2.17", "GLIBC_PRIVATE", "GLIBCXX_3.4.9", "GLIBCXX_3.4.21", "GLIBCXX_LDBL_3.4.25", + "CXXABI_1.3.9", "CXXABI_TM_1", "CXXABI_LDBL_1.3"])) + self.assertEqual((info["glibc"], info["glibcxx"], info["cxxabi"]), ((2, 17), (3, 4, 25), (1, 3, 9))) def test_elf_load_alignments(self): self.assertEqual(nativedeps.elf_load_alignments(elf64_segments([16384, 65536])), [16384, 65536]) @@ -329,7 +338,32 @@ def test_the_manylinux_directories_keep_their_glibc_floor(self): self.assertIn("GLIBC_2.28", failures[0]) # a build on the ubuntu runner promises no floor self.assertEqual(nativedeps.violations("x/Linux/x86_64/vulkan/libggml-vulkan.so", - ["libggml-base.so", "libc.so.6"], runpath="$ORIGIN", glibc=(2, 39)), []) + ["libggml-base.so", "libc.so.6"], runpath="$ORIGIN", glibc=(2, 39), + glibcxx=(3, 4, 33), cxxabi=(1, 3, 15)), []) + + def test_s390x_keeps_the_manylinux_floor(self): + # what the 6.0.0 jar, cross-built against the ubuntu runner's sysroot, referenced + rel = "x/Linux/s390x/cpu/libjllama.so" + deps = ["libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld64.so.1"] + failures = nativedeps.violations(rel, deps, glibc=(2, 38), glibcxx=(3, 4, 32), cxxabi=(1, 3, 13)) + self.assertEqual(len(failures), 3, failures) + for found, floor in (("GLIBC_2.38", "GLIBC_2.28"), ("GLIBCXX_3.4.32", "GLIBCXX_3.4.25"), + ("CXXABI_1.3.13", "CXXABI_1.3.11")): + self.assertTrue(any(found in f and floor in f for f in failures), (found, failures)) + # the build against the manylinux_2_28 sysroot: glibc 2.28 names libpthread and libdl itself + self.assertEqual(nativedeps.violations(rel, deps + ["libpthread.so.0", "libdl.so.2"], glibc=(2, 27), + glibcxx=(3, 4, 25), cxxabi=(1, 3, 11)), []) + + def test_the_manylinux_directories_keep_their_libstdcxx_floor(self): + for rel in ("x/Linux/x86_64/cpu/libllama.so", "x/Linux/aarch64/cpu/libllama.so"): + self.assertEqual(nativedeps.violations(rel, ["libc.so.6"], glibcxx=(3, 4, 25), cxxabi=(1, 3, 11)), []) + failures = nativedeps.violations(rel, ["libc.so.6"], glibcxx=(3, 4, 26)) + self.assertEqual(len(failures), 1, failures) + self.assertIn("GLIBCXX_3.4.26", failures[0]) + self.assertIn("libstdc++_nonshared.a", failures[0]) + rel = "x/Linux/x86_64/cuda13/libggml-cuda.so" + self.assertEqual(len(nativedeps.violations(rel, ["libggml-base.so", "libc.so.6"], runpath="$ORIGIN", + cxxabi=(1, 3, 12))), 1) def test_gpu_modules_are_held_to_the_denylist_and_must_be_modules(self): rel = "x/Linux/x86_64/cuda13/libggml-cuda.so" diff --git a/.github/buildcheck/tests/test_natives.py b/.github/buildcheck/tests/test_natives.py index 52329958a..2391da96c 100644 --- a/.github/buildcheck/tests/test_natives.py +++ b/.github/buildcheck/tests/test_natives.py @@ -79,6 +79,15 @@ def matrix_jobs(targets, download_name="llama-smoke-${{ matrix.target }}"): ("smoke-macos", "package", download("llama-smoke-macos-aarch64"))])) +def floor_jobs(targets, upload_set=True, download_name="llama-glibc-floor"): + """`package` uploading the glibc-floor set (unless `upload_set` is false) and the floor job as a + matrix over `targets`, downloading `download_name`.""" + rows = "\n".join(f" - {{ target: {t}, runner: ubuntu-24.04 }}" for t in targets) + body = [" strategy:", " matrix:", " include:", rows, " steps:"] + download(download_name) + sets = upload("llama-glibc-floor", "smoke-sets/glibc-floor/") if upload_set else [] + return workflow.parse(workflow_text([("package", None, sets), ("smoke-glibc-floor", "package", body)])) + + class ListTest(unittest.TestCase): def test_rows_skip_comments(self): @@ -243,6 +252,34 @@ def test_a_matrix_launches_its_rows(self): self.assertEqual(natives.check_workflow(ROWS, matrix_jobs(["windows-aarch64"], "llama-smoke-linux-x86-64")), ["publish.yml smoke runs (a job downloading llama-smoke-): missing windows-aarch64"]) + def test_the_floor_set_is_the_library_jars_with_a_glibc_ceiling(self): + # cuda13-linux-x86-64 has a ceiling too, but is a module (it needs a CUDA runtime); the + # library jars of Windows, macOS and Android promise no glibc floor + self.assertEqual([r["classifier"] for r in natives.floor_set(ROWS)], ["cpu-linux-x86-64", "cpu-linux-s390x"]) + self.assertEqual(natives.floor_targets(ROWS), ["linux-s390x", "linux-x86-64"]) + + def test_every_floor_jar_is_loaded_on_rhel8(self): + self.assertEqual(natives.check_floor(ROWS, floor_jobs(["linux-x86-64", "linux-s390x"])), []) + self.assertEqual(natives.check_floor(ROWS, floor_jobs(["linux-x86-64"])), + ["publish.yml smoke-glibc-floor: no matrix row for linux-s390x, whose jar promises the " + "glibc floor of nativedeps.GLIBC_CEILING"]) + self.assertEqual(natives.check_floor(ROWS, floor_jobs(["linux-x86-64", "linux-s390x", "windows-aarch64"])), + ["publish.yml smoke-glibc-floor: the matrix row windows-aarch64 is no library jar with a " + "glibc floor (nativedeps.GLIBC_CEILING)"]) + self.assertEqual(natives.check_floor(ROWS, floor_jobs(["linux-x86-64", "linux-s390x"], upload_set=False)), + ["publish.yml: package does not upload llama-glibc-floor, the set smoke-glibc-floor loads " + "(package-smoke-sets.sh writes it)"]) + self.assertEqual(natives.check_floor(ROWS, floor_jobs(["linux-x86-64", "linux-s390x"], + download_name="llama-smoke-linux-x86-64")), + ["publish.yml smoke-glibc-floor does not download llama-glibc-floor"]) + self.assertEqual(natives.check_floor(ROWS, jobs()), + ["publish.yml: package does not upload llama-glibc-floor, the set smoke-glibc-floor loads " + "(package-smoke-sets.sh writes it)", + "publish.yml has no `smoke-glibc-floor` job, which loads linux-s390x, linux-x86-64 on RHEL 8"]) + # a list without a floor jar needs no such job + macos = [r for r in ROWS if r["directory"].startswith("Mac/")] + self.assertEqual(natives.check_floor(macos, jobs()), []) + def test_a_module_jar_without_a_smoke_target_is_refused(self): rows = natives.rows(CSV + "vulkan-linux-s390x,Linux/s390x/vulkan,libggml-vulkan.so,no,module,no\n") failures = natives.check_workflow(rows, jobs(rows=rows)) diff --git a/.github/buildcheck/tests/test_postrelease.py b/.github/buildcheck/tests/test_postrelease.py new file mode 100644 index 000000000..e792ffa8b --- /dev/null +++ b/.github/buildcheck/tests/test_postrelease.py @@ -0,0 +1,288 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +import hashlib +import io +import os +import struct +import subprocess +import tarfile +import tempfile +import unittest +import zipfile + +from buildcheck import natives, postrelease + +CSV = """# the natives jars +classifier,directory,library,platform,kind,optin +cpu-linux-x86-64,Linux/x86_64/cpu,libjllama.so,yes,library,no +cpu-android-aarch64,Linux-Android/aarch64/cpu,libjllama.so,no,library,no +cpu-android-x86-64,Linux-Android/x86_64/cpu,libjllama.so,no,library,no +vulkan-linux-x86-64,Linux/x86_64/vulkan,libggml-vulkan.so,no,module,no +""" +PATCHES = [("0001-a.patch", "1" * 64), ("0012-b.patch", "2" * 64)] +COMMIT = "c" * 40 +UPSTREAM = "u" * 40 + + +def elf(align): + """A minimal little-endian ELF64 with one PT_LOAD segment of the given alignment.""" + header = b"\x7fELF\x02\x01" + b"\x00" * 26 + struct.pack(" \n" per patch, in name order + manifest = "0001-a.patch " + "1" * 64 + "\n0012-b.patch " + "2" * 64 + "\n" + self.assertEqual(postrelease.patches_sha256(PATCHES), hashlib.sha256(manifest.encode()).hexdigest()) + self.assertEqual(postrelease.patch_ids(PATCHES), "0001,0012") + + def test_the_patch_set_is_read_from_the_blobs_at_the_revision(self): + with tempfile.TemporaryDirectory() as repo: + def git(*args): + subprocess.run(["git", "-C", repo, *args], check=True, capture_output=True) + git("init", "-q") + os.makedirs(os.path.join(repo, "llama", "patches")) + for name, body in (("0002-b.patch", b"b\n"), ("0001-a.patch", b"a\n"), ("README.md", b"x")): + with open(os.path.join(repo, "llama", "patches", name), "wb") as f: + f.write(body) + git("add", ".") + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "x") + with open(os.path.join(repo, "llama", "patches", "0001-a.patch"), "wb") as f: + f.write(b"changed in the working tree\n") + self.assertEqual(postrelease.patch_set(repo), [("0001-a.patch", hashlib.sha256(b"a\n").hexdigest()), + ("0002-b.patch", hashlib.sha256(b"b\n").hexdigest())]) + + +PATCH_A = """--- a/src/x.cpp ++++ b/src/x.cpp +@@ -1,1 +1,2 @@ + int x; ++int jllama_patch_a_marker = 1; +""" +PATCH_B = """--- a/src/x.cpp ++++ b/src/x.cpp +@@ -1,2 +1,2 @@ +-int jllama_patch_a_marker = 1; ++int jllama_patch_b_rewrote_it = 2; +--- /dev/null ++++ b/src/new.h +@@ -0,0 +1 @@ ++#define JLLAMA_NEW_HEADER 1 +""" + + +class SourceTest(Fixture): + def archive(self, stamp, files, owner=(0, 0, "", ""), mtimes=(100,)): + top = "llama.cpp-b100-jllama-1.2.0-src" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + entries = dict(files, **{".jllama-patches-applied": stamp}) + for i, (name, text) in enumerate(entries.items()): + info = tarfile.TarInfo(f"{top}/{name}") + data = text.encode() + info.size, info.mtime = len(data), mtimes[i % len(mtimes)] + info.uid, info.gid, info.uname, info.gname = owner + tar.addfile(info, io.BytesIO(data)) + self.write(top + ".tar.gz", buffer.getvalue()) + + def check(self, patches=PATCHES): + builds = {"cpu-linux-x86-64": {"llama.cpp.commit": UPSTREAM}} + texts = {"0001-a.patch": PATCH_A, "0012-b.patch": PATCH_B} + return postrelease.check_source(self.assets, "1.2.0", builds, patches, texts.get) + + def stamp(self, head=UPSTREAM, patches=PATCHES): + return f"head {head}\n" + "".join(f"{n} {h}\n" for n, h in patches) + "tree abc\n" + + def test_a_reproducible_archive_with_every_patch_in_it_passes(self): + # 0012 rewrote 0001's line: x.cpp is held to 0012, 0001 to "one added line survives" -- which fails + # here, as it must when a later patch removed everything an earlier one added + self.archive(self.stamp(), {"src/x.cpp": "int x;\nint jllama_patch_b_rewrote_it = 2;\n", + "src/new.h": "#define JLLAMA_NEW_HEADER 1\n"}) + self.assertEqual(self.check().problems, + ["0001-a.patch: none of its added lines survives in ['src/x.cpp']"]) + self.archive(self.stamp(), {"src/x.cpp": "int x;\nint jllama_patch_a_marker = 1;\nint jllama_patch_b_rewrote_it = 2;\n", + "src/new.h": "#define JLLAMA_NEW_HEADER 1\n"}) + self.assertEqual(self.check().problems, []) + + def test_stamp_head_stamp_patches_missing_patch_content_and_a_non_reproducible_pack(self): + self.archive(self.stamp(head="z" * 40, patches=PATCHES[:1]), {"src/x.cpp": "int x;\n"}, + owner=(1000, 1000, "runner", "docker"), mtimes=(100, 200)) + problems = self.check().problems + self.assertIn("2 different mtimes (a reproducible pack has one)", problems) + self.assertTrue(any(p.startswith("owners [(1000, 1000, 'runner', 'docker')]") for p in problems)) + self.assertIn(f"the stamp's head {'z' * 40}, the natives jars say ['{UPSTREAM}']", problems) + self.assertTrue(any(p.startswith("the stamp's patches differ from the tag's") for p in problems)) + self.assertIn("0012-b.patch: src/new.h is not in the archive", problems) + self.assertIn("0012-b.patch: none of its added lines is in src/x.cpp", problems) + + +class AndroidTest(Fixture): + def setUp(self): + super().setUp() + with open(os.path.join(self.root, "pom.xml"), "w") as f: + f.write("1") + os.makedirs(os.path.join(self.root, "llama")) + with open(os.path.join(self.root, "llama", "pom.xml"), "w") as f: + f.write("gj" + "${j.version}") + + def release(self, cpu_jni_align=16384, opencl_classes=None, extra_dep="", opencl_cpu_version="1.2.0"): + self.release_natives() + core = {"net/ladenthin/llama/A.class": b"A", "module-info.class": b"M"} + self.write("llama-1.2.0.jar", zipped(core)) + manifest = '' + jni = {} + for abi, classifier in postrelease.ANDROID_ABIS.items(): + row = next(r for r in self.rows if r["classifier"] == classifier) + with zipfile.ZipFile(os.path.join(self.assets, f"llama-1.2.0-{classifier}.jar")) as jar: + prefix = f"net/ladenthin/llama/{row['directory']}/" + for n in jar.namelist(): + if n.startswith(prefix) and n.endswith(".so"): + jni[f"jni/{abi}/{n[len(prefix):]}"] = jar.read(n) + jni["jni/x86_64/libggml-base.so"] = elf(cpu_jni_align) + self.write("llama-android-1.2.0.aar", zipped({"AndroidManifest.xml": manifest, "proguard.txt": "-keep x", + "classes.jar": zipped({"net/ladenthin/llama/A.class": b"A"}), **jni})) + self.write("llama-android-opencl-1.2.0.aar", zipped({ + "AndroidManifest.xml": manifest, "proguard.txt": "-keep x", + "classes.jar": zipped(opencl_classes or {"META-INF/MANIFEST.MF": "x"}), + "jni/arm64-v8a/libggml-opencl.so": elf(16384)})) + dep = "gj1compile" + self.write("llama-android-1.2.0.pom", f"{dep}{extra_dep}") + self.write("llama-android-opencl-1.2.0.pom", f"{dep}{extra_dep}net.ladenthin" + f"llama-android{opencl_cpu_version}aar" + "compile") + + def test_a_correct_pair_passes(self): + self.release() + result = postrelease.check_android(self.assets, "1.2.0", self.root) + self.assertEqual(result.problems, []) + + def test_alignment_jni_bytes_opencl_classes_and_the_poms(self): + checker = ("org.checkerframeworkchecker-qual" + "4compile") + self.release(cpu_jni_align=4096, opencl_classes={"a/A.class": b"A"}, extra_dep=checker, opencl_cpu_version="1.1.0") + problems = postrelease.check_android(self.assets, "1.2.0", self.root).problems + self.assertIn("llama-android.aar!jni/x86_64/libggml-base.so: LOAD alignment [4096] (Google Play needs 16 KB)", problems) + self.assertTrue(any(p.startswith("llama-android jni/x86_64/ differs from cpu-android-x86-64") for p in problems)) + self.assertIn("llama-android-opencl: classes.jar is not empty (duplicating the API breaks D8)", problems) + self.assertIn("llama-android pom: org.checkerframework:checker-qual (scope compile) is not a compile-scope " + "dependency of the core", problems) + self.assertTrue(any("the CPU AAR dependency is" in p and "expected version 1.2.0" in p for p in problems)) + + +class CacheTest(unittest.TestCase): + def test_off_everywhere_passes_and_a_job_with_the_cache_on_fails(self): + logs = {"Build Linux": " USE_CACHE: false\nbuild.sh: sccache OFF, building with -j4\n", "Tests": "ok\n"} + self.assertEqual(postrelease.check_cache(logs).problems, []) + logs["Build Windows"] = " USE_CACHE: true\nbuild.bat: sccache probe OK (wrapped cl.exe).\n" + self.assertEqual(postrelease.check_cache(logs).problems, + ["Build Windows: USE_CACHE: true, build.bat: sccache probe OK"]) + self.assertEqual(postrelease.check_cache({"Tests": "ok"}).problems, + ["no job log shows the cache off -- the evidence is missing, not the cache"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/check-natives.py b/.github/check-natives.py index 3e4b69a45..3057c8d75 100755 --- a/.github/check-natives.py +++ b/.github/check-natives.py @@ -12,6 +12,8 @@ check-natives.py pom print the natives jar executions for llama/pom.xml check-natives.py smoke-targets print the smoke targets (-), one per line check-natives.py smoke-set print the natives jar classifiers of smoke target , one per line + check-natives.py floor-set print the classifiers of the library jars with a glibc floor (the + set smoke-glibc-floor loads on RHEL 8), one per line """ import os @@ -37,6 +39,9 @@ def main(argv): return 2 print("\n".join(r["classifier"] for r in natives.smoke_set(rows, argv[2]))) return 0 + if argv[1:] == ["floor-set"]: + print("\n".join(r["classifier"] for r in natives.floor_set(rows))) + return 0 if argv[1:]: print(__doc__, file=sys.stderr) return 2 diff --git a/.github/manylinux/manylinux_2_28-s390x-sysroot b/.github/manylinux/manylinux_2_28-s390x-sysroot new file mode 100755 index 000000000..084974f92 --- /dev/null +++ b/.github/manylinux/manylinux_2_28-s390x-sysroot @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Unpacks the pypa manylinux_2_28 s390x image as a SYSROOT for the cross build of the +# cpu-linux-s390x natives jar (s390x-toolchain.cmake next to this file): the x86-64 runner compiles +# with clang against the image's headers and libraries, and nothing of the image is executed -- +# the build stays at native speed, and pulling a foreign-architecture image needs no qemu. +# +# Why this image: it is what the other two Linux CPU jars are built in (../dockcross +# manylinux_2_28-x64, manylinux_2_28-aarch64), so s390x gets the same floor by the same means. +# Contents (checked 2026-10-25): AlmaLinux 8.10, glibc 2.28, gcc-toolset-14 (GCC 14.2.1) with the +# system libstdc++ of GCC 8 (GLIBCXX_3.4.25). gcc-toolset's libstdc++.so is a linker script that +# takes the system libstdc++.so.6 plus libstdc++_nonshared.a, which links what GCC 14's headers +# need beyond 3.4.25 statically -- the gcc-toolset treatment, here exactly as in a native build, +# because clang uses the toolset's install directory (--gcc-install-dir) for headers and libraries. +# The previous build used the cross toolchain of the ubuntu runner, whose glibc 2.39 sysroot and +# GCC 13 libstdc++ made the jar need glibc 2.38 and GLIBCXX_3.4.32 (CLAUDE.md, "Linux s390x"). +# +# Pinned by digest, tag 2026.10.03-1 (the tag of the aarch64 image). Bump it deliberately, with a +# Publish run: the image decides the artifact's glibc floor (verify-native-deps.py holds +# Linux/s390x/cpu to GLIBC_2.28) and its C++ runtime. +# +# Only what a compile, a link and the qemu run of the test binary read is unpacked (~300 MB): +# the dynamic loader symlinks, /usr/lib64, /usr/include and gcc-toolset-14's headers and +# compiler directory. The image's absolute symlinks (e.g. /usr/lib/ld64.so.1 -> /usr/lib64/...) +# are rewritten relative so that they resolve inside the sysroot, not on the host. +# +# Usage: .github/manylinux/manylinux_2_28-s390x-sysroot +set -euo pipefail + +IMAGE="quay.io/pypa/manylinux_2_28_s390x@sha256:c64d9390efad901ed09ca959df4bc441468c65478f5750e37197813a6017a49c" +GCC_DIR="opt/rh/gcc-toolset-14/root/usr/lib/gcc/s390x-redhat-linux/14" + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +SYSROOT="$1" +mkdir -p "$SYSROOT" +if [ -n "$(ls -A "$SYSROOT")" ]; then + echo "$0: $SYSROOT is not empty" >&2 + exit 2 +fi + +docker pull --quiet --platform linux/s390x "$IMAGE" >/dev/null +cid="$(docker create --platform linux/s390x "$IMAGE" /bin/true)" +trap 'docker rm "$cid" >/dev/null' EXIT +docker export "$cid" | tar -x --no-same-owner -C "$SYSROOT" \ + lib lib64 usr/lib/ld64.so.1 usr/lib64 usr/include \ + opt/rh/gcc-toolset-14/root/usr/include "$GCC_DIR" +# RHEL ships /usr/lib64 read-only (dr-xr-xr-x). Unpacked by a non-root user (the runner), the +# symlink rewrite below could not write into it ("Permission denied" in the first CI run; a root +# shell ignores the mode) and the job's cleanup could not delete it. +chmod -R u+w "$SYSROOT" + +# Absolute symlinks -> relative ones inside the sysroot. A target outside the unpacked set stays +# dangling, as it would be at an absolute path; only those the build reads matter, checked below. +SYSROOT="$(cd "$SYSROOT" && pwd)" +find "$SYSROOT" -type l -lname '/*' -print0 | while IFS= read -r -d '' link; do + target="$(readlink "$link")" + ln -sfn "$(realpath -m --relative-to="$(dirname "$link")" "$SYSROOT$target")" "$link" +done + +# What the toolchain relies on; a different image layout fails here, not in the middle of a build. +for f in "lib/ld64.so.1" "usr/lib64/libc.so.6" "usr/lib64/libstdc++.so.6.0.25" "usr/lib64/libgcc_s.so.1" \ + "$GCC_DIR/crtbeginS.o" "$GCC_DIR/libstdc++.so" "$GCC_DIR/libstdc++_nonshared.a" \ + "opt/rh/gcc-toolset-14/root/usr/include/c++/14/vector"; do + if [ ! -e "$SYSROOT/$f" ]; then + echo "$0: the sysroot lacks $f (image layout changed?)" >&2 + exit 1 + fi +done +echo "s390x sysroot ready: $SYSROOT ($(du -sh "$SYSROOT" | cut -f1), glibc $(basename "$(readlink "$SYSROOT/usr/lib64/libc.so.6")"))" diff --git a/.github/manylinux/s390x-toolchain.cmake b/.github/manylinux/s390x-toolchain.cmake new file mode 100644 index 000000000..5a850da47 --- /dev/null +++ b/.github/manylinux/s390x-toolchain.cmake @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# CMake toolchain for the cpu-linux-s390x natives jar (publish.yml build-linux-s390x): clang on the +# x86-64 runner cross-compiles for IBM Z against the manylinux_2_28 s390x image unpacked as a sysroot +# by manylinux_2_28-s390x-sysroot, and GNU ld of binutils-s390x-linux-gnu links. The result has the +# floor of the other Linux CPU jars: glibc 2.28, GLIBCXX_3.4.25, CXXABI_1.3.11 (verify-native-deps.py +# holds GLIBC; the README states all three). +# +# JLLAMA_S390X_SYSROOT the unpacked sysroot (cache variable or environment variable, required) +# CMAKE_C_COMPILER / +# CMAKE_CXX_COMPILER clang-18 / clang++-18 by default (Ubuntu 24.04's clang; the runner is +# pinned to ubuntu-24.04 for it) +# +# Why clang and not a GCC: no packaged x86-64 GCC targets s390x with glibc 2.28, and the image's own +# gcc-toolset-14 is an s390x binary, which would have to run under qemu for the whole build (hours, +# and every tag build is uncached). clang is a cross compiler by construction: --gcc-install-dir +# points it at gcc-toolset-14's directory inside the sysroot, so it takes the same crt files, libgcc +# and libstdc++ (GCC 14 headers, the system libstdc++.so.6 plus libstdc++_nonshared.a through the +# toolset's linker script) that a native gcc-toolset build would. The linker scripts name absolute +# paths (/usr/lib64/libstdc++.so.6, /lib64/libgcc_s.so.1), which ld resolves inside --sysroot. +# +# -march=z13 is the arch11 baseline the previous GCC cross build defaulted to (and RHEL 8's): z13 is +# the oldest machine RHEL 8, SLES 15 and Ubuntu support. It does not switch on ggml's vector code: +# that needs __VEC__ (-mzvector), which nothing passes -- the job builds the scalar s390x path, see +# the GGML_NATIVE note in publish.yml. +# +# CMAKE_CROSSCOMPILING_EMULATOR runs the test binaries under qemu-user with the sysroot as the +# loader prefix, so ctest exercises the jar's libraries against glibc 2.28 and the GCC 8 +# libstdc++.so.6 -- the oldest runtime the jar promises, not the build host's. The prefix goes in +# as QEMU_LD_PREFIX, not as qemu's -L: googletest's death tests re-execute the test binary, and that +# child reaches qemu through binfmt_misc without any argument, so only the environment carries the +# prefix to it (with -L the six RpcComm death tests failed on "Could not open '/lib/ld64.so.1'"). + +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR s390x) + +# Cached, so that the reconfigure a build triggers on its own (a changed CMakeLists.txt) still finds +# the sysroot when the environment variable is gone. +if(NOT JLLAMA_S390X_SYSROOT AND DEFINED ENV{JLLAMA_S390X_SYSROOT}) + set(JLLAMA_S390X_SYSROOT "$ENV{JLLAMA_S390X_SYSROOT}" CACHE PATH + "The manylinux_2_28 s390x sysroot (.github/manylinux/manylinux_2_28-s390x-sysroot)") +endif() +if(NOT JLLAMA_S390X_SYSROOT OR NOT EXISTS "${JLLAMA_S390X_SYSROOT}/usr/lib64/libc.so.6") + message(FATAL_ERROR "s390x-toolchain.cmake: set JLLAMA_S390X_SYSROOT to a sysroot unpacked by " + ".github/manylinux/manylinux_2_28-s390x-sysroot (got '${JLLAMA_S390X_SYSROOT}')") +endif() +# try_compile projects re-read this file; hand them the sysroot too. +list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES JLLAMA_S390X_SYSROOT) + +set(CMAKE_SYSROOT "${JLLAMA_S390X_SYSROOT}") +if(NOT CMAKE_C_COMPILER) + set(CMAKE_C_COMPILER clang-18) +endif() +if(NOT CMAKE_CXX_COMPILER) + set(CMAKE_CXX_COMPILER clang++-18) +endif() +set(CMAKE_C_COMPILER_TARGET s390x-linux-gnu) +set(CMAKE_CXX_COMPILER_TARGET s390x-linux-gnu) + +set(_jllama_s390x_gcc "${JLLAMA_S390X_SYSROOT}/opt/rh/gcc-toolset-14/root/usr/lib/gcc/s390x-redhat-linux/14") +set(CMAKE_C_FLAGS_INIT "--gcc-install-dir=${_jllama_s390x_gcc} -march=z13") +set(CMAKE_CXX_FLAGS_INIT "--gcc-install-dir=${_jllama_s390x_gcc} -march=z13") +foreach(_kind EXE SHARED MODULE) + set(CMAKE_${_kind}_LINKER_FLAGS_INIT "--ld-path=/usr/bin/s390x-linux-gnu-ld") +endforeach() + +set(CMAKE_CROSSCOMPILING_EMULATOR /usr/bin/env "QEMU_LD_PREFIX=${JLLAMA_S390X_SYSROOT}" /usr/bin/qemu-s390x-static) + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/.github/package-smoke-sets.sh b/.github/package-smoke-sets.sh index 4263cdf0b..677fd8610 100755 --- a/.github/package-smoke-sets.sh +++ b/.github/package-smoke-sets.sh @@ -11,6 +11,11 @@ # plus the GPU classifiers), with no fat jar in between. The sets are the only thing the smoke jobs # download: the natives jars of the other platforms (ROCm alone is hundreds of MB) stay behind. # +# One more set, /glibc-floor/: the classes jar, its dependencies and every library jar whose +# directory promises a glibc floor (check-natives.py floor-set: today the three Linux CPU jars), which +# the smoke-glibc-floor job loads on RHEL 8, on each jar's own architecture (smoke-glibc-floor.sh). +# Its name is no -, so it never collides with a smoke target. +# # Fail-loud: a natives jar the list names that is not in aborts (the package job's # natives profile builds one per row; a missing one means a row without a build), and so does a # target without any jar. @@ -18,7 +23,7 @@ # Usage: package-smoke-sets.sh # directory holding llama-.jar and the llama--.jar natives jars # the classes jar's runtime dependencies as jars (mvn dependency:copy-dependencies) -# output directory; one subdirectory per smoke target +# output directory; one subdirectory per smoke target, plus glibc-floor/ set -euo pipefail TARGET_DIR="${1:?usage: package-smoke-sets.sh }" @@ -55,3 +60,17 @@ for target in $targets; do find "$set_dir" -maxdepth 1 -name 'llama-*.jar' -exec basename {} \; | sort | sed 's/^/ /' done echo "$(wc -w <<< "$targets") smoke set(s) in $OUT_DIR" + +floor="$(python3 "$CHECK" floor-set)" +[ -n "$floor" ] || fail "check-natives.py floor-set printed no classifier" +floor_dir="$OUT_DIR/glibc-floor" +mkdir -p "$floor_dir" +cp "$classes" "$floor_dir/" +cp "$DEPS_DIR"/*.jar "$floor_dir/" +for classifier in $floor; do + jar="$TARGET_DIR/llama-$version-$classifier.jar" + [ -f "$jar" ] || fail "natives jar missing for the glibc-floor set: $jar" + cp "$jar" "$floor_dir/" +done +echo "glibc-floor set ($(du -sh "$floor_dir" | cut -f1)):" +find "$floor_dir" -maxdepth 1 -name 'llama-*.jar' -exec basename {} \; | sort | sed 's/^/ /' diff --git a/.github/post-release-check.py b/.github/post-release-check.py new file mode 100755 index 000000000..72a18c4e6 --- /dev/null +++ b/.github/post-release-check.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""This repository's own post-release checks of a published release. + +See buildcheck/postrelease.py. Usage: post-release-check.py --tag v --assets [--checks ...] +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from buildcheck import postrelease # noqa: E402 + +if __name__ == "__main__": + sys.exit(postrelease.main(sys.argv)) diff --git a/.github/post-release-consumer.sh b/.github/post-release-consumer.sh new file mode 100755 index 000000000..4b1184292 --- /dev/null +++ b/.github/post-release-consumer.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Post-release check 11 (docs/post-release-check.md): a consumer resolves the published release from +# Maven Central. Nothing in the publish workflow ever resolves the release from Central -- the +# smoke sets are assembled from the build tree -- so the parent pom's property substitutions, the +# scopes a consumer actually gets and the presence of every platform jar are first tried here. +# +# A throwaway consumer pom depends on net.ladenthin:llama-platform (type pom) plus the given extra +# classifiers of net.ladenthin:llama, and is resolved against an EMPTY local repository into +# : the classes jar, its runtime dependencies and the natives jars, flat -- a smoke set, so +# .github/smoke-test-natives.sh and friends run on it unchanged. Asserted on the resolved graph: +# - the classes jar and the library jar of every `platform=yes` row of natives.csv (at the tag); +# - jackson-databind, slf4j-api and jspecify at compile scope, slf4j-simple at runtime; +# - neither logback nor checker-qual anywhere: both break a Java 8 consumer, and only the pom +# scopes keep them out, which no compile-time check sees (6.0.0's AAR poms got this wrong). +# Afterwards the natives jars of other platforms are removed from , so it is the smoke set +# of (-, e.g. linux-x86-64) that the smoke scripts expect: one library jar. +# +# Usage: post-release-consumer.sh [extra classifier...] +set -euo pipefail + +USAGE="usage: post-release-consumer.sh [classifier...]" +VERSION="${1:?$USAGE}" +NATIVES_CSV="${2:?$USAGE}" +SET_DIR="${3:?$USAGE}" +TARGET="${4:?$USAGE}" +shift 4 + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +[ -f "$NATIVES_CSV" ] || fail "no natives list at $NATIVES_CSV" +[ ! -e "$SET_DIR" ] || [ -z "$(ls -A "$SET_DIR")" ] || fail "$SET_DIR is not empty" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +extra="" +for classifier in "$@"; do + extra="$extra + net.ladenthinllama$VERSION$classifier" +done +cat > "$WORK/pom.xml" << EOF + + 4.0.0 + local.post-release + consumer + 1 + + net.ladenthinllama-platform$VERSIONpom$extra + + +EOF + +mvn_consumer() { + mvn -B -ntp -f "$WORK/pom.xml" "-Dmaven.repo.local=$WORK/m2" "$@" +} +mvn_consumer -q dependency:list -DincludeScope=runtime -DoutputFile="$WORK/deps.txt" -DexcludeTransitive=false \ + || fail "net.ladenthin:llama-platform:$VERSION does not resolve from Maven Central" +mvn_consumer -q dependency:copy-dependencies -DincludeScope=runtime "-DoutputDirectory=$(cd "$(dirname "$SET_DIR")" && pwd)/$(basename "$SET_DIR")" +# dependency:list writes " group:artifact:type[:classifier]:version:scope" (plus a module suffix on newer plugins) +deps="$(sed -n 's/^ *\([^ ]*:[^ ]*\).*/\1/p' "$WORK/deps.txt" | grep ':' || true)" +echo "resolved from Central (runtime scope):" +sed 's/^/ /' <<< "$deps" + +has() { grep -qE "^$1:" <<< "$deps"; } +scope_of() { grep -E "^$1:" <<< "$deps" | head -1 | awk -F: '{print $NF}'; } + +for dep in com.fasterxml.jackson.core:jackson-databind org.slf4j:slf4j-api org.jspecify:jspecify; do + has "$dep" || fail "$dep is not in the consumer's graph" + [ "$(scope_of "$dep")" = "compile" ] || fail "$dep has scope $(scope_of "$dep"), expected compile" +done +has org.slf4j:slf4j-simple || fail "org.slf4j:slf4j-simple (the shipped binding) is not in the consumer's graph" +[ "$(scope_of org.slf4j:slf4j-simple)" = "runtime" ] || fail "slf4j-simple has scope $(scope_of org.slf4j:slf4j-simple), expected runtime" +if grep -qE '^(ch\.qos\.logback|org\.checkerframework):' <<< "$deps"; then + fail "logback or checker-qual reached the consumer: $(grep -E '^(ch\.qos\.logback|org\.checkerframework):' <<< "$deps" | tr '\n' ' ')" +fi +[ -f "$SET_DIR/llama-$VERSION.jar" ] || fail "the classes jar llama-$VERSION.jar was not resolved" +missing="" +while IFS=, read -r classifier _ _ platform _; do + [ "$platform" = "yes" ] || continue + [ -f "$SET_DIR/llama-$VERSION-$classifier.jar" ] || missing="$missing $classifier" +done < <(grep -v -e '^#' -e '^classifier,' -e '^$' "$NATIVES_CSV" | tr -d '\r') +[ -z "$missing" ] || fail "llama-platform did not bring the library jar of:$missing" +for classifier in "$@"; do + [ -f "$SET_DIR/llama-$VERSION-$classifier.jar" ] || fail "the classifier $classifier was not resolved" +done +echo "consumer resolution OK: $(find "$SET_DIR" -maxdepth 1 -name '*.jar' | wc -l | tr -d ' ') jars, no logback, no checker-qual" +while IFS=, read -r classifier _; do + case "$classifier" in *-"$TARGET") ;; *) rm -f "$SET_DIR/llama-$VERSION-$classifier.jar" ;; esac +done < <(grep -v -e '^#' -e '^classifier,' -e '^$' "$NATIVES_CSV" | tr -d '\r') +find "$SET_DIR" -maxdepth 1 -name "llama-$VERSION-*-$TARGET.jar" | grep -q . || fail "no natives jar of $TARGET in the set" +echo "smoke set of $TARGET: $(cd "$SET_DIR" && ls llama-*.jar | tr '\n' ' ')" diff --git a/.github/post-release-webui.sh b/.github/post-release-webui.sh new file mode 100755 index 000000000..d4d47df37 --- /dev/null +++ b/.github/post-release-webui.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Post-release check 15 (docs/post-release-check.md): the embedded WebUI of the RELEASED library. A +# local cmake build compiles the empty-asset stub, so "the WebUI is served" is a property of the +# published jar and of no build a developer makes: the stub answers GET / with 404, the release +# with the gzip-compressed app shell. Also /props, and a chat completion whose system_fingerprint +# names the build the natives jars declare (jllama-build.txt `llama.cpp`). +# +# Usage: post-release-webui.sh [port] (a smoke set, e.g. from post-release-consumer.sh) +set -euo pipefail + +SET_DIR="${1:?usage: post-release-webui.sh [port]}" +MODEL="${2:?usage: post-release-webui.sh [port]}" +PORT="${3:-18090}" +BASE="http://127.0.0.1:$PORT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=smoke-lib.sh +. "$SCRIPT_DIR/smoke-lib.sh" + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +CP="$(smoke_classpath "$SET_DIR")" || exit 1 +java -cp "$CP" "$SMOKE_MAIN" -m "$MODEL" --host 127.0.0.1 --port "$PORT" --chat-template chatml \ + > webui-out.log 2> webui-err.log & +PID=$! +trap 'kill "$PID" 2> /dev/null || true' EXIT +for _ in $(seq 1 100); do + kill -0 "$PID" 2> /dev/null || { cat webui-out.log webui-err.log; fail "the server exited before /health answered"; } + [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/health" || true)" = "200" ] && break + sleep 3 +done +[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/health")" = "200" ] || fail "/health never answered 200" + +curl -s -D webui-headers.txt -o webui-index.gz -H 'Accept-Encoding: gzip' "$BASE/" || fail "GET / failed" +status="$(head -1 webui-headers.txt | tr -d '\r')" +grep -q ' 200' <<< "$status" || fail "GET / answered '$status' -- the empty-asset stub answers 404, a release must serve the WebUI" +grep -qi '^content-type: text/html' webui-headers.txt || fail "GET / is not text/html: $(grep -i '^content-type' webui-headers.txt)" +grep -qi '^content-encoding: gzip' webui-headers.txt || fail "GET / is not gzip-encoded" +python3 - webui-index.gz << 'EOF' || fail "the WebUI body is not the app shell" +import gzip, sys +html = gzip.decompress(open(sys.argv[1], "rb").read()).decode("utf-8", "replace").lower() +assert " +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Loads one Linux library jar of the glibc-floor set (package-smoke-sets.sh, check-natives.py +# floor-set) on the oldest distribution its floor promises: RHEL 8.10 -- glibc 2.28 and the system +# libstdc++.so.6 of GCC 8 -- in Red Hat's UBI 8 OpenJDK 21 runtime image, on the jar's own +# architecture. A target that is not the runner's (s390x on an x86-64 runner) runs under qemu-user +# through binfmt_misc, which the job installs (qemu-user-static); the image is multi-arch, so the same +# digest serves every row. +# +# What it proves and nothing else checks: that the dynamic loader of the old runtime accepts the +# library -- every GLIBC_x.y, GLIBCXX_x.y.z and CXXABI_x.y.z it references exists there, and so does +# every DT_NEEDED file. verify-native-deps.py compares the version numbers with the ceiling; this is +# the load itself, from the jar, as a consumer's JVM does it (LlamaLoader extracts the directory and +# System.load()s it), followed by a JNI call (NativeLoadSmoke.java, the model-free load smoke the +# macOS row runs too). The s390x jar of 6.0.0 needed GLIBC_2.38 and failed exactly here +# ("UnsatisfiedLinkError ... version `GLIBC_2.38' not found"), with every job green. +# +# The set holds every floor jar; the loader picks the one of the JVM's os.arch, and the build info it +# prints must name cpu- -- a load of another jar, or none, fails. The image must report glibc +# 2.28, so a drifted image cannot turn the check into a load on a newer runtime. +# +# The smoke class is compiled on the runner (javac --release 8 against the set), so the image needs +# only a JRE. +# +# Pinned by digest: the multi-arch index (amd64, arm64, ppc64le, s390x) of ubi8/openjdk-21-runtime +# 1.23, checked 2026-10-26 -- RHEL 8.10, glibc 2.28, OpenJDK 21.0.12, uid 185. Bump it deliberately; +# it must stay an RHEL 8 image. +# +# Usage: smoke-glibc-floor.sh +# the glibc-floor set: the classes jar, its dependencies, the floor jars +# linux-x86-64 | linux-aarch64 | linux-s390x +set -euo pipefail + +SET_DIR="${1:?usage: smoke-glibc-floor.sh }" +TARGET="${2:?usage: smoke-glibc-floor.sh }" +IMAGE="registry.access.redhat.com/ubi8/openjdk-21-runtime@sha256:4ce885c8f0c78dfaa11d53b12e99b4e61c02c30b65943ac3ac2e77923ce36906" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +case "$TARGET" in + linux-x86-64) platform="linux/amd64"; machine="x86_64" ;; + linux-aarch64) platform="linux/arm64"; machine="aarch64" ;; + linux-s390x) platform="linux/s390x"; machine="s390x" ;; + *) fail "no glibc-floor platform for target '$TARGET'" ;; +esac +classifier="cpu-$TARGET" +SET_DIR="$(cd "$SET_DIR" && pwd)" +compgen -G "$SET_DIR/llama-*-$classifier.jar" >/dev/null || fail "the set in $SET_DIR has no $classifier jar" +# A foreign target needs a binfmt_misc entry for it: Ubuntu's qemu-user-static registers qemu-s390x, +# Docker Desktop plain s390x. Without one, docker run fails with "exec format error", which the image +# checks below would misreport. +if [ "$(uname -m)" != "$machine" ] && [ -e /proc/sys/fs/binfmt_misc/status ] \ + && ! compgen -G "/proc/sys/fs/binfmt_misc/*$machine*" >/dev/null; then + fail "the runner is $(uname -m) and binfmt_misc has no entry for $machine: install qemu-user-static first" +fi + +classes="$(mktemp -d)" +trap 'rm -rf "$classes"' EXIT +javac -nowarn -Xlint:-options --release 8 -cp "$SET_DIR/*" -d "$classes" "$SCRIPT_DIR/smoke/NativeLoadSmoke.java" +chmod -R a+rX "$classes" + +docker pull --quiet --platform "$platform" "$IMAGE" >/dev/null +log="$(mktemp)" +# The image runs as uid 185; the set and the class are mounted read-only, the extraction goes to /tmp. +set +e +docker run --rm --platform "$platform" -v "$SET_DIR":/set:ro -v "$classes":/cls:ro "$IMAGE" \ + sh -c 'cat /etc/redhat-release; uname -m; ldd --version | head -n 1; java -version 2>&1 | head -n 1; + exec java -cp "/cls:/set/*" NativeLoadSmoke' 2>&1 | tee "$log" +rc=${PIPESTATUS[0]} +set -e + +grep -q "^Red Hat Enterprise Linux release 8\." "$log" || fail "the image is no RHEL 8 (see the first line above)" +grep -q "^$machine\$" "$log" || fail "the container does not run on $machine" +grep -qE "^ldd .* 2\.28\$" "$log" || fail "the image's glibc is not 2.28 -- the load proves nothing about the floor" +[ "$rc" -eq 0 ] || fail "$classifier does not load on RHEL 8 ($machine), exit $rc -- see the output above" +grep -q "native load smoke OK: .*classifier=$classifier[,}]" "$log" \ + || fail "the JVM did not load $classifier (no build info naming it above)" +echo "$classifier loads on RHEL 8 ($machine, glibc 2.28)" diff --git a/.github/verify-aar-poms.py b/.github/verify-aar-poms.py new file mode 100755 index 000000000..6c03d01eb --- /dev/null +++ b/.github/verify-aar-poms.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Fail when a published Android AAR pom does not mirror the core's compile-scope dependencies. + +See buildcheck/aarpoms.py. Usage: verify-aar-poms.py ... +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from buildcheck import aarpoms # noqa: E402 + +if __name__ == "__main__": + sys.exit(aarpoms.main(sys.argv)) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f58243f07..baba73a38 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,7 +18,7 @@ permissions: jobs: analyze: - uses: bernardladenthin/actions/.github/workflows/java-codeql.yml@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/.github/workflows/java-codeql.yml@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 permissions: actions: read contents: read diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index f031c5310..bdc975157 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -16,6 +16,6 @@ permissions: jobs: submit: - uses: bernardladenthin/actions/.github/workflows/java-maven-dependency-submission.yml@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/.github/workflows/java-maven-dependency-submission.yml@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 permissions: contents: write diff --git a/.github/workflows/java-tests.yml b/.github/workflows/java-tests.yml index cf8af3af4..10b15c55c 100644 --- a/.github/workflows/java-tests.yml +++ b/.github/workflows/java-tests.yml @@ -43,7 +43,7 @@ jobs: with: persist-credentials: false - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: ${{ inputs.natives-artifact }} @@ -116,7 +116,7 @@ jobs: # Echo the crash logs into the job log (workspace/policies/ci-test-diagnostics.md 3.1). - name: Print crash logs (on failure) if: failure() - uses: bernardladenthin/actions/java/print-crash-logs@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/print-crash-logs@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: modules: llama - if: failure() diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 474ad01dd..3c47a064e 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -19,7 +19,7 @@ permissions: jobs: scan: - uses: bernardladenthin/actions/.github/workflows/osv-scanner.yml@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/.github/workflows/osv-scanner.yml@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 permissions: actions: read contents: read diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml new file mode 100644 index 000000000..cac91b2cc --- /dev/null +++ b/.github/workflows/post-release.yml @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +name: Post-release check +on: + workflow_run: + workflows: [Publish] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: "The release tag to audit (v), e.g. to re-audit an older release" + required: true + +# docs/post-release-check.md, automated: the published release, checked from the outside after the +# Publish run that made it -- against the GitHub release, Maven Central and a real JVM, not against +# the build tree. A green pipeline is evidence about the build; every failure class CLAUDE.md +# records as having reached a user reached them through one. +# +# Triggered by the end of a successful Publish run dispatched on a v* tag (a release created with +# the workflow token starts no `release:` workflow), or by hand with the tag. The tools and the +# configuration are this checkout (the default branch); the release's own facts (its contract, +# natives.csv, patches, poms) come from a second checkout of the tag in release/, so an older +# release is audited with today's checks. +# +# audit bernardladenthin/actions' java/audit-publication (checks 1, 2, 4-7, 10, 16: the run, +# its jobs, the files, Maven Central -- polled while it still publishes --, the +# signatures, the provenance, the bytecode floors, the next version), then this +# repository's own: natives jars, provenance recomputed, source archive, AARs, the +# cache-free build (checks 3, 8, 9, 13, 14; .github/post-release-check.py) +# consumer per platform, a consumer resolves llama-platform from Central into an empty local +# repository (check 11, .github/post-release-consumer.sh) and runs it on Java 8, the +# floor (check 12): the server smoke of the natives jars -- inference, TLS, an https:// +# model download, the natives directory -- and, on Linux x86-64, the embedded WebUI of +# the released library (check 15, .github/post-release-webui.sh) +# What stays by hand: GPU inference (no GPU runner) -- docs/post-release-check.md, check 12. +permissions: + contents: read + +concurrency: + group: post-release-${{ inputs.tag || github.event.workflow_run.head_branch }} + cancel-in-progress: false + +env: + TAG: ${{ inputs.tag || github.event.workflow_run.head_branch }} + +jobs: + audit: + name: Audit the published release + # Only a successful run dispatched on a v* tag published a release: pull request runs never + # publish, and their head branch is not ours to check out. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_dispatch' && + startsWith(github.event.workflow_run.head_branch, 'v')) + runs-on: ubuntu-latest + timeout-minutes: 240 + permissions: + contents: read + actions: read + attestations: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Check out the tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ env.TAG }} + path: release + persist-credentials: false + - name: Audit the release (run, jobs, files, Maven Central, signatures, provenance, bytecode) + uses: bernardladenthin/actions/java/audit-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 + with: + tag: ${{ env.TAG }} + root: release + assets: release-assets + wait-minutes: '180' + - name: This repository's own artifacts (natives jars, provenance, source archive, AARs, cache-free build) + if: ${{ !cancelled() }} + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: python3 .github/post-release-check.py --root release --tag "$TAG" --assets release-assets ${RUN_ID:+--run "$RUN_ID"} + + consumer: + name: A consumer on Java 8 (${{ matrix.target }}) + needs: audit + # After the audit, which waited for Maven Central; a finding there does not hide this one. + if: ${{ !cancelled() && needs.audit.result != 'skipped' }} + strategy: + fail-fast: false + matrix: + include: + - { target: linux-x86-64, runner: ubuntu-latest, jdk: temurin, extra: vulkan-linux-x86-64 } + - { target: linux-aarch64, runner: ubuntu-24.04-arm, jdk: temurin, extra: vulkan-linux-aarch64 } + - { target: windows-x86-64, runner: windows-latest, jdk: temurin, extra: vulkan-windows-x86-64 } + # Temurin publishes no Java 8 for macOS on arm64; Zulu does. + - { target: macos-aarch64, runner: macos-latest, jdk: zulu, extra: '' } + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Check out the tag's natives list + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ env.TAG }} + path: release + sparse-checkout: .github/natives.csv + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + with: + distribution: ${{ matrix.jdk }} + java-version: '8' + - name: Resolve the release from Maven Central into an empty local repository (check 11) + shell: bash + run: | + java -version + .github/post-release-consumer.sh "${TAG#v}" release/.github/natives.csv consumer "${{ matrix.target }}" ${{ matrix.extra }} + - name: Download the 1 MB test model + shell: bash + run: | + mkdir -p models + curl -sSfL --retry 3 -o models/stories260K.gguf "$(grep '^stories260K\.gguf,' .github/models.csv | cut -d, -f2-)" + - name: The server smoke on the resolved jars, Java 8 (check 12) + if: runner.os == 'Linux' + run: .github/smoke-test-natives.sh consumer models/stories260K.gguf + - name: The server smoke on the resolved jars, Java 8 (check 12) + if: runner.os == 'Windows' + shell: pwsh + run: .github/smoke-test-natives.ps1 -SetDir consumer -Model models/stories260K.gguf + - name: The macOS smoke on the resolved jars, Java 8 (check 12) + if: runner.os == 'macOS' + run: .github/smoke-native-macos.sh consumer + - name: The embedded WebUI of the released library (check 15) + if: matrix.target == 'linux-x86-64' + run: .github/post-release-webui.sh consumer models/stories260K.gguf + - name: Upload server logs + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: post-release-${{ matrix.target }}-logs + path: | + *.log + webui-headers.txt + if-no-files-found: warn diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0e7d81120..04741885e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -120,19 +120,19 @@ jobs: with: persist-credentials: false - name: Files equal their templates in bernardladenthin/actions - uses: bernardladenthin/actions/common/check-templates@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/check-templates@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 # This repository's own build checks build on actionlib (the setup step puts it on PYTHONPATH). - - uses: bernardladenthin/actions/common/setup@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/common/setup@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build-check tests of this repository run: python3 -m unittest discover -s .github/buildcheck/tests -t .github - name: Release gate (every job gates both publish jobs, or release-gate-exemptions.txt says why) - uses: bernardladenthin/actions/common/check-release-gate@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/check-release-gate@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Run scripts parse (bash -n over every bash run script of the workflows and actions) - uses: bernardladenthin/actions/common/check-run-scripts@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/check-run-scripts@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Maven versions (warns where a dependency or plugin differs from a sibling repository) - uses: bernardladenthin/actions/java/check-maven-versions@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/check-maven-versions@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Publication wiring and release asset contract (dry run) - uses: bernardladenthin/actions/java/check-publication@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/check-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 # --------------------------------------------------------------------------- # GPG signing-key preflight (standalone, no `needs:` -- runs in parallel at the @@ -148,7 +148,7 @@ jobs: runs-on: ubuntu-latest environment: maven-central steps: - - uses: bernardladenthin/actions/common/verify-gpg-key@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/common/verify-gpg-key@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: private-key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -168,7 +168,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: bernardladenthin/actions/java/verify-signing-key-gradle@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/java/verify-signing-key-gradle@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: private-key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -272,7 +272,7 @@ jobs: print(f'{len(files)} workflow files, {bad} that do not parse') sys.exit(1 if bad else 0) PY - - uses: bernardladenthin/actions/common/setup@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/common/setup@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Natives list check (everything that names a natives jar agrees with natives.csv) run: python3 .github/check-natives.py - name: Patch hunk check (every hunk of llama/patches/*.patch declares exactly the lines it carries) @@ -353,7 +353,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Download Linux x86_64 native library (reused, not rebuilt) uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -378,7 +378,7 @@ jobs: # Echo the crash logs into the job log (workspace/policies/ci-test-diagnostics.md 3.1). - name: Print crash logs (on failure) if: failure() - uses: bernardladenthin/actions/java/print-crash-logs@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/print-crash-logs@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: modules: llama-langchain4j - if: failure() @@ -410,7 +410,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Download Linux x86_64 native library (reused, not rebuilt) uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -441,7 +441,7 @@ jobs: done - name: Print crash logs (on failure) if: failure() - uses: bernardladenthin/actions/java/print-crash-logs@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/print-crash-logs@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: modules: llama-ffm - if: failure() @@ -534,7 +534,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Download Linux x86_64 native library (reused, not rebuilt) uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -561,7 +561,7 @@ jobs: # Echo the crash logs into the job log (workspace/policies/ci-test-diagnostics.md 3.1). - name: Print crash logs (on failure) if: failure() - uses: bernardladenthin/actions/java/print-crash-logs@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/print-crash-logs@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: modules: llama-atmosphere-agent - if: failure() @@ -713,7 +713,7 @@ jobs: name: webui-generated path: ${{ github.workspace }}/llama/webui-generated/ - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: bash run: | @@ -750,7 +750,7 @@ jobs: name: webui-generated path: ${{ github.workspace }}/llama/webui-generated/ - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: bash run: | @@ -839,7 +839,7 @@ jobs: # source directory, so the stamp the archive carries is what proves the patches are in. bash .github/verify-patches-applied.sh "$RUNNER_TEMP/llama-src" - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries from the archive shell: bash run: | @@ -874,7 +874,7 @@ jobs: name: webui-generated path: ${{ github.workspace }}/llama/webui-generated/ - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: bash run: | @@ -888,24 +888,30 @@ jobs: build-linux-s390x: name: Build and Test Linux s390x (big-endian, qemu) needs: [startgate, build-webui] - # Cross-compile for IBM Z (s390x, BIG-ENDIAN) with the GCC cross toolchain, then run the full - # C++ unit suite under qemu-user — a real big-endian correctness gate for our helpers and - # serializers (esp. the little-endian WAV writer, JSON/token/embedding transforms). The BUILD - # is native speed (x86 cross-gcc); only the tiny test binary is emulated. s390x is a CPU - # platform like aarch64 and ships as the cpu-linux-s390x natives jar. Model-backed Java tests - # are NOT run under emulation (a JVM + GGUF inference under qemu-user is slow/flaky); the - # C++ gate covers the actual byte-order risk since the Java<->JNI boundary uses host-native - # array copies. GGML_OPENMP=OFF avoids cross-libgomp issues (ggml uses - # its own std::thread pool). CMAKE_CROSSCOMPILING_EMULATOR makes ctest run the s390x exe via qemu; - # QEMU_LD_PREFIX lets the emulated binary find the s390x sysroot libs. - runs-on: ubuntu-latest + # Cross-compile for IBM Z (s390x, BIG-ENDIAN), then run the full C++ unit suite under qemu-user -- + # a real big-endian correctness gate for our helpers and serializers (esp. the little-endian WAV + # writer, JSON/token/embedding transforms). The BUILD is native speed: clang on the x86-64 runner + # against the manylinux_2_28 s390x image unpacked as the sysroot (.github/manylinux/ + # manylinux_2_28-s390x-sysroot and s390x-toolchain.cmake), linked by binutils-s390x-linux-gnu, so + # the jar has the floor of the other Linux CPU jars -- glibc 2.28, GLIBCXX_3.4.25 -- which + # verify-native-deps.py holds and smoke-glibc-floor proves on RHEL 8. (Until 2026-10-25 the job + # used the runner's GCC 13 cross toolchain and its glibc 2.39 sysroot: the 6.0.0 jar needed glibc + # 2.38 and GCC 13's libstdc++, CLAUDE.md "Linux s390x".) Only the test binary is emulated, and it + # runs against the sysroot's glibc 2.28 and GCC 8 libstdc++.so.6, the oldest runtime the jar + # promises. s390x is a CPU platform like aarch64 and ships as the cpu-linux-s390x natives jar. + # Model-backed Java tests are NOT run under emulation (a JVM + GGUF inference under qemu-user is + # slow/flaky); the C++ gate covers the actual byte-order risk since the Java<->JNI boundary uses + # host-native array copies. GGML_OPENMP=OFF avoids cross-libgomp issues (ggml uses its own + # std::thread pool). The toolchain's CMAKE_CROSSCOMPILING_EMULATOR runs the s390x executables + # through qemu with QEMU_LD_PREFIX pointing at the sysroot. + # Pinned to ubuntu-24.04: the toolchain names its clang-18. + runs-on: ubuntu-24.04 env: - QEMU_LD_PREFIX: /usr/s390x-linux-gnu SCCACHE_WEBDAV_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Download shared WebUI assets uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -915,10 +921,14 @@ jobs: with: distribution: 'temurin' java-version-file: .java-version - - name: Install s390x cross toolchain + qemu-user + - name: Install clang, the s390x binutils and qemu-user run: | sudo apt-get update - sudo apt-get install -y gcc-s390x-linux-gnu g++-s390x-linux-gnu qemu-user-static + sudo apt-get install -y clang-18 binutils-s390x-linux-gnu qemu-user-static + - name: Unpack the manylinux_2_28 s390x sysroot + run: | + .github/manylinux/manylinux_2_28-s390x-sysroot "$RUNNER_TEMP/s390x-sysroot" + echo "JLLAMA_S390X_SYSROOT=$RUNNER_TEMP/s390x-sysroot" >> "$GITHUB_ENV" # NOTE: GGML_NATIVE=OFF is required here (an x86 build host must not bake -march=native into an # s390x artifact), and it has a non-obvious second effect: ggml declares # `option(GGML_VXE "ggml: enable vxe" ${GGML_NATIVE})`, so VXE is off too. No `-mvx -mzvector` is @@ -926,16 +936,20 @@ jobs: # never self-defines `__VXE__`/`__VXE2__`. This job therefore builds a SCALAR s390x binary -- which is # exactly right for what it is (a big-endian correctness gate for our own layer, not a perf target). # Do NOT "fix" a VXE-related compile error by adding -DGGML_VXE=ON: that define sets __VXE__ and - # __VXE2__ together while -march stays at the toolchain default arch11, so every z14+ builtin is - # rejected. See the `0013` note in docs/history/dropped-llama-patches.md for the measured + # __VXE2__ together while -march stays at z13 (arch11, s390x-toolchain.cmake), so every z14+ + # builtin is rejected. See the `0013` note in docs/history/dropped-llama-patches.md for the measured # comparison (the patch itself is gone -- upstream merged it as ggml-org/llama.cpp#28775). - name: Build libraries (cross-compile s390x) shell: bash run: | mvn --no-transfer-progress -f llama/pom.xml compile - .github/build.sh "-DGGML_NATIVE=OFF -DGGML_OPENMP=OFF -DBUILD_TESTING=ON -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=s390x -DCMAKE_C_COMPILER=s390x-linux-gnu-gcc -DCMAKE_CXX_COMPILER=s390x-linux-gnu-g++ -DCMAKE_CROSSCOMPILING_EMULATOR=/usr/bin/qemu-s390x-static -DOS_NAME=Linux -DOS_ARCH=s390x" + .github/build.sh "-DGGML_NATIVE=OFF -DGGML_OPENMP=OFF -DBUILD_TESTING=ON -DCMAKE_TOOLCHAIN_FILE=$GITHUB_WORKSPACE/.github/manylinux/s390x-toolchain.cmake -DOS_NAME=Linux -DOS_ARCH=s390x" - name: Run C++ unit tests under qemu-s390x (big-endian gate) run: ctest --test-dir llama/build --no-tests=error --output-on-failure + # The same check `package` runs over the merged tree, here so a lost floor fails in this job: + # GLIBC_2.28, GLIBCXX_3.4.25, CXXABI_1.3.11 and the exact dependency list. + - name: Check the dependencies and the glibc floor + run: python3 .github/verify-native-deps.py llama/src/main/natives/net/ladenthin/llama/ - name: Upload artifacts uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: @@ -1041,7 +1055,7 @@ jobs: name: webui-generated path: ${{ github.workspace }}/llama/webui-generated/ - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: bash run: | @@ -1171,6 +1185,17 @@ jobs: ls -la llama-android/natives/*/* - name: Assemble AARs + publish to mavenLocal run: gradle -p llama-android aarCpu aarOpencl publishToMavenLocal + # The AAR's classes are the core's, so its pom must carry exactly the core's compile-scope + # dependencies (versions AND scopes, read from llama/pom.xml): the 6.0.0 poms shipped + # checker-qual at compile scope, which the core keeps provided. Checks the published files. + - name: AAR poms mirror the core's compile-scope dependencies + shell: bash + run: | + set -euo pipefail + VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version | tail -n1) + python3 .github/verify-aar-poms.py . \ + "$HOME/.m2/repository/net/ladenthin/llama-android/${VERSION}/llama-android-${VERSION}.pom" \ + "$HOME/.m2/repository/net/ladenthin/llama-android-opencl/${VERSION}/llama-android-opencl-${VERSION}.pom" - name: Validate AAR structure shell: bash run: | @@ -1299,7 +1324,7 @@ jobs: swap-storage: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: java-version-file: .java-version @@ -1639,7 +1664,7 @@ jobs: distribution: 'temurin' java-version-file: .java-version - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Install sccache (shared compiler cache) if: env.USE_CACHE == 'true' && env.SCCACHE_WEBDAV_TOKEN != '' continue-on-error: true @@ -1676,7 +1701,7 @@ jobs: distribution: 'temurin' java-version-file: .java-version - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Install sccache (shared compiler cache) if: env.USE_CACHE == 'true' && env.SCCACHE_WEBDAV_TOKEN != '' continue-on-error: true @@ -1712,7 +1737,7 @@ jobs: name: webui-generated path: ${{ github.workspace }}/llama/webui-generated/ - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: cmd run: | @@ -1771,7 +1796,7 @@ jobs: with: arch: x64 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Install sccache (shared compiler cache) if: env.USE_CACHE == 'true' && env.SCCACHE_WEBDAV_TOKEN != '' continue-on-error: true @@ -1821,7 +1846,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Download shared WebUI assets uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -2633,7 +2658,7 @@ jobs: distribution: 'temurin' java-version-file: .java-version - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries run: | mvn -q --no-transfer-progress -f llama/pom.xml compile @@ -2669,7 +2694,7 @@ jobs: echo "CC=gcc-14" >> "$GITHUB_ENV" echo "CXX=g++-14" >> "$GITHUB_ENV" - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries run: | mvn -q --no-transfer-progress -f llama/pom.xml compile @@ -2698,7 +2723,7 @@ jobs: with: arch: x64 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: cmd run: | @@ -2725,7 +2750,7 @@ jobs: with: arch: arm64 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Build libraries shell: cmd run: | @@ -2755,7 +2780,7 @@ jobs: distribution: 'temurin' java-version-file: .java-version - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - name: Install sccache (shared compiler cache) if: env.USE_CACHE == 'true' && env.SCCACHE_WEBDAV_TOKEN != '' continue-on-error: true @@ -2784,7 +2809,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: natives-cpu-linux-x86-64 @@ -2838,7 +2863,7 @@ jobs: # Echo the crash logs into the job log (workspace/policies/ci-test-diagnostics.md 3.1). - name: Print crash logs (on failure) if: failure() - uses: bernardladenthin/actions/java/print-crash-logs@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/print-crash-logs@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: modules: llama - if: failure() @@ -3145,7 +3170,7 @@ jobs: # dylib shipped; the test-only macOS builds (macos-26-metal, macos-15-no-metal) are named # outside the glob for that reason. - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: pattern: "natives-*" @@ -3187,7 +3212,7 @@ jobs: # module-info.class and META-INF/versions/** are skipped unconditionally because a # classpath JVM never loads them. Kept byte-identical across all four sibling repos. - name: Verify Java 8 bytecode (no class newer than major 52) - uses: bernardladenthin/actions/java/verify-bytecode@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/verify-bytecode@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: max-major: "52" paths: | @@ -3273,6 +3298,56 @@ jobs: compression-level: 0 retention-days: 7 if-no-files-found: error + # The library jars that promise a glibc floor (check-natives.py floor-set), with the classes + # jar and its dependencies, for smoke-glibc-floor below. + - name: Upload the glibc-floor set + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: llama-glibc-floor + path: smoke-sets/glibc-floor/ + compression-level: 0 + retention-days: 7 + if-no-files-found: error + + # Every library jar that promises a glibc floor (nativedeps.GLIBC_CEILING: the three Linux CPU + # jars) loaded on the oldest distribution of that floor, RHEL 8.10 (glibc 2.28, GCC 8's + # libstdc++.so.6), on its own architecture: x86-64 and aarch64 natively, s390x under qemu-user + # (smoke-glibc-floor.sh, NativeLoadSmoke.java in Red Hat's UBI 8 OpenJDK 21 runtime image). + # verify-native-deps.py in `package` compares the symbol versions with the ceiling; this is the load + # itself. Before it no job ran a jar on an old runtime, and the s390x jar of 6.0.0 shipped needing + # glibc 2.38 with every job green (CLAUDE.md, "Linux s390x"). check-natives.py holds the rows to the + # floor set. Model-free and quick (the s390x row JIT-starts a JVM under qemu: ~15 s locally). + smoke-glibc-floor: + name: Load the Linux CPU jars on RHEL 8 (${{ matrix.target }}) + needs: [package] + strategy: + fail-fast: false + matrix: + include: + - { target: linux-x86-64, runner: ubuntu-24.04 } + - { target: linux-aarch64, runner: ubuntu-24.04-arm } + - { target: linux-s390x, runner: ubuntu-24.04 } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: llama-glibc-floor + path: floor/ + # javac for the smoke class; the image brings its own JRE. + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + with: + distribution: 'temurin' + java-version-file: .java-version + - name: Install qemu-user (s390x on the x86-64 runner) + if: matrix.target == 'linux-s390x' + run: | + sudo apt-get update + sudo apt-get install -y qemu-user-static + - name: Load the jar on RHEL 8 + run: .github/smoke-glibc-floor.sh floor ${{ matrix.target }} # Every smoke set, launched on a GPU-less runner of its OS/arch: the loader puts every GPU module # jar of the platform next to the CPU library (the loader line names each one, checked against @@ -3303,7 +3378,7 @@ jobs: with: persist-credentials: false - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: llama-smoke-${{ matrix.target }} @@ -3318,7 +3393,7 @@ jobs: # script is bash-only, so the Linux rows check; the Windows sets hold the same jars. - name: Verify Java 8 bytecode (no class newer than major 52) if: runner.os == 'Linux' - uses: bernardladenthin/actions/java/verify-bytecode@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/verify-bytecode@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: max-major: "52" paths: | @@ -3379,7 +3454,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: llama-atmosphere-agent-jar @@ -3407,7 +3482,7 @@ jobs: # dumb terminal instead of failing (verified on 21.0.10). Only that package is waived, so # any other class above 65 still fails the gate. - name: Verify Java 21 bytecode (no class newer than major 65) - uses: bernardladenthin/actions/java/verify-bytecode@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/verify-bytecode@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: max-major: "65" allow: | @@ -3450,7 +3525,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Display CPU Info - uses: bernardladenthin/actions/common/print-host-info@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/common/print-host-info@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: llama-smoke-macos-aarch64 @@ -3516,7 +3591,7 @@ jobs: # a release is tagged on. - name: Require a SNAPSHOT version if: inputs.publish_to_central - uses: bernardladenthin/actions/java/check-release-ref@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/check-release-ref@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: snapshot @@ -3531,7 +3606,7 @@ jobs: persist-credentials: false # The tag names exactly the project version (v1.2.3 for 1.2.3), which is no SNAPSHOT. - name: Require the release version the tag names - uses: bernardladenthin/actions/java/check-release-ref@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/check-release-ref@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: release @@ -3553,6 +3628,7 @@ jobs: - smoke-natives - smoke-natives-macos - smoke-agent-linux + - smoke-glibc-floor - vmlens - repository-checks if: needs.check-snapshot.result == 'success' && inputs.publish_to_central @@ -3637,7 +3713,7 @@ jobs: MAVEN_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - name: Finalize and secure the signed publication id: finalize - uses: bernardladenthin/actions/java/finalize-publication@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/finalize-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: snapshot @@ -3653,7 +3729,7 @@ jobs: permissions: contents: write steps: - - uses: bernardladenthin/actions/java/publish-github@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/java/publish-github@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: snapshot assets-complete: ${{ needs.publish-snapshot.outputs.assets_complete }} @@ -3678,6 +3754,7 @@ jobs: - smoke-natives - smoke-natives-macos - smoke-agent-linux + - smoke-glibc-floor - vmlens - repository-checks runs-on: ubuntu-latest @@ -3757,7 +3834,7 @@ jobs: MAVEN_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - name: Finalize and secure the signed publication id: finalize - uses: bernardladenthin/actions/java/finalize-publication@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/finalize-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: release @@ -3770,7 +3847,7 @@ jobs: permissions: contents: write steps: - - uses: bernardladenthin/actions/java/publish-github@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/java/publish-github@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: release assets-complete: ${{ needs.publish-release.outputs.assets_complete }} @@ -3795,7 +3872,7 @@ jobs: with: persist-credentials: false - name: Transfer frozen artifacts (no rebuild, signing or validation polling) - uses: bernardladenthin/actions/java/publish-central@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/publish-central@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: snapshot assets-complete: ${{ needs.publish-snapshot.outputs.assets_complete }} @@ -3816,7 +3893,7 @@ jobs: with: persist-credentials: false - name: Transfer frozen artifacts (no rebuild, signing or validation polling) - uses: bernardladenthin/actions/java/publish-central@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/java/publish-central@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: release assets-complete: ${{ needs.publish-release.outputs.assets_complete }} @@ -3834,7 +3911,7 @@ jobs: id-token: write attestations: write steps: - - uses: bernardladenthin/actions/java/attest-publication@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/java/attest-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: snapshot assets-complete: ${{ needs.publish-snapshot.outputs.assets_complete }} @@ -3850,7 +3927,7 @@ jobs: id-token: write attestations: write steps: - - uses: bernardladenthin/actions/java/attest-publication@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + - uses: bernardladenthin/actions/java/attest-publication@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 with: channel: release assets-complete: ${{ needs.publish-release.outputs.assets_complete }} diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index ac8738a61..b467713cd 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -14,4 +14,4 @@ permissions: jobs: test: - uses: bernardladenthin/actions/.github/workflows/reuse.yml@23543c8554ca9de6792a1b8d8aae0bcd001d8508 # v1.0.3 + uses: bernardladenthin/actions/.github/workflows/reuse.yml@44390bd464395f560f4170d2c4bfb7a6348541b3 # v1.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cac8b6a..3d6a4b73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,51 @@ from version 5.0.0 onward. Pre-fork releases (`1.x`–`4.2.0`) were authored by ## [Unreleased] +### Changed +- **`bernardladenthin/actions` pinned at v1.1.0.** v1.1.0 adds `java/audit-publication`, which + `post-release.yml` uses (see Added); nothing changes for the actions already in use. + +### Fixed +- **`cpu-linux-s390x` loads on glibc 2.28, like the other Linux CPU jars** (RHEL 8, Ubuntu 20.04, + Debian 10 and later, z13 or newer). The 6.0.0 jar was cross-compiled against the + ubuntu runner's glibc 2.39 sysroot and needed `GLIBC_2.38`, `GLIBCXX_3.4.32` and `CXXABI_1.3.13`, so + it failed on RHEL 8/9 and Ubuntu 22.04 with `GLIBC_2.38 not found`. `build-linux-s390x` now + cross-compiles with clang against the manylinux_2_28 s390x image unpacked as a sysroot + (`.github/manylinux/manylinux_2_28-s390x-sysroot`, `s390x-toolchain.cmake`: glibc 2.28, + gcc-toolset-14's libstdc++ treatment); the library references `GLIBC_2.27`, `GLIBCXX_3.4.25` and + `CXXABI_1.3.11`, and its C++ suite runs under qemu against that runtime. +- **The Android AAR poms no longer carry `checker-qual`.** The 6.0.0 `llama-android` and + `llama-android-opencl` poms listed `org.checkerframework:checker-qual:4.3.1` at `compile` scope, + which the core keeps `provided`, so it was in every Android consumer's dependency graph. Not a crash + on Android (D8 consumes its major 55 classes), an avoidable dependency. The AAR poms now take their + dependency list from `llama/pom.xml`, scopes included, instead of a hand-kept one, and the consumer + R8 rules gain `-dontwarn org.checkerframework.**` for the checker's qualifiers in the class files. + An app that used checker-qual's annotations itself through the AAR has to declare it. + +### Added +- **The post-release check runs by itself** (`.github/workflows/post-release.yml`, after the Publish + run of a `v*` tag, or by hand with a tag). It audits the published release from the outside: + - through bernardladenthin/actions' new `java/audit-publication`: the run and its jobs, the files + against the contract, Maven Central, the signatures, every file's provenance, the bytecode + floors and the next version; + - through `.github/post-release-check.py`: the natives jars, their provenance with the patch hash + recomputed from the tag, the source archive, the AARs and the cache-free tag build; + - through a consumer on Java 8 per platform: `llama-platform` resolved from Central into an empty + local repository, the server smoke and the embedded WebUI. + + Run against 6.0.0 it reports exactly the one known finding, checker-qual in the AAR poms. +- **`verify-aar-poms.py`: the published AAR poms are checked against the core pom** in + `package-android-aar` -- exactly the core's compile-scope dependencies, at its versions and at + `compile`. +- **`smoke-glibc-floor`: every Linux CPU jar is loaded on RHEL 8.10 in CI**, on its own architecture + (x86-64 and aarch64 natively, s390x under qemu), from the published jars, in Red Hat's UBI 8 OpenJDK + 21 runtime image (glibc 2.28, GCC 8's libstdc++). The set is derived from the glibc floors + (`check-natives.py floor-set`), and `check-natives.py` fails when the job's rows differ from it. +- **`verify-native-deps.py` also holds the libstdc++ floor**: the directories with a glibc floor may + not reference a version above `GLIBCXX_3.4.25` / `CXXABI_1.3.11`, and `Linux/s390x/cpu` is held to + `GLIBC_2.28` like the other manylinux directories. Its output names the highest `GLIBC`, `GLIBCXX` + and `CXXABI` version of every library. + ## [6.0.0] - 2026-10-25 ### Upgrading from 5.1 diff --git a/CLAUDE.md b/CLAUDE.md index 986df3362..acc107860 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,8 @@ it or is checked against it: | `examples/jbang/Chat.java`, `examples/server/pom.xml` | the JBang `//DEPS` lines name the classes jar and every `platform=yes` jar; the server example's `` and profiles follow the list — both held to the README's install version | | `.github/check-natives.py` | runs in `code-style` (first minutes of every run); fails when any row of this table disagrees with the list — including CMake's backend names, the dependency allowlists and the README rows | | `.github/merge-native-artifacts.sh` | reads the list: every listed `natives-*` artifact present, no other, each holding its library (or module) and nothing outside its directory, each with a `jllama-build.txt` naming a tag, the provenance keys and the row's classifier, next to a `jllama-sbom.json` naming the same classifier, a module directory holding nothing but its module, the `jllama-*.txt` lists and the BOM, no path claimed twice; writes `jllama-extras.txt` (sibling files loaded before the library, e.g. OpenVINO's `OpenCL.dll` on Windows -- never the files a build's own `jllama-files.txt` names, which are only extracted; each of those must exist) | -| `.github/package-smoke-sets.sh` | reads the list through `check-natives.py smoke-targets` / `smoke-set`: fails when a natives jar of a set was not built | +| `.github/package-smoke-sets.sh` | reads the list through `check-natives.py smoke-targets` / `smoke-set`: fails when a natives jar of a set was not built; also writes the glibc-floor set (`floor-set`) | +| the glibc-floor set | **derived**: the library rows whose directory promises a glibc floor (`nativedeps.GLIBC_CEILING`: the three Linux CPU jars) — `check-natives.py floor-set` prints them; `package` uploads them with the classes jar and its dependencies as `llama-glibc-floor`, and `smoke-glibc-floor` loads each on RHEL 8.10 (glibc 2.28, GCC 8's libstdc++) on its own architecture, s390x under qemu (`smoke-glibc-floor.sh`, `NativeLoadSmoke.java` in the UBI 8 OpenJDK 21 runtime image); `check-natives.py` holds the job's matrix rows to the set | | `.github/verify-native-deps.py` | every library of a directory: exact dependency allowlist per library directory (`cpu`/`metal`) plus the files next to it; **the export set of every `jllama` library** (`JNI_OnLoad`, `JNI_OnUnload`, `Java_*`, `jllama_*` and nothing else -- `cmake/jllama-exports.map` on ELF and `cmake/jllama-exports-macos.txt` on macOS keep the statically linked llama/ggml/mtmd/BoringSSL symbols local, Windows exports only `dllexport`; before the lists the Linux library exported 14,220 symbols, 47 of them JNI), and for the Android OpenCL module (bionic + `libOpenCL.so` + `libggml-base.so`); denylist for the other GPU modules, each of which must import `ggml-base` and, as ELF, reach it only through run path `$ORIGIN` (an Android library carries no run path -- bionic's search path is the app's native-library directory -- and only a build-tree one fails there); the manylinux_2_28 builds (both Linux CPU jars, CUDA) held to `GLIBC_2.28`; every Android library 16 KB page-aligned (Google Play). Runs in `package` and on the staged AAR libraries | | `.github/smoke-natives-jars.sh` (`package` job) | loads the real jars: classes + all 23 natives jars at once, on the classpath **and** the module path; it passes `net.ladenthin.llama.backend` naming **every** module of the runner's platform, opt-in ones included, and the loader line must then name each -- so a module jar the loader skipped fails here, and this is also the only place in CI that loads an opt-in module at all | | `.github/smoke-lib.sh` / `smoke-test-natives.ps1` | the model-backed smoke rows expect every module of the set **except** the opt-in ones (`optin` column), because the loader deliberately leaves those out -- it is the same list, read with one more condition | @@ -299,11 +300,15 @@ measurements behind the Windows step are in `docs/handover/local-agent-report-b1 build. - **Built in manylinux_2_28** (AlmaLinux 8, gcc-toolset-14, glibc 2.28): x86-64 in the dockcross image the CUDA job already used, aarch64 natively on `ubuntu-24.04-arm` inside the pypa image - (`.github/manylinux/manylinux_2_28-aarch64`, see "Linux aarch64"). manylinux2014's GCC 10 lacked - `-mavxvnni` and `-mamx-tile` (`alderlake`, `sapphirerapids`; ggml does not check). The glibc floors - meet at **2.28**: x86-64 rises from 2.17, aarch64 falls from ~2.39. `verify-native-deps.py` reads - every library's version-needs table and holds both directories (and CUDA) to `GLIBC_2.28`; the - modules themselves reference `GLIBC_2.27` at most (measured in the image). + (`.github/manylinux/manylinux_2_28-aarch64`, see "Linux aarch64"); the static s390x library is + cross-compiled against the pypa s390x image as a sysroot (see "Linux s390x"). manylinux2014's GCC 10 + lacked `-mavxvnni` and `-mamx-tile` (`alderlake`, `sapphirerapids`; ggml does not check). The glibc + floors meet at **2.28**: x86-64 rises from 2.17, aarch64 falls from ~2.39, s390x from 2.38. + `verify-native-deps.py` reads every library's version-needs table and holds these directories (and + CUDA) to `GLIBC_2.28`, `GLIBCXX_3.4.25` and `CXXABI_1.3.11` (the system libstdc++ of manylinux_2_28 + is GCC 8's; gcc-toolset links the rest from `libstdc++_nonshared.a`); the modules themselves + reference `GLIBC_2.27` at most (measured in the image). `smoke-glibc-floor` loads each of the three + CPU jars on RHEL 8.10. - **One `ctest` in a modular build job, `c_abi`, and no `jllama_test`**: `jllama_test` calls the CPU/RPC backends directly, so the option refuses `BUILD_TESTING`; what a library-jar build does run (`JLLAMA_C_ABI_TEST`, on by default, not when cross-compiling) is `src/test/c/test_c_abi.c` @@ -365,6 +370,8 @@ rejects anything else loudly. | `patches.py` | `check-patches.py` (`code-style`) | every hunk of `llama/patches/*.patch` declares exactly the lines it carries -- the half `git apply --check` does not see (see "Local llama.cpp source patches"); and `docs/patches.md` is what `check-patches.py --render` generates from the patch headers (a stale page fails) | | `nativedeps.py` | `verify-native-deps.py` (`package`, `package-android-aar`) | dependency allowlists, 16 KB Android alignment | | `hipoffload.py` | `verify-hip-offload-compressed.py` (the two ROCm jobs) | no uncompressed GPU code bundle | +| `aarpoms.py` | `verify-aar-poms.py` (`package-android-aar`, on the poms in mavenLocal) | both AAR poms carry exactly the core's compile-scope dependencies, at its versions and at `compile` (plus the CPU AAR, `type aar`, in the OpenCL one) | +| `postrelease.py` | `post-release-check.py` (`post-release.yml`, after the release) | the published natives jars against `natives.csv`, their provenance with `patches.sha256` recomputed from the tag, the source archive's stamp and reproducible pack, the AAR invariants and poms, the cache-free tag build (docs/post-release-check.md, checks 3, 8, 9, 13, 14) | | `webuilicenses.py` | `webui-licenses.py` (`build-webui`, and `webui-licenses.yml` on every pull request and push) | the WebUI's npm license texts cover the pinned lockfile (digest over LF line ends) and its imports; it fails every run -- until the release run of v6.0.0 it only warned outside a release tag, and a stale list surfaced there first | | Action (bernardladenthin/actions) | Job here | What it checks or does | @@ -375,6 +382,15 @@ rejects anything else loudly. | `java/check-maven-versions` | `repository-checks` | **warns** where a Maven dependency or plugin is used in another version than in a sibling repo | | `java/check-publication` | `repository-checks` | the publication jobs are wired as documented; a dry run of `.github/release-assets.json` collects what it promises | | `java/check-release-ref`, `java/finalize-publication`, `java/publish-central`, `java/publish-github`, `java/attest-publication` | `check-snapshot` / `check-tag`, the publish jobs, the six consumers | the signed publication (see "Signed publication") | +| `java/audit-publication` | `audit` of `post-release.yml` | the published release from the outside: the run and its jobs, the files against the contract, Maven Central (polled), the signatures with the release key from a keyserver, every file's provenance, the bytecode floors, the next version (`post-release` in `.github/actions-config.json`) | + +**The post-release check (`post-release.yml`).** Runs after a successful Publish run dispatched on a +`v*` tag, and by hand with a tag: `audit` (the action above, then `post-release-check.py` on the +downloaded files) and `consumer` (per platform: `post-release-consumer.sh` resolves `llama-platform` +from Central into an empty local repository, then the server smoke on Java 8, and on Linux x86-64 +`post-release-webui.sh`). Tools from the default branch, the release's facts from a checkout of the +tag in `release/`. It is not in the release gate -- it runs after the publication, on what was +published (docs/post-release-check.md). **The release gate.** A job nothing waits for can go red while a release still ships — the natives builds `package` once forgot to wait for, and the aarch64 fat jars that were signed and attached @@ -2549,9 +2565,37 @@ Wiring: ### Linux s390x: big-endian cross-build + qemu test gate `build-linux-s390x` adds **IBM Z (s390x, big-endian)** — the one target whose -byte order differs from every other platform. It **cross-compiles** with the GCC s390x toolchain -(`g++-s390x-linux-gnu`, native x86 speed — no emulated build) and then runs the **full C++ unit suite -under `qemu-user`** (`CMAKE_CROSSCOMPILING_EMULATOR=/usr/bin/qemu-s390x-static`, `QEMU_LD_PREFIX=/usr/s390x-linux-gnu`). +byte order differs from every other platform. It **cross-compiles** at native x86 speed — no emulated +build — and then runs the **full C++ unit suite under `qemu-user`**. + +**The toolchain gives it the manylinux_2_28 floor** (glibc 2.28, `GLIBCXX_3.4.25`, `CXXABI_1.3.11`), +like the other two Linux CPU jars, without an s390x runner: +`.github/manylinux/manylinux_2_28-s390x-sysroot` unpacks the pypa `manylinux_2_28_s390x` image +(pinned by digest; nothing of it is executed) as a sysroot, and `.github/manylinux/s390x-toolchain.cmake` +compiles with Ubuntu 24.04's `clang-18` (`--target=s390x-linux-gnu --sysroot=… +--gcc-install-dir= -march=z13`) and links with `binutils-s390x-linux-gnu` +(`--ld-path`). clang takes the toolset's crt files, libgcc and libstdc++ exactly as a native +gcc-toolset build would — the system `libstdc++.so.6` of GCC 8 plus `libstdc++_nonshared.a` through +the toolset's linker script, whose absolute paths ld resolves inside the sysroot. No packaged x86 GCC +targets s390x with glibc 2.28, and running the toolset's own s390x gcc under qemu would take hours per +(uncached) tag build. `-march=z13` is the arch11 baseline the former GCC cross build defaulted to (and +RHEL 8's). The toolchain's emulator is `env QEMU_LD_PREFIX= qemu-s390x-static`, so `ctest` +runs against glibc 2.28 and GCC 8's libstdc++ -- the oldest runtime the jar promises; the prefix must +be an environment variable, not qemu's `-L`, because googletest's death tests re-execute the binary +through binfmt_misc without arguments (with `-L` the six `RpcComm` death tests failed on +`Could not open '/lib/ld64.so.1'`). The sysroot path is a cache variable, so a reconfigure without +`JLLAMA_S390X_SYSROOT` in the environment still finds it. The job then runs `verify-native-deps.py` on +its own tree, and `smoke-glibc-floor` loads the jar on RHEL 8.10 s390x under qemu. + +Until 2026-10-25 the job used the runner's `g++-s390x-linux-gnu` (GCC 13) with its glibc 2.39 +sysroot: the 6.0.0 jar referenced `GLIBC_2.38` (`__isoc23_strtol` & co., `fmod`), `GLIBCXX_3.4.32` +and `CXXABI_1.3.13`, and failed to load on RHEL 8/9 and Ubuntu 22.04 while every job was green -- +`GLIBC_CEILING` held only the manylinux directories, and the qemu `ctest` ran against the same new +sysroot. Measured after the move (b11887): `GLIBC_2.27`, `GLIBCXX_3.4.25`, `CXXABI_1.3.11`, new +dependencies `libpthread.so.0` and `libdl.so.2` (glibc 2.28 has not merged them yet), 694/694 C++ +tests under qemu against the sysroot and natively on a LinuxONE z16 (cfarm191, Debian 13), and the +jar loads on RHEL 8.10 s390x where the 6.0.0 one fails with `GLIBC_2.38 not found`. + That `ctest` run is a **real big-endian correctness gate** for the byte-order-sensitive surface — the little-endian WAV writer (`tts_wav.hpp`), the JSON/token/embedding transforms, and the JNI helpers — which is where an endian bug in *our* code could hide. Model-backed **Java** tests are deliberately @@ -2917,7 +2961,12 @@ easy to undo by accident: own `jar-with-dependencies`, which takes scope `runtime`), so no checker-qual class of any version ships. `true` would not have been enough on its own — that descriptor filters on - scope only. Safe because no source imports `org.checkerframework`. + scope only. Safe because no source imports `org.checkerframework` — but the **class files do + reference it**: the Nullness Checker writes its qualifiers (`@NonNull`, `@UnknownKeyFor`, + `@Initialized`, `@Pure`, `@SideEffectFree`, …) into the classes it checks, 133 of the core's 146 + (measured on the 6.0.0-SNAPSHOT jar). Harmless where checker-qual is absent: a JVM or ART that + reflects over an annotated element skips an annotation type it cannot resolve. R8 does not skip it — + it reports the missing class — hence `-dontwarn org.checkerframework.**` in the AAR's consumer rules. **The gate: the `java/verify-bytecode` action of [bernardladenthin/actions](https://github.com/bernardladenthin/actions)** (its `verify-bytecode-version.sh`). It opens every `.class` in every jar it is given and fails on any whose class-file major version @@ -3373,8 +3422,15 @@ the recommended path (README "Importing in Android", Option 1): full set. The additive OpenCL AAR has an empty `classes.jar`: its CPU AAR dependency already supplies the Java API, and duplicating it breaks D8/R8. Built by the **standalone plain-Gradle build** in `llama-android/` (see "Repository layout" for why it is not a Maven module); the POM mirrors the core's - compile-scope deps (jackson/slf4j-api/jspecify/checker-qual, versions parsed from - `llama/pom.xml` — deliberately NOT the SLF4J binding, which is the JVM-only runtime dependency). + compile-scope deps — the list itself, versions **and scopes**, is read from `llama/pom.xml` + (`coreCompileDependencies`: no `` or `compile`, not optional; today jackson-databind, + slf4j-api, jspecify). Not mirrored: the `provided` ones (checker-qual, lombok, animal-sniffer) and the + SLF4J binding, which is the JVM-only runtime dependency. The 6.0.0 AAR poms were written from a + hand-kept list that also carried checker-qual at `compile`, which the core keeps `provided` (major + 55, see "Java 8 bytecode floor"); the versions were derived and stayed right, the scopes drifted. + `.github/verify-aar-poms.py` checks the poms `publishToMavenLocal` wrote against the core pom in + `package-android-aar`. The consumer rules carry `-dontwarn org.checkerframework.**`: the classes + reference the checker's qualifiers (below), which no Android app has. - **`net.ladenthin:llama-kotlin`** — Maven reactor module; pure-Kotlin (2.4, jvmTarget 1.8) coroutines façade: `generateFlow`/`generateChatFlow` (cold `Flow`, source closed on completion/error/cancellation) and `completeSuspend`/`chatSuspend`/`chatCompleteTextSuspend`/ diff --git a/README.md b/README.md index 1ddab0e1f..20ce89b77 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ To use the latest snapshot, add the repository and dependency to your `pom.xml`: net.ladenthin llama-platform - 6.0.0-SNAPSHOT + 6.1.0-SNAPSHOT pom ``` @@ -265,7 +265,7 @@ iGPU, the example being a Core Ultra 7 165U). |---|---|---|---| | `cpu-linux-x86-64` | CPU | Linux x86-64 | A JDK 8+ JVM; glibc ≥ 2.28 (manylinux_2_28: RHEL 8, Ubuntu 20.04, Debian 10 and later). Ships one CPU backend module per instruction-set level (x86-64 baseline, SSE4.2, AVX, AVX2, AVX-512, AVX-VNNI, AMX), of which ggml loads the best for the running CPU at start-up — a CPU without AVX2 works, and an AVX-512/AMX machine uses its kernels. | | `cpu-linux-aarch64` | CPU | Linux aarch64 | glibc ≥ 2.28 (RHEL 8, Ubuntu 20.04, Debian 10, Amazon Linux 2023 and later) — built in the manylinux_2_28 image on an arm64 runner. Ships one CPU backend module per ARM feature level (armv8.0 up to armv9.2: dotprod, fp16, SVE, i8mm, SVE2, SME), chosen at start-up like the x86-64 ones. | -| `cpu-linux-s390x` | CPU | Linux s390x (IBM Z, big-endian) | A JDK 8+ JVM; **glibc ≥ 2.38 and the libstdc++ of GCC 13** (`GLIBCXX_3.4.32`): Ubuntu 24.04, SLES 15 SP6, Debian 13 and later. **Not RHEL 8 or 9, not Ubuntu 22.04** -- the load fails there with `GLIBC_2.38 not found`. Cross-compiled on `ubuntu-latest`, not in a manylinux image, so the 2.28 floor of the other Linux CPU jars does not apply. | +| `cpu-linux-s390x` | CPU | Linux s390x (IBM Z, big-endian) | A JDK 8+ JVM; glibc ≥ 2.28 (RHEL 8, Ubuntu 20.04, Debian 10 and later) on z13 or newer — cross-compiled against the manylinux_2_28 s390x image, so the floor of the other Linux CPU jars applies; CI loads it on RHEL 8.10. (The 6.0.0 jar needed glibc 2.38 and did not load on RHEL 8/9 or Ubuntu 22.04.) | | `cpu-windows-x86-64` | CPU | Windows x86-64 | A JDK 8+ JVM; **Windows 10 or newer**. Ships one CPU backend module per instruction-set level (as the Linux x86-64 jar: x86-64 baseline, SSE4.2, AVX, AVX2, AVX-512, AVX-VNNI, AMX), of which ggml loads the best for the running CPU at start-up. Built with clang, hybrid CRT: the STL and vcruntime are static (no `msvcp140.dll` / `vcruntime140.dll`, no VC++ redistributable), the Universal CRT is the OS one (`ucrtbase.dll`), so Microsoft's UCRT security updates apply. | | `cpu-windows-aarch64` | CPU | Windows on ARM (Snapdragon X / Surface) | A JDK 8+ JVM; **Windows 10 or newer**. Built natively on `windows-11-arm` with `clang-cl`, same hybrid CRT as the x86-64 jar; one CPU backend module (ggml builds no ARM variant set for Windows), so the Vulkan and OpenCL module jars can join it. | | `metal-macos-aarch64` | Metal + CPU | macOS aarch64 (Apple silicon) | A JDK 8+ JVM. | @@ -297,6 +297,19 @@ iGPU, the example being a Core Ultra 7 165U). > classpath; unset, every GPU module jar on the classpath is used except the opt-in `openvino` one > (see below). +> [!NOTE] +> **Two GPU modules that see the same GPU use it once.** With `cuda13-windows-x86-64` and +> `vulkan-windows-x86-64` on the classpath, ggml registers an NVIDIA GPU twice, as `CUDA0` and as a +> `Vulkan` device. llama.cpp then skips the second entry by its PCI id ("skipping device Vulkan0 … +> already using device CUDA0 … with the same id"). The first registered backend wins, which is CUDA, +> and the whole model goes to `CUDA0`. An integrated GPU next to a discrete one is not used either +> way. Measured on a published 6.0.0 set (an RTX 3070 Laptop GPU and an AMD iGPU), the three sets +> perform the same: [`docs/benchmarks/gpu-module-jars.md`](docs/benchmarks/gpu-module-jars.md), +> section 3. **The device numbering is not stable across module sets:** with only the Vulkan module, +> the RTX is `Vulkan1` and the iGPU `Vulkan0`; with CUDA loaded as well, Vulkan lists the RTX as +> `Vulkan0`. A `--device` / `setDevices(...)` name is only valid for the set of modules it was read +> from, so re-read the device list in the log after adding or removing a GPU jar. + > [!IMPORTANT] > **Two GPU jars need more than their runtime, and both were found by measurement rather than by > reading the code** ([`docs/benchmarks/gpu-module-jars.md`](docs/benchmarks/gpu-module-jars.md)). diff --git a/TODO.md b/TODO.md index c7cf12c15..5feb14859 100644 --- a/TODO.md +++ b/TODO.md @@ -41,12 +41,12 @@ and footer, `SECURITY.md` on `6.x`) is the pull request `release/6.0.0`. token was refused (`Resource not accessible by integration`, 403), so the `snapshot` tag and notes still name `53f307ac` (May) and nothing was pruned. Presumably the workflow token may not move a ref across commits that change `.github/workflows/` (12 files between the two); - not verified. actions v1.0.3 (pinned) deletes and re-creates the tag on that 403 and points + not verified. actions v1.0.3 (and later; v1.1.0 is pinned) deletes and re-creates the tag on that 403 and points the release at it; whether GitHub accepts that shows only the next snapshot dispatch. `github-release` does not move a tag (the owner's `v6.0.0` exists already). - `central-snapshot`: `superseded()` died on a GitHub API `502` listing the jobs of an older run, in the re-run too: with 100 per page GitHub fails that listing for large runs, with 50 - it answered every try. actions v1.0.3 (pinned) lists 50 per page and repeats GitHub reads on + it answered every try. actions v1.0.3 (and later) lists 50 per page and repeats GitHub reads on 5xx. `central-release` does not call it. - [ ] **Owner: settings of bernardladenthin/actions** like the four repositories: a ruleset on `main` (pull request required, no force push, no deletion), Dependabot alerts and security updates, @@ -63,15 +63,29 @@ and footer, `SECURITY.md` on `6.x`) is the pull request `release/6.0.0`. contribution by its author; KoboldCpp, where it came from, is AGPL-3.0); the third-party logos the WebUI embeds (trademarks, no license text). The WebUI package list is an upper bound (imports + lockfile); an exact list from the bundle would need a plugin in upstream's build. -- [ ] After publication: bump `main` to the next `-SNAPSHOT` in all seven poms and the README's - snapshot line (it stays `6.0.0-SNAPSHOT` in the release commit), and carry out the upstream - submissions queued below. -- [ ] After publication: srcmorph (#238) builds against `llama:6.0.0` from Central; its Build, - plugin IT, code style, CodeQL and SonarQube jobs are red until then, and a local srcmorph - build stops at its license collector against a llama snapshot older than the natives license - texts. Re-run its checks once the release is on Central. +- [ ] After publication: carry out the upstream submissions queued below (`main` is at + `6.1.0-SNAPSHOT` since the post-release bump). +- [ ] **The automated post-release check** (`.github/workflows/post-release.yml`, + docs/post-release-check.md "Automated"). Run locally against 6.0.0 before the first CI run: + the 8 checks of `java/audit-publication` pass (60 files + 60 signatures from one subkey, 118 + Central files with the release's bytes, 120 files attested by run 40239731871; `next` warns, + `main` was still at 6.0.0), `post-release-check.py` passes natives (23/23), provenance + (patches.sha256 recomputed), source and cache (73 logs, 68 show the cache off, none on) and + fails android on exactly the known checker-qual finding; the consumer resolution, the server + smoke and the WebUI check pass on the jars from Central (in Docker, JDK 21, not 8). Open: + - [x] The `java/audit-publication` pin is the actions release that carries it: v1.1.0 + (`44390bd4`), like every other pin of the workflows. + - [ ] Read the first real run: a `workflow_dispatch` with `v6.0.0` (expected: one android + finding) and the first `workflow_run` after 6.1.0. Unverified on a runner: the Java 8 + rows (Temurin 8 on Linux and Windows, Zulu 8 on macOS arm64), `smoke-native-macos.sh` + and `smoke-test-natives.ps1` on Java 8, `gh attestation verify` with the workflow token + (`attestations: read`), and the keyserver fetch from a runner. +- [ ] After publication: srcmorph builds against `llama:6.0.0` from Central -- locally verified + 2026-10-26 from an empty local repository (668 + 39 + 32 tests, BUILD SUCCESS); its TODO item + is closed in srcmorph#243. Open: that PR's CI, the first run of its Build, plugin IT, code + style, CodeQL and SonarQube jobs that can resolve the release. - [ ] After publication: the first snapshot dispatches of BitcoinAddressFinder and streambuffer on - actions v1.0.3 (none has run on `main` since the pin bumps), and the next one of this + actions v1.0.3 or later (none has run on `main` since the pin bumps), and the next one of this repository, which shows whether the re-created `snapshot` tag is accepted. ## Delivery roadmap — remaining work @@ -195,6 +209,22 @@ Kept as a list because each entry names the machine it needs -- the harness is Also check whether CUDA, Vulkan and OpenCL deduplicate the same NVIDIA GPU by `device_id` and which backend wins. Linux CUDA on a Tesla T4 has already been measured; see [the GPU module measurements](docs/benchmarks/gpu-module-jars.md). + **Measured 2026-10-26 on the published 6.0.0 jars** + ([`docs/benchmarks/gpu-module-jars.md`](docs/benchmarks/gpu-module-jars.md), section 3; RTX 3070 + Laptop + AMD iGPU, `-lv 4`): + - [x] Registration lists the discrete GPU twice (`CUDA0`, a `Vulkan` device). llama.cpp + **deduplicates it in device selection by PCI id** ("skipping device Vulkan0 … already using + device CUDA0 … with the same id"). The first registered backend wins (CUDA), and all 29 + layers go to `CUDA0`. + - [x] The iGPU is not drafted in, with Vulkan alone or with both modules. + - [x] OpenCL drops the NVIDIA GPU as unsupported (no device), so it takes no part. + - [x] CUDA, Vulkan and both are equal on Qwen2.5-1.5B Q4_K_M: ~190 t/s generation, ~2100–2500 + t/s prompt. + - [x] README sentence added next to the module list. The device numbering depends on the module + set (Vulkan alone: RTX = `Vulkan1`; with CUDA: RTX = `Vulkan0`), so a device name is valid + only for the set it was read from. + - [ ] Still open: the tg128 of the CUDA module against the static build's 265.6 t/s. That + figure used another model, so it needs the same model, `llama-bench`-style, in both builds. 2. **CUDA for Windows arm64 (`cuda13-windows-aarch64`) -- upstream builds it, we do not.** Their `windows-cuda` job is a matrix over `x64` and `arm64`, the latter through `cmake/arm64-windows-msvc-cuda` plus the arm64 `cudart`/`cublas` redistributables. This project has @@ -473,58 +503,64 @@ ROCm anyway. So this needs a real Radeon machine, or a deliberate decision to sh of a green link plus `verify-native-deps.py`'s dependency allowlist. Do not quietly treat "the job is green" as the measurement. -### Linux s390x: glibc 2.38 floor (after the release) - -**Measured 2026-10-25** on the CI artifacts of Publish runs 40217818548 and 40227501358. - -What each Linux CPU jar references: - -| jar | GLIBC | GLIBCXX | built with | -|---|---|---|---| -| `cpu-linux-x86-64` | 2.27 | 3.4.25 | GCC 14.2.1, manylinux_2_28 | -| `cpu-linux-aarch64` | 2.27 | 3.4.25 | GCC 14.2.1, manylinux_2_28 | -| `cpu-linux-s390x` | **2.38** | **3.4.32** | GCC 13.3.0, Ubuntu 24.04 cross toolchain | - -The 2.38 symbols are `__isoc23_strtol` / `strtoul` / `sscanf` and their relatives, and `fmod`. The -2.34 symbols are `pthread_*` and `dl*`. All of them are only the newest symbol versions of the -cross sysroot (glibc 2.39); no code needs them. - -The load was tested as a consumer does it: `llama` + `cpu-linux-s390x` from `llama-jars` on the -classpath, then `LlamaLoader.initialize()`, in Docker with s390x emulated by qemu. - -- **Fails** on RHEL 8.10 (glibc 2.28) with `UnsatisfiedLinkError … GLIBC_2.38 not found`. `ldd` - also lists `GLIBCXX_3.4.26`..`3.4.32` and `CXXABI_1.3.13`, so libstdc++ blocks it as well. -- **Fails** on RHEL 9 (2.34) and Ubuntu 22.04 (2.35). -- **Loads** on Ubuntu 24.04. -- **`ldd` clean** on SLES 15 SP6 (BCI, glibc 2.38), Debian 13 and Ubuntu 25.04. -- **Control:** `cpu-linux-x86-64` and `cpu-linux-aarch64` load on RHEL 8. - -**Not a decision but a side effect.** `GLIBC_CEILING` in `.github/buildcheck/nativedeps.py` holds -only the manylinux directories. Every other Linux build "inherits the floor of the ubuntu runner"; -CLAUDE.md justifies that for GPU modules only. `1559fe4c` never mentions s390x. The CI test cannot -see the gap: its `ctest` runs under qemu against the same 2.39 sysroot. The README and the -CHANGELOG now state the real floor. - -Follow-ups, none of them before the release (no build change now): - -- [ ] **Pin `build-linux-s390x` to `runs-on: ubuntu-24.04`** instead of `ubuntu-latest`. The output - stays identical (24.04 is `ubuntu-latest` today), but a runner image change can no longer - raise the floor to ~2.42 without anyone noticing. -- [ ] **`"Linux/s390x/cpu": (2, 38)` in `GLIBC_CEILING`.** This freezes today's floor, so - `verify-native-deps.py` fails loudly on any further rise instead of passing it. Extend - `test_nativedeps.py` with it. Optionally add a GLIBCXX ceiling too; the check reads GLIBC - only today. -- [ ] **The real fix: floor 2.28 like the other Linux jars.** Then set `(2, 28)`, and put back the - README row and CHANGELOG item 5. - - **Candidates** (measure them, do not guess): - - (a) build in `quay.io/pypa/manylinux_2_28_s390x` (gcc-toolset-14) as aarch64 does. No - GitHub s390x runner exists: either emulate (slow) or check whether IBM's runner offer for - open-source projects applies; - - (b) cross-compile against a glibc 2.28 s390x sysroot (AlmaLinux 8) with a newer cross GCC. - - **libstdc++:** either way it needs the gcc-toolset treatment (newer parts linked statically, - `GLIBCXX` ≤ 3.4.25). - - **Acceptance:** on real LinuxONE hardware, cfarm191/192 (`workspacePrivate/cfarm`), with - RHEL 8 in Docker as the quick check. +### Linux s390x: the glibc floor is 2.28 (fixed on `fix/s390x-glibc-floor`, 2026-10-26) + +**The defect, measured 2026-10-25** on the CI artifacts of Publish runs 40217818548 and 40227501358: +the 6.0.0 `cpu-linux-s390x` jar referenced `GLIBC_2.38` (`__isoc23_strtol` / `strtoul` / `sscanf` +and relatives, `fmod`), `GLIBCXX_3.4.32` and `CXXABI_1.3.13` -- only the newest symbol versions of +the ubuntu runner's cross sysroot (glibc 2.39, GCC 13.3.0), no code needed them. Loaded as a consumer +does it (`llama` + `cpu-linux-s390x`, `LlamaLoader.initialize()`, s390x under qemu in Docker) it +failed on RHEL 8.10 (`UnsatisfiedLinkError … GLIBC_2.38 not found`), RHEL 9 and Ubuntu 22.04, and +loaded on Ubuntu 24.04; x86-64 and aarch64 (2.27 / 3.4.25, manylinux_2_28) loaded on RHEL 8. Not a +decision but a side effect: `GLIBC_CEILING` held only the manylinux directories, and the qemu `ctest` +ran against the same new sysroot. + +**The fix** (CLAUDE.md, "Linux s390x"): clang-18 cross-compiles against the pypa +`manylinux_2_28_s390x` image unpacked as a sysroot, with gcc-toolset-14's libstdc++ treatment, on a +runner pinned to `ubuntu-24.04`. Measured locally (b11887): `GLIBC_2.27`, `GLIBCXX_3.4.25`, +`CXXABI_1.3.11`, new dependencies `libpthread.so.0` and `libdl.so.2`; 694/694 C++ tests under qemu +against the sysroot's glibc 2.28 and natively on cfarm191 (LinuxONE z16, Debian 13, 2.7 s); the jar +loads on RHEL 8.10 s390x (UBI 8 OpenJDK 21 runtime under qemu) where the 6.0.0 jar still fails with +`GLIBC_2.38 not found` -- the same script, so the check really catches it. + +- [x] `build-linux-s390x` pinned to `ubuntu-24.04` (the toolchain names `clang-18`). +- [x] `"Linux/s390x/cpu": (2, 28)` in `GLIBC_CEILING`, plus a libstdc++ ceiling for every floor + directory (`LIBSTDCXX_CEILING`: `GLIBCXX_3.4.25`, `CXXABI_1.3.11`, which x86-64, aarch64 and + the CUDA module already met); `test_nativedeps.py` covers both, and the job runs + `verify-native-deps.py` on its own tree. +- [x] The floor 2.28 itself: candidate (b) of this list, with clang instead of a cross GCC (none is + packaged for glibc 2.28). Candidate (a), the image's own gcc under qemu, would take hours per + uncached tag build. README row back to 2.28; the 6.0.0 CHANGELOG item 5 stays as it was (it is + what 6.0.0 shipped), [Unreleased] records the fix. +- [x] `smoke-glibc-floor`: the three Linux CPU jars are loaded on RHEL 8.10 in every run, s390x under + qemu; `check-natives.py` holds the rows to `floor-set` (derived from `GLIBC_CEILING`). +- [x] **The first Publish run of this branch** (40309665294, `c454baa5`) is green on every s390x job, + after the sysroot permission fix: + - `build-linux-s390x`: the sysroot came out at 289 MB with glibc 2.28; sccache wrapped clang + (260 requests, 99.62 % hits on the second run); the C++ suite passed 694/694 under qemu; + `verify-native-deps.py` reads `GLIBC_2.27`, `GLIBCXX_3.4.25`, `CXXABI_1.3.11`. + - All three `smoke-glibc-floor` rows passed, including s390x under qemu (the runner's binfmt + entry reached Docker). +- [x] **The CI-built jar on real IBM Z** (cfarm191, z16, Debian 13; 2026-10-26): + - `NativeLoadSmoke` loads `llama-6.1.0-SNAPSHOT-cpu-linux-s390x.jar` from that run: linked + b11887, `compiler=Clang 18.1.3`, the same patch hash as 6.0.0. + - **Real big-endian inference**, which no CI job does: Qwen3-0.6B Q4_K_M, converted with + llama.cpp's `gguf_convert_endian.py`, served by `ServerLauncher` on JDK 21, answers "Paris." + with `system_fingerprint` b11887-6050b467c. + - **Speed against the 6.0.0 GCC 13 jar**, same model, prompt and `-t 8`, three alternating + rounds on the shared host: generation 0.62–1.87 t/s (clang) against 0.31–0.93 t/s (GCC), + prompt 2.0–6.5 against 1.9–2.8 t/s. Clang was ahead in every round. Absolute numbers are + noisy and low. +- [ ] **s390x runs scalar.** Both the GCC and the clang build have `GGML_NATIVE=OFF`, so `GGML_VXE` is + off: no vector code for any IBM Z. ggml's `GGML_CPU_ALL_VARIANTS` has s390x variants for + z15–z17 (`-march=z15/z16/arch15` with VXE2, NNPA on z16+) that a `GGML_BACKEND_DL` build picks + at run time, as the x86 jars do. Moving s390x from the static single library to the modular + build with those variants would keep the z13 floor (a scalar baseline variant) and use the + vector unit where it exists. To check first: whether clang 18 builds the z15–z17 variants, and + what they gain on cfarm191. +- [ ] **Owner:** cfarm192 refused the connection with "Host key verification failed" on 2026-10-26 + (cfarm191 worked): its host key changed. Compare the new fingerprint with the farm's published + one before accepting it; nothing was changed in `known_hosts`. - **Ruled out:** `ubuntu-22.04` as the runner (glibc 2.35 still misses RHEL 8/9, and its cross GCC 11 cannot build llama.cpp, which needs GCC >= 12). `-static-libstdc++` alone is ruled out too: it fixes GLIBCXX but not GLIBC. @@ -596,3 +632,40 @@ measurements and is not evidence that GPU testing is currently blocked. - **REUSE attribution review:** check the remaining Java/documentation attribution to the original fork author with `git blame`, file by file. Attribution is not removed without an explicit owner decision. The generated dockcross wrappers already carry dockcross's own copyright attribution. +- **The two AAR poms ship `checker-qual` at `compile` scope, which the core deliberately does not** + (found by the post-release check of 6.0.0, `docs/post-release-check.md` check 14/16). Both + `llama-android-6.0.0.pom` and `llama-android-opencl-6.0.0.pom` carry + `org.checkerframework:checker-qual:4.3.1` with an explicit `compile`, so it is in + every Android consumer's dependency graph. In `llama/pom.xml` the same dependency is `provided`, and + the property's own comment says why: "neither ships (checker-qual is provided scope)" — its 417 + classes are all class-file major 55 with `@Retention(RUNTIME)`, which is exactly the + `UnsupportedClassVersionError` that #411 put on `main` (CLAUDE.md, "Java 8 bytecode floor"). + **Not a release blocker and not the Java 8 failure on Android**: D8/AGP consume major 55 fine, so + this is an avoidable dependency rather than a crash, which is why it ships in 6.0.0 unfixed. Two + parts to it, and the second is the reason it was never noticed: the scopes in those poms are + `llama-android/build.gradle.kts`'s own (only the *versions* are parsed out of the Maven poms), and + CLAUDE.md's "Android AAR + Kotlin façade" section describes the AAR pom as mirroring "the core's + compile-scope deps (jackson/slf4j-api/jspecify/checker-qual)" — but checker-qual is not a + compile-scope dep of the core. Fix the Gradle build (drop it, or mirror it as `provided`) and + correct that sentence in the same commit; `jspecify` is the one of the four that is genuinely + compile scope and stays. A cheap guard would be to assert the published AAR poms' scopes against + the core pom's, the way the versions are already derived from it. + **Fixed on `fix/s390x-glibc-floor` (2026-10-26)**, CLAUDE.md "Android AAR + Kotlin façade": + - [x] Dropped, not mirrored as `provided` (a Gradle consumer ignores `provided` in a pom, so it + would document nothing). The AAR build no longer keeps a list at all: `coreCompileDependencies` + reads the core's compile-scope, non-optional dependencies from `llama/pom.xml`, versions and + scopes -- jackson-databind, slf4j-api **and** jspecify are compile scope there (all three + stay; the sentence above undercounts). The Gradle-generated poms (Docker, `gradle:9.8.2`) list + exactly those three, plus the CPU AAR in the OpenCL one. + - [x] The guard: `.github/verify-aar-poms.py` (`buildcheck/aarpoms.py`, `test_aarpoms.py`) checks + the poms `publishToMavenLocal` wrote in `package-android-aar`. Run on the two 6.0.0 poms from + Central it reports checker-qual and nothing else. + - [x] The CLAUDE.md sentence corrected; the "provided" bullet there now also says what the TODO did + not: **the classes do reference checker-qual** -- the Nullness Checker writes its qualifiers + into 133 of the core's 146 class files (RUNTIME retention). So dropping the dependency needs + `-dontwarn org.checkerframework.**` in `consumer-proguard.txt`, or a minified app fails R8 on + "Missing class"; added, like lombok and animal-sniffer. + - [ ] **Read the first Publish run:** `package-android-aar`'s new step, the consumer fixture's R8 + `assembleRelease` (cpu and opencl) now that checker-qual is off its classpath, and the + emulator instrumentation test (Jackson reflecting over the annotated classes without + checker-qual -- ART is expected to skip the unresolvable annotation types, as the JVM does). diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 895002b4a..222ea8600 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -5,6 +5,12 @@ The maintainer-facing release procedure is **centralized in the workspace repo** java-llama.cpp is a **Maven reactor**, so two repo-specific points extend the canonical procedure. +Once a release is published, audit it from the outside with +[`post-release-check.md`](post-release-check.md): that file checks the artifacts on the GitHub +release, on Maven Central and in a real JVM, deliberately without trusting the run that built them. +`.github/workflows/post-release.yml` runs every check a runner can answer by itself after the +Publish run of the tag; read its result before announcing the release. + ## Reactor version bump (all six poms) The root `pom.xml` is the parent (`net.ladenthin:llama-parent`); the `llama/`, diff --git a/docs/benchmarks/gpu-module-jars.md b/docs/benchmarks/gpu-module-jars.md index d3443cf81..64bf76177 100644 --- a/docs/benchmarks/gpu-module-jars.md +++ b/docs/benchmarks/gpu-module-jars.md @@ -215,6 +215,55 @@ jars is "a module that cannot help is skipped, one that can help is used" — Op neither on a CPU-only host. The README says that the jar needs an Intel GPU/NPU *and* an OpenCL ICD loader. +## 3 · `cuda13` and `vulkan` together on one NVIDIA GPU: deduplicated, CUDA wins + +Measured **2026-10-26** on a Windows laptop with an NVIDIA RTX 3070 Laptop GPU (8 GB, driver 581.83, +CUDA 13.3 toolkit on `PATH`) and an AMD Radeon iGPU. The jars were the **published 6.0.0** set: +`llama`, `cpu-windows-x86-64`, `cuda13-windows-x86-64` and `vulkan-windows-x86-64`. The test ran the +embedded server on JDK 21, with `Qwen2.5-1.5B-Instruct-Q4_K_M` at `-c 4096 -ngl 99 -lv 4` and three +identical chat requests of 128 tokens each (`cache_prompt: false`). `-Dnet.ladenthin.llama.backend` +selected the set without swapping jars. + +The question came from the post-release check of 6.0.0. The registry lists the discrete GPU twice, +as `CUDA0` and as a Vulkan device. Does llama.cpp use it twice, or once, and through which backend? +At the default verbosity the answer was filtered out of the log; at `-lv 4` it is printed: + +``` +load_backends_next_to_this_library: 4 backend(s) registered [CUDA, RPC, Vulkan, CPU], 4 device(s): CUDA0 (CUDA), Vulkan0 (Vulkan), Vulkan1 (Vulkan), CPU (CPU) +llama_prepare_model_devices: skipping device Vulkan0 (NVIDIA GeForce RTX 3070 Laptop GPU) with id 0000:01:00.0 - already using device CUDA0 (NVIDIA GeForce RTX 3070 Laptop GPU) with the same id +llama_prepare_model_devices: using device CUDA0 (NVIDIA GeForce RTX 3070 Laptop GPU) (0000:01:00.0) - 7116 MiB free +load_tensors: offloaded 29/29 layers to GPU +load_tensors: CUDA0 model buffer size = 934.70 MiB +``` + +| Set (`-Dnet.ladenthin.llama.backend`) | Device used | Prompt t/s (requests 2, 3) | Generation t/s (requests 1–3) | +|---|---|--:|--:| +| `cuda13` | `CUDA0` | 2357, 2370 | 186.5, 193.5, 187.7 | +| `vulkan` | `Vulkan1` (the RTX) | 2134, 2120 | 193.6, 189.3, 188.7 | +| both (unset) | `CUDA0`; `Vulkan0` skipped | 2571, 2426 / 2231, 2013 | 185.9, 194.6, 190.9 / 179.7, 190.8, 192.6 | + +The first request of each server is left out of the prompt column: it includes warm-up (950–1400 t/s). +The two "both" rows are two separate server runs. + +- **Deduplication happens in device selection, by PCI id, and the first registered backend wins.** + CUDA registers before Vulkan, so the model goes to `CUDA0`. No VRAM is spent twice, and nothing is + split between two handles of the same GPU. +- **The iGPU is not drafted in.** Only the discrete GPU is used, with Vulkan alone as well as with + both modules. That agrees with the earlier throughput pair on this host: 4 devices at 92.0 t/s + against cuda13 alone at 94.3 t/s. +- **CUDA and Vulkan are equal on this model** (~190 t/s generation, within noise). The 1.5B model is + small; a larger one may separate them. The tg128 comparison against the static build's 265.6 t/s + in TODO.md used a different model and is not answered by this table. +- **OpenCL does not take the NVIDIA GPU at all.** With `opencl-windows-x86-64` added as a third + module, ggml's OpenCL backend selects the "NVIDIA CUDA" platform, then logs `unsupported GPU + 'NVIDIA GeForce RTX 3070 Laptop GPU'` and `drop unsupported device`. It registers as a backend with + no device: 5 backends, still 4 devices. The model goes to `CUDA0` as before (generation 182–190 + t/s), because ggml's OpenCL backend targets Qualcomm Adreno and Intel GPUs. +- **Vulkan's device order depends on the modules loaded.** With Vulkan alone, `ggml_vulkan: 0` is the + AMD iGPU and `1` the RTX. With CUDA loaded as well, `0` is the RTX and `1` the iGPU. A device name + such as `Vulkan1` therefore names a different GPU depending on which module jars are on the + classpath; the README says so next to the module list. + ## What these runs did *not* test * **No Intel GPU or NPU was available**, so nothing here says what OpenVINO does on the hardware it diff --git a/docs/local-test-plan.md b/docs/local-test-plan.md index dd7811e93..48bc71d04 100644 --- a/docs/local-test-plan.md +++ b/docs/local-test-plan.md @@ -22,6 +22,7 @@ expectation phrased like a result. | the C++ suite on x86-64, aarch64, s390x (big-endian, under qemu) | `C++ Tests …`, `build-linux-s390x` | | every native build of the 23 natives jars (8 library, 15 GPU module), plus their dependency allowlists | the build jobs, `package` | | the published jars launched as a consumer's classpath (library jar + every GPU module jar of the platform, the modules skipped for lack of a runtime) | the `smoke-natives` matrix, `smoke-natives-macos`, `smoke-agent-linux` | +| the three Linux CPU jars loaded on their glibc floor, RHEL 8.10 (glibc 2.28), each on its own architecture (s390x under qemu) | the `smoke-glibc-floor` matrix | | the agent's `--web` at HTTP level (401, token cookie, console page) and `--acp` at protocol level | `smoke-agent-linux` | So a green CI run already means: it builds everywhere, the unit suites pass, the jars load, and the diff --git a/docs/post-release-check.md b/docs/post-release-check.md new file mode 100644 index 000000000..b36d319a6 --- /dev/null +++ b/docs/post-release-check.md @@ -0,0 +1,374 @@ + + +# Post-release check + +What to verify **after** a release has been published, from the outside: against the GitHub +release, Maven Central and a real JVM, not against the build tree it came from. + +This is not the release procedure — that is [`RELEASE.md`](RELEASE.md) and the canonical +[workspace release procedure](../../workspace/workflows/release-process.md). Those describe how a +release is *made*; this file describes how a release is *audited once it exists*. The two are kept +apart on purpose: a green pipeline is evidence about the build, and every failure class recorded in +`CLAUDE.md` that reached a user reached them *through* a green pipeline — a jar that loaded on the +runner and nowhere else, a dylib assembled from two artifacts, a model-gated suite that reported +nothing and passed. So every check below is phrased to be answerable **without trusting the run that +produced the artifacts**, and several deliberately recompute from the tagged sources what the +artifacts merely claim. + +**Run it on the published artifacts, every release.** A check that can only say "CI was green" does +not belong in this file. + +## Automated: `.github/workflows/post-release.yml` + +Every check below that a GitHub runner can answer runs by itself after the release: the workflow +starts when a Publish run dispatched on a `v*` tag succeeds (a release created with the workflow +token starts no `release:` workflow), and by hand with `workflow_dispatch` and the tag. The tools and +the settings come from the default branch, the release's own facts (contract, `natives.csv`, +patches, poms) from a second checkout of the tag, so an older release is audited with today's checks +too. Run against 6.0.0 it reports exactly the one finding recorded below, the AAR poms' checker-qual. + +| Check | Where it runs | +|---|---| +| 1 ref/run/tag, 2 jobs, 4 completeness, 5 Central, 6 signatures, 7 provenance, 10 bytecode, 16 next version | `audit` job: `java/audit-publication` of [bernardladenthin/actions](https://github.com/bernardladenthin/actions) (shared by every repository there; settings in the `post-release` section of `.github/actions-config.json`) | +| 3 cache-free build, 8 natives jars, 9 provenance recomputed, 13 source archive, 14 Android | `audit` job: `.github/post-release-check.py` (`buildcheck/postrelease.py`), on the files the action downloaded | +| 11 consumer resolution | `consumer` matrix: `.github/post-release-consumer.sh` -- `llama-platform` plus a GPU classifier into an empty local repository | +| 12 runs on the floor JDK | `consumer` matrix on Java 8 (Linux x86-64 and aarch64, Windows x86-64, macOS arm64): the server smoke of the natives jars on the resolved set -- inference, TLS, an `https://` model download, the natives directory | +| 15 server and WebUI | `consumer`, Linux x86-64: `.github/post-release-webui.sh` | + +**Still by hand:** the GPU half of check 12 (no GPU runner: a real driver loading a shipped module, +the device list, the throughput baseline) and reading check 16's standing gaps. The automated checks +are what to read first; the sections below say what each one proves and how to run it locally. + +## Conventions + +Each check states **what it proves** (the failure class it would catch), **how** to run it, and the +**pass criterion**. Nothing is hardcoded to a version: the expected sets are derived from the +repository at the tag — above all [`.github/natives.csv`](../.github/natives.csv), the one list every +packaging step reads (`CLAUDE.md`, "Natives jars"). Throughout: + +```bash +VERSION= # e.g. 6.0.0 +TAG="v$VERSION" +RUN= # the workflow_dispatch run on $TAG +GH=bernardladenthin/java-llama.cpp +CENTRAL=https://repo1.maven.org/maven2/net/ladenthin +``` + +Work in a scratch directory, and keep the downloaded artifacts in a directory of their own — they +are untrusted input until checked, so no script is run from inside it. + +```bash +gh release download "$TAG" --dir assets +``` + +The whole publication is a few hundred MB (the two CUDA module jars dominate). Download it once: +every check below reads those bytes, and Central is proven equal to them in check 6 rather than +fetched a second time. + +## 1. The ref, the run and the tag agree + +**What it proves.** That the artifacts came from the commit the tag names, through the publishing +workflow, and not from a re-pointed tag or a second attempt whose inputs differed. A tag is a moving +reference; during release preparation it is routinely deleted and re-created, so a cancelled run on +an earlier commit is normal and the only safe statement is the one tying *tag → commit → run → +artifact* together. + +```bash +git fetch origin --tags --force +git rev-parse "$TAG^{commit}" +gh run list --workflow publish.yml --limit 10 \ + --json databaseId,headBranch,headSha,event,conclusion,runAttempt +``` + +**Pass.** Exactly one successful `workflow_dispatch` run on `$TAG`; its `headSha` equals the tag +commit; the tag commit is an ancestor of `main`. Note the attempt number — a re-run attempt is not +wrong, but every later check must then name that same attempt. + +## 2. Every job either succeeded or was skipped for a stated reason + +**What it proves.** That nothing red hid behind a release gate, and that the only skipped jobs are +the ones the other channel owns. A skip is not benign: GitHub propagates a skip down the whole +`needs:` chain past any intervening `if:`, which once turned a run "successful" while `package`, +every smoke set and both publish jobs did nothing (`CLAUDE.md`, "Code signing"). + +```bash +gh run view "$RUN" --json jobs \ + --jq '.jobs | group_by(.conclusion) | map({c:.[0].conclusion, n:length})' +gh run view "$RUN" --json jobs --jq '.jobs[] | select(.conclusion!="success") | .conclusion+" "+.name' +``` + +**Pass.** No failure and no cancellation. The skipped set is exactly the *other* channel's jobs — on +a release run, the snapshot channel check, its gate, its Central upload, its GitHub pre-release and +its attestation. Any other skip is a finding, not a detail. + +## 3. The build was cache-free + +**What it proves.** That the shared compiler cache is outside the release's trust chain. A `v*` tag +build must be from scratch whatever the dispatch input said (`CLAUDE.md`, "CI build cache"), so no +shipped object can come out of a third-party cache. + +```bash +gh api "repos/$GH/actions/jobs//logs" | grep -iE 'USE_CACHE|sccache (ON|OFF)' +``` + +**Pass.** `USE_CACHE: false` in the job environment **and** the build script's own line reporting the +launcher off. Both matter: the first is the intent, the second is what the build did — the build +scripts self-enable a cache they find on `PATH` unless explicitly told not to, which has silently +re-enabled one before. + +## 4. The publication is complete, and its size is derived rather than remembered + +**What it proves.** That no artifact is missing and none is unexpected. The number to compare against +is computed from the repository, never copied from the last release: the natives rows of +`natives.csv`, the reactor modules, the standalone agent and the two Android publications. + +```bash +gh release view "$TAG" --json assets --jq '.assets[] | "\(.size)\t\(.name)"' +``` + +Every file carries a detached `.asc`, so the asset count is even and exactly twice the file count. +Derive the expected Central set: for the core, the classes jar + sources + javadoc + pom + CycloneDX +plus one jar per `natives.csv` row; for each other reactor module its own four or five; for the agent +and the two AARs theirs. The GitHub set is that plus the **signed llama.cpp source archive**, which is +a GitHub-only file of the publication (`CLAUDE.md`, "Source archive"). + +**Pass.** The derived Central count equals the count the publication gate reported ("Publication +ready: … N Central artifacts"), and the GitHub file count is that plus one. Two independent routes to +the same number; agreeing on a wrong number requires both to be wrong the same way. + +## 5. Presence on Maven Central + +**What it proves.** That what was uploaded is actually resolvable. The Portal accepts a bundle and +validates asynchronously, so the gate's success is not evidence that the artifacts are published. + +```bash +for c in $(grep -v '^#' .github/natives.csv | tail -n +2 | cut -d, -f1); do + curl -s -o /dev/null -w "%{http_code} $c\n" "$CENTRAL/llama/$VERSION/llama-$VERSION-$c.jar" +done +``` + +**Pass.** HTTP 200 for every natives jar and its `.asc`, for every module's jar/sources/javadoc/pom, +for both AARs and for every pom-only module. A classifier that 404s is invisible to CI, because CI +never resolves from Central. + +## 6. Every signature verifies, from one key + +**What it proves.** That the publication is signed by the release key throughout, not partly. The +publication gate verifies signatures as well, but with the key the runner imported; this re-verifies +with the key a consumer would trust, and additionally pins that **one** signing subkey signed +everything. + +```bash +cd assets +for a in *.asc; do gpg --status-fd 1 --verify "$a" "${a%.asc}" | grep -oE 'VALIDSIG [A-F0-9]+'; done \ + | awk '{print $2}' | sort | uniq -c +gpg --list-keys --with-subkey-fingerprints +``` + +**Pass.** `GOODSIG` for every file; a single `VALIDSIG` fingerprint across all of them; and that +fingerprint is the `[S]` subkey of the release key. A mixed set of signing keys is a finding even when +every signature is individually good. + +## 7. Build provenance covers every file + +**What it proves.** That each file was produced by this workflow at this commit — which the `.asc` +cannot say, since it attests the key and not the build (`CLAUDE.md`, "Modular natives"). Checking a +sample is not enough: the question is whether anything is *un*attested. + +```bash +gh attestation verify --owner bernardladenthin --format json "assets/llama-$VERSION.jar" > att.json +``` + +Then, from the attestations' subject lists, confirm every local file appears **and** its SHA-256 +matches the recorded digest, and report any local file that appears in neither list. + +**Pass.** Each attestation names `refs/tags/$TAG`, the tag commit, `publish.yml` and the run (and +attempt) from check 1; every published file is covered with a matching digest; nothing is unattested. +Expect more than one attestation and more subjects than files — the `package` job attests the +individual native libraries too — so a subject that is not a release file is normal, while a release +file that is not a subject is not. + +## 8. The natives jars match the list + +**What it proves.** That each jar is the one `natives.csv` declares and carries its provenance +record. This is the merge step's check re-run on the *published* jar: the same properties, read from +the shipped bytes rather than from the build tree. + +Per row of `natives.csv`, assert the jar holds exactly one directory +`net/ladenthin/llama//` (plus `META-INF/`), contains the declared library, carries +`jllama-build.txt` with **all** provenance keys — `llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, +`classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, +`signed` — whose `classifier`, `kind` and `jllama.version` equal the row and the release, has +`jllama-sbom.json` beside it, and declares +`Automatic-Module-Name: net.ladenthin.llama.natives.`. Unfold the manifest's +72-byte line wraps before reading that name: a wrapped value looks truncated and is not. A `module` +row holds exactly four files — the module, the two list files and the BOM. + +**Pass.** Zero disagreements over all rows. Also compare the per-directory file counts with the ones +`CLAUDE.md` documents per platform ("Modular natives"): a plausible-looking count that moved is how a +dropped CPU variant would present. + +## 9. The provenance is internally consistent, and the patch set is recomputed + +**What it proves.** That the binaries were built from the tagged sources — and this is the one check +that takes the artifact's word for nothing. `patches.sha256` is a hash over ` \n` per +patch; recomputing it from the patches committed at the tag ties the shipped binaries to the reviewed +patch set. Reading the key out of the jar proves only that the jar is self-consistent. + +```bash +git ls-tree --name-only "$TAG" llama/patches/ | sort # extract each with git show +for f in *.patch; do printf '%s %s\n' "$f" "$(sha256sum "$f" | cut -d' ' -f1)"; done | sha256sum +``` + +**Pass.** One `llama.cpp` tag and one `llama.cpp.commit` across all natives jars; `jllama.version` +the release; `jllama.commit` the tag commit **with no `-dirty` suffix**; and the recomputed hash equal +to the `patches.sha256` every jar declares. Record each GPU jar's `toolkit` as well — it is the vendor +SDK that module was built against, and nothing else reports it. + +## 10. The bytecode floors + +**What it proves.** That the artifact a consumer loads can be loaded by the JVM the README promises. +The core's floor is Java 8 (major 52), held by two deliberate pom decisions that are easy to undo by +accident (`CLAUDE.md`, "Java 8 bytecode floor"). The other modules have higher floors on purpose, so +a single global threshold would be wrong. + +Scan every class of every jar, skipping `module-info.class` and `META-INF/versions/**` (a classpath +JVM loads neither): + +| Artifact | Expected class-file major | +|---|---| +| the classes jar and every natives jar | 52, plus a single `module-info` at 53 | +| `llama-kotlin` | 52 | +| `llama-langchain4j` | 61 (Java 17) | +| `llama-ffm` | 66 (Java 22) | +| the standalone agent | 65 (Java 21) | +| each AAR's `classes.jar` | 52, and the classes jar's count minus `module-info` | + +**Pass.** No class above its artifact's floor — and an empty scan is a failure rather than a pass: a +directory a failed build left empty is the reason that rule exists. + +## 11. A consumer can resolve it + +**What it proves.** That the published poms resolve from Central for a real build, property +substitutions included — those come only from the parent pom. CI never resolves the release from +Central, so nothing upstream of this check has tried it. + +Write a throwaway consumer pom depending on `llama-platform` (`pom`) plus a GPU +classifier or two, and resolve it against an **empty local repository**: + +```bash +mvn -B -Dmaven.repo.local=./m2 dependency:tree +mvn -B -Dmaven.repo.local=./m2 dependency:copy-dependencies -DoutputDirectory=./cp -DincludeScope=runtime +``` + +**Pass.** `llama-platform` brings the classes jar and the library jar of every `platform=yes` row; the +classes jar brings jspecify, Jackson and `slf4j-api` at compile and `slf4j-simple` at runtime; and the +graph contains **neither logback nor checker-qual** — both would break a Java 8 consumer, and both are +kept out by pom scope alone, which no compile-time check can see. + +## 12. It runs — on the oldest supported JDK + +**What it proves.** The thing nothing else proves. CI runs the jars on its runners; this runs the +*published* jars, resolved from Central, on the floor JDK. And a load is not the check: in the Windows +measurements recorded in `CLAUDE.md` every crash came *after* a successful load that reported the +right device count, so the criterion is real inference, not initialization. + +On the classpath from check 11, compile a small driver with the **floor JDK's** `javac` against the +published jars and run it on that JDK. Assert, in order: the provenance map (`getJllamaBuildInfo()`) +matches check 9; `getLlamaCppBuildInfo()` starts with the `LlamaCppVersion` constant — the pure-Java +pin and the linked binary must agree; a streamed generation yields tokens; a tokenize/detokenize round +trip; a blocking completion. Then read the loader's own line from stderr. + +**Pass.** Every assertion holds on the floor JDK, with no `UnsupportedClassVersionError` and no SLF4J +provider complaint (that is check 11's pom scopes, observed at runtime). The loader line names the +library backend **and every GPU module jar on the classpath**, and ggml's registry line names the +backends and devices it ended up with — on a GPU host the accelerator must appear, which is the only +evidence that a shipped GPU module opened against a real driver instead of failing silently. Record +the throughput; it is the baseline the next release is read against. + +## 13. The source archive stands on its own + +**What it proves.** That the release stays rebuildable without upstream. The archive is the pinned +upstream commit plus `llama/patches/`, and because `FetchContent` skips population for an overridden +source directory, the applier never runs for a consumer rebuilding from it — the stamp is what proves +the patches are in (`CLAUDE.md`, "Source archive"). + +```bash +tar -xzf "assets/llama.cpp--jllama-$VERSION-src.tar.gz" +cat /.jllama-patches-applied +``` + +**Pass.** The stamp's `head` equals the `llama.cpp.commit` the natives jars declare; its per-patch +hashes equal the tag's patches line for line (the same lines check 9 recomputed); and each patch's +distinctive added symbol is actually present in the unpacked tree — the stamp records intent, a grep +for what each patch adds records the result. The pack is reproducible: a single mtime (the commit's +time), numeric owner `0/0`, no owner names. Entry order is GNU tar's per-directory `--sort=name`, so +it is deterministic without being flat lexicographic — do not read that as unsorted. + +## 14. The Android artifacts + +**What it proves.** The two invariants that make the AARs consumable, each of which has failed before: +Google Play's 16 KB page size, and a `DT_NEEDED` set bionic can satisfy. + +**Pass.** Both AARs declare the documented `minSdkVersion`; the CPU AAR's `classes.jar` is the classes +jar minus `module-info` and it carries every ABI's library set under `jni//`; the additive AAR +holds exactly its one module, an **empty** `classes.jar` (duplicating the API breaks D8) and a pom +depending on the CPU AAR at `aar` and the same version; both carry their consumer +ProGuard rules; and **every** `LOAD` segment of **every** released Android ELF — in the AARs and in +the Android natives jars alike — is aligned to at least 16384. + +## 15. The server surface and the embedded WebUI + +**What it proves.** That the headline consumer-facing surface works from the published jars: the full +upstream server inside `libjllama`, with the WebUI assets compiled in. A local `cmake` build uses the +empty-asset stub, so "the WebUI is served" is a property of the *released* jar specifically and of no +build a developer makes. + +Start `ServerLauncher` from the check-11 classpath with a model, then probe it. + +**Pass.** `/health` answers 200; `GET /` answers 200 with `Content-Type: text/html` and +`Content-Encoding: gzip`, and the body decompresses to the app shell rather than to an error page; +`/props` answers; and a `/v1/chat/completions` request returns a choice whose `system_fingerprint` is +the linked build from check 9. An empty-asset stub answers `/` with 404 — that is the single +observation separating a released jar from a local one. + +## 16. Standing gaps to re-read every release + +Not failures — properties of the current release that a reader should be told rather than left to +discover. Each is observed by a check above and is expected to read a certain way until the work +behind it lands. + +- **`signed=no` in every natives jar.** The Windows DLLs are not Authenticode-signed; the signing path + exists and stays inert until the certificate and its four secrets do (`CLAUDE.md`, "Code signing"). + The compensating controls are the detached `.asc` and the Sigstore attestation of check 7. Re-read + `signed=` each release: once a release signs, the value flips and + `verify-native-deps.py --require-signed` becomes the gate. +- **The next development version: bump the _minor_, not the patch.** After a release, `main` moves to + the next **minor** `-SNAPSHOT` (`6.0.0` → `6.1.0-SNAPSHOT`), deliberately leaving the patch number + free so an emergency release off the released line can always take it (`6.0.1`). The bump covers all + six reactor poms **and** the standalone agent pom — `versions:set` reaches the reactor but not the + agent, so that one is edited by hand (`CLAUDE.md`, "Repository layout"). + + Until the bump lands, `main` still names the released version, and the next snapshot publication is + refused by its own channel check — correctly, since that channel only publishes a `-SNAPSHOT`, but + the refusal reads like a workflow defect. Right after a release the bump is commonly still in flight + on a branch, which is normal; what this check asks is that it is not *forgotten*. So read it off + `main` and, when it is not there yet, name the branch that carries it: + + ```bash + git show origin/main:pom.xml | grep -m1 -A1 llama-parent + git branch -r --contains + ``` +- **Scope drift between the reactor poms and the Gradle-built AAR poms.** Up to 6.0.0 the AAR poms + took their dependency *versions* from the core pom, but their *list and scopes* were the Gradle + build's own, and 6.0.0 shipped checker-qual at `compile` that way. Since 6.1.0 the list is read + from the core pom as well and `package-android-aar` checks the result + (`.github/verify-aar-poms.py`), so this is now a confirmation, not a hunt: run the same check on the + poms resolved from Central — + `python3 .github/verify-aar-poms.py . llama-android-.pom llama-android-opencl-.pom` (at the + tag) — and a dependency the core keeps out of the consumer graph at `provided` must not appear in + either. diff --git a/docs/windows-intel-core-ultra.md b/docs/windows-intel-core-ultra.md index eb159704d..c52171a98 100644 --- a/docs/windows-intel-core-ultra.md +++ b/docs/windows-intel-core-ultra.md @@ -51,16 +51,19 @@ you need. The agent is Java 21 bytecode; on a JDK 8 it fails in the compiler with `invalid target release: 21` and no flag helps. So the server runs on the 8, the agent on the 21, and `JAVA_HOME` decides which — -per window, because `exec:java` runs in Maven's own JVM: +set per window, because `exec:java` runs in Maven's own JVM. Both steps below open with exactly these +two lines, naming the JDK that step needs: -```powershell -# the window that starts the agent -$env:JAVA_HOME = 'C:\Program Files\Java\openjdk21.0.11' -mvn -v # must report: Java version: 21... +```bat +set "JAVA_HOME=C:\Program Files\Java\openjdk1.8.0_491" +set "PATH=%JAVA_HOME%\bin;%PATH%" +mvn -v ``` -Leave the machine-wide `JAVA_HOME` on the JDK 8 so the server keeps working in every other window. -Nothing else needs setting: Maven resolves the classpath from `~\.m2\repository` itself. +`mvn -v` must then report that JDK (`Java version: 1.8.0_491`, or `21.0.11` for the agent) — that +number decides, not `java -version`. The two `set` lines live and die with the window, so the +machine-wide `JAVA_HOME` can stay wherever it is. Nothing else needs setting: Maven resolves the +classpath from `%USERPROFILE%\.m2\repository` itself. ## Step 1 — two poms @@ -143,8 +146,8 @@ excluded because the agent's published pom pulls the library jars of all six des - + net.ladenthin llama @@ -176,11 +179,14 @@ excluded because the agent's published pom pulls the library jars of all six des ## Step 2 — pre-load everything, once Once per pom, with network. This downloads every artifact, every transitive dependency **and the -plugins** into `~\.m2\repository`: +plugins** into `%USERPROFILE%\.m2\repository`: -```powershell -cd C:\llama\server ; mvn dependency:go-offline -cd C:\llama\agent ; mvn dependency:go-offline +```bat +cd /d C:\llama\server +mvn dependency:go-offline + +cd /d C:\llama\agent +mvn dependency:go-offline ``` Either JDK does this — Maven only downloads here, it neither compiles nor loads the jars, so the @@ -197,9 +203,12 @@ what runs — the effective classpath still honours the exclusion. Put the GGUF anywhere, e.g. `C:\models\`. Any tool-capable instruct model works; a reasonable default here is Qwen3-4B-Instruct-2507-Q4_K_M (2.3 GB). -```powershell -cd C:\llama\server -mvn -o exec:java "-Dexec.args=-m C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --host 127.0.0.1 --port 9931 -c 16384 -ngl 99 -t 8 -tb 10 --jinja" +```bat +set "JAVA_HOME=C:\Program Files\Java\openjdk1.8.0_491" +set "PATH=%JAVA_HOME%\bin;%PATH%" + +cd /d C:\llama\server +mvn -o exec:java -Dexec.args="-m C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --host 127.0.0.1 --port 9931 -c 16384 -ngl 99 -t 8 -tb 10 --jinja" ``` This is the **real upstream `llama_server` running inside `libjllama`** over JNI, so every @@ -207,11 +216,13 @@ llama-server flag works verbatim and the bundled WebUI is served. `--jinja` enab template, which is what makes tool calling work — the agent in step 4 needs it. `-ngl 99` offloads to the iGPU through Vulkan; drop it to compare against the CPU. -Test the port from another window: +Test the port from another window (`curl.exe`, not `curl` — in PowerShell 5.1 `curl` is an alias for +`Invoke-WebRequest`): + +```bat +curl.exe http://127.0.0.1:9931/health -```powershell -Invoke-RestMethod http://127.0.0.1:9931/health -Invoke-RestMethod -Method Post http://127.0.0.1:9931/v1/chat/completions -ContentType application/json -Body '{"messages":[{"role":"user","content":"2+2=?"}],"max_tokens":16}' +curl.exe http://127.0.0.1:9931/v1/chat/completions -H "Content-Type: application/json" -d "{\"messages\":[{\"role\":\"user\",\"content\":\"2+2=?\"}],\"max_tokens\":16}" ``` and open in a browser for the WebUI. The OpenAI-compatible API lives on the @@ -220,36 +231,70 @@ same port under `/v1/…`. (To embed the server in your own Java 8 code instead, ## Step 4 — the Atmosphere agent on the JDK 21 -Leave the server running. In a new window, point `JAVA_HOME` at the 21 and start the agent against -that server — the model stays loaded once, in the server, and the agent drives it over HTTP: +Leave the server running. In a **new** window — this is where the other JDK goes — start the agent +against that server, so the model stays loaded once, in the server, and the agent drives it over +HTTP: -```powershell -$env:JAVA_HOME = 'C:\Program Files\Java\openjdk21.0.11' -cd C:\llama\agent -mvn -o -q exec:java "-Dexec.args=--base-url http://127.0.0.1:9931/v1 --workspace C:\path\to\project --allow-shell" +```bat +set "JAVA_HOME=C:\Program Files\Java\openjdk21.0.11" +set "PATH=%JAVA_HOME%\bin;%PATH%" + +cd /d C:\llama\agent +mvn -o -q exec:java -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace C:\path\to\project --allow-shell" ``` A `you>` prompt appears. `/clear` drops the history, `/exit` quits, `--prompt "…"` runs a single turn and exits, `--plain` switches to a line-oriented console for piping or logging. -For **Atmosphere's web console on its own port** instead of the terminal, add `--web`; it listens on -`127.0.0.1:8787` and prints a URL with a fresh access token on every start: +> [!IMPORTANT] +> **The agent's flags are not the server's.** The server takes llama.cpp's argv verbatim (`-m`, `-c`, +> `-ngl`), the agent has its own parser and accepts only the long forms — `--model`, `--ctx-size`, +> `--ngl`/`--gpu-layers`. Passing the server's `-m` to the agent fails with `unknown argument: -m` +> followed by its usage block. Flags it does not know are never ignored, which is deliberate. + +**Atmosphere's web console** is `--web`. It is not an addition to the terminal but a replacement for +it: `--web`, `--acp` and `--prompt` exclude each other, so one agent process serves exactly one +front end. To have both, start a **second** agent process against the same server — the model stays +loaded once, in the server, and each agent is only an HTTP client of it. A third window, the JDK 21 +again: + +```bat +set "JAVA_HOME=C:\Program Files\Java\openjdk21.0.11" +set "PATH=%JAVA_HOME%\bin;%PATH%" -```powershell -mvn -o -q exec:java "-Dexec.args=--base-url http://127.0.0.1:9931/v1 --workspace C:\path\to\project --allow-shell --web --web-port 8787 --web-token my-own-access-token-123" +cd /d C:\llama\agent +mvn -o -q exec:java -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace C:\path\to\project --allow-shell --web --web-port 8787" ``` -`--web-port 0` takes any free port, and `--web-host 0.0.0.0` exists but warns loudly — tunnel over -SSH rather than binding to the network. One conversation is served: every tab holding the token -shares it. For an IDE, `--acp` speaks the [Agent Client -Protocol](https://agentclientprotocol.com) on stdin/stdout instead; `--web`, `--acp` and `--prompt` -exclude each other. +It prints `Open in a browser: http://127.0.0.1:8787/?token=`. **The token is part of the +URL** and is a new random one on every start, unless `--web-token` pins it (16 or more characters of +`A-Z a-z 0-9 _ -`) — which is what makes the address worth bookmarking. `--web-port 0` takes any +free port. Binding is loopback only, and `--web-host 0.0.0.0` exists but warns loudly: tunnel with +`ssh -L 8787:127.0.0.1:8787 @` and open the same address on the other machine +instead. One conversation per process — every tab holding the token shares it, and a message sent +while another is running replaces it, as typing over a running turn does on the console. +`/mode auto` in the browser stops the approval prompts for the session. The console agent in the +other window keeps its **own** history: the two share the model, not the conversation. + +The console's pages come from `atmosphere-spring-boot-starter`, which the agent pom carries for +those resources alone (every transitive Spring dependency excluded), so Step 2 already cached them +and `--web` works offline. If they were missing, the agent says so on stderr and still serves the +endpoint. + +> [!NOTE] +> The server of Step 3 runs with one slot, so two agents **serialise**: the second waits for the +> first to finish its turn. `--parallel 2` on the server gives each its own slot, and that splits +> `-c 16384` into 8192 per slot — so it is not free. (`--kv-unified` makes the slots share one pool +> at the full size instead, which trades the guarantee for the capacity.) + +For an IDE, `--acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com) on +stdin/stdout instead. Without a running server the agent can load the GGUF itself (`--model` instead of `--base-url`), which is what the two natives dependencies in its pom are for: -```powershell -mvn -o -q exec:java "-Dexec.args=--model C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --ngl 99 --ctx-size 16384 --workspace C:\path\to\project --allow-shell" +```bat +mvn -o -q exec:java -Dexec.args="--model C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --ngl 99 --ctx-size 16384 --workspace C:\path\to\project --allow-shell" ``` > [!WARNING] @@ -282,8 +327,8 @@ load_backends_next_to_this_library: 3 backend(s) registered [RPC, Vulkan, CPU], The `ggml-cpu-.dll` line is the one that tells you whether this CPU got `alderlake`. A one-line build stamp per natives directory (llama.cpp tag, commit, patches, compiler) comes from: -```powershell -cd C:\llama\server +```bat +cd /d C:\llama\server mvn -o exec:java -Dexec.mainClass=net.ladenthin.llama.Diagnose ``` @@ -312,8 +357,8 @@ measured here**, the device choice is OpenVINO's own, and it needs the Intel Ope on `PATH`. To try it: add a fourth dependency with classifier `openvino-windows-x86-64` to the server pom, re-run `go-offline`, and name both modules explicitly. -```powershell -mvn -o exec:java "-Dexec.args=-m C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --port 9931 -c 16384 -ngl 99 -lv 4" "-Dnet.ladenthin.llama.backend=vulkan,openvino" +```bat +mvn -o exec:java -Dnet.ladenthin.llama.backend=vulkan,openvino -Dexec.args="-m C:\models\Qwen3-4B-Instruct-2507-Q4_K_M.gguf --port 9931 -c 16384 -ngl 99 -lv 4" ``` Read the device list in the log before trusting any number: if a device named like OpenVINO's CPU diff --git a/llama-android/build.gradle.kts b/llama-android/build.gradle.kts index 690e95cfa..32333c4c7 100644 --- a/llama-android/build.gradle.kts +++ b/llama-android/build.gradle.kts @@ -115,6 +115,43 @@ fun coreProperty(name: String): String { ) } +// The core's compile-scope dependencies (no or compile, not optional) as +// (groupId, artifactId, version) -- exactly what a Maven consumer of net.ladenthin:llama gets, and +// therefore what the AAR pom mirrors. Scopes are read here as well as versions: the 6.0.0 AAR poms +// were written from a hand-kept list and shipped checker-qual at compile scope although the core +// keeps it provided (its classes are major 55, CLAUDE.md "Java 8 bytecode floor"); the versions were +// derived and stayed right, the scopes were not and drifted. Provided (lombok, checker-qual, +// animal-sniffer), runtime (the JVM-only slf4j-simple binding: Android consumers pick their own, +// e.g. slf4j-android, or run with the no-op one) and test dependencies are not mirrored. The +// published poms are checked against the core pom in CI (.github/verify-aar-poms.py). +fun coreCompileDependencies(): List> { + val coreDependencies = directChildElement(corePom.documentElement, "dependencies") + ?: error("No found in ../llama/pom.xml") + val result = mutableListOf>() + val children = coreDependencies.childNodes + for (i in 0 until children.length) { + val node = children.item(i) + if (node.nodeType != Node.ELEMENT_NODE || node.nodeName != "dependency") { + continue + } + val dependency = node as Element + val scope = directChildText(dependency, "scope") ?: "compile" + if (scope != "compile" || directChildText(dependency, "optional") == "true") { + continue + } + val groupId = directChildText(dependency, "groupId") ?: error("A core dependency has no ") + val artifactId = directChildText(dependency, "artifactId") ?: error("$groupId: no ") + val version = directChildText(dependency, "version") + ?: error("$groupId:$artifactId has no in ../llama/pom.xml -- the AAR pom needs an explicit one") + val property = Regex("""\$\{([^}]+)}""").matchEntire(version)?.groupValues?.get(1) + result += Triple(groupId, artifactId, if (property != null) coreProperty(property) else version) + } + if (result.isEmpty()) { + error("No compile-scope dependency found in ../llama/pom.xml -- the parsing is broken, not the pom") + } + return result +} + group = "net.ladenthin" version = reactorVersion @@ -289,23 +326,17 @@ fun org.gradle.api.publish.maven.MavenPom.commonMetadata( url.set("https://github.com/bernardladenthin/java-llama.cpp") } // Mirror the core's compile-scope dependencies (the AAR repackages those - // classes, so their imports must resolve on the consumer classpath). - // logback-classic is deliberately NOT mirrored: it is the core's JVM-only - // runtime SLF4J binding and does not run on Android — Android consumers - // pick their own binding (e.g. slf4j-android) or run with the no-op one. + // classes, so their imports must resolve on the consumer classpath). The list + // is read from llama/pom.xml, scopes included -- see coreCompileDependencies. withXml { val dependencies = asNode().appendNode("dependencies") - fun dependency(groupId: String, artifactId: String, versionProperty: String) { + for ((groupId, artifactId, version) in coreCompileDependencies()) { val node = dependencies.appendNode("dependency") node.appendNode("groupId", groupId) node.appendNode("artifactId", artifactId) - node.appendNode("version", coreProperty(versionProperty)) + node.appendNode("version", version) node.appendNode("scope", "compile") } - dependency("com.fasterxml.jackson.core", "jackson-databind", "jackson.version") - dependency("org.slf4j", "slf4j-api", "slf4j.version") - dependency("org.jspecify", "jspecify", "jspecify.version") - dependency("org.checkerframework", "checker-qual", "checker.version") if (dependsOnCpuAar) { // The OpenCL AAR is one backend module; the library it plugs into is the CPU AAR // of the same version (type aar, so AGP merges both jni/ trees into the APK). diff --git a/llama-android/consumer-proguard.txt b/llama-android/consumer-proguard.txt index f047395dc..30ee2e193 100644 --- a/llama-android/consumer-proguard.txt +++ b/llama-android/consumer-proguard.txt @@ -34,6 +34,12 @@ # Android entry points never touch them). # - lombok / animal-sniffer: CLASS-retention build-time annotations referenced # from the bytecode; they exist on no runtime classpath anywhere. +# - org.checkerframework: the Nullness Checker writes its qualifiers (@NonNull, +# @Initialized, @Pure, ...) into the class files it checks. They are RUNTIME +# retention, but checker-qual is provided scope in the core and not in the AAR +# pom (major 55 classes), so no app has it; a runtime that reflects over an +# annotated element skips an annotation type it cannot resolve. -dontwarn com.sun.net.httpserver.** -dontwarn lombok.** -dontwarn org.codehaus.mojo.animal_sniffer.** +-dontwarn org.checkerframework.** diff --git a/llama-atmosphere-agent/CLAUDE.md b/llama-atmosphere-agent/CLAUDE.md index 5b54f2604..a1714ab74 100644 --- a/llama-atmosphere-agent/CLAUDE.md +++ b/llama-atmosphere-agent/CLAUDE.md @@ -10,7 +10,7 @@ that pairs [Atmosphere](https://github.com/Atmosphere/atmosphere)'s built-in Ope agent runtime with this project's `OpenAiCompatServer`. Like `android-llmservice/` it is a **standalone Maven project, NOT a reactor module** — it is an application, it needs Java 21 (Atmosphere's floor) while the core stays Java 8, and it is built without a parent so the folder can be -copied out and run on its own. **Its version is the core's** (`6.0.0-SNAPSHOT` on `main`), and +copied out and run on its own. **Its version is the core's** (`6.1.0-SNAPSHOT` on `main`), and `check-natives.py` fails when the two differ: `versions:set` does not reach this pom, so a release bump that forgot it would otherwise publish an agent naming the wrong core. `llama.version` defaults to `${project.version}`; CI still passes `-Dllama.version=` (the same value). diff --git a/llama-atmosphere-agent/pom.xml b/llama-atmosphere-agent/pom.xml index 8497e7f0b..bb6bff807 100644 --- a/llama-atmosphere-agent/pom.xml +++ b/llama-atmosphere-agent/pom.xml @@ -23,7 +23,7 @@ SPDX-License-Identifier: MIT --> net.ladenthin llama-atmosphere-agent - 6.0.0 + 6.1.0-SNAPSHOT jar ${project.groupId}:${project.artifactId} diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/probe/ReflowProbe.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/probe/ReflowProbe.java index 991c6b614..ad01a57dc 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/probe/ReflowProbe.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/probe/ReflowProbe.java @@ -36,7 +36,7 @@ * terminal library the agent uses: * *
- * mvn -q test-compile exec:java -Dexec.classpathScope=test  *     -Dexec.mainClass=net.ladenthin.llama.atmosphere.probe.ReflowProbe  *     "-Dllama.version=6.0.0-SNAPSHOT" "-Djline.version=4.4.6-atmosphere"
+ * mvn -q test-compile exec:java -Dexec.classpathScope=test  *     -Dexec.mainClass=net.ladenthin.llama.atmosphere.probe.ReflowProbe  *     "-Dllama.version=6.1.0-SNAPSHOT" "-Djline.version=4.4.6-atmosphere"
  * 
* *

What its answers were used for, and the numbers they produced, is in diff --git a/llama-ffm/pom.xml b/llama-ffm/pom.xml index 205613999..8c886dbc4 100644 --- a/llama-ffm/pom.xml +++ b/llama-ffm/pom.xml @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT ../pom.xml diff --git a/llama-kotlin/pom.xml b/llama-kotlin/pom.xml index 2e650ae91..6b402b2af 100644 --- a/llama-kotlin/pom.xml +++ b/llama-kotlin/pom.xml @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT ../pom.xml diff --git a/llama-langchain4j/pom.xml b/llama-langchain4j/pom.xml index 84aaa5f75..70134a4cb 100644 --- a/llama-langchain4j/pom.xml +++ b/llama-langchain4j/pom.xml @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT ../pom.xml diff --git a/llama-platform/pom.xml b/llama-platform/pom.xml index 7776ef357..2c28ab817 100644 --- a/llama-platform/pom.xml +++ b/llama-platform/pom.xml @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT ../pom.xml diff --git a/llama/pom.xml b/llama/pom.xml index 4e6eda65c..63ae102d3 100644 --- a/llama/pom.xml +++ b/llama/pom.xml @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT ../pom.xml @@ -103,7 +103,7 @@ SPDX-License-Identifier: MIT 3.10.4 2.102.0 UTF-8 - 2026-10-25T21:44:25Z + 2026-10-25T22:38:31Z net.ladenthin llama-parent - 6.0.0 + 6.1.0-SNAPSHOT pom ${project.groupId}:${project.artifactId}