PySide6 in Practice #8 Release Polish: Icons, Versioning, Signing, GitHub Actions

5 min read

Between an app whose features are done and an app you can ship lies a final stretch: icons, version info, signing, and moving beyond the state where “it only builds on my machine.” Intro #7 covered PyInstaller basics; this part is the finishing work layered on top.

The spec file: build configuration is code too #

The intro built with command-line options, but as options multiply, moving to a spec file is right. Commit the daily.spec that the first pyinstaller run generates, and maintain it by hand.

daily.spec
# daily.spec (essentials)
a = Analysis(
    ["src/daily/__main__.py"],
    datas=[("assets/icons", "icons")],      # bundle resources
)
pyz = PYZ(a.pure)
exe = EXE(
    pyz, a.scripts, a.binaries, a.datas,
    name="Daily",
    icon="assets/daily.ico",                # Windows icon
    console=False,                          # no terminal window
)
app = BUNDLE(                               # macOS .app bundle
    exe,
    name="Daily.app",
    icon="assets/daily.icns",
    bundle_identifier="com.example.daily",
)

The build is now pinned to one line: pyinstaller daily.spec. Two details to mind. First, icons are per-platform formats (Windows .ico, macOS .icns) — generate both from one source PNG with a converter and keep them in assets. Second, resources listed in datas unpack to a temp folder at runtime, so code locating resources needs a helper that distinguishes development runs from bundled runs.

src/daily/resources.py
# src/daily/resources.py
import sys
from pathlib import Path


def resource_path(relative: str) -> Path:
    if getattr(sys, "_MEIPASS", None):          # inside a PyInstaller bundle
        return Path(sys._MEIPASS) / relative
    return Path(__file__).parent.parent.parent / "assets" / relative

Versioning: define once, read everywhere #

Version strings scattered across files get missed one at a time at release. Make pyproject.toml’s version the single source and read it everywhere else.

src/daily/__init__.py
# src/daily/__init__.py
from importlib.metadata import version

__version__ = version("daily")

The About dialog, the first log line, and the CI release tag below all use this one value. The release procedure compresses into two motions: bump the version in pyproject.toml, push a tag.

Signing: the final gate of distribution #

Send your build to another computer and the first thing it meets is a warning. macOS blocks “unidentified developer” apps; Windows SmartScreen throws up its blue banner. This is the OS’s default defense against unsigned executables, and the principles and procedures were covered in depth in Wails in Practice #5: Signing and Notarization. A Python app is no different, so here are just the PySide6/PyInstaller-specific notes.

  • macOS: join the Apple Developer Program → sign the .app with codesign → submit for notarization with notarytool → staple. PyInstaller-specific cautions: every binary inside the bundle (Qt frameworks included) is subject to signing, and the hardened runtime option is required.
  • Windows: sign with signtool and a code-signing certificate. Certificate cost is real for individuals, so shipping unsigned at first and documenting the SmartScreen warning is a legitimate early-stage choice.
  • The common principle: a signature is proof of who made this. Skippable for hobby distribution among friends; a rite of passage the moment you distribute to strangers.

GitHub Actions: three operating systems in one push #

The standard exit from “it only builds on my Mac” is a CI matrix build.

.github/workflows/build.yml
# .github/workflows/build.yml
name: build
on:
  push:
    tags: ["v*"]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      QT_QPA_PLATFORM: offscreen          # part 7: headless Qt tests
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync
      - run: uv run pytest

  build:
    needs: test
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv sync
      - run: uv run pyinstaller daily.spec
      - uses: actions/upload-artifact@v4
        with:
          name: daily-${{ matrix.os }}
          path: dist/

The structure is simple: a tag push first runs part 7’s tests offscreen, and on success, three OS runners each build their own platform’s executable and upload artifacts. PyInstaller does not cross-compile, so building on each OS via a matrix is the only correct answer. Chain a release-creation action after this and “tag push = three-platform release” is complete. When signing moves into CI, certificates live in repository secrets.

Closing the series #

The eight-part journey in one paragraph: we defined requirements as a screen list and erected three layers (part 1), confined SQL to a repository and prepared migrations with user_version (part 2), joined data to screen with a custom model (part 3), painted the widget that did not exist with QPainter (part 4), dressed the app in desktop manners with tray residency and reminders (part 5), returned decisions to the user via QSettings and themes (part 6), froze the finished state with per-layer tests (part 7), and finally finished it into a handable artifact with icons, versioning, signing, and CI (part 8).

The sequence itself is a reusable template. Whatever the next app is — notes, time tracking, photo organizing — the same skeleton (structure → data → screens → residency → settings → tests → release) takes new flesh. For the same journey on the Go side, reading the Wails in Practice series in parallel is an instructive comparison.

Summary #

  • Build configuration lives in a committed spec file. Prepare per-platform icons (.ico/.icns), and locate resources with a helper that detects bundled runs.
  • Version is defined once in pyproject.toml and read via importlib.metadata. A release is a version bump plus a tag push.
  • Signing and notarization are the rite of passage for public distribution: codesign + notarization on macOS, signtool on Windows — same principles as Wails in Practice #5.
  • CI flows test (offscreen) → three-OS matrix build → artifacts. With no cross-compilation in PyInstaller, the matrix is the answer.
  • The series’ sequence — structure → data → screens → residency → settings → tests → release — is a template you can reuse for the next app.
X