Skip to content

DEV_GUIDE

Quynn Bell edited this page Aug 24, 2026 · 1 revision

Developer guide

How to add QUpdateTool to your app: build a branded updater and wire it into CI/CD.

See also: README for flags and config.yaml, and SECURITY.md for why each step below matters.

The integration shape

Your app does not import this package. It ships a branded updater.exe and runs it as a separate process:

1. App startup        updater --check-only --json    ->  "an update is available"
2. Help > Check        updater --gui                  ->  progress window
3. Updater             stops the app, installs, relaunches it

This guide covers building that binary and calling it. Full flag and config.yaml reference: README quick start and configuration file.

1. Write a brand file

A brand file is a YAML file describing your app: name, release repo, signing key, install behaviour. It gets compiled into the binary at build time, so the branded updater.exe needs no flags or config file.

Copy examples/qsnippet-brand.yaml as a starting point - it's a full, working example. You need to set:

  • app.name, app.process_names - what to look for and stop before install
  • source.provider / source.repo - where releases are published
  • security.allowed_fingerprints - which signing key(s) are trusted (see SECURITY.md §1)
  • install.installer_args - silent-install flags for your installer (Inno Setup, WiX/MSI, etc.)

Keep the brand file in your app's repo, not this one. tools/build_branded.py reads it at build time.

2. Build the branded binary locally

python tools/build_branded.py \
    --brand ../MyApp/updater-brand.yaml \
    --icon  ../MyApp/assets/MyApp.ico \
    --key   ../MyApp/release-key.asc \
    --out   ../MyApp/output/updater.exe \
    --clean

--key pins the OpenPGP public key used to check release signatures. It overrides anything inlined in the brand file, so CI keeps the real key in your app's repo. Add --gui to bundle PySide6 so updater --gui works; skip it for a smaller, headless-only binary.

This produces two files:

  • updater.exe - the branded binary
  • updater.exe.sha256 - its hash, needed in step 4

Or use the Makefile wrapper: make brand BRAND=... ICON=... KEY=... OUT=... (make help lists every target).

3. Build it in CI/CD

Run this build in your app's own release pipeline, since it needs your repo's brand file, icon, and signing key. Example GitHub Actions job, triggered on the same tag that cuts your app release:

build-updater:
  runs-on: windows-latest
  steps:
    - uses: actions/checkout@v7
      with:
        repository: your-org/QUpdateTool2.0
        path: qupdatetool

    - uses: actions/setup-python@v7
      with:
        python-version: "3.13"

    - run: pip install -r qupdatetool/requirements.txt -r qupdatetool/requirements-dev.txt

    - name: Write signing key
      shell: bash
      run: echo "${{ secrets.RELEASE_PUBLIC_KEY }}" > release-key.asc

    - name: Build branded updater
      run: |
        python qupdatetool/tools/build_branded.py \
          --brand updater-brand.yaml \
          --icon assets/MyApp.ico \
          --key release-key.asc \
          --out dist/updater.exe \
          --sign-command "azuresigntool sign -kvu ... -kvc ... {file}" \
          --clean

    - uses: actions/upload-artifact@v4
      with:
        name: updater
        path: |
          dist/updater.exe
          dist/updater.exe.sha256

Notes:

  • --key is the public signing key. It's safe to embed, but keep it in a secret/variable anyway so the brand file stays generic across environments.
  • --sign-command code-signs the binary as part of the build. This matters more than any check the binary could do on itself - see SECURITY.md §3.2.
  • Publish updater.exe.sha256 as a build artifact or attach it to the release. Your app needs that value too, in step 4.
  • --clean clears old build//dist/ output first, so a reused CI runner can't ship a stale binary.

4. Pin the updater's hash in your app

A binary can't prove its own integrity - only an outside check can. updater.exe.sha256 exists so your app can be that check. Wire the hash from step 2/3 into your app's build info:

# build_info.py, written by your app's CI, not by hand
UPDATER_SHA256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

And check it before ever launching the updater:

from qupdatetool.integrity import verify_file_hash

if not verify_file_hash(updater_path, UPDATER_SHA256):
    logger.error("Updater binary failed its integrity check; refusing to run it")
    return

Why this is the important check, and what code signing and install permissions add on top of it: SECURITY.md §3.

5. Call the updater from your app

Two calls cover the whole lifecycle:

# Silent check, e.g. on app startup - parse the JSON, show a badge/toast
updater --check-only --json

# User-initiated update, e.g. from a Help > Check for Updates menu item
updater --gui

Branch on the exit code, not stdout. Full table: README. Most common:

Code What your app should do
10 --check-only: update available - show the badge/prompt
11 --check-only: already up to date - do nothing
4 Verification failed - tell the user, do not retry silently

Pass --calling-pid <your PID> on a real (non-check-only) update, so the updater waits for your process to exit before overwriting files. Pass --executable so it knows what to relaunch.

6. Logging

Point the updater at your app's own log directory, so support bundles interleave cleanly by timestamp:

updater --log-file "C:/Users/me/AppData/Local/MyApp/logs"

Format and rotation already match a typical app logger. Details: README Logging section.

7. Test before shipping

make check REPO=your-org/your-app   # live --check-only against your real repo
updater --self-check                # hash, brand, signature status, install
                                     # permissions, all in one report

Then run the SECURITY.md checklist once before your first real release. It's short, and each item maps to a step above.

Where to go next

Clone this wiki locally