Thunderstore packaging and releases
Markdown options
The public product is Megabonk Level Editor; its package identifier is
Megabonk_Level_Editor. The repository remains megabonk_mods, the assembly is
MegabonkLevels.Mod.dll and the stable BepInEx GUID is megabonklevels.runtime.
The team namespace is configured in the root thunderstore.toml.
Package source and build
Section titled “Package source and build”pkg/ contains the package’s player-facing README, reference manifest, icon and
NLayer license. The root README describes the repository, not the package page.
The 256×256 icon is a rasterization of site/public/favicon.svg; regenerate it
with rsvg-convert -w 256 -h 256 -o pkg/icon.png site/public/favicon.svg.
From the repository root:
task package:thunderstoretask package:thunderstore:checkThe build installs tcli 0.2.4 locally in .tools/tcli/ if missing, builds the
Release DLLs (with Task’s .env loading), runs package tests, invokes tcli build
and validates the resulting .tools/thunderstore/<team>-Megabonk_Level_Editor-<version>.zip.
It never publishes. After CLI installation and a Release build,
.tools/tcli/tcli build also builds the ZIP directly.
tcli generates the ZIP’s manifest.json from thunderstore.toml; pkg/manifest.json
is a reviewable reference, checked against that generated manifest. tcli adds
namespace and FullName fields; the ZIP checker permits those only with the
configured identity. Update both metadata sources and the BepInPlugin display
version together for each release.
The ZIP has exactly these files, with no enclosing directory:
manifest.jsonREADME.mdicon.pngBepInEx/plugins/MegabonkLevels/MegabonkLevels.Mod.dllBepInEx/plugins/MegabonkLevels/NLayer.dllBepInEx/plugins/MegabonkLevels/NLayer.LICENSE.txtThe dependency is BepInEx-BepInExPack_IL2CPP-6.0.788. The package does not
redistribute BepInEx, its .NET runtime, Harmony, generated game/Unity interop,
game assets, PDBs, installers or developer test data. The ZIP checker,
scripts/check_thunderstore_package.py, rejects unexpected files and DLLs that
differ from the current Release output.
The older package:windows, package:linux and package:macos tasks describe
the legacy MelonLoader installer format and are internal: Task hides them from
listings and rejects direct CLI invocation. Do not expose a wrapper that combines
them with this BepInEx DLL. Rebuilding legacy packages requires a separately
retained legacy checkout, not the public BepInEx source history.
Shared release version
Section titled “Shared release version”release-please-config.json defines one root release, with Git tags such as
v0.1.0. The Python release strategy updates pyproject.toml; targeted
extra-files keep the other product-version fields synchronized:
- the root project entry in
uv.lock, selected by name rather than array index - the mod project’s
<Version>and the BepInPlugin display version - Thunderstore’s
versionNumberand the reference manifest’sversion_number - the site’s
package.jsonand both root version fields inpackage-lock.json
The Go installer has no embedded product-version field: its source follows the
same Git tag. Do not change the go toolchain directive to a product version.
Dependencies, game builds, level/pack schemas and the standalone boot spike have
their own versions and are not part of this synchronization.
Keep the plugin’s inline x-release-please-version annotation; the generic
updater uses it to change only that line. The uv.lock selector accepts both
plain names and the tagged-value objects used by Release Please’s TOML parser,
without updating third-party package versions. Two JSON updaters target the npm
lockfile’s top-level version and packages[""].version; no dependency entries
are selected. Version examples in player documentation use placeholders so they
do not become stale on the next release.
First release and subsequent bumps
Section titled “First release and subsequent bumps”.release-please-manifest.json is Release Please’s current/last-released version
ledger, not the requested next version. It is bootstrapped at 0.1.0; the
temporary "release-as": "0.1.0" override ensures the first release PR targets
exactly that version instead of deriving a higher one from existing commits.
Remove the override after the first release PR is merged and the GitHub release
exists. Otherwise later runs remain pinned at 0.1.0. The option is supported
but deprecated upstream; use it only for this bootstrap, not normal releases.
After that, let conventional commits and Release Please advance the ledger and all version files together. Do not independently release the website or add separate language packages to the manifest unless their release lifecycles are intentionally being split.
.github/workflows/release-please.yml runs on pushes to main. It reads the
repository Actions secret RELEASE_PLEASE_TOKEN with contents and pull-request
write permissions. The optional placeholder in .env.example is for local
tooling only: Actions does not read .env. Keep the real token out of Git,
command arguments and logs. This workflow creates release PRs and GitHub
releases; it does not publish to Thunderstore, npm, NuGet or PyPI, build a
player package, or deploy the website. Thunderstore submission is a separate workflow, described in Automated builds and publishing. A push that adds the workflow can start
release automation, so review the configuration before pushing.
Check current version consistency with:
uv run pytest tests/test_release_versions.py -n 0For the optional offline future-bump integration test, install the upstream updaters locally first; this does not contact GitHub or create a release:
npm install --prefix .tools/release-please --no-save --ignore-scripts release-please@17.11.2uv run pytest tests/test_release_versions.py -n 0The integration test applies Release Please’s actual updaters and composite update handling to temporary copies at a different version, then checks that all product fields moved and dependency/toolchain versions did not.
Dependency updates
Section titled “Dependency updates”.github/renovate.json5 groups npm, NuGet, Go modules and Python dependencies.
Python uses pep621 for pyproject.toml/uv.lock and an explicit pep723 file
pattern for inline script metadata under scripts/. There is no Cargo project,
so no Rust rule is configured. Other default Renovate managers are not disabled.
The schedule permits new update branches during Saturday 10–11 AM America/Chicago, including lockfile maintenance. It is an allowed window, not a guarantee that the bot will run at exactly 10:00. Validate without running dependency updates:
npx --yes --package renovate@44.222.5 -- renovate-config-validator --strict .github/renovate.json5Validate a manifest offline
Section titled “Validate a manifest offline”scripts/validate_thunderstore_manifest.py reimplements
the site’s Manifest Validator,
which needs a logged-in account and a team you belong to. It ports
ManifestV1Serializer and the Django 4.2 / DRF validators behind it, including their
message text, so a line printed here matches the line the site prints.
uv run scripts/validate_thunderstore_manifest.py # pkg/manifest.jsonuv run scripts/validate_thunderstore_manifest.py --online # plus thunderstore.iouv run scripts/validate_thunderstore_manifest.py .tools/thunderstore/<team>-<package>.ziptask package:thunderstore:manifest # same, via Tasktask package:thunderstore:manifest ONLINE=trueIt takes a manifest.json or a built package ZIP, and the team defaults to
thunderstore.toml’s [package].namespace; --team picks another, --json emits
machine-readable output. Exit 1 means the manifest would be rejected, exit 2 means the
input could not be read. task package:thunderstore runs it on pkg/manifest.json
before building, and task package:thunderstore:check runs it on the built ZIP.
Offline it checks everything the serializer can decide from the document alone: required
fields, the name and version patterns and length caps, a version that is not written in
its canonical form, the URL rules, dependency strings, self-dependency, duplicate
dependencies, the dependency and installer list caps, installer identifier shape and
reuse, and the team name’s shape. The four checks that read the site’s database run only
with --online, and only once the offline rules pass – as on the site, where they are
serializer-level checks that valid fields must reach:
- each dependency resolves to a published package version
- this
namespace-name-versionis not already published - no package exists with the same name in a different capitalization
- installer identifiers exist, which stays a note because that table is not public
--online uses the public read-only JSON API through scripts/asset_download.py, so no
token is involved: /api/experimental/package/<ns>/<name>/<version>/ and
/api/cyberstorm/listing/<community>/<ns>/ for the team’s package list. The
api/v1/package/... detail paths return 404 for the same package, so those two are
deliberate. An unreachable host is a note, not a failure.
Behaviours worth knowing before debugging a disagreement with the site:
- Extra keys are ignored, which is why tcli’s
namespaceandFullNameare harmless. PackageReference.parsetakes the version and then the name from the right, soDash-Ed-Team-Mythic-TestMod-1.1.0is one reference and a version with two dots is mandatory.- A bare
mailto:address is rejected whilemailto://anythingpasses, andipfs://needs something that looks like a host:ipfs://CIDfails.
Content and licensing
Section titled “Content and licensing”The Thunderstore package is runtime-only: no levels, third-party packs or
music are included. In particular, extracted THPS assets and music must not
be published with it. Screenshots in the README are publicly hosted links, not
bundled game assets. NLayer 1.16.0 is redistributed under MIT with its upstream
license notice preserved in pkg/NLayer.LICENSE.txt.
The repository currently has no project-wide license file. Packaging does not choose a code/content license on the maintainer’s behalf. Confirm the intended release terms and rights to any newly added content before first publication; adding a level or pack is a separate licensing and manager-layout review.
Test the final ZIP, not the build folder
Section titled “Test the final ZIP, not the build folder”- Quit the game. Back up the manager’s settings/profile metadata and inventory the affected game files. Preserve the existing profile and manual development DLLs outside the manager’s sync tree.
- Create a new Megabonk profile with only the declared BepInEx dependency. Do not reuse a development profile or another plugin’s configuration files.
- Import the built ZIP with the manager’s local/custom mod import, then Apply. Do not copy the plugin DLL manually as a substitute for this test.
- Check that the installed plugin and NLayer hashes match the ZIP, and that only
one copy of
megabonklevels.runtimecan load. The manager may wrap or flatten the package’s plugin subfolder; inspect the actual installed paths. - Launch and check
BepInEx/LogOutput.logforLoading [Megabonk Level Editor <version>],MegabonkLevels mod initialized.andChainloader startup complete. Check errors associated with this plugin, not just the chainloader’s ownErrorLog.log. - Confirm the console opens and a separately supplied, pack-free level appears
on Map Selection and plays. Enter the editor, make and undo an edit, and save
a copy. No mod-specific BepInEx config is expected; developer tools require
MEGABONK_DEV=truein the launch environment. - Review
pkg/README.mdusing Thunderstore’s Markdown Preview, including both externally hosted screenshots. Validate the generated manifest withtask package:thunderstore:manifest ONLINE=true, and confirm it with the Manifest Validator, which is the final word. The screenshots use the public showcase site, not private-repository raw URLs. Recheck the editor image URL after a website rebuild changes its asset hash. - Record the ZIP SHA-256, profile, installed hashes, log evidence and screenshots. Rebuild and repeat the import test if any packaged file changes afterward.
On the tested macOS setup, r2modmac 1.1.0 manages files but does not successfully
launch the Windows game through plain Wine. Use the existing Wine launch path.
Its Apply omits the loader pack’s dotnet/, .doorstop_version and changelog.txt;
supply these from the dependency ZIP, not from this mod package. Set the prefix’s
winhttp=native,builtin override (the repo launcher passes it in the environment).
Park the MelonLoader version.dll; both proxies must not be active together.
See Running BepInEx under Wine.
For an isolated test game outside the Wine prefix, inspect the manager-written
doorstop_config.ini before launching. r2modmac may write a host path such as
\Users\... without a Windows drive letter. Back up that file, then use paths
relative to the game folder instead:
[General]target_assembly=BepInEx\core\BepInEx.Unity.IL2CPP.dll
[UnityMono]dll_search_path_override=BepInEx\coreKeep the existing sections and other settings, including the relative
dotnet\coreclr.dll runtime path. Re-Apply may overwrite these changes. Launch
with the game folder as the working directory and the test copy’s executable
explicitly; the repo’s current plain-Wine launcher still names the default Steam
executable even when MEGABONK_GAME_DIR changes its working/log directory.
Use an explicit Z:\...\Megabonk.exe path for a host-side test copy, and point
MEGABONK_GAME_DIR at that same folder for harness commands. Do not mistake a
successful launch of the development install for a manager-install test.
The manager’s sync is bidirectional: deleting installed mod files can also remove them from its profile at the next sync. Restore backups with the manager stopped; do not delete an installed tree to simulate disabling a mod.
With r2modmac 1.1.0, local ZIP imports appear as
Local-Megabonk_Level_Editor-<version>. Apply preserves the plugin’s nested folder:
BepInEx/plugins/Local-Megabonk_Level_Editor-<version>/MegabonkLevels/ contains the
DLL, NLayer and its license. That layout loads correctly; do not flatten it by
hand. A clean-profile install was verified under Wine 11.18 with the omitted
runtime supplied from the dependency ZIP and relative Doorstop paths. Console,
pack-free level loading/gameplay, terrain editing, undo and save-copy all work.
Release gate and publishing
Section titled “Release gate and publishing”Before the first publish:
- Use the BepInEx runtime on main. Legacy MelonLoader rollback source lives only in the maintainer’s private history backup; it is not a public download or tag. Preserve local loader backups when testing a cutover or rollback.
- Confirm the team’s service account has package-upload permission. Read Global Rules and any Megabonk-specific rules. Do not distribute game assemblies or unlicensed assets.
- Confirm all linked documentation and feedback destinations are publicly accessible. A private GitHub repository’s source/docs/issues links do not work for players without access; make them public or replace them before release.
- Complete the clean-profile ZIP test, manifest validation and README preview. Record rights/licensing decisions and the exact tested archive hash.
- Confirm community
megabonk, categoriesmodsandtools, and NSFW false for every upload. The category API currently exposes no AI Generated category; check again before uploading and apply it if it becomes available and applies. - Obtain explicit publication approval. Building a package is not approval to upload it or to merge the cutover.
Create a team service account, not a personal token, at Thunderstore’s team
settings. Supply its token only through TCLI_AUTH_TOKEN in the shell environment
or a gitignored local environment file. Never put it in TOML, command arguments,
task notes or logs. After approval, validate and publish the already tested ZIP:
task package:thunderstore:check.tools/tcli/tcli publish --file .tools/thunderstore/<team>-Megabonk_Level_Editor-<version>.zipDo not use bare tcli publish: it rebuilds by default and could upload a ZIP that
was not tested. Recheck the stored SHA-256 immediately before the upload.
After publishing, verify the page’s title, icon, screenshots, dependency, category
and NSFW settings, then install the published version in another clean profile.
That last download check cannot be performed before publication.
Package ZIPs are immutable. Binary or archive updates require a higher version
with the same team/name identity. A displayed README can be updated separately
without rebuilding or publishing a version: send an authenticated
PATCH /api/cyberstorm/package/{namespace}/{name}/v/{version}/markdown/readme/update/
with JSON {"content":"<updated Markdown>"} and the team service-account Bearer
token. Keep pkg/README.md in sync for future archives. This is a display override;
the README inside the existing downloadable ZIP does not change. Verify the
displayed README after saving and confirm the package version remains unchanged.
The API docs expose a
machine-readable OpenAPI schema,
but the schema does not currently include this Cyberstorm endpoint.
Retain superseded releases; after a soak period and approval, deprecate, never delete an obsolete package. Do not automatically deprecate anything during the initial upload.
Automated builds and publishing
Section titled “Automated builds and publishing”.github/workflows/thunderstore.yml builds, validates and, for an approved release, submits the package. scripts/thunderstore_release_gate.py holds the decisions (path filtering, publication guards, checksums) so they are unit-tested in tests/test_thunderstore_release_gate.py without uploading anything.
Triggers
Section titled “Triggers”- Push to
mainwith apaths:filter: builds and validates the ZIP and keeps it as a 14-day artifact. It never publishes. The filter covers the ZIP’s inputs (src/MegabonkLevels.Mod/**,thunderstore.toml,pkg/**) and the tooling that builds or validates it (taskfiles/package.yml, the package check, manifest validator, downloader and gate scripts, and the workflow itself). Website, documentation, backlog,.agents,.claude, level and unrelated script changes do not start it. A test keeps the workflow’s list identical toPACKAGE_PATHSin the gate script. - Release published (Release Please creates it).
releaseevents do not supportpaths:, so agatejob compares the tag with the previous stable tag. Only the ZIP’s inputs count there, and a file whose only changes are the shared-version lines (versionNumber,version_number, the csproj<Version>, thex-release-please-versionline) does not, because Release Please rewrites those in every release. A release whose only changes are website, docs or tooling therefore builds nothing and publishes nothing. The first stable tag, with no earlier one to compare, always counts. Prereleases are never built. - Manual run (
workflow_dispatch) with ataginput and apublishinput that defaults to false. It rebuilds that exact tag and bypasses the gate, which is the recovery path when a release was skipped or a run failed. Withpublishfalse it is build-only.
The build needs the game’s generated BepInEx interop assemblies, which cannot be committed or redistributed, so it runs on a self-hosted runner labelled self-hosted, megabonk-mod (and megabonk) on the Mac that holds the game, never on a hosted runner. The repository is public, so no job on that runner runs on pull_request; also set the repository’s fork-pull-request approval to require approval for all outside collaborators. The runner is registered to this repository only, in its own directory with its own service, separate from any organisation runners on the same machine. Its .env sets MEGABONK_WINE_PREFIX to the Wine prefix that holds the game; the workflow sets ModInstanceFolder to a temporary directory so the build does not overwrite the developer’s installed plugin.
The job checks out the exact tag (or the pushed commit) and, beside it in ci-tools/, the commit the workflow itself runs from, so a manual rebuild of an old tag still uses the current gate script. It runs scripts/thunderstore_release_gate.py versions to require every version source to agree, runs task package:thunderstore (which builds, runs the package tests and validates the ZIP and manifest), writes <zip>.sha256, re-verifies it and uploads both as the thunderstore-package artifact. Release and manual builds keep the artifact 90 days. The csproj sets ContinuousIntegrationBuild, a PathMap and disables the SDK’s source-control queries, so the DLL’s bytes depend only on the source and the referenced assemblies: not on the checkout directory (which used to leak into the PDB path and module id), the commit hash, the origin URL or Source Link. A local build of the same source therefore hash-matches the CI archive’s DLL, provided the same game interop assemblies are referenced.
Publishing
Section titled “Publishing”The publish job runs only for a release or a manual run with publish true. It runs on a hosted runner so the token never reaches the self-hosted machine, and in the thunderstore environment, which must be configured with required reviewers: that approval is the publication approval. It downloads the artifact, re-verifies the checksum and manifest, then runs the guards and submits with tcli publish --file <zip> (never a bare tcli publish, which would rebuild). The guards refuse when the event is not a release or manual run, the release is a prerelease, the tag is not v<version> of thunderstore.toml, or Thunderstore cannot say whether the version exists. A version Thunderstore already lists ends the run successfully with a summary note and uploads nothing, so a retry or a second run cannot duplicate or overwrite a submission. Publication jobs share one concurrency group with cancel-in-progress: false, so runs queue rather than race. The workflow never deletes releases or packages.
Secrets and setup
Section titled “Secrets and setup”TCLI_AUTH_TOKEN: the team service-account token, as an Actions secret (preferably an environment secret onthunderstore). It is read only by the publish step. Never print it or pass it as an argument.RELEASE_PLEASE_TOKEN: already required. It must be a token rather thanGITHUB_TOKEN, otherwise the releases it creates would not start the workflow.- The
thunderstoreenvironment with required reviewers.
Manual recovery
Section titled “Manual recovery”- A skipped or failed release: run the workflow manually with the release’s tag,
publishtrue, and approve it. The same guards apply. - A build that failed on the runner: check the runner service is online (repository Settings, Actions, Runners) and that the game’s
BepInEx/interopfolder exists, then re-run. - Published with a defect: Thunderstore ZIPs are immutable. Release a higher version; do not delete or overwrite. See Release gate and publishing.
- A displayed-README-only change needs no workflow; use the README update call described above.
Rollback
Section titled “Rollback”Quit the game and disable the package in the manager; Apply and verify the plugin
is no longer in the active plugin tree. Keep user-authored UserData/ files.
For a loader rollback, park winhttp.dll and doorstop_config.ini, restore the
preserved MelonLoader version.dll, and deploy the DLL from the final legacy tag,
not this BepInEx package. Leave inactive loader trees intact. Reverse the proxy
renames to return to BepInEx. Restore the original manager profile/settings and
manual development artifacts from the pre-test inventory if required.