How to set up secure updates with tufup¶
This guide explains how to integrate tufup (Trust Updates for Python) into your ezcompiler build pipeline to produce signed, TUF-compliant update repositories for your compiled applications.
Scope. This guide covers the full loop: packaging a compiled bundle into a signed TUF tree, uploading it, and wiring the self-updating client into your app. For background on the pipeline design and publish layout, see Release pipeline.
🔧 Prerequisites¶
Install the optional extra:
tufup requires Python ≥ 3.13 and depends on python-tuf and cryptography.
📝 Step 1 — Initialize signing keys (one-time admin operation)¶
Important. Key initialization is a deliberate, offline step. Keys are never generated automatically during a build.
Use the ezcompiler CLI to initialize the key set and the repository skeleton:
The paths (tuf_repo_dir, tuf_keys_dir) are read from the project config file (auto-detected in the current directory). You can also point to a specific config:
This creates:
./keystore/— signing keys (root, targets, snapshot, timestamp)../repo/metadata/— the initial signed metadata (./repo/targets/receives the archives at release time).
Security rules for keys:
- Add
keystore/to.gitignore— never commit private keys. - Store offline keys (root, targets) on encrypted media; keep online keys (snapshot, timestamp) accessible only from the build machine.
📝 Step 2 — Configure CompilerConfig¶
from pathlib import Path
from ezcompiler import EzCompiler, CompilerConfig
config = CompilerConfig(
version="2.0.0",
project_name="MyApp",
main_file="src/main.py",
include_files={"files": [], "folders": []},
output_folder=Path("dist"),
# Release options
tuf_enabled=True, # turn on the TUF release + updater
tuf_repo_dir=Path("repo"), # local TUF repository root
tuf_keys_dir=Path("keystore"), # signing keys directory
# Upload destinations (split: TUF tree vs. installer zip)
repo_destination="server", # disk | server | r2 — where the TUF tree lands
repo_endpoint="https://uploads.example.com/MyApp", # upload target for the TUF tree
release_destination="server", # disk | server — where the installer zip lands
release_endpoint="https://uploads.example.com/MyApp",
# Client updater — the URL the compiled app polls for updates
repo_public_url="https://updates.example.com/MyApp",
)
Destinations. The TUF tree and the installer ZIP are uploaded separately:
repo_destination/repo_endpointcontrol the signed TUF tree,release_destination/release_endpointcontrol the distributable ZIP. An*_endpointis required whenever its*_destinationis not"disk"(fordiskit is a local path). Forr2, the endpoint is"bucket/prefix".
repo_public_urlis the public URL the compiled client application polls for updates. It is required whentuf_enabled=Trueandrepo_destinationis not"disk". This value is written into the generatedsettings.pyso the end-user app knows where to fetch update metadata.
📝 Step 3 — Compile, then release¶
compiler = EzCompiler(config)
# 1. Compile the project as usual.
compiler.compile_project()
# 2. Package the compiled output into a signed TUF repository.
# bundle_dir is the compiled application directory (onedir output).
repository_path = compiler.release(bundle_dir=Path("dist/MyApp"))
print(f"Signed repository written to: {repository_path}")
release() calls tufup.repo.Repository.add_bundle() + publish_changes() and returns the path to the local TUF repository (tuf_repo_dir).
📝 Step 4 — Build via the pipeline, then publish¶
The build pipeline runs the stages version → compile → zip → installer → release. When tuf_enabled
is set, run_pipeline() builds the signed TUF tree locally — it does not
transfer anything. Publication is a separate, explicit CLI step:
ezcompiler publish update # TUF tree — recap + confirmation
ezcompiler publish release # installer + ZIP (recap + confirmation on GitHub)
The two commands perform two independent transfers, mirrored by the client's update URL:
| Artifact | Destination | Landing path |
|---|---|---|
| TUF tree | repo_destination / repo_endpoint |
<repo_endpoint>/update/ |
| Installer ZIP | release_destination / release_endpoint |
<release_endpoint>/release/ |
publish update uploads only the public part of the local tree: metadata/,
targets/ and withdrawn.json. Anything else under tuf_repo_dir — including
the default keystore <tuf_repo_dir>/keystore/ when tuf_keys_dir is unset —
never leaves the machine.
For r2, each tree is uploaded straight to its bucket prefix (no /update/
or /release/ subdir). The /update/ suffix is what the
generated client polls — keep repo_public_url pointing at the same root
(the client appends /update itself for disk and server).
publish release can also create a GitHub Release (release_destination = "github");
see the CLI reference.
Deprecated
compiler.upload(), ezcompiler upload and
compiler.release(bundle_dir, publish=True) are deprecated and will be removed
in v5: use the ezcompiler publish commands. The calls still work but warn.
📝 Step 5 — Generate client updater files¶
Once the TUF repository is initialized and the config contains a valid repo_public_url, generate the client-side bootstrap files:
from pathlib import Path
from ezcompiler import EzCompiler, CompilerConfig
config = CompilerConfig(
version="2.0.0",
project_name="MyApp",
main_file="src/main.py",
include_files={"files": [], "folders": []},
output_folder=Path("dist"),
tuf_enabled=True,
tuf_repo_dir=Path("repo"),
tuf_keys_dir=Path("keystore"),
repo_public_url="https://updates.example.com/MyApp",
)
compiler = EzCompiler(config)
generated = compiler.generate_updater(output_dir=Path("src/updater"))
# Returns a list of generated paths: [update.py, settings.py, root.json]
for path in generated:
print(f"Generated: {path}")
This produces three files in output_dir (defaults to the project root):
| File | Description |
|---|---|
update.py |
Client update logic — checks for and applies updates via tufup |
settings.py |
Update settings, including repo_public_url |
root.json |
Copy of the TUF root metadata, bundled with the app |
By default (patch_config=True), generate_updater() adds the three generated files to include_files so they are bundled into the compiled app. Because it mutates the config, call it before run_pipeline():
if config.tuf_enabled:
compiler.generate_updater() # must precede run_pipeline()
compiler.run_pipeline()
Via CLI:
📝 Step 6 — Wire the updater into your app¶
Call update.main() at the very top of your app's entry point. It checks the
repository once, and if a newer version is available it downloads and applies it,
then exits so tufup can swap the files and relaunch. It never blocks startup: a
failed check is reported and ignored.
# main.py
try:
import update # generated by generate_updater(), bundled at compile time
update.main()
except ImportError:
pass # updater files absent in dev — app still runs
The generated client is bundler-agnostic (PyInstaller, cx_Freeze, Nuitka) and
resolves its own install directory from the running executable. The TUF
metadata/target cache, however, is kept separate from that install directory —
under %LOCALAPPDATA%\<app>\tuf on Windows (XDG_CACHE_HOME or ~/.cache
otherwise) — since the install directory may not be writable without
elevation (see per-user installs if you also ship a
Windows installer).
On Windows, applying an update relaunches the app automatically once the files are swapped, matching the existing macOS behavior.
Calling the updater from a GUI thread
update.main() and check_and_apply() assume the main thread: tufup's
installer ends the process with sys.exit(0) once the swap script is
launched, which only terminates the calling thread if called from
elsewhere (e.g. a pywebview worker thread). Call update.apply_and_quit()
instead from a non-main thread — it wraps check_and_apply() and forces
the whole process to exit via os._exit(0).
Disk-served repositories work client-side
When repo_destination="disk", the client reads updates over a file:// URL
via a built-in file fetcher — useful for offline, LAN, or test installs, with
no HTTP server required. HTTP(S) via repo_public_url remains the norm for
production.
Rebuild after changing the destination
UPDATE_URL is baked into the compiled settings.py. Changing
repo_destination/repo_public_url requires regenerating the client and
recompiling — an already-shipped executable keeps its original URL.
⚙️ Variations¶
The steps above cover the regular release loop. The cases below apply only under the stated condition.
Metadata expiration on irregularly-updated projects¶
TUF metadata carries an expiration date per role. tufup's defaults are short
for the online roles — root=365, targets=7, snapshot=7, timestamp=1 (days).
Once timestamp/snapshot expire, clients refuse to trust the repository even
if no new version was published. For a project you release irregularly, that
silently breaks updates between releases.
Two native tufup mechanisms address this:
1. Longer lifetimes via config¶
Set tuf_expiration_days to raise the per-role
lifetime (unset roles fall back to tufup defaults). It is applied on tuf init
and every release:
config = CompilerConfig(
...,
tuf_enabled=True,
tuf_expiration_days={"timestamp": 30, "snapshot": 30, "targets": 90},
)
2. Keep-alive re-sign¶
Re-sign the metadata to push the expiration forward without cutting a new release — run this periodically (e.g. a scheduled job):
ezcompiler tuf refresh # targets/snapshot/timestamp
ezcompiler tuf refresh --role timestamp --days 60
Both require the signing keys (tuf init). After a refresh, re-run
ezcompiler publish update so the freshly-signed metadata reaches the clients.
Withdraw a broken version¶
When the latest published version turns out to be broken, withdraw it from the signed tree, then ship a mandatory fix:
ezcompiler tuf remove-latest # withdraw 1.2.3 from the local tree, re-sign
ezcompiler publish update # republish the tree without 1.2.3
# set `version` to 1.2.4 in the config before building
ezcompiler compile --required # build 1.2.4, mandatory
ezcompiler publish update # clients on 1.2.3 move to 1.2.4
Bump version in the config first: a build that keeps 1.2.3 is refused, because a new release must be above every withdrawn version.
ezcompiler tuf status shows the versions, their flags, the role expirations and the withdrawn versions before and after.
Clients never downgrade: withdrawing stops the version from reaching clients that do not have it yet; clients that installed it leave it only through a higher version, which --required makes mandatory.
The first release after a withdrawal ships without a patch: tufup would build it from the latest archive left in the tree (1.2.2), which clients on the withdrawn version do not have. Clients on 1.2.3 therefore download the full archive of 1.2.4; patches resume from the next release.
Only the latest version can be withdrawn. The archive and patch stay on the remote storage, unusable because no signed metadata references them.
withdrawn.json at the repository root records withdrawn versions; ezcompiler refuses to release a version that is not above them. It is published with the tree and ignored by clients. The rule also applies from the Python API (EzCompiler.release(..., required=True), run_pipeline(..., required=True)).
Error handling¶
| Exception | Raised when |
|---|---|
ReleaseError |
General release failure (wraps tufup errors) |
SigningKeyError |
keys_dir is missing or inaccessible |
BundleBuildError |
bundle_dir is missing or empty |
ReleaserTypeError |
Unknown releaser backend requested |
UpdaterError |
General updater generation failure |
UpdaterConfigError |
repo_public_url missing or config invalid |
UpdaterGenerationError |
File generation or copy failure |
from ezcompiler import ReleaseError
from ezcompiler.shared.exceptions import SigningKeyError
try:
compiler.release(bundle_dir=Path("dist/MyApp"))
except SigningKeyError:
print("Initialize keys first — see Step 1.")
except ReleaseError as e:
print(f"Release failed: {e}")
✅ Result¶
Your build produces a signed TUF tree that ezcompiler publish update transfers to repo_public_url, and your compiled application ships a client that polls that tree, downloads a newer version and applies it. ezcompiler tuf status lists the published versions, the role expirations and any withdrawn version.
➡️ Going further¶
The generated client covers the common check-download-apply loop. For advanced
scenarios — custom pre-release channels, patch (delta) updates, or bespoke
confirmation UI — see the tufup documentation
and adapt the generated update.py to your needs.