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:
Alternatively, call it from your setup script (one-time, before the first build):
This creates:
./keystore/— signing keys (root, targets, snapshot, timestamp)../repo/repository/— the initial signed metadata.
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 repository/ tree.
Step 4 — Build via the pipeline, then upload¶
The build pipeline runs the stages compile → zip → release. When tuf_enabled
is set, run_pipeline() builds the signed TUF tree locally — it does not
transfer anything. Upload is a separate, explicit step:
compiler.run_pipeline(console=False) # compile → zip → release (local only)
compiler.upload() # transfer TUF tree + installer zip
upload() performs two sequential 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/ |
For r2, the TUF tree is uploaded straight to the bucket prefix (no /update/
subdir) and the installer ZIP is skipped. 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).
Deprecated
compiler.release(bundle_dir, publish=True) is deprecated: remote transfer is
now handled by upload(). The call still works but emits a DeprecationWarning.
You can drive the CLI instead of the Python API:
ezcompiler upload --repo-destination server --release-destination server \
--destination https://uploads.example.com/MyApp
Metadata expiration (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 release 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 release refresh # targets/snapshot/timestamp
ezcompiler release refresh --role timestamp --days 60
Both require the signing keys (release init). After a refresh, re-run
ezcompiler upload so the freshly-signed metadata reaches the clients.
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.
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}")
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.