build: wire desktop-bundle pipeline (CI matrix + per-platform installers)

Stand up the seamless-download path for non-technical buyers:

* .github/workflows/build.yml — matrix CI (mac/win/linux) that builds
  PyInstaller bundles and packages them per platform on tag push,
  attaching the resulting installers to a GitHub Release.
* build/installer.iss — Inno Setup script for the Windows installer
  (per-user install, optional desktop shortcut, runs on finish).
* build/macos/build_dmg.sh — wraps DataTools.app into a .dmg with a
  drag-to-/Applications layout.
* build/appimage/{AppRun,datatools.desktop,build.sh} — AppImage recipe.
* src/__init__.py — single source of truth for __version__; the spec
  reads it (was hardcoded), CI passes it through to all packagers.

Buyer download path now lives in the top-level README. Per-build
README documents the Phase 2 step (signing/notarization) that needs
the owner's Apple Developer + Windows code-signing credentials —
those are intentionally not in CI yet because they require setup
outside this repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 13:58:43 +00:00
parent ea89c4d399
commit 4706ed571e
10 changed files with 368 additions and 4 deletions

View File

@@ -16,15 +16,75 @@ build/
│ Streamlit server, opens browser, locks server
│ to 127.0.0.1 so the privacy claim holds.
├── datatools.spec PyInstaller spec — hidden imports, data files,
│ Mac .app bundle config.
│ Mac .app bundle config. Reads the version
│ from src/__init__.py.
├── installer.iss Inno Setup script — Windows .exe installer.
├── macos/
│ └── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a
│ drag-to-/Applications layout.
├── appimage/
│ ├── AppRun Entry point invoked when the AppImage runs.
│ ├── datatools.desktop Linux desktop-entry metadata.
│ └── build.sh Wraps dist/DataTools/ into an .AppImage.
├── hooks/ PyInstaller hooks for libs the static analyser
│ └── hook-streamlit.py misses (Streamlit's dynamic imports).
├── icon.icns macOS app icon (TODO: produce from a 1024×1024
│ PNG. Optional — bundle still builds without).
├── icon.ico Windows app icon (TODO).
├── icon.png Linux AppImage icon (TODO — build.sh generates
│ a placeholder if missing).
└── README.md this file
```
CI: `.github/workflows/build.yml` runs the full pipeline on tag push
(matrix: macos-latest, windows-latest, ubuntu-latest) and attaches
the resulting installers to a GitHub Release. Manual
`workflow_dispatch` runs upload them as workflow artifacts only.
## Releasing
1. Bump `__version__` in `src/__init__.py`.
2. `git commit -am "release: vX.Y.Z" && git tag vX.Y.Z`.
3. `git push && git push --tags`.
4. CI builds all three platforms and creates a GitHub Release with
the installers attached.
5. Mirror the GitHub Release assets to Gumroad (manual until v2).
## Signing (Phase 2 — needs accounts/credentials)
Both code-signing steps are intentionally not in CI yet because they
require credentials the owner sets up first.
**macOS** — Apple Developer Program enrollment ($99/yr). Once enrolled,
add these GitHub Secrets and uncomment the `codesign` + `notarytool`
steps in `build.yml`:
| Secret | Value |
|---|---|
| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` | base64-encoded `.p12` cert |
| `MACOS_DEVELOPER_ID_CERT_PASSWORD` | password for the .p12 |
| `MACOS_NOTARY_APPLE_ID` | Apple ID email |
| `MACOS_NOTARY_TEAM_ID` | 10-char team ID |
| `MACOS_NOTARY_PASSWORD` | app-specific password |
**Windows** — Code-signing cert from Sectigo / DigiCert (~$200-400/yr,
or ~$300-500 for an EV cert that bypasses SmartScreen). Add:
| Secret | Value |
|---|---|
| `WINDOWS_CERT_PFX_BASE64` | base64-encoded `.pfx` cert |
| `WINDOWS_CERT_PASSWORD` | password for the .pfx |
Until those are wired, buyers will see:
- macOS: "DataTools is damaged and can't be opened" — fix by removing
the quarantine attribute (`xattr -cr /Applications/DataTools.app`).
Acceptable for the technical buyer; **blocking** for the
non-technical buyer. Don't ship to non-technical without notarization.
- Windows: SmartScreen "Windows protected your PC" — buyer clicks
"More info → Run anyway". Friction but not blocking.
- Linux: AppImage runs without complaint (Linux has no equivalent
trust-store).
## Per-platform recipe
Each platform builds on its own machine — PyInstaller does **not**

8
build/appimage/AppRun Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# AppImage entry point. AppImage mounts the bundle and runs this
# script. We chdir into the embedded usr/bin so the PyInstaller
# bundle's relative paths resolve, then exec the launcher binary.
set -e
HERE="$(dirname -- "$(readlink -f -- "${0}")")"
exec "${HERE}/usr/bin/DataTools/DataTools" "$@"

62
build/appimage/build.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Wrap dist/DataTools/ (PyInstaller folder mode) into a distributable
# AppImage.
#
# Usage:
# bash build/appimage/build.sh <version>
#
# Requires ``appimagetool`` on PATH (CI installs it; locally grab the
# latest release from https://github.com/AppImage/AppImageKit/releases).
#
# Output: dist/DataTools-<version>-linux-x86_64.AppImage
set -euo pipefail
VERSION="${1:-0.0.0-dev}"
DIST="dist/DataTools"
OUT="dist/DataTools-${VERSION}-linux-x86_64.AppImage"
if [[ ! -d "$DIST" ]]; then
echo "Error: $DIST not found. Run pyinstaller build/datatools.spec first." >&2
exit 1
fi
if ! command -v appimagetool >/dev/null 2>&1; then
echo "Error: appimagetool not on PATH. See build/appimage/build.sh header." >&2
exit 1
fi
# Lay out the AppDir.
APPDIR="$(mktemp -d)/DataTools.AppDir"
trap 'rm -rf "$(dirname -- "$APPDIR")"' EXIT
mkdir -p "$APPDIR/usr/bin"
cp -R "$DIST" "$APPDIR/usr/bin/"
cp build/appimage/AppRun "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun"
cp build/appimage/datatools.desktop "$APPDIR/datatools.desktop"
# Icon. AppImage requires a top-level <appname>.png next to the
# .desktop. Use the build/icon.png if present, otherwise generate a
# blank placeholder so the build doesn't fail on a fresh checkout.
if [[ -f build/icon.png ]]; then
cp build/icon.png "$APPDIR/datatools.png"
else
# 256x256 single-colour PNG via printf — appimagetool needs *some*
# icon present. Replace with a real 1024x1024 PNG before launch.
python3 - <<'PY'
import struct, zlib, os
def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
W = H = 256
ihdr = struct.pack(">IIBBBBB", W, H, 8, 2, 0, 0, 0) # 8-bit RGB
raw = b"".join(b"\x00" + b"\x16\x19\x22" * W for _ in range(H)) # filter byte + dark pixels
idat = zlib.compress(raw, 9)
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
out = os.environ["APPDIR"] + "/datatools.png"
open(out, "wb").write(png)
PY
fi
export APPDIR
ARCH=x86_64 appimagetool "$APPDIR" "$OUT"
echo "Built $OUT"

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=DataTools
Comment=Local CSV / Excel cleaning suite
Exec=DataTools
Icon=datatools
Categories=Office;Utility;
Terminal=false

View File

@@ -34,6 +34,14 @@ from PyInstaller.utils.hooks import (
# Repo root from this spec's location (PyInstaller sets SPECPATH).
REPO = Path(SPECPATH).resolve().parent
# Single source of truth for the version string. Read directly from
# src/__init__.py instead of importing src/ — importing pulls in
# heavy deps (pandas etc) that PyInstaller's spec parser doesn't need.
import re as _re
_init_py = (REPO / "src" / "__init__.py").read_text(encoding="utf-8")
_m = _re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', _init_py)
VERSION = _m.group(1) if _m else "0.0.0"
# ----- Hidden imports ------------------------------------------------
# PyInstaller's static analyser misses everything Streamlit reaches
# through ``importlib`` and the per-tool registries our app uses. We
@@ -142,8 +150,8 @@ if _sys.platform == "darwin":
bundle_identifier="com.datatools.desktop",
info_plist={
"CFBundleDisplayName": "DataTools",
"CFBundleVersion": "1.0.0",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": VERSION,
"CFBundleShortVersionString": VERSION,
"NSHighResolutionCapable": True,
# Buyer's macOS will not show the app's window in the dock
# if this is True. We want the dock icon so the buyer can

49
build/installer.iss Normal file
View File

@@ -0,0 +1,49 @@
; Inno Setup script for DataTools — Windows installer.
;
; Compile from the repo root:
; iscc /DAppVersion=1.0.0 build\installer.iss
;
; CI passes the version via /DAppVersion to keep src/__init__.py the
; single source of truth. Local manual builds: pass /DAppVersion or
; let the default kick in.
#ifndef AppVersion
#define AppVersion "0.0.0-dev"
#endif
[Setup]
AppId={{D4A07001-DA7A-4001-8001-DA7A70013700}}
AppName=DataTools
AppVersion={#AppVersion}
AppVerName=DataTools {#AppVersion}
AppPublisher=DataTools
AppPublisherURL=https://datatools.app
DefaultDirName={autopf}\DataTools
DefaultGroupName=DataTools
DisableProgramGroupPage=yes
OutputDir=..\dist
OutputBaseFilename=DataTools-{#AppVersion}-win-setup
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
; Allow per-user install (no UAC prompt) when admin isn't available.
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"
[Files]
Source: "..\dist\DataTools\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
[Icons]
Name: "{group}\DataTools"; Filename: "{app}\DataTools.exe"
Name: "{group}\Uninstall DataTools"; Filename: "{uninstallexe}"
Name: "{autodesktop}\DataTools"; Filename: "{app}\DataTools.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\DataTools.exe"; Description: "Launch DataTools"; Flags: nowait postinstall skipifsilent

41
build/macos/build_dmg.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Wrap dist/DataTools.app into a distributable .dmg.
#
# Usage:
# bash build/macos/build_dmg.sh <version>
#
# Run after ``pyinstaller build/datatools.spec --clean --noconfirm``
# has produced ``dist/DataTools.app``. The output DMG goes to
# ``dist/DataTools-<version>-mac.dmg``.
#
# Code signing + notarization happen separately (see build/README.md
# "Signing"). This script only handles the packaging step.
set -euo pipefail
VERSION="${1:-0.0.0-dev}"
APP="dist/DataTools.app"
DMG="dist/DataTools-${VERSION}-mac.dmg"
if [[ ! -d "$APP" ]]; then
echo "Error: $APP not found. Run pyinstaller build/datatools.spec first." >&2
exit 1
fi
# Drag-target convenience: a /Applications symlink inside the DMG so
# the buyer can drag the app icon to it without leaving the DMG.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
# UDZO = compressed read-only DMG, the standard distribution format.
hdiutil create \
-volname "DataTools" \
-srcfolder "$STAGE" \
-ov \
-format UDZO \
"$DMG"
echo "Built $DMG"