Skip to content

Release & versioning

How the editor and the MainframeEngine NuGet package are versioned, built and released. What the package contains and how games consume it: Distribution via NuGet.

main is protected by the main-protection repository ruleset (no bypass actors, admins included):

  • changes land only through pull requests; squash or rebase merges (merge commits are disabled);
  • the ci-success check (ci.yml) must pass, on a branch that is up to date with main;
  • linear history; no force pushes; no deletion.

Release tags (v*) are immutable (release-tags-immutable ruleset: no update, no deletion).

SemVer. The version lives only in git tags vX.Y.Z; nothing in the repo is bumped. Local builds are 0.0.0-dev (Directory.Build.props); releases pass -p:Version=X.Y.Z.

Each publish bumps the latest vX.Y.Z tag (build/next-version.sh; first release v1.0.0) by its bump input: patch by default, minor (v1.0.1 → v1.1.0) or major (→ v2.0.0), e.g. gh workflow run publish.yml --ref main -f bump=minor. just next-version [patch|minor|major] prints what the next publish would create.

Engine and editor are versioned in lock step. There is one version for the whole product: the release tag. dotnet publish -p:Version=X.Y.Z is a global MSBuild property, so it flows to every project in the build graph — the editor, the engine core, the generator and every referenced project get the same X.Y.Z. No project sets its own Version: the editor’s build fails if its version differs from the engine core’s (CheckEngineVersionLockStep in the editor’s csproj, via GetMainframeVersion in Directory.Build.targets). At run time EngineInfo.Version is the single source of truth: the editor shows it (EditorBrand.Version; the window title ends in Mainframe Editor vX.Y.Z, as does the About box), checks at startup that its own assembly version matches the engine core it loaded (EditorBrand.VersionsMatch, an error otherwise), new projects record it in project.mfproj (engineVersion) and reference the MainframeEngine package of that version, the editor link handshake carries it, and the package (distribution via NuGet) uses it as its package version.

publish.yml — Actions → publish → Run workflow on main.

flowchart LR
    G["guard<br/>main only · actor brogan89<br/>next version"] --> C["ci.yml<br/>(full suite, reused)"]
    C --> B["build matrix<br/>osx-arm64 · win-x64 · linux-x64<br/>dotnet publish -r rid --self-contained"]
    C --> P["package<br/>build/pack-nuget.sh<br/>MainframeEngine + .Templates X.Y.Z"]
    B --> R["release<br/>tag vX.Y.Z + GitHub Release<br/>+ archives + .nupkg"]
    P --> R
    R --> N["nuget.org<br/>Trusted Publishing (OIDC)<br/>dotnet nuget push"]
  • Who/where. The guard job runs only when github.ref == refs/heads/main and github.actor == brogan89; every other job depends on it, so a dispatch from another branch or user skips the whole run. Jobs also run in the release environment, whose deployment branch policy allows main only. (Required reviewers on environments need GitHub Enterprise for private repos, so the actor check is the gate.)
  • Tests first. The full CI suite runs again (workflow_call) before anything is built.
  • Artifacts. Self-contained, ReadyToRun editor builds, packaged by build/package-editor.sh: MainframeEngine-X.Y.Z-osx-arm64.tar.gz (Mainframe Engine.app, unsigned — first launch: right-click → Open), MainframeEngine-X.Y.Z-win-x64.zip, MainframeEngine-X.Y.Z-linux-x64.tar.gz; the GitHub Release is titled “Mainframe Engine vX.Y.Z”. just publish-local [rid] produces the same archive locally. The executable inside stays MainframeEngine.Editor (the assembly name the workflow and tests use).
  • NuGet packages. The package job runs build/pack-nuget.sh with the release version: MainframeEngine.X.Y.Z.nupkg + .snupkg (engine, generator, natives, content, mf-l10n) and MainframeEngine.Templates.X.Y.Z.nupkg (mfgame, defaulting to the engine of the same version); see Distribution via NuGet. The release job attaches both .nupkg files to the GitHub Release and, last of all, pushes them to nuget.org through Trusted Publishing. NuGet/login exchanges the job’s OIDC token for a 1-hour key against a policy owned by the Mainframe-Games organization (workflow publish.yml, environment release), so there is no stored API key or secret and the packages belong to the organization; NUGET_USER in the workflow is the policy creator’s nuget.org username (setup). The push comes after the tag because a nuget.org package can only be unlisted, never deleted; --skip-duplicate makes a re-run harmless.
  • Docs site. A successful run triggers docs.yml (workflow_run), which builds the docs from the released commit and deploys them to GitHub Pages with the new version in the title. This is the only automatic docs deploy: merges to main do not publish docs (Docs site).
  • Demo and Forest assets. The demo and forest jobs (need guard and ci; the release waits for them) pull the example’s LFS content and run build/package-example.sh <Demo|Forest> <version> <out-dir> (build/package-demo.sh <version> <out-dir> is the Demo’s wrapper). For the Demo it zips Examples/Demo (tracked and unignored files, no bin//obj/, failing on LFS pointer files) under a top folder MainframeEngine.Demo/ as MainframeEngine.Demo-vX.Y.Z.zip (plus a generated Directory.Packages.props with the versions of the packages the Demo references, read from the repo’s, since the Demo builds outside it) and a stable-name copy MainframeEngine.Demo.zip; the Forest gives MainframeEngine.Forest-vX.Y.Z.zip and MainframeEngine.Forest.zip (≈ 155 MB) the same way. All are attached to the release; the editor’s Download Example menu fetches the versioned one (or the second from a development build), see Editor: projects. CI zips the Demo the same way (version 0.0.0-ci) and runs MainframeEngine.Editor --validate-demo-zip <zip> --build on it (validates, points it at the checkout like Download Demo and builds Demo.slnx out of tree), so a Demo zip that is broken or only builds inside the repo fails before a release.
  • Requirements on the user’s machine. Creating and playing game projects runs dotnet build, so the .NET 10 SDK must be installed even though the editor itself is self-contained. New projects restore the MainframeEngine package of the editor’s version from nuget.org (no engine checkout); the editor ships the mfgame template (Templates/mfgame next to the executable) for it.

Released editors check GitHub Releases at start-up and update themselves in place — badge, release notes, Update & restart: download, SHA-256 check, swap by the downloaded editor (--apply-update), relaunch. Nothing in the release workflow is specific to it: the archives’ names and layout are the contract. See Editor updates.

End-to-end check (manual, on each OS, before a release that changes the updater):

  1. just publish-local <rid> 0.9.0 and unpack artifacts/release/MainframeEngine-0.9.0-<rid>.* somewhere writable (macOS: ~/Applications, then right-click → Open once).
  2. Start it, open a project, click the badge, Update & restart.
  3. The editor relaunches as the latest release with the project open; ~/.mainframe/updates/update.log shows the run. When the latest release itself contains the updater, <root>.old and the staging folder are gone after it starts and the Output panel says “Updated to vX.Y.Z”.
  4. Repeat from a read-only location (macOS: straight from Downloads, i.e. translocated) to see Download and the hint.

The product name macOS shows — Dock tooltip, the bold app menu next to the Apple menu, Cmd+Tab, Finder — is Mainframe Engine, never the executable name. macOS takes it from the CFBundleName/CFBundleDisplayName of the bundle the executable runs from, once, when the process registers with LaunchServices (ADR 0096). One template, build/macos/Info.plist.in (@NAME@, @BUNDLE_ID@, @EXECUTABLE@, @ICON_FILE@, @VERSION@), feeds both bundles:

ReleaseDevelopment (just editor, dotnet run)
Built bybuild/package-editor.shbuild/macos/DevAppBundle.targets (after every macOS build of the editor; the editor’s csproj sets MacAppDisplayName, MacAppBundleId, MacAppIcon)
BundleMainframe Engine.app in the archiveMainframeEngine.Editor/bin/<cfg>/net10.0/Mainframe Engine.app
Contents/MacOS/MainframeEngine.Editorthe published filesa symlink to ../../../MainframeEngine.Editor (the apphost)
Identifier / versioncom.mainframegames.editor, X.Y.Zcom.mainframegames.editor.dev, $(Version) (0.0.0-dev)

In development the build output stays where it is: the .NET apphost resolves its own path with realpath, so the app base directory (assemblies, Content/) is still bin/<cfg>/net10.0/, while CoreFoundation picks the main bundle from the unresolved exec path. The .dev identifier keeps development builds apart from an installed release (LaunchServices, preferences, permissions). dotnet run starts the bundle path directly (not via open), so console output, exit codes and Ctrl+C are unchanged. RID-specific builds/publishes skip the dev bundle; -p:MacDevAppBundle=false turns it off. Starting bin/…/MainframeEngine.Editor itself still works and shows the executable name. Changing the name in-process does not work: NSProcessInfo.processName (even set before NSApplication exists) and SDL hints leave the LaunchServices name alone; only private LaunchServices calls could change it.

The same targets wrap every game’s desktop executable, so dotnet run / just forest show the game’s name and icon instead of MyGame.Desktop and a blank icon. build/MainframeGame.props (imported by every game project) reads project.mfproj and imports the targets:

MSBuild propertyDefault (game)Use
MacAppDisplayName"name" (MainframeAppName)bin/<cfg>/net10.0/<name>.app, CFBundleName/CFBundleDisplayName
MacAppBundleIdcom.mainframegames.<name, letters/digits/hyphens, lower case>CFBundleIdentifier (+ MacAppBundleIdSuffix, default .dev)
MacAppIcon"window.icon" (a PNG, converted by build/macos/png-to-icns.sh into obj/, once per change)an .icns is copied as is: set it for an icon on Apple’s grid
MacAppVersion"version", else $(Version)CFBundleShortVersionString/CFBundleVersion

The props also set Product (every game assembly) and the executable’s AssemblyTitle (the Windows file description) to the name; the Windows exe icon is the desktop project’s ApplicationIcon (an .ico). On Windows the SDK writes the icon and version strings into the apphost only when building on Windows. Bundles are built for executables only (not IsTestProject), skipped for RID-specific builds and with -p:MacDevAppBundle=false. The editor’s Play launches the game through <name>.app/Contents/MacOS/<assembly> when it exists (ProcessGameLauncher.FindLauncherProgram).

On macOS an SDL window icon only replaces the Dock icon, so GameHost skips window.icon when the process runs from a bundle that names an icon (MacAppBundle.HasIcon, CoreFoundation’s main bundle CFBundleIconFile): the Dock keeps the .icns. Unbundled runs (dotnet Game.Desktop.dll, the scripts) still get window.icon, which is full-bleed for Windows and Linux. The template smoke test (just template-smoke) checks SmokeGame.app’s Info.plist and icon.

build/package-game.sh <Desktop.csproj> <rid> <out-dir> [--name] [--exe] [--version] [--bundle-id] [--copyright] [--icon] (just package-game) packages a GameHost game for players: a self-contained, untrimmed Release dotnet publish for one RID (project.mfproj and Content/ ride along as the desktop project’s copied items), the apphost renamed to the player-facing executable, and the engine’s natives for other platforms (enet, mfrmlui, mfsvg) removed. Then per platform:

RIDOutputNotes
osx-*<exe>-<version>-<rid>.zip with <Name>.appInfo.plist (name, bundle id, version, copyright, games category), the desktop project’s MacAppIcon when it is an .icns, else .icns from the PNG (build/macos/png-to-icns.sh), ad-hoc codesign by default (Apple Silicon refuses unsigned code). --sign "Developer ID Application: …" (or MF_SIGN_IDENTITY) signs each Mach-O and then the bundle, with the hardened runtime, a timestamp and the .NET entitlements (JIT, unsigned executable memory, library validation off). --notarize <profile> (or MF_NOTARY_PROFILE, from xcrun notarytool store-credentials) then submits, staples and re-zips
win-*<exe>-<version>-<rid>.zipthe desktop project’s ApplicationIcon, else the exe icon from the PNG (magick → .ico → ApplicationIcon)
linux-*<exe>-<version>-<rid>.tar.gzalso the dedicated server (<exe> --headless ++ …, ADR 0120)

Defaults: name and version from project.mfproj, the exe from the name without spaces, the icon from window.icon. Cross-publishing works from any host (self-contained); only the .icns needs macOS.

Editor updates · Build & platforms · Testing · Distribution via NuGet