Skip to content

ci: publish each release to Modrinth from the release workflow - #79

Merged
robinbraemer merged 1 commit into
mainfrom
ci/publish-releases-to-modrinth
Jul 28, 2026
Merged

ci: publish each release to Modrinth from the release workflow#79
robinbraemer merged 1 commit into
mainfrom
ci/publish-releases-to-modrinth

Conversation

@robinbraemer

Copy link
Copy Markdown
Member

Check this first: the event condition

- name: Publish to Modrinth
  if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'

release.yml also runs on every push to main, where it builds the same
jars and updates latest-prerelease. Without that condition this step would
publish a development build to the public Modrinth listing on every merge —
and it would not fail loudly. The run stays green, the release is fine, the
repository looks fine; the listing just quietly fills with builds we never
released, and the first report comes from a user who installed one.

It is the same condition, character for character, as the two steps that
already publish only on a real release (Upload Release Artifacts,
Update Latest Release), so this workflow has one event gate rather than two
that can drift apart. ReleaseModrinthPublishTest asserts that byte-identity,
so removing or reshaping the condition fails the build instead of shipping.

Evidence that the condition actually blocks a push, from GitHub evaluating
this exact expression in this exact workflow, in both directions:

run event steps with this condition if: push steps ungated step
30349075287 push to main skipped ran ran
30322321401 workflow_dispatch (0.13.0) ran skipped ran

The push run is the control that matters: the job ran to completion and other
steps in it executed, so "skipped" is the condition doing its work rather than
the job never starting.

What is not yet observed: the new step has never itself been through a real
push run, because it does not exist on main yet. What is established is that
the expression blocks pushes and that this step carries that expression
unchanged. The first push to main after merge is the direct observation —
worth a glance at that run to see Publish to Modrinth skipped.

What the step does

Publishes the jars this run built, off the runner — never fetched back from
the release. Fetching from the release would couple Modrinth publishing to the
release having landed correctly, which is the exact failure
Verify published release assets exists to catch, so one broken release would
corrupt the listing too. The step runs after that verification, so a run whose
release did not land does not reach the listing either.

Three versions per release, not one. Modrinth runs every validator whose
loaders intersect the declared loaders against every file in a version
(validate/plugin.rs),
so one version declaring velocity + bungeecord + paper is rejected
outright: the velocity jar has no plugin.yml, the spigot jar has no
velocity-plugin.json. Version numbers are <tag>+velocity, <tag>+spigot,
<tag>+bungee, matching the versions already on the listing.

Every upload is confirmed by digest. After the upload, the stored version is
read back and its sha1 and sha512 compared against the jar this run built.
The create call returning 200 is the API describing its own request — the same
"trust the run, not the artifact" mistake the release verification avoids. Size
would not do either: two different jars can share a size and cannot share a
digest.

Minecraft versions are resolved at publish time from Modrinth's tag list,
floored at the api-version the shipped plugin.yml declares (so the floor
cannot drift from the jar). A hard-coded list would stop matching searches the
day Mojang ships a release, without anything going red.

The credential is passed as an environment variable and reaches curl through a
config on stdin, so it is never in a process argument list, never on disk, and
never interpolated into the script body.

Two things for the reviewer to decide

  1. A workflow_dispatch at an OLD tag will publish that tag to Modrinth.
    That path exists to repair a release, and release-please uses it for every
    normal release, so it cannot be gated out without breaking releases. If the
    listing should never receive an old tag, that needs a rule this PR does not
    invent. The step will not create a duplicate of a version the listing
    already holds; a tag that was never published would be published.
  2. MODRINTH_TOKEN scopes. The step needs VERSION_CREATE to upload and
    VERSION_READ to read the version back for the digest check. It also lists
    existing versions first, which needs PROJECT_READ + VERSION_READ; that
    call is allowed to be refused (it warns and continues), so the read scopes
    for the digest check are the hard requirement. A refusal names the exact
    missing scope in the error rather than failing vaguely. The scopes on the
    configured secret have not been exercised — the first release after merge is
    what proves them.

Verification

  • Every property ReleaseModrinthPublishTest pins was proven to fail against
    its own defect before being trusted: condition deleted, condition replaced by
    a behaviourally-equivalent != 'push', step moved before verification, jars
    sourced from the release, sha512 dropped, game versions hard-coded, and the
    secret interpolated into the script body — each fails exactly its own test and
    no other.
  • The publish script was run end-to-end against a stand-in API. It succeeds when
    the stored bytes match, and fails when the stored file has the same name and
    the same size but different bytes
    — the case this check exists to catch, as
    opposed to a 404 or an auth error, which are different failures. It also fails
    when the stored version does not carry the expected filename, when the token is
    empty, when the tag is empty, when a jar is missing, and when either scope is
    refused; it is a no-op when the listing already carries the version.
  • ./gradlew :core:test green; shellcheck clean.

Connect's Modrinth listing is currently fed by hand, so it freezes at
whatever version was last uploaded while GitHub releases move on. A
listing that silently stops tracking releases is the failure this closes:
nothing goes red, operators just keep installing an old build.

The step uploads the jars this run built, straight off the runner. It
deliberately does not fetch them from the release: doing so would couple
Modrinth publishing to the release having landed correctly, which is the
exact failure "Verify published release assets" exists to catch, and one
broken release would then corrupt both.

Three versions per release, not one. Modrinth runs every validator whose
loaders intersect the declared loaders against every file in a version,
so a single version declaring velocity + bungeecord + paper is rejected:
the velocity jar carries no plugin.yml and the spigot jar carries no
velocity-plugin.json.

Each upload is confirmed by reading the stored version back and comparing
sha1 and sha512 against the jar this run built. The create call returning
200 is the API describing its own request; size would not do either, since
two different jars can share a size and cannot share a digest.

The event condition is the safety property. It is identical to the one on
"Upload Release Artifacts" and "Update Latest Release", so a push to main
publishes nothing. Removed, it would not fail loudly - every merge would
quietly add a development build to a public listing and the first report
would come from a user. ReleaseModrinthPublishTest pins it byte-identical
to those two steps, along with the ordering, the build-output source, the
digest check, publish-time game version resolution, and the credential
staying out of the script body.

Minecraft versions come from Modrinth's tag list at publish time, floored
at the api-version the shipped plugin.yml declares. A hard-coded list
would stop matching searches the day Mojang ships a release, without
anything going red.
@robinbraemer
robinbraemer merged commit dfa3594 into main Jul 28, 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