Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/smoke-jar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash

# SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
#
# 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 <jar-dir> <jar-glob>
# <jar-dir> directory to search for the jar (recursively)
# <jar-glob> filename glob; must match EXACTLY ONE jar

set -euo pipefail

JAR_DIR="${1:?usage: smoke-jar.sh <jar-dir> <jar-glob>}"
JAR_GLOB="${2:?usage: smoke-jar.sh <jar-dir> <jar-glob>}"

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"
69 changes: 69 additions & 0 deletions .github/smoke/StreamBufferSmoke.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
//
// 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 <jar> StreamBufferSmoke.java}) — no Maven, no test
* framework.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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");
}
}
98 changes: 96 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading