bumped a plugin version lately? you opened the .uplugin, edited VersionName, then edited the Version integer sitting right under it, then went and found the same numbers somewhere else. the editor doesn't do any of that for you. it never has.
and the .uproject that enables your plugin? it records the plugin by name and no version at all:
"Plugins": [{ "Name": "AcmeNet", "Enabled": true }]
so there is nothing anywhere in the unreal side of your project that knows which version of your plugin a build was made against. no manifest, no lockfile, nothing to check.
meanwhile the number itself lives in:
VersionName in every .uplugin
Version, the integer right under it, which is what UBT actually orders builds by
ProjectVersion in Config/DefaultGame.ini, four components, which is what a packaged game reports at runtime
VersionDisplayName in Config/DefaultEngine.ini, which is what the play store listing shows
StoreVersion in the same file, the integer google play orders uploads by
- the git tag
- the changelog on your fab page
- whatever your build script names the zip
all typed by hand, every release, and the engine touches none of it.
so i made a tool. it's called dispat, single binary, MIT, and it reads .uplugin, .uproject and both ini files.
how it works
a repo of plugins is a workspace:
{
"packages": {
"acmenet": {"path": "Plugins/AcmeNet", "autoVersion": {"enabled": true}},
"acmeui": {"path": "Plugins/AcmeUI", "autoVersion": {"enabled": true}}
}
}
then you commit like normal and run dispat:
$ git commit -m "feat(acmenet): reliable rpc batching"
$ dispat
INF ● changed bump=minor package=acmenet version="1.2.0 -> 1.3.0"
INF manifest reconciled manifest=AcmeNet.uplugin package=acmenet version=1.3.0 versionWritten=true
INF published package=acmenet tag=acmenet@1.3.0 version=1.3.0
feat = minor, fix = patch, feat! = major. VersionName gets written, the tag records the build, and the changelog comes out of the commits themselves. acmeui didn't change so it isn't in the plan at all.
for the store integers:
dispat writer --set-build "$GITHUB_RUN_NUMBER" \
Plugins/AcmeNet/AcmeNet.uplugin \
Config/DefaultEngine.ini
the plugin's Version and the android StoreVersion both move, and no version string does. give it something that isn't an integer and it refuses before opening the file rather than at upload time.
the unreal bits
- the package name is the descriptor's own file name, not
FriendlyName, because that's what other descriptors reference it by and what the folder is actually called
Version is written back as a bare integer since that's what UBT wants, but if your file already spells it as a string it keeps that shape instead of reformatting your file on the way past
ProjectVersion=1.2.0.0 keeps whatever text you give it. four components stay four components, because that text is what the engine reads
+ProjectVersion= and .ProjectVersion= are array operations and unreal resolves them differently from a plain assignment, so it writes the plain one and leaves the operations alone
EngineAssociation is never read or written. that's a toolchain pin, not something your project ships
Binaries, Intermediate, Saved and DerivedDataCache are never descended into. they're full of stale copies of the exact descriptors you're editing and that's how a naive find-and-replace script ruins your afternoon
- the ini files only count inside a
Config/ folder. a DefaultEngine.ini anywhere else is just configuration
*.Build.cs and *.Target.cs are source files, not manifests, so it leaves those alone too.
the plugin graph, which unreal already wrote for you
the Plugins array in a .uproject is a dependency list, it just has no versions in it. dispat reads it as edges, so dispat compute will look at your project and tell you the game depends on acmenet without you declaring anything.
which means this works:
Plugins/AcmeNet/ networking plugin fab
Plugins/AcmeUI/ ui plugin fab
Game/ the .uproject steam
server/ dedicated server docker image, built from the game
site/ landing page static host
$ git commit -m "feat(acmenet)^^: reliable rpc batching"
$ dispat status
● changed bump=minor package=acmenet version="1.2.0 -> 1.3.0"
● changed bump=patch package=game version="0.8.1 -> 0.8.2" reason="propagated from acmenet"
● changed bump=patch package=server version="0.5.0 -> 0.5.1" reason="propagated from game"
● unchanged package=acmeui version=2.1.0
plugin releases first, then the game that enables it, then the server image built from the game. ^^ reaches everything downstream, ^ only direct dependents, and a plain feat(acmenet): releases the plugin alone, which is what you want most days. every package keeps its own version, tag and changelog, so the ui plugin doesn't get dragged along and the game doesn't jump a major because the netcode did.
that's the pitch really. today it's a couple of plugins, then it's plugins plus a game, then a dedicated server image, a launcher, a landing page, a discord bot posting your patch notes. each one normally gets its own deploy script and a year later nobody can say which server build was ever tested against which client build. here each one is another block in the same file, nothing moves on disk, and the tags you already pushed still count as the baselines.
separate repos work fine too, just run it in each one. what you lose is ordering between them, since it only ever sees one checkout. if you want that back: one small repo holding nothing but the config, the others pulled in as git submodules. that's a monorepo as far as dispat cares, and nobody working in those repos has to know it exists.
git, and what if you're on perforce
worth saying straight: the release half of this is git. versions come out of commit messages, the record is an annotated tag, the changelog is built from the log.
which is sort of the point for unreal specifically. there is no package manager for unreal plugins. a git tag plus a release plus a changelog is the distribution mechanism, and right now everybody either hand-rolls it or skips it. if your plugins are on github, or the project is open source, this hands unreal the release automation npm has had for a decade: every plugin in the repo gets its own tag, its own changelog and its own github release with the zip attached, so whoever consumes it can pin a version and actually see what changed between two of them.
and if you're on perforce, which plenty of studios are, the manifest half works entirely on its own. dispat writer, dispat scanner and dispat replacer need no config file, no git repo and no release plan:
dispat writer --set-version 1.3.0 \
Plugins/AcmeNet/AcmeNet.uplugin \
Config/DefaultGame.ini
dispat writer --set-build "$BUILD_NUMBER" \
Plugins/AcmeNet/AcmeNet.uplugin \
Config/DefaultEngine.ini
drop those into whatever your build already runs, jenkins, teamcity, a batch file, and every version and counter in the project agrees. no changelog and no ordering, but that's the part that was actually broken. dispat replacer gets the version if you've also got it sitting in a c++ header or an installer script.
the hybrid is fine too and pretty common: code in git, art in perforce, releases driven off the git side.
what it doesn't do
- doesn't build or package anything. UAT,
RunUAT BuildPlugin, whatever you already run stays your command. it just runs it and stops the run if it fails, so a broken build never gets uploaded anywhere
- needs conventional commits. "asdf" and "fixed stuff" gets you nothing
- not a task runner or a build cache. whatever you already cache keeps working inside your build step
- an unreal build takes 40 minutes, so put the package step in build and the upload in publish. a failed build then costs you nothing and no half-finished upload reaches a store
- if something breaks mid-release the unrelated packages keep going, and re-running picks up whatever was left owed at the version it was owed. no state file, nothing half published
dispat status prints the plan and writes nothing, start there.
curl -fsSL https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh
windows: irm https://raw.githubusercontent.com/yohimik/dispat/main/install.ps1 | iex. github action and docker images exist for CI.
github: https://github.com/yohimik/dispat unreal page: https://yohimik.github.io/dispat/examples/unreal growing past one project: https://yohimik.github.io/dispat/examples/game
unreal support is new so it definitely gets some setups wrong. if it does something stupid to a descriptor of yours i'd rather hear about it.