A release pipeline for BepInEx mods, run from the mod's own repository on a machine that has the game installed. One command clean-builds the plugin, checks the built assemblies, zips them so the archive drops straight onto the game folder, creates the GitHub release, and writes a folder with everything needed to post the same archive on Nexus by hand.
It exists because a plugin compiles against the game's own assemblies, which cannot be committed or sent to a CI runner, so releases are made locally. The tool makes a local release as hard to get wrong as a scripted one.
- Preflight. Refuses unless the working tree is clean, HEAD carries exactly one
vX.Y.Ztag, that tag is on origin at the same commit,CHANGELOG.mdhas a## [X.Y.Z]section, the installed game matches the recorded fingerprint, and no GitHub release for the tag exists yet. - Clean build of the plugin project in Release with
ContinuousIntegrationBuild, intoartifacts/build/, with any extra MSBuild properties from the config (for instance one that stops a dev-loop copy step). - Verification of the built assemblies' metadata, without loading them: the plugin's
BepInPluginversion is the build's major.minor.patch, the build's informational version is the tag's, every assembly reference resolves to the package, the game or the loader, and nothing of the game's or the loader's is in the package. - Archive of the listed files under
BepInEx/plugins/<mod>/(configurable), with entry timestamps fixed to the commit time, so the same commit always gives the same bytes. A SHA-256 file sits beside it. - GitHub release with the changelog section as its notes and the archive and checksum attached.
- Nexus kit under
artifacts/nexus-kit-X.Y.Z/: the archive, the checksum,changelog.txt, the page description and screenshots from the repo'snexus/folder, and aPOSTING.mdchecklist.
package runs the same build, checks and kit on any commit, with warnings in place of refusals, so the pipeline
can be tried without tagging. A package from an untagged commit is named with its pre-release version, so it
cannot be mistaken for a release.
Versions come from git tags. The tool expects the plugin to be built with MinVer,
so the assembly's informational version is the tag's, and expects the BepInPlugin attribute to carry the same
version. The cleanest way to get both is to let MSBuild write the version into a generated class. In the plugin's
.csproj (or a Directory.Build.props):
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="6.0.0" PrivateAssets="all" />
</ItemGroup>
<Target Name="GeneratePluginInfo" BeforeTargets="CoreCompile" DependsOnTargets="MinVer">
<PropertyGroup>
<PluginInfoFile>$(IntermediateOutputPath)PluginInfo.g.cs</PluginInfoFile>
</PropertyGroup>
<ItemGroup>
<PluginInfoLine Include="// <auto-generated />" />
<PluginInfoLine Include="namespace $(RootNamespace)%3B" />
<PluginInfoLine Include="internal static class PluginInfo" />
<PluginInfoLine Include="{" />
<PluginInfoLine Include="%20%20%20%20public const string Version = "$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)"%3B" />
<PluginInfoLine Include="%20%20%20%20public const string FullVersion = "$(MinVerVersion)"%3B" />
<PluginInfoLine Include="}" />
<Compile Include="$(PluginInfoFile)" />
</ItemGroup>
<WriteLinesToFile File="$(PluginInfoFile)" Lines="@(PluginInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" Condition="'$(MinVerVersion)' != ''" />
</Target>Then [BepInPlugin(Guid, Name, PluginInfo.Version)]. BepInEx 5 parses the plugin version with System.Version,
which is why the attribute gets major.minor.patch and the full version (with the pre-release suffix an untagged
build carries, e.g. 1.0.1-alpha.0.3) is left for the log line.
Install the tool into the mod's repository with a tool manifest, so its version is pinned there:
dotnet new tool-manifest
dotnet tool install ModReleaseAdd modrelease.json at the repository root:
Keep CHANGELOG.md in Keep a Changelog form, and put the Nexus page text in
nexus/description.bbcode with screenshots under nexus/images/. Add artifacts/ to .gitignore.
git tag v1.2.3 && git push origin main v1.2.3
GITHUB_TOKEN=$(cat ~/.config/modrelease/github-token) dotnet modrelease releaseUse a fine-grained personal access token limited to the repository with Contents read and write, or a classic token
with repo scope. Keep it in a file only you can read, such as ~/.config/modrelease/github-token at mode 600,
and read it at the point of use as above rather than exporting it from your shell profile.
--dry-run does everything but the GitHub calls; --draft creates the release as a draft. Then post to Nexus
from artifacts/nexus-kit-1.2.3/, following its POSTING.md.
After a game patch, re-test the mod and record the new fingerprint with dotnet modrelease fingerprint.
dotnet test
dotnet pack src/ModRelease -p:MinVerVersionOverride=0.1.0-devTo try the unpublished build against a mod, in that mod's repository pin 0.1.0-dev in
.config/dotnet-tools.json and restore from the pack output:
dotnet tool restore --add-source ../ModRelease/artifacts/packagesNuGet caches packages by version, so after repacking the same version clear
~/.nuget/packages/modrelease/0.1.0-dev before restoring again.
Releases of the tool itself follow its own convention: a ## [X.Y.Z] section in CHANGELOG.md, a vX.Y.Z tag,
and the CI workflow packs and publishes to nuget.org through Trusted Publishing.
{ "modName": "SomeMod", "pluginProject": "SomeMod/SomeMod.csproj", "packagedFiles": ["SomeMod.dll", "SomeMod.pdb", "SomeMod.Core.dll", "SomeMod.Core.pdb"], // "packageRoot": "BepInEx/plugins/{modName}", // the default; the folder inside the archive "buildProperties": { "CopyToPlugins": "false" }, // extra -p: values for the release build "game": { "dirEnv": "SOME_GAME_DIR", // environment variable naming the game folder "defaultDir": "~/.local/share/Steam/steamapps/common/SomeGame", "runtimeAssemblyDirs": ["SomeGame_Data/Managed", "BepInEx/core"], "fingerprint": { // optional, strongly recommended "file": "SomeGame_Data/Managed/Assembly-CSharp.dll", "sha256": "...", // from: dotnet modrelease fingerprint "note": "Game 1.2.3, Steam build 12345" } }, "loader": "bepinex", // or "none" to skip the attribute check "requirements": [ { "name": "BepInEx 5.4.23.5", "url": "https://github.com/BepInEx/BepInEx/releases/tag/v5.4.23.5" } ], "nexus": { "gameDomain": "somegame", "modId": null } // omit the section for no Nexus kit }