From 4706ed571e56bc2f262e812fd711419f5a5aef39 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 5 May 2026 13:58:43 +0000 Subject: [PATCH] build: wire desktop-bundle pipeline (CI matrix + per-platform installers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/build.yml | 108 +++++++++++++++++++++++++++++++ README.md | 14 +++- build/README.md | 62 +++++++++++++++++- build/appimage/AppRun | 8 +++ build/appimage/build.sh | 62 ++++++++++++++++++ build/appimage/datatools.desktop | 8 +++ build/datatools.spec | 12 +++- build/installer.iss | 49 ++++++++++++++ build/macos/build_dmg.sh | 41 ++++++++++++ src/__init__.py | 8 +++ 10 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100755 build/appimage/AppRun create mode 100755 build/appimage/build.sh create mode 100644 build/appimage/datatools.desktop create mode 100644 build/installer.iss create mode 100755 build/macos/build_dmg.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bc8fb67 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index e33bc3c..b88be10 100644 --- a/README.md +++ b/README.md @@ -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 ~150–200 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 diff --git a/build/README.md b/build/README.md index a3db7e6..e0cee78 100644 --- a/build/README.md +++ b/build/README.md @@ -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** diff --git a/build/appimage/AppRun b/build/appimage/AppRun new file mode 100755 index 0000000..e38e0c0 --- /dev/null +++ b/build/appimage/AppRun @@ -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" "$@" diff --git a/build/appimage/build.sh b/build/appimage/build.sh new file mode 100755 index 0000000..f77c194 --- /dev/null +++ b/build/appimage/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Wrap dist/DataTools/ (PyInstaller folder mode) into a distributable +# AppImage. +# +# Usage: +# bash build/appimage/build.sh +# +# Requires ``appimagetool`` on PATH (CI installs it; locally grab the +# latest release from https://github.com/AppImage/AppImageKit/releases). +# +# Output: dist/DataTools--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 .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" diff --git a/build/appimage/datatools.desktop b/build/appimage/datatools.desktop new file mode 100644 index 0000000..fff0a49 --- /dev/null +++ b/build/appimage/datatools.desktop @@ -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 diff --git a/build/datatools.spec b/build/datatools.spec index dee7db1..5b3212c 100644 --- a/build/datatools.spec +++ b/build/datatools.spec @@ -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 diff --git a/build/installer.iss b/build/installer.iss new file mode 100644 index 0000000..fa7a464 --- /dev/null +++ b/build/installer.iss @@ -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 diff --git a/build/macos/build_dmg.sh b/build/macos/build_dmg.sh new file mode 100755 index 0000000..011b3ea --- /dev/null +++ b/build/macos/build_dmg.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Wrap dist/DataTools.app into a distributable .dmg. +# +# Usage: +# bash build/macos/build_dmg.sh +# +# Run after ``pyinstaller build/datatools.spec --clean --noconfirm`` +# has produced ``dist/DataTools.app``. The output DMG goes to +# ``dist/DataTools--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" diff --git a/src/__init__.py b/src/__init__.py index e69de29..e2f2983 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -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"