diff --git a/.github/sign-fatjars.sh b/.github/sign-fatjars.sh new file mode 100755 index 0000000..c430c39 --- /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 6603086..e165c9f 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: Apache-2.0 +// 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 2167b8c..211f253 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: Apache-2.0 +// 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 e72388f..aa4f890 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -486,10 +486,10 @@ jobs: - name: Build & sign classifier fat jars (GitHub-only assets, never Central) if: ${{ !cancelled() }} env: - MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -euo pipefail - if [ -n "${MAVEN_GPG_PASSPHRASE:-}" ]; then echo "::add-mask::${MAVEN_GPG_PASSPHRASE}"; fi # Source of truth: the set in java-llama.cpp llama/pom.xml for the pinned # ${llama.version} (all published to Maven Central). Keep in sync on a llama bump. CLASSIFIERS=( @@ -509,10 +509,10 @@ jobs: done # default CPU build LAST so the unsuffixed fat jar is the all-platform CPU variant build "" - for f in srcmorph-cli/target/srcmorph-cli-*-jar-with-dependencies*.jar; do - printf '%s' "$MAVEN_GPG_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --detach-sign --armor "$f" - cp "$f" "$f".asc signed-snapshot-assets/ - done + # Collect every fat jar variant, then GPG-sign each via the cross-repo shared script + # (.github/sign-fatjars.sh, byte-identical with java-llama.cpp) — a .asc per fat jar. + cp srcmorph-cli/target/srcmorph-cli-*-jar-with-dependencies*.jar signed-snapshot-assets/ + bash .github/sign-fatjars.sh signed-snapshot-assets - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: @@ -608,10 +608,10 @@ jobs: - name: Build & sign classifier fat jars (GitHub-only assets, never Central) if: ${{ !cancelled() }} env: - MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -euo pipefail - if [ -n "${MAVEN_GPG_PASSPHRASE:-}" ]; then echo "::add-mask::${MAVEN_GPG_PASSPHRASE}"; fi # Source of truth: the set in java-llama.cpp llama/pom.xml for the pinned # ${llama.version} (all published to Maven Central). Keep in sync on a llama bump. CLASSIFIERS=( @@ -631,10 +631,10 @@ jobs: done # default CPU build LAST so the unsuffixed fat jar is the all-platform CPU variant build "" - for f in srcmorph-cli/target/srcmorph-cli-*-jar-with-dependencies*.jar; do - printf '%s' "$MAVEN_GPG_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --detach-sign --armor "$f" - cp "$f" "$f".asc signed-release-assets/ - done + # Collect every fat jar variant, then GPG-sign each via the cross-repo shared script + # (.github/sign-fatjars.sh, byte-identical with java-llama.cpp) — a .asc per fat jar. + cp srcmorph-cli/target/srcmorph-cli-*-jar-with-dependencies*.jar signed-release-assets/ + bash .github/sign-fatjars.sh signed-release-assets - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} with: diff --git a/CLAUDE.md b/CLAUDE.md index 889f6df..19e1fb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -456,6 +456,15 @@ clear of the JPMS module-mode javadoc trap that bit BAF. **Before raising the Ja level to ≥ 9 in any module, read** [`../workspace/policies/jpms-module-descriptor.md`](../workspace/policies/jpms-module-descriptor.md). +## Fat-jar release assets + +The `srcmorph-cli` fat jar (`jar-with-dependencies`) is a **GitHub-Release asset only — never +Maven Central** (`srcmorph-cli/pom.xml` sets `false`), attached with a detached +GPG `.asc`. CI builds **one fat jar per `net.ladenthin:llama` classifier** (default CPU + every GPU +classifier) and signs them via the cross-repo shared `.github/sign-fatjars.sh` (byte-identical with +java-llama.cpp). The convention + per-repo shapes + the classifier keep-in-sync rule are documented +in [`../workspace/policies/fat-jar-release-assets.md`](../workspace/policies/fat-jar-release-assets.md). + ## Open TODOs Open TODOs for this repo live in [`TODO.md`](TODO.md). Cross-repo status diff --git a/lombok.config b/lombok.config index 8a7c0bf..c6fc5b0 100644 --- a/lombok.config +++ b/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,7 +51,5 @@ lombok.toString.doNotUseGetters = true # needed by this codebase and pulls in the desktop module on some JDKs. lombok.anyConstructor.addConstructorProperties = false -# Mojo @Parameter POJOs use direct setter generation (@Setter at field -# level) when needed; flagUsage = ALLOW keeps Lombok quiet on the -# accessor-generation default rules. +# Allow Lombok-style accessor patterns without warnings. lombok.accessors.flagUsage = ALLOW