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

108
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: Build installers
# Triggers:
# * Tag push (v*) → produces installers, attaches to a GitHub Release.
# * Manual dispatch → produces installers as workflow artifacts only.
#
# What this workflow doesn't do (yet):
# * Code signing (Mac Developer ID, Windows code-signing cert).
# Those need GitHub Secrets the owner sets up first. See
# build/README.md "Signing" for the secret names this workflow
# will read once they exist.
# * Auto-update endpoint generation. v1 distributes via Gumroad;
# buyers re-download for updates.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: write # needed to create the release on tag push
jobs:
build:
name: Build (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
artifact_name: DataTools-mac.dmg
artifact_path: dist/DataTools-*-mac.dmg
- os: windows-latest
artifact_name: DataTools-win.exe
artifact_path: dist/DataTools-*-win-setup.exe
- os: ubuntu-latest
artifact_name: DataTools-linux.AppImage
artifact_path: dist/DataTools-*-linux-x86_64.AppImage
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- name: Install build deps
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
- name: Read version
id: version
shell: bash
run: |
VER=$(python -c "import re; print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', open('src/__init__.py').read()).group(1))")
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Build PyInstaller bundle
run: pyinstaller build/datatools.spec --clean --noconfirm
# ---- Per-platform packaging ----------------------------------
- name: Package macOS DMG
if: matrix.os == 'macos-latest'
run: bash build/macos/build_dmg.sh "${{ steps.version.outputs.version }}"
- name: Install Inno Setup (Windows)
if: matrix.os == 'windows-latest'
run: choco install innosetup --no-progress -y
- name: Package Windows installer
if: matrix.os == 'windows-latest'
shell: cmd
run: |
iscc /DAppVersion=${{ steps.version.outputs.version }} build\installer.iss
- name: Install AppImage tooling (Linux)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libfuse2 wget
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
sudo chmod +x /usr/local/bin/appimagetool
- name: Package Linux AppImage
if: matrix.os == 'ubuntu-latest'
run: bash build/appimage/build.sh "${{ steps.version.outputs.version }}"
# ---- Upload + release ----------------------------------------
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_path }}
if-no-files-found: error
- name: Attach to Release (tag push only)
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.artifact_path }}
fail_on_unmatched_files: true
generate_release_notes: true

View File

@@ -16,7 +16,19 @@ Local CSV / Excel cleaning. CLI + browser GUI, no cloud, no install ceremony.
| 08 | Validator & Reporter | Coming Soon |
| 09 | **Pipeline Runner** — chain tools with recommended (not forced) order, save/load JSON, automate weekly cleanups | Ready |
## Install
## Download (non-technical users)
Pre-built installers — no Python required:
| Platform | Download | First-launch note |
|---|---|---|
| **macOS** | `DataTools-X.Y.Z-mac.dmg` | Drag DataTools.app into /Applications, then double-click. |
| **Windows** | `DataTools-X.Y.Z-win-setup.exe` | Run the installer; launches from Start Menu. |
| **Linux** | `DataTools-X.Y.Z-linux-x86_64.AppImage` | `chmod +x` the file, then double-click. |
Latest release: see [GitHub Releases](https://git.invixiom.com/giteadmin/datatools-dev/releases) (or the Gumroad listing). The installers are ~150200 MB; the launcher boots a local server at http://127.0.0.1:8501 and opens your browser. Nothing is sent to the cloud.
## Install from source (developers)
```bash
pip install -r requirements.txt

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"

View File

@@ -0,0 +1,8 @@
"""DataTools — local CSV / Excel cleaning suite.
Version is the single source of truth read by the PyInstaller spec,
the Windows Inno Setup script, the macOS DMG packaging script, and
the AppImage build. Bump here on release and CI propagates it.
"""
__version__ = "1.0.0"