Skip to content

fix: fail releases without published build artifacts - #69

Merged
robinbraemer merged 3 commits into
mainfrom
fm/connect-java-release-zero-asset-guard-r2
Jul 27, 2026
Merged

fix: fail releases without published build artifacts#69
robinbraemer merged 3 commits into
mainfrom
fm/connect-java-release-zero-asset-guard-r2

Conversation

@robinbraemer

Copy link
Copy Markdown
Member

Intent

Transplant the proven R2 fail-loud release-asset guard from geyserlite (minekube/geyserlite#136) into connect-java's release workflow, adapted to connect-java's build-artifact names. This is deliberately the SAME change that landed in geyserlite, in one more place - sameness is the justification for it shipping under standing authority, so faithfulness to the proven original is a hard requirement, not an accident. Do not redesign it, do not generalize it, do not force-fit extras.

The problem: connect-java's .github/workflows/release.yml ended at 'upload the jars and hope'. Nothing ever re-read the release that actually landed, so a release publishing zero assets - or only LICENSE - looked exactly like a healthy one: green run, release page exists, and the hole only surfaces later when a server owner's download link 404s.

The fix: a new unconditional 'Verify published release assets' step, placed after every upload step, that re-reads each PUBLISHED release via 'gh api /repos/.../releases/tags/' rather than trusting the upload steps it just ran. Trusting the step we just ran would rebuild the same 'green run, empty artifact' defect one layer up, so the guard deliberately asserts on the landed release, not on local build output. It classifies assets by name/type and requires a real downloadable BUILD artifact (the plugin jars), then range-requests the first byte of one to prove it is actually served. A positive asset count is explicitly NOT accepted as proof - connect-java uploads LICENSE alongside the jars, so a LICENSE-only release is connect-java's exact analogue of the geyserlite v0.3.6 header-only release that motivated the original guard.

Deliberate decisions a reviewer reading only the diff would not know:

  • The checksums.txt manifest sub-checks from the geyserlite original are intentionally OMITTED, not overlooked. connect-java publishes no checksums.txt and has no auto-download path that resolves one; keeping those checks would fail every release. This is the one part of the original that genuinely does not transplant.
  • The guard covers EVERY release target a run publishes, not just the version tag: the release path writes both the version tag and the stable 'latest' release whose download URLs the release body advertises, and the push path writes 'latest-prerelease'. Verifying only the tag would leave the others free to ship empty. This is the same assertion parameterized over the targets this workflow publishes, not a new mechanism.
  • The step is intentionally unconditional (no 'if:'), unlike the upload steps which are event-gated. It selects its targets from the event instead, so the guard can never be skipped into silence. The regression test asserts this absence of 'if:' on purpose.
  • The 12-attempt/10-second polling loop before failing is inherited from the proven original: release asset visibility is eventually consistent right after upload, so a failing release legitimately takes ~2 minutes per target to conclude. That slowness is intended, not a bug.
  • The jq exclusion regex keeps geyserlite's full metadata list (.sig, .sigstore.json, SBOM, .h, .hpp, .md, .txt, checksums, SHA256SUMS, LICENSE, README) even though connect-java does not currently publish most of those types. Keeping the list identical to the proven original is the point; trimming it to only what connect-java ships today would be drift and would silently weaken the guard if signing or SBOMs are added later.

Tests: core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java mirrors geyserlite's go/release_asset_verification_test.go, ported to JUnit + SnakeYAML since connect-java is Java/Gradle rather than Go. It pins the guard against removal, reordering before the uploads, being made conditional, having its classifier weakened, and dropping a release target. All five tests were verified to FAIL on the pre-fix workflow and pass on the fix, so they are non-vacuous. release.yml is declared a test task input in core/build.gradle.kts because Gradle's build cache otherwise served stale results and hid workflow edits from the test. snakeyaml 1.27 was added as an explicit testImplementation dependency pinned to the version already resolving transitively via configutils, rather than relying on that transitive.

The guard script itself was validated out-of-band beyond the unit tests: bash -n syntax check, the jq classifier exercised against empty/LICENSE-only/checksums+sig-only/real-jars/still-uploading/zero-byte/prerelease asset sets, and the whole step run end-to-end against mocked gh and curl confirming it exits 1 on an empty release, exits 1 on a LICENSE-only release, exits 1 when a listed jar 404s, and exits 0 on real jars plus LICENSE. './gradlew build' is green.

Important caveat that must survive into the PR description: this guard is proven WRITTEN, not yet proven to FIRE. Its tests pass here, but it will not have actually run against a real GitHub release until connect-java's next release executes the workflow. geyserlite's behavioural proof does not carry over to connect-java. The PR text says so plainly and should keep saying so.

AGENTS.md (CLAUDE.md is a symlink to it) gained a short pointer under Release Flow noting that release.yml now enforces the asset check in CI and that the test's step names must stay in sync with the workflow. Scope was kept tight deliberately: no unrelated cleanup, no changes to the release-please flow, no new release features.

What Changed

  • Added an unconditional release asset guard that re-reads every published target, requires a downloadable plugin JAR, and rejects empty or metadata-only releases.
  • Added JUnit/SnakeYAML regression coverage, Gradle workflow tracking, and release-flow documentation for the guard and its targets.
  • Proven written, not yet proven to fire; behavioural proof at connect-java’s next release. Geyserlite’s behavioural proof does not carry over; current evidence is limited to regression tests and mocked API validation.

Risk Assessment

✅ Low: The release guard and regression-test fixes are well-bounded, with no remaining material source issues; live workflow behavior is deferred as explicitly acknowledged by the intent.

Testing

Focused JUnit, guard syntax, and mocked end-to-end release/dispatch/push scenarios passed; evidence was saved, and generated Gradle outputs were removed. Live release execution remains unverified as expected.

Evidence: Release asset guard E2E evidence

Mocked matrix passed: empty/LICENSE-only/metadata-only/still-uploading/zero-byte/404 cases exited 1; real jars passed for v-test, latest, and latest-prerelease. HARNESS: PASS.

bash -n: PASS
CASE empty release            expected=1 actual=1
--- v-test ---
Real build artifacts published (0):
  (none)
::error::Release v-test published with ZERO downloadable assets.
::error::A release with no artifact silently did not happen. Failing loudly.
CASE LICENSE only             expected=1 actual=1
--- v-test ---
Real build artifacts published (0):
  (none)
::error::Release v-test has 1 asset(s) but NO real build artifact.
::error::Only LICENSE/checksums/signatures/SBOMs were published - no plugin jar.
::error::A positive asset count is not a release; a downloadable build is.
CASE checksums and sig only   expected=1 actual=1
--- v-test ---
Real build artifacts published (0):
  (none)
::error::Release v-test has 2 asset(s) but NO real build artifact.
::error::Only LICENSE/checksums/signatures/SBOMs were published - no plugin jar.
::error::A positive asset count is not a release; a downloadable build is.
CASE still uploading jar      expected=1 actual=1
--- v-test ---
Real build artifacts published (0):
  (none)
::error::Release v-test has 1 asset(s) but NO real build artifact.
::error::Only LICENSE/checksums/signatures/SBOMs were published - no plugin jar.
::error::A positive asset count is not a release; a downloadable build is.
CASE zero-byte jar            expected=1 actual=1
--- v-test ---
Real build artifacts published (0):
  (none)
::error::Release v-test has 1 asset(s) but NO real build artifact.
::error::Only LICENSE/checksums/signatures/SBOMs were published - no plugin jar.
::error::A positive asset count is not a release; a downloadable build is.
CASE jar returns 404          expected=1 actual=1
--- v-test ---
Real build artifacts published (1):
::error::Build artifact connect-velocity.jar is listed on v-test but not downloadable (HTTP 404); the build is undownloadable.
::error::https://github.com/minekube/connect-java/releases/download/v-test/connect-velocity.jar
CASE real jar plus LICENSE    expected=0 actual=0
--- v-test ---
Real build artifacts published (1):
OK: v-test publishes 1 real build artifact(s) of 2 assets;
OK: connect-velocity.jar downloads (HTTP 206).
--- latest ---
Real build artifacts published (1):
OK: latest publishes 1 real build artifact(s) of 2 assets;
OK: connect-velocity.jar downloads (HTTP 206).
CASE push prerelease          expected=0 actual=0
--- latest-prerelease ---
Real build artifacts published (1):
OK: latest-prerelease publishes 1 real build artifact(s) of 2 assets;
OK: connect-spigot-prerelease.jar downloads (HTTP 200).
HARNESS: PASS

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (2) ✅
  • ⚠️ core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java:70 - assumeTrue(Files.exists(WORKFLOW_PATH)) skips all tests if the workflow is deleted or renamed, so the guard can disappear without failing CI. Only skip outside a checkout; fail when the file is missing in-repo.
  • ⚠️ core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java:222 - Target coverage uses raw substring checks; removing latest from the actual TARGETS assignment can still pass because it appears in comments and latest-prerelease. Assert the actual target assignments/iteration.

🔧 Fix: Harden release asset guard regression tests
1 warning still open:

  • ⚠️ core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java:68 - The revised missing-workflow branch inverts the out-of-checkout assumption: assumeTrue(!Files.exists(REPOSITORY_GIT_PATH), ...) passes when .git is absent, then reaches assertTrue(false), so the test fails instead of skipping outside a checkout. Use an assumption that is false outside a checkout or return after skipping.

🔧 Fix: Fix checkout-aware release test skipping
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • ./gradlew :core:test --tests 'com.minekube.connect.release.ReleaseAssetVerificationTest'
  • Extracted workflow guard bash -n syntax check
  • Mocked gh api/curl end-to-end asset matrix
  • Transient Gradle output cleanup and clean-worktree check
⚠️ **Document** - 1 warning
  • ⚠️ Carry the required caveat that the guard is proven written, not yet proven to fire, into the PR description.

🔧 Fix: PR caveat remains pending; lint checks clean
1 warning still open:

  • ⚠️ The required caveat remains unresolved because no PR exists for this branch to edit. The future PR description must plainly state: “Proven written, not yet proven to fire; behavioural proof at connect-java's next release.” Clarify that geyserlite's proof does not carry over; connect-java is supported only by passing regression tests and mocked API validation until its next real release.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

robinbraemer and others added 3 commits July 27, 2026 07:22
A release that publishes no downloadable asset is indistinguishable from a
healthy one: the run is green, the release page exists, and the hole only
surfaces later when a server owner's download link 404s. release.yml ended
at "upload the jars and hope" - nothing ever re-read the release that
actually landed.

Add a "Verify published release assets" step that re-reads each PUBLISHED
release via `gh api /repos/.../releases/tags/<tag>` rather than trusting the
upload steps it just ran. Trusting those steps would rebuild the same
"green run, empty artifact" defect one layer up: the upload action can skip
files, partially fail, or be silently gated off, and only the landed release
tells the truth.

A positive asset count is not proof of a usable release either - connect-java
uploads LICENSE alongside the jars, so a release carrying only LICENSE would
pass a count-based check while offering nothing anyone can run. The guard
classifies assets by name/type and requires a real downloadable build jar,
then range-requests its first byte to prove it is actually served.

This is the same fail-loud guard proven written in geyserlite (GeyserMC#136),
transplanted and adapted to connect-java's artifacts. The checksums.txt
manifest sub-checks are omitted because connect-java publishes no manifest
and has no auto-download path that resolves one. The guard runs unconditionally
and covers every target a run publishes: the version tag and the stable
`latest` release whose download URLs the release body advertises, or
`latest-prerelease` on the push path.

ReleaseAssetVerificationTest pins the guard against removal, reordering,
being made conditional, and having its classifier weakened; all five tests
fail on the pre-fix workflow. The release workflow is declared a test input
so a workflow edit re-runs them instead of being served from the build cache.
@robinbraemer
robinbraemer merged commit 8f0a274 into main Jul 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant