diff --git a/.github/sign-fatjars.sh b/.github/sign-fatjars.sh new file mode 100755 index 000000000..c430c39a6 --- /dev/null +++ b/.github/sign-fatjars.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Cross-repo shared script — kept BYTE-IDENTICAL in java-llama.cpp and srcmorph (sync any +# edit to both). GPG-signs the fat jars (jar-with-dependencies) in a directory with a +# detached, armored .asc signature — the authenticity counterpart to any .sha256 integrity +# file. The caller builds/collects the fat jars; this only signs every +# *-jar-with-dependencies*.jar it finds in (thin jars are left untouched). +# +# Signed in the GitHub-Release attach path, not at Maven build time, because only that +# dispatch-gated path receives the signing key: GPG_PRIVATE_KEY / GPG_PASSPHRASE are scoped +# to the `maven-central` GitHub Environment. Cross-repo convention + per-repo shapes: +# workspace/policies/fat-jar-release-assets.md. +# +# Usage: sign-fatjars.sh +# directory holding the fat jars (and possibly thin jars); every +# *-jar-with-dependencies*.jar in it is signed in place (-> .asc). +# +# Env: GPG_PRIVATE_KEY armored secret key (required) +# GPG_PASSPHRASE passphrase for the key (may be empty) +# +# Fail-loud: aborts if the key is absent, if no fat jar is found, or if any signature +# fails to verify. + +set -euo pipefail + +DIR="${1:?usage: sign-fatjars.sh }" + +if [ -z "${GPG_PRIVATE_KEY:-}" ]; then + echo "::error::GPG_PRIVATE_KEY is empty — cannot sign the fat jars. The maven-central environment did not deliver the secret to this ref." >&2 + exit 1 +fi +if [ -n "${GPG_PASSPHRASE:-}" ]; then echo "::add-mask::${GPG_PASSPHRASE}"; fi + +# Ephemeral, private keyring; removed on exit (do NOT touch the runner's default one). +GNUPGHOME="$(mktemp -d)" +export GNUPGHOME +chmod 700 "$GNUPGHOME" +cleanup() { gpgconf --kill gpg-agent >/dev/null 2>&1 || true; rm -rf "$GNUPGHOME"; } +trap cleanup EXIT + +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +KEYID="$(gpg --list-secret-keys --with-colons --fixed-list-mode | awk -F: '$1=="sec"{print $5; exit}')" +if [ -z "$KEYID" ]; then + echo "::error::No secret key imported from GPG_PRIVATE_KEY." >&2 + exit 1 +fi + +shopt -s nullglob +jars=("$DIR"/*-jar-with-dependencies*.jar) +shopt -u nullglob +if [ "${#jars[@]}" -eq 0 ]; then + echo "::error::No *-jar-with-dependencies*.jar found in '$DIR' to sign." >&2 + exit 1 +fi + +for f in "${jars[@]}"; do + # Skip a signature file itself if the glob ever catches one. + case "$f" in *.asc) continue ;; esac + printf '%s' "${GPG_PASSPHRASE:-}" | gpg --batch --yes --pinentry-mode loopback \ + --passphrase-fd 0 --local-user "$KEYID" --detach-sign --armor "$f" + gpg --batch --verify "$f.asc" "$f" + echo "signed + verified: $(basename "$f") -> $(basename "$f").asc" +done + +echo "Signed ${#jars[@]} fat jar(s) in '$DIR'." diff --git a/.github/signing-selftest/build.gradle.kts b/.github/signing-selftest/build.gradle.kts index 6524c02df..e165c9f67 100644 --- a/.github/signing-selftest/build.gradle.kts +++ b/.github/signing-selftest/build.gradle.kts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 // Throwaway signing project used ONLY by the `verify-signing-key-gradle` // preflight job in .github/workflows/publish.yml. It signs a tiny throwaway Zip diff --git a/.github/signing-selftest/settings.gradle.kts b/.github/signing-selftest/settings.gradle.kts index e420db00a..211f25338 100644 --- a/.github/signing-selftest/settings.gradle.kts +++ b/.github/signing-selftest/settings.gradle.kts @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 rootProject.name = "signing-selftest" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2de8caad0..8ea8e2ec4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3188,9 +3188,15 @@ jobs: # the GitHub pre-release assets must not be lost in that case. if: ${{ !cancelled() && (needs.publish-snapshot.result == 'success' || needs.publish-snapshot.result == 'failure') && needs.package-fatjars.result == 'success' }} runs-on: ubuntu-latest + # maven-central so the GPG_PRIVATE_KEY / GPG_PASSPHRASE secret is delivered (it is + # scoped to this environment) for signing the fat jars below. This environment has + # no approval gate (the standalone verify-signing-key jobs use it on every run), so + # declaring it here does not block the release. + environment: maven-central permissions: contents: write steps: + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: signed-snapshot-assets @@ -3203,6 +3209,16 @@ jobs: with: name: llama-fatjars path: snapshot-assets/ + # GPG-sign the fat jars so each carries a detached .asc signature alongside its + # .sha256 checksum — signature parity with the thin jars (which maven-gpg signs at + # deploy) and with the BAF / srcmorph sibling fat jars. The .sha256 files (integrity) + # are kept; the .asc adds authenticity. Signed here (not in package-fatjars) because + # only this dispatch-gated path has the key. See workspace/policies/fat-jar-release-assets.md. + - name: GPG-sign the fat jars (.asc) + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: bash .github/sign-fatjars.sh snapshot-assets - name: Update snapshot pre-release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -3386,9 +3402,15 @@ jobs: # the GitHub release assets must not be lost in that case. if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.result == 'failure') && needs.package-fatjars.result == 'success' }} runs-on: ubuntu-latest + # maven-central so the GPG_PRIVATE_KEY / GPG_PASSPHRASE secret is delivered (it is + # scoped to this environment) for signing the fat jars below. This environment has + # no approval gate (the standalone verify-signing-key jobs use it on every run), so + # declaring it here does not block the release. + environment: maven-central permissions: contents: write steps: + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: signed-release-assets @@ -3401,6 +3423,16 @@ jobs: with: name: llama-fatjars path: release-assets/ + # GPG-sign the fat jars so each carries a detached .asc signature alongside its + # .sha256 checksum — signature parity with the thin jars (which maven-gpg signs at + # deploy) and with the BAF / srcmorph sibling fat jars. The .sha256 files (integrity) + # are kept; the .asc adds authenticity. Signed here (not in package-fatjars) because + # only this dispatch-gated path has the key. See workspace/policies/fat-jar-release-assets.md. + - name: GPG-sign the fat jars (.asc) + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: bash .github/sign-fatjars.sh release-assets - name: Upload release assets uses: softprops/action-gh-release@v3 with: diff --git a/CLAUDE.md b/CLAUDE.md index a203058d2..5ca68a949 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -348,8 +348,11 @@ are all git-ignored (staged by CI, never committed). Every pipeline run assembles **per-OS multi-backend server fat jars** and, on the release paths, attaches them to GitHub: `llama--all---jar-with-dependencies.jar` for `linux-x86-64`, `linux-aarch64`, `windows-x86-64`, `windows-aarch64`, plus the default -CPU fat jar — each with a `.sha256` file. They are **download assets only**: the Central -deploy invocations run without the `assembly` profile and are untouched. +CPU fat jar — each with a `.sha256` **and** a detached GPG `.asc` signature. They are +**download assets only**: the Central deploy invocations run without the `assembly` profile +and are untouched. (The cross-repo "fat jar → GitHub Release, never Central, signed `.asc`" +convention shared with BAF and srcmorph is documented in +[`../workspace/policies/fat-jar-release-assets.md`](../workspace/policies/fat-jar-release-assets.md).) Mechanism (three pieces): @@ -379,8 +382,13 @@ Mechanism (three pieces): `--chat-template chatml`): poll `/health` to 200, assert a `/v1/chat/completions` choice, and require the loader's backend-selection log line. `publish-snapshot`/`publish-release` `need` `package-fatjars` + both smokes (fail-loud gating); `github-release-signed` and - `github-snapshot` additionally download `llama-fatjars` into their asset directory so the - fat jars land on the tag release and the rolling `snapshot` pre-release. + `github-snapshot` additionally download `llama-fatjars` into their asset directory, then + **GPG-sign each fat jar** via `.github/sign-fatjars.sh` (a detached `.asc` alongside the + `.sha256`), so the fat jars land signed on the tag release and the rolling `snapshot` + pre-release. Both jobs declare `environment: maven-central` (where the signing key secret is + scoped; it has no approval gate) and `checkout` the repo so the script is present. Signing + happens in these attach jobs — not in `package-fatjars` — because only this dispatch-gated + release path receives the key. A backend loading successfully but finding **zero usable devices** (e.g. CUDA toolkit installed, no NVIDIA GPU) is benign: ggml's backend registry contributes no devices and diff --git a/llama/lombok.config b/llama/lombok.config index 1e02f1adc..c6fc5b0fd 100644 --- a/llama/lombok.config +++ b/llama/lombok.config @@ -7,25 +7,43 @@ config.stopBubbling = true # Emit @lombok.Generated on every generated member. SpotBugs / JaCoCo / # SonarQube special-case this annotation and skip the synthetic methods -# from coverage requirements and bug detectors. +# from coverage requirements and bug detectors. Without this, SpotBugs at +# effort=Max + threshold=Low surfaces dozens of synthetic-bytecode findings +# (USBR_UNNECESSARY_STORE_BEFORE_RETURN, IMC_IMMATURE_CLASS_NO_TOSTRING, +# NM_FIELD_NAMING_CONVENTION, ...) on every Lombok-generated method. lombok.addLombokGeneratedAnnotation = true # Default to "skip" on @EqualsAndHashCode / @ToString: we inherit from # Object in almost all cases; "skip" is the right default for # Object-extending classes. Classes that extend a non-Object base override # per-annotation with @EqualsAndHashCode(callSuper = true) / -# @ToString(callSuper = true). +# @ToString(callSuper = true). Without this, Lombok emits a WARNING on +# every @EqualsAndHashCode without explicit callSuper, which -Werror +# promotes to a build break. lombok.equalsAndHashCode.callSuper = skip lombok.toString.callSuper = skip # Force Lombok's @EqualsAndHashCode / @ToString to read FIELDS directly -# instead of routing through `this.getX()` (the default). Rationale lives -# in ../workspace/policies/lombok-config.md. Cross-repo invariant: all -# three Lombok-using repos ship the same setting. Without it, -# fb-contrib's OI_OPTIONAL_ISSUES_CHECKING_REFERENCE fires on every -# Lombok-generated `this$x == null` branch when `x` is an Optional, and -# Optional/unmodifiable-wrapper getters allocate fresh wrappers on every -# equals call. +# instead of routing through `this.getX()` (the default). Rationale: +# +# Some classes expose value-add getters that wrap their @Nullable field in +# Optional or wrap a list field in Collections.unmodifiableList + Optional. +# Those wrappers are the public-API contract, not the equality contract: +# +# 1. fb-contrib's OI_OPTIONAL_ISSUES_CHECKING_REFERENCE fires on every +# Lombok-generated `this$x == null` branch when `x` is an Optional — +# Optional is the standard "never null" type, so the null branch is +# dead code. +# 2. unmodifiableList + Optional wrapper getters allocate fresh wrappers +# on every equals call. Field access avoids the allocations. +# 3. The two forms are semantically equivalent: Optional.equals and +# Collections.unmodifiableList(x).equals(...) both delegate to value- +# based comparison of the underlying state. +# +# All value classes in these repos are `final`, so subclass-override of a +# getter cannot change equality. callSuper=true chains are unaffected — +# `super.equals()` is still a method call, and the parent class's own +# field handling is governed by the same setting. lombok.equalsAndHashCode.doNotUseGetters = true lombok.toString.doNotUseGetters = true @@ -33,4 +51,5 @@ lombok.toString.doNotUseGetters = true # needed by this codebase and pulls in the desktop module on some JDKs. lombok.anyConstructor.addConstructorProperties = false +# Allow Lombok-style accessor patterns without warnings. lombok.accessors.flagUsage = ALLOW