A PyInstaller app with GitHub Actions release automation and a built-in self-updater that checks this (private) repo's GitHub Releases.
The app is a tiny stdlib-only "click counter" web demo, structured as four
modules under app/:
app/main.py— entry point.main()runscheck_and_apply_update()first, thenrun()initializes the versioned database and starts the HTTP server. This is what PyInstaller builds into the packaged executable.app/server.py— a minimalhttp.server-based web server (no framework/dependencies) serving one page at/that shows a click count and a button thatPOSTs to/clickto increment it. All rendering is done with simple string templating.app/db.py— versioned SQLite storage. Each released version gets its own database file (clicks_v<version>.db) in a per-OS data directory (seeget_data_dir()). On every startup it: backs up existing version databases to.bakfiles, creates/upgrades the current version's schema (additively, from theSCHEMAdict), and — if this version's db is brand new — copies data forward from the most recent older version's db so click history survives upgrades.app/updater.py— the self-updater (see below). Only active in the packaged executable (sys.frozen); running from source (python -m app.main) skips it entirely.app/_version.py— holds__version__. Checked into source as a dev placeholder ("0.0.0-dev") and overwritten at build time to match the pushed git tag (see below).
Running locally: pip install -r requirements.txt && python -m app.main,
then open http://127.0.0.1:8765/.
Shipping a new version is entirely driven by pushing an annotated git tag — there's no manual build or upload step. The flow:
git tag v1.2.3
git push origin v1.2.3
- Tag push triggers the workflow. Pushing a tag matching
v*.*.*fires.github/workflows/release.yml. - Version stamping. The workflow strips the
vprefix and writes it intoapp/_version.pyas__version__, so the build and the git tag always agree on the version string. This happens fresh on every build — nothing is committed back to the repo. - Credential embedding. The workflow also writes the
UPDATE_READ_TOKENsecret and theowner/repostring intoapp/updater.py, replacing theREPLACED_AT_BUILD_TIMEplaceholders, so each binary carries its own read-only credential for calling the Releases API later. - Build. PyInstaller builds a
--onefileexecutable on Windows, macOS, and Linux runners in parallel (matrix build), using the version-stamped, credential-embedded source. - Packaging. Each platform's raw binary is also wrapped into a native
installer (NSIS
.exeon Windows,.dmgon macOS, AppImage on Linux — seepackaging/). - Release publish. A GitHub Release is published for the pushed tag with
six assets: the three raw binaries (
myapp-windows.exe,myapp-macos,myapp-linux— these are what the self-updater downloads, see below) and the three installers (myapp-windows-installer.exe,myapp-macos-installer.dmg,myapp-linux-installer.AppImage— these are what end users should download for a first install).
Because the version comes solely from the tag, re-pushing a build for the same version isn't possible without deleting and re-tagging — always bump the tag for a new build.
- Windows: run
myapp-windows-installer.exe. It installs per-user to%LOCALAPPDATA%\Programs\myapp(no admin/UAC needed) and adds Start Menu / Desktop shortcuts. Since the installer is unsigned, SmartScreen will warn "Windows protected your PC" — click More info → Run anyway. - macOS: open
myapp-macos-installer.dmgand dragMyApp.appto Applications (or run it from anywhere). Since the app is unsigned/not notarized, Gatekeeper will block a plain double-click the first time — right-click the app → Open → Open to confirm. - Linux: download
myapp-linux-installer.AppImage,chmod +xit, and run it directly. No install step; the AppImage is the app.
Uninstalling (Windows: "Add or remove programs", or uninstall.exe in the
install directory) leaves each version's SQLite database behind so click
history survives a reinstall — see app/db.py for how per-version databases
and schema migrations work.
Add a repository secret UPDATE_READ_TOKEN: a GitHub fine-grained PAT scoped
to this repository only, with Contents: read-only permission. This is
the credential every shipped binary uses to call the Releases API, since the
repo is private.
On startup (app/main.py → check_and_apply_update()), the packaged
executable:
- Calls
GET /repos/<owner>/<repo>/releases/latestusing the embedded token. - Compares the release tag against its own built-in version.
- If newer, downloads the raw per-OS binary (not the installer — the
installer is only used for the initial install) and replaces itself:
- macOS/Linux: atomic
os.replaceonto the running binary, then re-exec. On Linux this targets the real.AppImagefile via theAPPIMAGEenv var the AppImage runtime sets, notsys.executable(which points at a temporary mounted copy inside a running AppImage). - Windows: stages the new exe, spawns a detached batch script to swap it in after this process exits, then exits. Works unmodified against the per-user install location.
- macOS/Linux: atomic
Running from source (python -m app.main) skips the update check entirely —
it only applies to the packaged executable (sys.frozen).
The UPDATE_READ_TOKEN is baked into every binary at build time — there is
no way to change a token after it has shipped. This creates a chicken-and-egg
risk: if the embedded PAT is ever revoked, every previously-shipped build
immediately loses the ability to fetch updates (including the update that
would carry the new token).
To rotate safely:
- Create the new PAT first. Do not revoke the old one yet.
- Update the
UPDATE_READ_TOKENsecret in GitHub to the new PAT. - Cut a new release tag. This ships a build with the new token embedded.
- Only after you're confident the new build is distributed/adopted, revoke the old PAT.
Give the PAT a long expiry so this isn't an emergency on a schedule, and treat "the token is extractable from the shipped binary by anyone who has the exe" as a known, accepted tradeoff of this approach (it only grants read-only access to this repo's contents, nothing else).