diff --git a/.github/smoke-jar.sh b/.github/smoke-jar.sh new file mode 100755 index 0000000..deeb2fa --- /dev/null +++ b/.github/smoke-jar.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: Apache-2.0 + +# Post-`package` smoke for the PACKAGED library jar: puts it on a classpath, exactly as a consumer +# does, and calls the API through the JDK single-file source launcher. +# +# Why this exists: every other check in the pipeline (unit tests, jqwik, Lincheck, PIT, SpotBugs, +# javadoc) runs off `target/classes`. Nothing ever loads the assembled jar, yet the assembled jar is +# what is attached to the GitHub release and deployed to Central. So a jar that is missing classes, +# carries a broken module-info.class, or was assembled from a stale target/ passes an all-green +# pipeline. See workspace/policies/fat-jar-release-assets.md ("No release asset is attached that CI +# has not run"). +# +# streambuffer is the one sibling that ships NO fat jar — it is a library with no Main-Class, so +# `java -jar` is not a contract it can satisfy and the shared smoke-fatjar-cli.sh (BitcoinAddressFinder +# + srcmorph) does not apply. Same job shape, repo-appropriate assertion: classpath + API call +# instead of launch + exit code. +# +# Usage: smoke-jar.sh +# directory to search for the jar (recursively) +# filename glob; must match EXACTLY ONE jar + +set -euo pipefail + +JAR_DIR="${1:?usage: smoke-jar.sh }" +JAR_GLOB="${2:?usage: smoke-jar.sh }" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MARKER="packaged-jar smoke OK" + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +[ -d "$JAR_DIR" ] || fail "jar directory '$JAR_DIR' does not exist" + +# Exactly one match, never "pick the first": an ambiguous glob is precisely how the wrong artifact +# gets smoke-tested while the shipped one is not. The -sources / -javadoc siblings are excluded +# unconditionally -- `mvn package` builds them into the same directory and they are never the +# artifact under test. +jars=() +while IFS= read -r j; do jars+=("$j"); done < <( + find "$JAR_DIR" -type f -name "$JAR_GLOB" ! -name '*-sources.jar' ! -name '*-javadoc.jar' | sort +) +[ "${#jars[@]}" -eq 1 ] \ + || fail "expected exactly 1 jar matching '$JAR_GLOB' under '$JAR_DIR', got ${#jars[@]}: ${jars[*]:-none}" +JAR="$(cd "$(dirname "${jars[0]}")" && pwd)/$(basename "${jars[0]}")" +echo "smoke jar: $JAR ($(wc -c < "$JAR") bytes)" + +# The jar must carry its JPMS descriptor: it is compiled in a separate `release 9` execution, so a +# broken or reordered build can drop it without failing anything else. +unzip -l "$JAR" | grep -q ' module-info\.class$' \ + || fail "the jar contains no module-info.class — the release 9 compile execution did not reach it" + +echo "== classpath load + API round-trip ==" +out="$(java -cp "$JAR" "$SCRIPT_DIR/smoke/StreamBufferSmoke.java" 2>&1)" || { + echo "$out" + fail "the packaged jar did not run a StreamBuffer round-trip" +} +echo "$out" + +# Exit code 0 alone is satisfied by a JVM that starts and does nothing; the marker proves the +# round-trip actually completed. +grep -qF "$MARKER" <<<"$out" || fail "success marker '$MARKER' missing from the output" + +echo "smoke test PASSED" diff --git a/.github/smoke/StreamBufferSmoke.java b/.github/smoke/StreamBufferSmoke.java new file mode 100644 index 0000000..b296f74 --- /dev/null +++ b/.github/smoke/StreamBufferSmoke.java @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import net.ladenthin.streambuffer.StreamBuffer; + +/** + * Post-{@code package} smoke for the PACKAGED jar, run by {@code .github/smoke-jar.sh} via the JDK + * single-file source launcher ({@code java -cp StreamBufferSmoke.java}) — no Maven, no test + * framework. + * + *

Why this exists: every other check in the pipeline (unit tests, jqwik, Lincheck, PIT, SpotBugs) + * runs off {@code target/classes}. Nothing ever loads the assembled jar, yet the assembled jar is + * what is attached to the GitHub release and deployed to Central. This is the streambuffer member of + * the cross-repo "no release asset is attached that CI has not run" convention + * (workspace/policies/fat-jar-release-assets.md). streambuffer ships no fat jar — it is a library + * with no {@code Main-Class} — so the packaged artifact cannot be launched with {@code java -jar}; + * the equivalent real use is a consumer putting the jar on its classpath and calling the API, which + * is exactly what this file does.

+ * + *

Deliberately tiny (~1 s, no network): it catches the failure class a green Maven build cannot + * see — a jar that is missing classes, carries a broken {@code module-info.class}, or was assembled + * from the wrong {@code target/} — not the library's behaviour, which the real suite covers.

+ */ +public final class StreamBufferSmoke { + + /** Payload written through the buffer; the tail byte is non-ASCII on purpose. */ + private static final byte[] PAYLOAD = {0x73, 0x62, 0x2D, 0x73, 0x6D, 0x6F, 0x6B, 0x65, (byte) 0xFF}; + + private StreamBufferSmoke() {} + + public static void main(String[] args) throws Exception { + try (StreamBuffer buffer = new StreamBuffer()) { + final OutputStream out = buffer.getOutputStream(); + final InputStream in = buffer.getInputStream(); + + out.write(PAYLOAD); + out.flush(); + + if (in.available() != PAYLOAD.length) { + throw new IllegalStateException( + "available() reported " + in.available() + " bytes, expected " + PAYLOAD.length); + } + + final byte[] read = new byte[PAYLOAD.length]; + int off = 0; + while (off < read.length) { + final int n = in.read(read, off, read.length - off); + if (n < 0) { + throw new IllegalStateException("stream ended after " + off + " of " + read.length + " bytes"); + } + off += n; + } + if (!Arrays.equals(PAYLOAD, read)) { + throw new IllegalStateException( + "round-trip mismatch: wrote " + Arrays.toString(PAYLOAD) + ", read " + Arrays.toString(read)); + } + + out.close(); + if (in.read() != -1) { + throw new IllegalStateException("stream did not report EOF after the writer closed"); + } + } + System.out.println("packaged-jar smoke OK: " + PAYLOAD.length + " bytes round-tripped"); + } +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7709cb4..92c82ab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -272,6 +272,32 @@ jobs: - uses: actions/upload-artifact@v7 with: { name: jars, path: target/*.jar } + # --------------------------------------------------------------------------- + # Packaged-jar smoke -- the streambuffer member of the cross-repo rule "no release + # asset is attached that CI has not run" (workspace/policies/fat-jar-release-assets.md). + # + # Everything else in this pipeline (tests, jqwik, Lincheck, PIT, SpotBugs, javadoc) + # runs off target/classes; nothing loads the assembled jar, which is what is attached + # to the GitHub release and deployed to Central. The sibling repos launch their fat + # jar with `java -jar`; streambuffer ships none (a library, no Main-Class), so the + # equivalent real use is a consumer putting the jar on a classpath and calling the + # API. Same job shape, repo-appropriate assertion. ~10 s, no network. + # --------------------------------------------------------------------------- + smoke-jar: + name: Smoke test packaged jar + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + java-version: '21' + distribution: temurin + - uses: actions/download-artifact@v8 + with: { name: jars, path: jars/ } + - name: Run packaged-jar smoke test + run: .github/smoke-jar.sh jars 'streambuffer-*.jar' + test: name: Test (JDK ${{ matrix.java-version }}) needs: [build] @@ -441,7 +467,7 @@ jobs: publish-snapshot: name: Publish Snapshot to Central - needs: [check-snapshot, code-style] + needs: [check-snapshot, code-style, smoke-jar] if: needs.check-snapshot.result == 'success' && inputs.publish_to_central runs-on: ubuntu-latest environment: maven-central @@ -508,6 +534,34 @@ jobs: with: name: signed-snapshot-assets path: snapshot-assets/ + - name: Report unsigned assets (does not block the upload) + # Deliberately NON-blocking, and deliberately BEFORE the upload. Both attach jobs run even + # when their publish job failed, because a Central publish-poll timeout must not cost the + # GitHub assets: if Central is unreachable these are the ONLY way to get the artifacts at + # all. Refusing to attach on a signing failure would defeat exactly that. So annotate here, + # upload regardless, and fail the job afterwards. Assets always land; an unsigned release is + # still loudly red rather than quietly wrong. + # See workspace/policies/fat-jar-release-assets.md, "Attach first, then go red". + id: signatures + run: | + set -uo pipefail + dir="snapshot-assets" + jars=$(find "$dir" -maxdepth 1 -name '*.jar' | sort) + if [ -z "$jars" ]; then + echo "::error::no jars in $dir -- the collection step produced nothing" + echo "missing=-1" >> "$GITHUB_OUTPUT" + exit 0 + fi + missing=0 + for jar in $jars; do + if [ ! -e "$jar.asc" ]; then + echo "::error::unsigned: $(basename "$jar") has no detached .asc" + missing=$((missing + 1)) + fi + done + echo "missing=$missing" >> "$GITHUB_OUTPUT" + [ "$missing" -eq 0 ] && echo "all $(echo "$jars" | wc -l) jar(s) signed" + exit 0 - name: Update snapshot pre-release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -521,10 +575,16 @@ jobs: gh release upload snapshot snapshot-assets/* \ --repo ${{ github.repository }} \ --clobber + - name: Fail if anything was attached unsigned + # After the upload on purpose: the assets must exist even when the signature does not. + if: ${{ always() && steps.signatures.outputs.missing != '0' }} + run: | + echo "::error::${{ steps.signatures.outputs.missing }} asset(s) attached without a signature (-1 means none were collected at all)" + exit 1 publish-release: name: Publish Release to Central - needs: [check-tag, code-style] + needs: [check-tag, code-style, smoke-jar] if: needs.check-tag.result == 'success' && inputs.publish_to_central runs-on: ubuntu-latest environment: maven-central @@ -584,7 +644,41 @@ jobs: with: name: signed-release-assets path: release-assets/ + - name: Report unsigned assets (does not block the upload) + # Deliberately NON-blocking, and deliberately BEFORE the upload. Both attach jobs run even + # when their publish job failed, because a Central publish-poll timeout must not cost the + # GitHub assets: if Central is unreachable these are the ONLY way to get the artifacts at + # all. Refusing to attach on a signing failure would defeat exactly that. So annotate here, + # upload regardless, and fail the job afterwards. Assets always land; an unsigned release is + # still loudly red rather than quietly wrong. + # See workspace/policies/fat-jar-release-assets.md, "Attach first, then go red". + id: signatures + run: | + set -uo pipefail + dir="release-assets" + jars=$(find "$dir" -maxdepth 1 -name '*.jar' | sort) + if [ -z "$jars" ]; then + echo "::error::no jars in $dir -- the collection step produced nothing" + echo "missing=-1" >> "$GITHUB_OUTPUT" + exit 0 + fi + missing=0 + for jar in $jars; do + if [ ! -e "$jar.asc" ]; then + echo "::error::unsigned: $(basename "$jar") has no detached .asc" + missing=$((missing + 1)) + fi + done + echo "missing=$missing" >> "$GITHUB_OUTPUT" + [ "$missing" -eq 0 ] && echo "all $(echo "$jars" | wc -l) jar(s) signed" + exit 0 - name: Upload release assets uses: softprops/action-gh-release@v3 with: files: release-assets/* + - name: Fail if anything was attached unsigned + # After the upload on purpose: the assets must exist even when the signature does not. + if: ${{ always() && steps.signatures.outputs.missing != '0' }} + run: | + echo "::error::${{ steps.signatures.outputs.missing }} asset(s) attached without a signature (-1 means none were collected at all)" + exit 1