Skip to content

fix(core): isolate release repair from tagged build code - #71

Merged
robinbraemer merged 6 commits into
mainfrom
fm/connect-java-r4-repair-backfill
Jul 27, 2026
Merged

fix(core): isolate release repair from tagged build code#71
robinbraemer merged 6 commits into
mainfrom
fm/connect-java-r4-repair-backfill

Conversation

@robinbraemer

@robinbraemer robinbraemer commented Jul 27, 2026

Copy link
Copy Markdown
Member

Intent

Close the $GITHUB_PATH privilege-escalation vector in the release-repair workflow on PR #71 by splitting it into two jobs, per an explicit ruling from the programme owner on the identical vector in a sibling repo (gate #941 landed the same split).

WHY THIS CHANGE EXISTS - the previous shape on this branch was WRONG and must not be restored:

  • A repair necessarily EXECUTES old, unreviewed tagged source (a years-old Gradle build and whatever its plugins resolve). The earlier fix on this branch scoped GH_TOKEN to individual steps and treated that as containment. It is not: within ONE job the tagged build can append to $GITHUB_PATH or $GITHUB_ENV and have a LATER token-bearing step in that same job execute a tool it planted. Step-level env: is not a sandbox. The job is the boundary. Do not suggest reverting to step-level token scoping - it was already tried on this branch and ruled insufficient.

THE MANDATED SHAPE (do not propose collapsing it back to one job for simplicity):

  • build job: permissions contents:read ONLY. Checks the tag out (persist-credentials:false), resolves the tag's own pinned JDK, runs ./gradlew build, stages assets, uploads them as an artifact. No step at or after the Build step carries any credential, and there is no job-level GH_TOKEN.
  • publish job: needs:build, permissions contents:write. Checks out NOTHING, runs NO tag code (no checkout, no setup-java, no gradle), downloads the artifact and does only the gh release upload plus the landed verification.
  • Top-level permissions: {} so neither job inherits anything.

DELIBERATE DECISIONS a reviewer reading only the diff would not know:

  • The pointer-tag refusal is intentionally DUPLICATED as the first step of both jobs. That is not copy-paste sloppiness: the guard belongs in the job that actually holds the write token, and the build-job copy fails fast before a pointless checkout.
  • The publish job re-validates the downloaded artifact's FILE NAMES (allowlist ^connect-(spigot|velocity|bungee)[A-Za-z0-9._-]*.jar$ plus LICENSE, rejecting symlinks, nested paths and empty files). This is deliberate: the artifact was produced by the job that ran untrusted source, so its filenames are untrusted input that would become public release asset names.
  • The pointer baseline moved from the build job into the publish job. That is still correct ordering because the build job now has no write capability at all, so a baseline taken at the start of publish predates every write the run performs.
  • Absent asset names are still uploaded WITHOUT --clobber on purpose: GitHub's 422 rejection is the atomic conditional the REST API does not otherwise offer. --clobber is reserved for names already present but broken. The residual window for the broken case is documented in-file rather than claimed closed.
  • The landed verification keeps the positive jar allowlist decided earlier in this PR (a non-jar such as source.tar.gz must not be able to satisfy BUILD_COUNT or the HTTP probe).
  • release.yml is deliberately NOT touched by this PR; its identical weak filter is tracked as a separate follow-up. release-repair.yml being stricter than release.yml is intended.

VERIFICATION ALREADY DONE LOCALLY, so a finding contradicting it needs strong evidence: full ./gradlew build green on JDK 17; shellcheck clean across all 12 run steps; and 8 mutations each turn ReleaseRepairCapabilityTest red - granting the build job contents:write, adding a checkout to the publish job, placing a token-bearing step after the tagged build, collapsing gradle back into the publish job, adding --clobber to the absent upload, weakening the jar allowlist, widening top-level permissions from {}, and removing the publish-side asset-name validation.

Context on scope: connect-java's two zero-asset releases (0.6.0, 0.7.0) are separately proven unrecoverable - their bungeecord transitive snapshot deps are 404 upstream - so this workflow lands for future value and is not exercised against them.

What Changed

  • Added a manual release-repair workflow with a read-only tagged build job and separate write-only publish job.
  • Added pointer/tag guards, artifact filename validation, race-safe missing-vs-broken uploads, and landed plugin-jar verification.
  • Added ReleaseRepairCapabilityTest, workflow input tracking, and repair guidance covering asset naming and unrecoverable zero-asset releases.

Risk Assessment

✅ Low: The workflow implements the mandated read-only build/write-only publish boundary and preserves the required artifact, race, pointer, and landed-verification guards.

Testing

Focused capability tests and end-user-oriented artifact validation passed; supplied baseline build, shellcheck, and mutation evidence was supplemented, and transient outputs were removed.

Evidence: Release-repair validation evidence

Valid plugin jar + LICENSE accepted; nested archive, empty jar, and symlink rejected by the actual workflow validation step.

Release-repair publish validation evidence

Source exercised: .github/workflows/release-repair.yml
Step exercised: Validate the downloaded assets

--- valid handover artifact ---
Accepted: LICENSE connect-velocity.jar
RESULT: accepted valid plugin jar + LICENSE

--- hostile handover artifact ---
Accepted: connect-velocity.jar
::error::The build job produced asset(s) a repair must not publish: connect-bungee.jar nested source.tar.gz connect-spigot.jar
::error::Only connect-<platform>*.jar and LICENSE may become release assets.
RESULT: rejected nested archive, empty jar, and symlink

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • ./gradlew :core:test --tests com.minekube.connect.release.ReleaseRepairCapabilityTest
  • Parsed the workflow and confirmed the build/publish job boundary
  • Executed the actual publish asset-validation step with valid and hostile artifacts
  • Removed transient Gradle outputs and verified a clean worktree
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

robinbraemer and others added 6 commits July 27, 2026 09:47
Releases 0.6.0 and 0.7.0 published with an empty asset list: the release
page exists, the run was green, and every download link 404s. There was no
way to fill those holes. Re-dispatching release.yml at an old tag is not
one: GitHub compiles a dispatched run from the workflow file at the
dispatched ref, and tags before 0.7.1 carry no workflow_dispatch trigger at
all. Worse, release.yml rewrites the live `latest` release, so running it
at an old tag would drag the stable releases/download/latest/*.jar URLs
backwards and silently downgrade every server owner using them.

release-repair.yml dispatches from the default branch, checks the tag out
itself, builds at the JDK that tag's own release.yml pinned (guessing a
toolchain would produce a false red about whether a tag builds), and
uploads only the assets that release is missing, under that era's own
naming convention.

The safety is a capability boundary, not an `if:` somebody can get wrong:
the workflow holds `contents: write` and nothing else, consumes no secret
beyond GITHUB_TOKEN, and never names a release other than the tag under
repair - so publishing a rebuilt old jar anywhere users pull from by
default is unrepresentable here rather than merely discouraged.
ReleaseRepairCapabilityTest asserts that, plus: the pointer tags are
refused outright, --clobber can only reach a hole and never a published
good asset, and the completeness guard runs before the build rather than
after it.

Both verification steps assert on landed facts instead of on the steps
that just ran - the same defect the release.yml guard closed. The asset
check re-reads the published release from the API and range-requests a jar
to prove it is served, not merely listed. The pointer check records
`latest` and `latest-prerelease` before the build and diffs them after the
upload, so "this workflow cannot move a pointer" is a check that can fail
rather than an argument in a comment.
… jobs

Scoping GH_TOKEN to individual steps does not contain a repair. A repair
necessarily executes old, unreviewed tagged source, and within a single job
that build can append to $GITHUB_PATH or $GITHUB_ENV so that a later,
token-bearing step in the same job executes a tool it planted. Step-level
`env:` is not a sandbox; the job is the boundary. The previous shape put
the tagged Gradle build and every write-scoped step in one `repair` job, so
the containment it claimed was not real.

Now the write capability lives where no tag code can reach it:

  build   - permissions: contents: read. Checks the tag out, runs its own
            pinned toolchain and Gradle build, hands the staged assets on
            as an artifact. Nothing it plants can be escalated, because
            there is no write capability in the job to escalate into. No
            step at or after the build carries a credential at all.
  publish - permissions: contents: write, needs: build. Checks out
            NOTHING, runs no tag code, downloads the artifact and uploads
            it to the one release named by the dispatch input.

Top-level permissions are now `{}`, so neither job inherits a scope it was
not explicitly granted.

The artifact crossing that split was produced by the untrusted job, so its
file NAMES are untrusted input to the publish job - they become public
release asset names. The publish job re-validates them against
connect-<platform>*.jar plus LICENSE and rejects symlinks, nested paths and
empty files before anything is uploaded.

Every previously pinned guard is kept and re-verified against the new
shape: the pointer-tag refusal is now the first step of BOTH jobs (it
belongs where the capability is), the completeness guard still runs before
the build, the toolchain is still pinned from the tag with a hard failure
when unreadable, absent names still upload without --clobber so GitHub's
own rejection is the atomic conditional, and the landed checks still
require a real plugin jar and prove the pointers did not move.

ReleaseRepairCapabilityTest gains three tests for the split itself and the
suite goes red on each of: granting the build job a write scope, adding a
checkout to the publish job, placing a token-bearing step after the tagged
build, and collapsing Gradle back into the publish job.
@robinbraemer robinbraemer changed the title fix(core): add safe repair path for zero-asset releases fix(core): isolate release repair from tagged build code Jul 27, 2026
@robinbraemer
robinbraemer merged commit f45f163 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