Hazel

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:

VariableValueAbsent means
RENZORA_RELEASE_TAGr1-alpha8 or r1-alpha8-nightly-16aug26built from source (a dev build)
RENZORA_BUILD_COMMITthe 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

TriggerTagKind
schedule, 02:00 UTC dailyr1-alpha8-nightly-16aug26prerelease
push of an r1-alpha* tagthe tag itselffull release
workflow_dispatchyour 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

PlatformWhereNotes
linux x64native ubuntu-latest runnerAppImage
linux arm64native ubuntu-24.04-arm runnerAppImage
macos x64native macos-26-intel runnersigned, notarized .dmg
macos arm64native macos-latest runnersigned, notarized .dmg
windows x64native windows-latest runnerflat tree
windows arm64native windows-11-arm runnersee below
wasm32ghcr.io/renzora/wasm containerruntime + 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:

AssetWhat it is
<platform>.zipthe 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>.dmgmacOS 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>.zipthe 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:

  1. the export template, cut from Contents/MacOS/ or from the AppDir
  2. on macOS, a check that sdk.tar.zst really 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 as Absent

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 compresses dist/<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 publish ships 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 profileTotal
renzora.exe187.0 MB138.2 MB−26.1%
renzora-editor.exe265.6 MB194.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.

ProfileUsed bySettings
distcargo renzora, cargo dist, every --profile dist command in the docs and CIopt-level = 2, lto = false
releasedocker/build-all.sh, i.e. every GitHub buildopt-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/updater stays on its own dist. It is a separate cargo workspace with its own tuned profile, and its output is kilobytes.
  • windows-arm64 builds through xtask, so it gets dist. That lane sets CARGO_PROFILE_DIST_OPT_LEVEL / CARGO_PROFILE_DIST_LTO in the workflow to match release; 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 with catch_unwind, and each script call is caught the same way. Under abort those 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 .pdata unwind 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.exe from r1-alpha7 as Trojan:Win32/Wacatac.C!ml. The !ml suffix means a machine-learning verdict rather than a signature match, and a self-extracting stub that decompresses and rewrites its own image before main runs 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_macos signs 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:

  1. The editor stages the new engine under ~/.renzora/updates/<tag>/staged/.
  2. 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.
  3. 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 a std-<hash>.dll that lives in the directory it is deleting. tools/updater/.cargo/config.toml switches 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 --workspace never sees it: build_updater in docker/build-all.sh (containers) and build_updater in xtask/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

  1. ENGINE_VERSION in crates/renzora/src/version.rs must already be the version you are releasing, with docs/<version>/ beside it. Do not bump it to the next version in this commit — the workflow reads that constant as version and 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.
  2. Write RELEASE_NOTES.md at the repo root for this version. Its first line must name the version.
  3. Record the release in releases.json: the version, the commit it is cut from, the ABI hash, and "status": "released".
  4. Land that on main.
  5. 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:

  1. The website job at the end of Build Engine sends a repository_dispatch of type engine-wasm to renzora/website, carrying the tag it just published. It uses WEBSITE_SYNC_TOKEN, the same fine-grained PAT sync-docs.yml holds, because POST /repos/{owner}/{repo}/dispatches needs exactly the Contents write it already grants.
  2. 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.
  3. scripts/fetch-engine-wasm.sh on the droplet downloads web-wasm32.zip for that tag, unpacks it to engine/builds/<tag>/, pre-compresses it, and moves the engine/current symlink.

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; only current inside it does, in one atomic rename.
  • The build is served pre-compressed. .br and .gz are written beside each file once, and ServeDir::precompressed_* hands them straight out. Without that, both nginx (gzip_types lists application/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