Releases & Nightlies
How the engine gets from main to something someone can download.
Everything here is the Build Engine workflow, .github/workflows/build-engine.yml, plus the packaging script it calls, scripts/package-release.sh.
The version is one string
renzora::version::ENGINE_VERSION in crates/renzora/src/version.rs is the engine's version — r1-alpha8 today. It is what:
- the About dialog and the splash show,
- the docs directory is named after (
docs/r1-alpha8/), - the release workflow builds its tag from,
- the export downloader asks GitHub for.
Bumping a version means editing that constant and creating docs/<new-version>/ in the same change. Nothing else hardcodes it; four places used to, and they disagreed.
A published binary also carries two values CI stamps in at compile time, read with option_env! when the contract crate compiles:
| Variable | Value | Absent means |
|---|---|---|
RENZORA_RELEASE_TAG | r1-alpha8 or r1-alpha8-nightly-16aug26 | built from source (a dev build) |
RENZORA_BUILD_COMMIT | the commit the release was cut from | — |
option_env! is baked in at compile time, so only a cold build picks these up. CI builds cold every run; a warm local tree ignores them, which is correct because a local tree is a dev build anyway.
Three ways a build starts
| Trigger | Tag | Kind |
|---|---|---|
schedule, 02:00 UTC daily | r1-alpha8-nightly-16aug26 | prerelease |
push of an r1-alpha* tag | the tag itself | full release |
workflow_dispatch | your choice of none / nightly / release | — |
The nightly date is %d%b%y lowercased — 16aug26. Tags sort readably per version because the version comes first.
Nightlies skip a quiet day. If nothing landed on main in the last 24 hours the run stops at the setup job rather than publishing an identical release and burning ~6 runner-hours. A same-day re-run replaces its nightly rather than inventing a suffix nobody could predict. The 14 most recent nightlies for a version are kept; older ones are deleted with their tags. r1-alpha* releases are never pruned.
What gets built where
| Platform | Where | Notes |
|---|---|---|
| linux x64 | native ubuntu-latest runner | AppImage |
| linux arm64 | native ubuntu-24.04-arm runner | AppImage |
| macos x64 | native macos-26-intel runner | signed, notarized .dmg |
| macos arm64 | native macos-latest runner | signed, notarized .dmg |
| windows x64 | native windows-latest runner | flat tree |
| windows arm64 | native windows-11-arm runner | see below |
| wasm32 | ghcr.io/renzora/wasm container | runtime + editor bundles |
Every desktop slice is native, and wasm is the only one left in a container. An editor ships a plugin SDK, and an SDK cannot be cross-built: its proc-macro dylibs are artifacts of whatever machine ran the compiler, so a Linux container produces Linux ones no matter what --target says. A Windows user handed those gets can't find crate for bevy_derive and, behind it, every name in bevy::prelude. docker/build-all.sh therefore stages no editor at all. The desktop containers were retired rather than kept for the runtimes, because a native tree already yields both assets: the engine, and the export template cut from the same directory.
Windows ARM64 is the one target the Docker toolchain cannot produce. The only MSVC pieces Microsoft allows to be redistributed (so xwin can bake them into a public image) are the CRT and SDK, which leaves clang as the C compiler — and clang can't stand in for MSVC on ARM64, because it emits MSVC NEON intrinsics as undefined externals no redistributable library provides. So that slice builds natively with the real MSVC toolchain on a GitHub-hosted arm64 runner.
That job has no build cache and takes hours, which makes it the long pole on every publishing run. The publish job therefore treats it as best-effort: whatever slices arrived get published, and a missing one is simply absent from manifest.json, which the editor reads as "no template for that platform".
What a release contains
Two assets per desktop platform, from scripts/package-release.sh:
| Asset | What it is |
|---|---|
<platform>.zip | the engine: renzora with the editor image (renzora_editor.*) beside it, plus plugins/ and sdk.tar.zst. Windows is that tree flat; Linux ships the .AppImage. |
<platform>.dmg | macOS only, in place of the zip. A mountable folder holding Renzora Engine.app and a symlink to /Applications, signed, notarized and stapled. The SDK rides inside the bundle, because install::root() on macOS is Contents/MacOS/ with no $APPIMAGE-style escape hatch. The updater mounts this and copies the app out. |
renzora-runtime-<platform>.zip | the export template — the game runtime and its plugins/, no editor. |
Plus manifest.json (every asset with size and SHA-256, keyed by platform) and SHA256SUMS.
The engine asset keeps the bare <platform>.zip name the earlier releases used, so links to it don't rot. The template name is derived from Platform::dist_dir_name() in code and from the platform directory name in the script, so the two halves of the contract cannot drift — they did once, and the result was a download feature that could never have succeeded.
The build lane cuts its own export template on macOS and Linux
Both of those platforms stage their output twice. A .dmg is the Renzora Engine.app inside it, and an .AppImage is the Renzora Engine.AppDir it was squashed from, so a lane that uploaded dist/ whole shipped every byte to the publish job two ways round: the macos-arm64 artifact was 1.16 GB against the 585 MB disk image cut from it.
The bundle cannot just be deleted before the upload, because package-release.sh reads two things out of it, and the publish job runs on Linux where neither a signed .dmg nor a squashfs can be opened:
- the export template, cut from
Contents/MacOS/or from the AppDir - on macOS, a check that
sdk.tar.zstreally is inside the bundle rather than beside it, which is the difference between a shipped editor that can compile a plugin and one that reports the SDK asAbsent
So both move onto the build lane, which then deletes the duplicate. The template is cut by scripts/package-release.sh --template-only <platform> <dist-dir> rather than by a second copy of the file list written out in YAML, so there is one definition of what belongs in an export template. Packaging picks up the renzora-runtime-<platform>.zip the lane left in the tree, the same way it already accepts an sdk.tar.zst a lane packed for it.
A lane without appimagetool builds no AppImage, and there the AppDir is the only copy of the binaries there is, so it is kept and packaging reads it the old way.
A stripped tree that arrives with no template is a hard failure, not a warning. Shipping a platform with no export template is the quiet kind of omission that cost r1-alpha7's Linux editors their SDK, and it would not be noticed until somebody tried to export months later.
Executable bits
actions/upload-artifact does not preserve unix file modes, so every binary reaches the publish job as 0644. package-release.sh restores the bit on renzora, renzora-update, AppRun and *.AppImage before zipping, because zip stores whatever mode a file has at the moment it is archived. Without that pass, Linux and macOS releases ship an engine nobody can launch.
Only things that are launched. The editor image and everything under plugins/ are dlopen'd rather than executed, so they stay 0644.
The plugins in a release
The first-party plugins are no longer in this repository. The marketplace
ships them, and everything here that existed only to build them went with them:
xtask/src/native_plugin.rs, cargo renzora plugin <name>, both plugin-staging
passes, the --plugins coverage scope, CI's plugin jobs, and build_plugins in
the container script.
stage_dist in docker/build-all.sh sweeps every cdylib the build produced into
dist/<platform>/plugins/, minus an explicit skip list: the shared engine images
(renzora_dylib, renzora_ember_dylib) ship beside the executable, never in
plugins/, because swept in there they would be tens of MB of duplicate weight
that the loader would then sniff for an entry point they do not export.
stage() no longer writes to that directory at all. It used to sweep every .so
in dist/<platform>/plugins/ on the assumption that xtask had put them there —
which stopped being true once the marketplace installed into it and users could
drop a plugin in by hand, so the sweep could only delete things it had not
created.
Every nightly checks that the published plugins still build
A native plugin ships as source and is compiled on the machine that installs
it, so an engine change that breaks one is invisible here: it surfaces when a
user launches an editor and finds the plugin skipped. Every desktop lane
therefore clones renzora/plugins and
compiles all of it against the SDK that lane just staged, using
renzora_plugin_build's own driver, which is the same code path a user's machine
takes.
Run the same check locally against any staged SDK:
cargo check-plugins --sdk dist/windows-x64/sdk --plugins ../plugins
Three things about where it sits are load-bearing:
- Before
Pack the plugin SDK. That step compressesdist/<platform>/sdk/and deletes the extracted tree, which is what the check reads. - On every platform's own runner. An SDK cannot be cross-built, because its proc-macro dylibs are artifacts of whatever machine ran the compiler. The desktop matrix is already one runner per platform, so all six are checked for free, and a plugin that builds on Linux but not against the MSVC SDK is caught.
- Report-only, written to the job summary. By the time it runs the lane has
spent hours building, and
publishships whichever slices arrived, so a hard failure would drop a whole platform out of a release over one broken third-party plugin. Enforcement belongs in the plugins repo instead, where a red run costs one rerun rather than six runner-hours.
It checks that each plugin compiles and exports renzora_native_plugin_ctor,
searching the built image for the symbol exactly as the loader does. It does not
load them, so a plugin that compiles and then panics in Plugin::build still
passes. Adding that is worth doing and has to run one plugin per subprocess,
since the loader never drops a Library.
The tool is an examples/ target rather than a crate or an xtask subcommand: an
example is not built by cargo build --workspace, so it costs nothing on every
other build. xtask was the obvious home and is the wrong one, because it is
deliberately its own workspace root so that it keeps building when a dangling
generated dependency stops the engine workspace from loading. A path dependency
on an engine crate would mean resolving lints.workspace = true against the very
workspace that is broken, and cargo renzora sync is the tool that repairs that
state.
Binary size
The engine is large — .text alone was 134 MB of the runtime's 187 MB — and essentially all of it is code, not data. Symbols are already stripped (strip = "symbols"; there are no .debug* sections and no PDB path embedded in a release binary), so there is nothing to sweep out. What there is, is monomorphized generics: a release .rdata carries ~12,000 distinct bevy_ecs:: type-name strings, one per instantiated system-param combination.
Measured on windows-x64:
| As shipped before | + size-opt profile | Total | |
|---|---|---|---|
renzora.exe | 187.0 MB | 138.2 MB | −26.1% |
renzora-editor.exe | 265.6 MB | 194.3 MB | −26.8% |
These numbers are from when the editor was a second executable. It is a loadable image now (renzora_editor.dll beside renzora.exe), so the second row no longer names a file that ships — the measurement stands, the filename is history.
Releases used to be UPX-packed on top of this, which took the two files to 24.9 MB and 35.1 MB and the whole installed tree to ~77 MB. That was removed — see below — so the profile is now the only size lever, and the 26% above is the whole story rather than a first step.
Two things act on that:
1. There are two release profiles, because shipping and iterating want opposite things — the smallest possible binary regardless of link time, versus a fast link regardless of size.
| Profile | Used by | Settings |
|---|---|---|
dist | cargo renzora, cargo dist, every --profile dist command in the docs and CI | opt-level = 2, lto = false |
release | docker/build-all.sh, i.e. every GitHub build | opt-level = "s", lto = "thin" |
dist is what a contributor uses all day and it is deliberately unchanged — thin LTO on this graph is minutes of link time on every build, and at opt-level = 2 it does not even shrink anything (measured: it made both binaries bigger, 170 → 174 MB and 238 → 243 MB, because at that opt-level its dominant effect is cross-crate inlining, which is not size-constrained).
release pairs size-opt with thin LTO, which is the combination that works: opt-level = "s" makes the inliner size-aware so LTO's cross-crate dead-stripping dominates. It is selected by PROFILE in build-all.sh (override with RENZORA_PROFILE=dist to reproduce a lane quickly), and it is reproducible by hand — cargo build --profile release --workspace — which is why it is a named profile rather than a pile of env overrides.
Two exceptions to know about:
tools/updaterstays on its owndist. It is a separate cargo workspace with its own tuned profile, and its output is kilobytes.windows-arm64builds through xtask, so it getsdist. That lane setsCARGO_PROFILE_DIST_OPT_LEVEL/CARGO_PROFILE_DIST_LTOin the workflow to matchrelease; keep them in step if the profile changes.
This trades frame time for size in shipped builds, deliberately, in the editor as much as the game — if the viewport regresses in a release but not locally, this is why.
Two knobs are deliberately not set:
codegen-units = 1— measured to help only fat LTO, and to cost a lot of build time for little size under thin.panic = "abort"— the network pump guards every call into a backend withcatch_unwind, and each script call is caught the same way. Underabortthose become no-ops and one malformed header or one bad script takes the editor down instead of being contained. It would save the ~6 MB of.pdataunwind tables; it is not worth it.
The tools/updater build is unaffected — it is its own workspace with its own [profile.dist].
2. Bevy's feature set is deliberately maximal and has not been trimmed. The justification recorded in Cargo.toml — that the shared bevy_dylib's feature set was the plugin API surface and an input to the ABI hash — no longer applies, since nothing links Bevy but the engine itself. Trimming it (bevy_solari, meshlet) is therefore now possible and would be a real cut, but it removes engine capability rather than build overhead, so it is a product decision rather than a build one.
Why releases are no longer packed
Every non-macOS executable used to be run through UPX with --best --lzma before it shipped. It worked, and it worked well: measured on the dist runtime, 187.3 MB → 31.7 MB, an 83% saving, with the packed binary booting through full plugin and scripting startup. It was removed anyway, for two reasons that are not going away:
- Antivirus. Windows Defender scored the packed
renzora.exefromr1-alpha7asTrojan:Win32/Wacatac.C!ml. The!mlsuffix means a machine-learning verdict rather than a signature match, and a self-extracting stub that decompresses and rewrites its own image beforemainruns is precisely the behaviour those classifiers are trained to catch. There is no packing setting that argues with a classifier. An engine users have to talk their antivirus out of is worse than a large one. - Signing. Packing rewrites the executable, which invalidates any code signature it already carries. macOS was excluded from packing for exactly that reason —
fixup_macossigns ad-hoc, and arm64 refuses a binary whose signature does not verify. Once the Windows binaries are signed too, the same constraint applies there, so the step was on borrowed time regardless.
Downloads are larger as a result. That is the accepted cost.
renzora upx [dist/<platform>] still exists and still packs an already-built tree on request — it is a developer's own call on their own binaries. It is simply not something a release does.
The updater
The editor updates itself from these releases: Help ▸ Check for Updates, or the same item labelled Update to <tag> when the background check at startup has already found one. When there is one, an Update to <tag> chip also appears on the right of the top bar; it opens the same overlay.
The dialog lists every version the channel offers, newest first — releases marked with a seal, nightlies with a moon, the running build marked current, and any release with no build for your platform greyed out rather than hidden. Picking a row selects it, including an older one: rolling back is a download like any other, and the check already fetched the whole list, so offering only the newest would be throwing information away. Selecting a different version discards anything already staged, since that download belongs to a different tag. It downloads the <platform>.zip for the host, verifies it against the SHA-256 GitHub publishes for the asset, and replaces the install.
crates/renzora_update does the checking, downloading and staging. The replacement itself is tools/updater — a separate ~220 KB binary, renzora-update, that ships beside the editor:
- The editor stages the new engine under
~/.renzora/updates/<tag>/staged/. - It copies the sidecar to a temp directory, spawns it with the staged path, the install path, its own PID and a relaunch path, and calls
exit. - The sidecar waits for that PID to disappear, moves the current install aside to a sibling
*.renzora-backup(same volume, so the rename is atomic), installs the staged one, deletes the backup, and relaunches.
Any failure after the rename puts the backup back before reporting. The worst case is "the update didn't happen", never "the engine is gone".
Three details are load-bearing and easy to undo by accident:
- The sidecar runs from a temp copy, not from the install folder. Launched in place it would hold an open handle inside the very directory it is about to rename — which Windows refuses outright.
- It must not inherit
prefer-dynamic. That would make it import astd-<hash>.dllthat lives in the directory it is deleting.tools/updater/.cargo/config.tomlswitches it off with an explicit=no, and for a worse failure mode: a plugin that won't load is skipped; an updater that won't load leaves a half-replaced engine and no process to repair it. - It is built by two separate paths, because it is its own cargo workspace and
--workspacenever sees it:build_updaterindocker/build-all.sh(containers) andbuild_updaterinxtask/src/main.rs(cargo renzora). Both are non-fatal — a missing sidecar costs the in-place update and nothing else, and the editor says so rather than failing silently.
What "the install" is depends on the platform, and the sidecar picks its behaviour from what it is given rather than from a platform flag: a directory (a Windows install folder, or a macOS .app) is replaced wholesale; a file (a Linux .AppImage) is replaced on its own. renzora_update::install::detect_layout works out which, reading $APPIMAGE on Linux and walking up to the .app on macOS.
The sidecar is deliberately excluded from export templates — renzora-runtime-<platform>.zip carries the game runtime and its plugins, and an exported game has no business shipping an engine updater.
Channels
auto (the default) follows the build: a nightly is offered newer nightlies, a release is offered releases, and a build from source tracks nightlies. stable and nightly override it, stored in ~/.renzora/editor.toml as update_channel. It is stored as auto rather than resolved once, because the answer changes when you update — taking a nightly user to a release should move them to the stable channel, which a resolved value would not do.
The ordering that makes this work is in crates/renzora_update/src/version.rs. Release and pre-release sort with absent above present (r1 > r1-alpha8), and within one version a three-state Stage orders Dev < Nightly(date) < Final.
That third state earns its place. It started as a two-state "is this a nightly?", which could say that r1-alpha8 outranks r1-alpha8-nightly-16aug26 — correct — but had no way to place a build from source, which has no tag at all. Such a build reported the bare version, parsed as the finished release, and so outranked every nightly of its own version: the dialog said "Renzora is up to date" while displaying the release notes of the nightly it was declining to offer. A source build is the least finished build of a version, not the most.
Running from a source checkout
The editor then lives in <checkout>/dist/<platform>/, so installing a release replaces the tree cargo renzora stages into. That is recoverable — rebuild and it comes back — but it is not something to do on one stray click, and the next cargo renzora would overwrite the downloaded engine again.
The updater detects the checkout (a Cargo.toml beside crates/ and src/main.rs, walking up) and makes you say it twice. The action button reads Overwrite dist/… in amber; the first click arms it, a line appears naming the exact directory about to be replaced, and the button turns red and reads Confirm — Overwrite & Restart. Any re-check or channel switch disarms it.
Downloading is never gated: it writes to ~/.renzora/updates/<tag>/, not to the install. Only the install itself asks.
Cutting a real release
ENGINE_VERSIONincrates/renzora/src/version.rsmust already be the version you are releasing, withdocs/<version>/beside it. Do not bump it to the next version in this commit — the workflow reads that constant asversionand compares it against the tag to decide what kind of build this is, so a constant that runs ahead of the tag makes the release classify itself as a nightly.- Write
RELEASE_NOTES.mdat the repo root for this version. Its first line must name the version. - Record the release in
releases.json: the version, the commit it is cut from, the ABI hash, and"status": "released". - Land that on
main. - Tag it:
git tag r1-alphaN && git push origin r1-alphaN.
The tag push triggers the workflow, which builds every platform and publishes the release. Nothing is uploaded by hand.
Opening the next version — bumping ENGINE_VERSION, forking docs/<next>/, pointing docs/_versions.json and CLAUDE.md §4 at it — is a separate change that lands after the tag.
The notes
RELEASE_NOTES.md at the repo root is the body of a GitHub release, and it is written as work lands rather than reconstructed at the end. Every feature and every fix adds its line in the same change that ships it, under a heading giving the date and time the change was made:
# Renzora Engine `r1-alpha8`
## 2026-09-15 14:32
- feat(editor): what it does, in the commit-subject voice
## 2026-09-15 11:46
- fix(plugin): what broke, and what now happens instead
## r1-alpha8-nightly-06sep26
- ...
Newest first. Lines that land together share a heading; work done at a different time gets its own.
A nightly publishes those sections. The publish job lifts every timestamped section at the top of the file into the nightly's release page under Since the last nightly, above the standard asset descriptions, headings included, so the page says when each line landed rather than only what it was.
It stops at the first ## heading that does not begin with a digit, which is every heading that is not a timestamp: the tag heading a published nightly leaves behind, and the prose headings of a curated release document. Keying on the shape of the heading rather than on a fixed section name is what lets the headings carry a date at all, and it is what keeps a nightly cut between a release and the next version's first commit from publishing that release's prose as if it were a list of unshipped changes.
After a nightly goes out, add its tag as a heading above the sections it shipped; new work then accumulates above that line. Nothing breaks if that is late: the worst case is the next nightly repeating a line. No timestamped section at all is not an error either: the nightly falls back to the asset boilerplate by itself.
A full release publishes the whole file. Cutting r1-alphaN replaces RELEASE_NOTES.md with curated notes for that version — prose organised by theme, not the running list, which has by then served its purpose of making the prose writable. The job cats the file in ahead of the asset boilerplate, so the notes and the "what's in the download" text are one page.
Because the file is written by hand, its failure mode is not a missing file but a stale one — the previous version's notes shipping under this version's tag, which nothing downstream could detect. So setup refuses to start a release whose RELEASE_NOTES.md does not name the version in its first line. That check lives in setup rather than in publish because setup costs seconds and publish is ~6 runner-hours downstream: a mismatch should stop before anything is built.
The ABI hash
releases.json at the repo root is the canonical record of what each release froze — version, commit, ABI hash. The value is RENZORA_BUILD_HASH, the FNV-1a in build.rs over <crate version>-<rustc version>-bevy<minor>. Read it from a build rather than deriving it by hand: it is emitted into target/<profile>/build/renzora_app-*/output, and because it is keyed on the toolchain and not on engine source, two releases built with the same pinned rustc and Bevy legitimately share a hash.
Publishing also updates renzora.com/engine
Every nightly and every release refreshes the browser build the website serves at renzora.com/engine: the editor and the runtime, running on WebGPU, from the same web-wasm32.zip the release carries.
The chain has three links:
- The
websitejob at the end of Build Engine sends arepository_dispatchof typeengine-wasmtorenzora/website, carrying the tag it just published. It usesWEBSITE_SYNC_TOKEN, the same fine-grained PATsync-docs.ymlholds, becausePOST /repos/{owner}/{repo}/dispatchesneeds exactly the Contents write it already grants. - The website's Engine WASM workflow catches that and SSHes into the droplet. Nothing is rebuilt and no image is pushed, because the website itself did not change.
scripts/fetch-engine-wasm.shon the droplet downloadsweb-wasm32.zipfor that tag, unpacks it toengine/builds/<tag>/, pre-compresses it, and moves theengine/currentsymlink.
The job is continue-on-error and runs after the release is already published. A website one build stale is not a reason to redden a green release, and the next nightly re-fires it anyway.
The wasm is never committed anywhere. renzora-editor_bg.wasm is ~100 MB and renzora-runtime_bg.wasm ~72 MB, so the editor module sits within a few MB of GitHub's 100 MiB per-file hard limit: a push would start failing outright before long, and until then would add ~170 MB per night of history that can never be pruned. It is downloaded onto the droplet instead, the way the marketplace's preview wasm already was.
Two consequences worth knowing:
- The symlink is load-bearing.
engine/is a bind mount into the app container, so replacing that directory would leave the container pinned to the old, unlinked inode, serving a build nobody can see on the host. The mount root never moves; onlycurrentinside it does, in one atomic rename. - The build is served pre-compressed.
.brand.gzare written beside each file once, andServeDir::precompressed_*hands them straight out. Without that, both nginx (gzip_typeslistsapplication/wasm) and the app's compression layer would compress 100 MB on every request, on a two-core VPS.
To pin the site to a specific engine build, or to pull one by hand: website → Actions → Engine WASM → Run workflow, with a tag or with none for the newest release (nightlies included).
Running it manually
Actions → Build Engine → Run workflow. Pick a platform (or all) and a publish mode. publish: none builds and uploads GitHub artifacts (7-day retention) without creating a release — the right choice for checking that a platform still builds.
A run that builds no wasm (any narrowed platform other than all or wasm) skips the website dispatch, since there would be no new bundle to publish.
See also
- Building Export Templates — how the editor resolves, downloads and uses a template.
- Cross-Compilation — the toolchain images the build jobs run in.
- Building from source —
cargo renzorafor local work.