A small, cross-platform CLI that bumps, packages, signs/notarizes, and
uploads to Steam any Unreal Engine project — without living inside the project.
It is a thin orchestration layer: Unreal's RunUAT does the cooking/packaging,
Apple's codesign/notarytool do macOS signing, and steamcmd does the upload.
uetool just wires them together with consistent versioning and config.
Install it once, put it on your PATH, and point it at any UE project.
Clone (or download-and-extract) this repo anywhere — it's self-contained, and only
the folder needs to go on your PATH:
git clone https://github.com/DukeGDDI/uetool.git
Requirements (both platforms):
- Python 3.11+ (uses stdlib
tomllib). - steamcmd — Valve's standalone build, or the Steamworks SDK's ContentBuilder.
- For macOS target builds: a Mac with Xcode's command-line tools and a Developer ID Application certificate (see the macOS section).
Then follow the setup for your OS below. Both set the same two machine-wide env vars
(UETOOL_PYTHON, UETOOL_STEAM_SDK) plus secrets; everything project-specific lives
in the project's uetool.toml / uetool.local.toml (see Per-project setup).
- Put uetool on
PATH(zsh shown — adjust for your shell):echo 'export PATH="$HOME/Workspace/uetool:$PATH"' >> ~/.zshrc
- Python 3.11+ — e.g.
brew install python@3.13. - steamcmd — install Valve's standalone build:
(Linux: use
mkdir -p ~/steamcmd && cd ~/steamcmd curl -sSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz | tar xz ./steamcmd.sh +quit # first run self-updates
steamcmd_linux.tar.gz.) - Machine-wide env vars (add to
~/.zshrc), shared by every project:export UETOOL_PYTHON=/opt/homebrew/bin/python3.13 # a 3.11+ interpreter export UETOOL_STEAM_SDK="$HOME/steamcmd" # folder containing steamcmd export UETOOL_APPLE_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # macOS notarization only
- One-time Steam login to cache the Steam Guard sentry — see Steam login.
- macOS signing (for
notarize): you need a Developer ID Application certificate in your login keychain (with Apple's Developer ID intermediate), and an Apple ID enrolled in the Apple Developer Program. Theapple_id,team_id, andsigning_identitygo in the project'suetool.local.toml; the app-specific password is theUETOOL_APPLE_APP_PASSWORDenv var above.
Open a new terminal so the changes take effect.
- Put uetool on
PATH— add theuetoolfolder (e.g.D:\Workspace\uetool) to yourPathvia System → Environment Variables (orsetx).uetool.cmdruns in bothcmd.exeand PowerShell (no execution-policy issues). - Python 3.11+ —
winget install Python.Python.3.13(or from python.org). - steamcmd — download and extract Valve's standalone build:
- Get https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip
- Extract to e.g.
C:\steamcmd\. - Run
C:\steamcmd\steamcmd.exeonce so it self-updates. - Add
C:\steamcmdtoPathas well (see the note below).
- Machine-wide env vars (System → Environment Variables, or
setx):(Use the fullsetx UETOOL_PYTHON python setx UETOOL_STEAM_SDK C:\steamcmdpython.exepath ifpythonisn't already onPATH. The macOS Apple vars don't apply on Windows.) - One-time Steam login to cache the Steam Guard sentry — see Steam login.
Important (Windows): the steamcmd folder goes in two places —
UETOOL_STEAM_SDK(so uetool findssteamcmd.exe) andPath(sosteamcmd.execan load its own runtime DLLs). Without it onPath, uploads can fail to start even thoughUETOOL_STEAM_SDKis correct.
Open a new terminal (or sign out/in) so the changes take effect.
Before the first upload/release, run the interactive login once. It prompts
for the build account's password and a Steam Guard 2FA code:
# macOS / Linux
~/steamcmd/steamcmd.sh +login <build_account> +quit:: Windows
C:\steamcmd\steamcmd.exe +login <build_account> +quitsteamcmd then caches a sentry file, so every later uetool upload (which logs in
non-interactively) runs unattended — you do not repeat this per build. Re-run it
only when the sentry lapses: after a password change, long inactivity, a Steam Guard
change, or moving/replacing steamcmd. The sentry is per-machine + per-OS-user, so
each build machine needs its own one-time login.
At the root of your UE project (next to YourGame.uproject):
- Copy the two templates from this repo and fill them in:
uetool.toml— committed, non-secret (Steamapp_id, depot ids, build config).uetool.local.toml— untracked, per-machine (ue_root; Steamuser; Apple ids).ue_rootis platform-specific, e.g."/Users/Shared/Epic Games/UE_5.7"(macOS) or"C:/Program Files/Epic Games/UE_5.7"(Windows — forward slashes).
- Add to the project's ignore rules (
.gitignore/.dvignore):Keepuetool.local.toml .version .uetool/.build_numbertracked — it's the monotonic counter and must persist.
The project name is auto-detected from the single *.uproject in the folder, so
there is nothing project-specific to hardcode.
usage: uetool [-P PATH] {bump,bootstrap,package,upload,notarize,archive,release} ...
[--platform win|mac] [--config Shipping] [--dry-run]
[--no-bump] [--no-bootstrap] [--clean] [--custom-config NAME]
Run from inside the project, or point at it with -P:
uetool bump # advance the build counter only (writes .version)
uetool bootstrap # build the editor target (headless; for a fresh checkout)
uetool package --platform mac # bump + RunUAT package
uetool package --platform mac --no-bump # package without bumping
uetool notarize # sign + notarize + staple the staged macOS .app
uetool upload --platform mac # push the staged build to Steam
uetool archive --platform win # zip the staged build for non-Steam distribution
uetool release --platform mac # bump -> package -> [notarize on mac] -> upload
uetool -P ~/games/MyGame release --platform win # operate on a project elsewhere--platform defaults to the host (win on Windows, mac on a Mac). --dry-run
prints every external command without executing it (and still validates config) — the
safe way to verify wiring with no engine or Steam access.
A C++ project that has never been built has no Binaries/<host>/<Project>Editor.target
receipt, and RunUAT's cook step reads it before doing anything — so a raw package
would fail with "Could not find file …Editor.target". (Normally you'd first open the
project in the editor, or build it in Rider / Visual Studio.)
package and release handle this automatically: if the receipt is missing they run
bootstrap first, which builds the editor target headlessly via UnrealBuildTool
(Build.bat/Build.sh) — no manual pre-build, no .sln/Xcode project needed. It's a
no-op once the project has been built, so a CI agent can run uetool release on a clean
checkout and it just works. Pass --no-bootstrap to skip it (e.g. when the editor target
is already cached), or run uetool bootstrap on its own as an explicit CI step.
Some hosts take a packaged build directly instead of a Steam depot — Arcware pixel
streaming, itch.io, a plain download. uetool archive --platform <win|mac> zips the
staged build into .uetool/dist/<Project>-<version>-<platform>.zip, with the executable
at the zip root (it zips the contents of the staged folder), names the file with
the current version, and prints the path. It streams large .pak files (no memory blow-up
on multi-GB builds), and preserves the Unix exec bit and symlinks so Linux/macOS builds
stay intact.
uetool package --platform win # produce Saved/StagedBuilds/Windows
uetool archive --platform win # -> .uetool/dist/<Project>-vX.Y.Z.N-win.zip, ready to uploadFor Arcware pixel streaming specifically, the project must have the Pixel Streaming
plugin enabled (UE 5.5+: Pixel Streaming 2) so it's compiled into the build — uetool
packages and zips, but it can't add the plugin. The streaming launch args
(-PixelStreamingURL, -RenderOffScreen, …) are supplied by Arcware at runtime, not baked
into the build.
A build for a streaming host usually needs settings that would be wrong on a storefront
— a capped framerate, audio that keeps playing while the window is unfocused, quality
pinned instead of benchmarked. Put those overrides in
<project>/Config/Custom/<Name>/Default*.ini and pass --custom-config NAME; uetool
merges the layer into the base Config/Default*.ini for the duration of the cook and
restores the originals afterwards, the same borrow-and-restore the version stamp uses.
Without the flag nothing is merged, so the storefront build is unaffected by construction.
Matching keys are replaced in place, new keys are appended to their section, and sections
the base lacks are appended at the end. Restore is byte-exact and runs from a finally, so
a failed or interrupted build still leaves your tracked config clean.
Why not Unreal's own
-CustomConfig? The engine has this exact feature — it layersConfig/Custom/<Name>/above the base when-CustomConfig=<Name>is passed — but it cannot work in a packaged Shipping build made with a Launcher (installed) engine, and it fails silently: the files stage, and nothing reads them. UBT bakes theCUSTOM_CONFIGdefine only for targets not using the shared build environment,BuildEnvironmentis forced toSharedwhenever the engine is installed, and forcingUniqueis rejected outright ("Targets with a unique build environment cannot be built with an installed engine"). The command-line fallback is compiled out of Shipping too (ALLOW_INI_OVERRIDE_FROM_COMMANDLINEis(UE_SERVER || !UE_BUILD_SHIPPING)). Hence the merge. uetool does not pass-CustomConfigto RunUAT.
# streaming build: base ini + Config/Custom/Arcware overrides
uetool package --platform win --custom-config Arcware
uetool archive --platform win
# storefront build: base ini only — no flag, nothing to remember
uetool release --platform winIt is deliberately not settable in uetool.toml. A committed default would apply
silently to every build including the ones headed for a depot, which is exactly the mistake
the layer exists to prevent — so it is opt-in per run.
For the same reason release refuses --custom-config: that command ends in a Steam
upload, and a custom layer leaves no visible trace in the staged output, so the mistake
would be silent. Use package + archive instead (the error message says so).
Note:
archivenames the zip<Project>-<version>-<platform>.zipwith no layer suffix, so a streaming zip and a storefront zip of the same version collide. Move or rename one before building the other.
package builds and cooks incrementally (RunUAT -build, plus -nocompileeditor), so
after a project change that must land in the shipped build — most commonly enabling a
plugin — an incremental package can miss it, and archive/upload would then ship a
stale build. package --clean (also on release) wipes Binaries/, Intermediate/,
Saved/Cooked/, and the target's staged output first, forcing a from-scratch compile +
cook. Deleting Binaries/ also drops the editor .target, so bootstrap rebuilds the
editor with the change before cooking.
uetool package --platform win --clean # after enabling a plugin, or any "why isn't it in the build?" momentIMPORTANT Uploading never auto-promotes a build to a live Steam branch; that stays a deliberate step on the Steamworks site.
Three sources, lowest precedence first: uetool.toml < uetool.local.toml < env.
| Where | Scope | Holds |
|---|---|---|
| Env vars (set once on the machine) | machine-wide | UETOOL_PYTHON, UETOOL_STEAM_SDK, secret passwords; optional overrides (UETOOL_UE_ROOT, UETOOL_STEAM_USER, UETOOL_APPLE_*, UETOOL_STEAMCMD) |
uetool.toml (committed, project root) |
per-project | app_id, depot ids, build_config, stage subdirs |
uetool.local.toml (untracked, project root) |
per-project, per-machine | ue_root, Steam user, Apple apple_id/team_id/signing_identity |
Files the tool reads/writes at the project root:
| Path | Tracked? | Purpose |
|---|---|---|
uetool.toml |
yes | committed config |
uetool.local.toml |
no | per-machine config |
.build_number |
yes | monotonic build counter |
.version |
no | full build label vX.Y.Z.N (generated) |
.uetool/ |
no | rendered VDFs, notarization zip, steamcmd output (generated) |
steamcmd is resolved from UETOOL_STEAM_SDK per-OS (standalone steamcmd.sh/
.exe or the SDK's ContentBuilder/builder_*), falling back to steamcmd on PATH.
ProjectVersion in Config/DefaultGame.ini (authored "vX.Y.Z" by hand; never carries .N in source)
│ bump(): increment .build_number (monotonic); write .version = "vX.Y.Z.N"
▼ (DefaultGame.ini is NOT modified by bump)
<project>/.version ("vX.Y.Z.N") ── full build label = base + counter
│ package(): stamp "vX.Y.Z.N" into the .ini -> RunUAT -cook -build -stage -pak
│ (NO -archive) -> restore the base "vX.Y.Z" (finally-guarded)
▼
Saved/StagedBuilds/<Windows|Mac>/ (self-contained build embedding vX.Y.Z.N;
source DefaultGame.ini stays vX.Y.Z)
│ notarize() [mac only]: sign inside-out -> notarytool submit -> staple
│ upload(): render VDFs into .uetool/, run steamcmd +run_app_build
▼
Steam build (labeled "vX.Y.Z.N"), NOT set live automatically
uetool/
├── uetool bash launcher (macOS/Linux)
├── uetool.cmd Windows launcher (works in cmd AND PowerShell)
├── uetool.py Python entry (sets sys.path, runs core.cli)
└── core/ the package (host detection, config, version, package, steam, notarize)
└── steam/ VDF templates (@TOKEN@ placeholders)
The launcher picks the interpreter (UETOOL_PYTHON) and runs uetool.py, which
resolves the project root (-P, default cwd) and dispatches to core.
- Deliver from
-stage, not-archive. On macOS, UE's-archivestep copies the bareBinaries/.app(no cooked content) instead of the staged bundle, so the archived app dies at launch with "Failed to open descriptor file". The reliable, self-contained build is always the-stageoutput underSaved/StagedBuilds/. - Sign macOS bundles inside-out, not with
--deep.codesign --deepmisses UE's embedded dylibs (libtbb,libonnxruntime, …), so notarization returns Invalid.uetoolsigns every nested.dylib/.soindividually (deepest first) then seals the.applast. - macOS builds must be built on a Mac (Apple toolchain) with a Developer ID Application certificate in the login keychain plus the Developer ID intermediate.
- Steam depots: the target depot must be created and published under the app before an upload to it will succeed (otherwise steamcmd reports Access Denied).
- TOML backslashes: in double-quoted strings
\is an escape — use forward slashes or 'single quotes' for Windows paths.
Orchestration in Python (cross-platform, strong string/path handling, absorbs the
macOS signing/notarization divergence cleanly); the real work is delegated to the
platform's own tools. Everything project-specific lives in the project's two
uetool*.toml files and the project's .uproject/Config/DefaultGame.ini, so the
tool itself carries nothing about any particular game and can serve every UE project
on the machine from a single install.