build: add single-command release script + portable zip artifacts
One-developer workflow: ``python build/make_release.py`` on each target OS produces both the installer and a portable .zip for that platform. Preflight checks PyInstaller / Pillow / iscc / hdiutil / ditto / appimagetool and bails with install hints if anything is missing — no half-built dist/. New scripts: - build/make_release.py — orchestrator, auto-detects host OS. - build/generate_icons.py — icon.ico / icon.icns / icon.png from src/gui/assets/datatools_icon_256.png (Pillow ships ICO + ICNS writers; no platform tooling needed). - build/build_portable_zip.py — Win/Linux portable zip via stdlib. - build/macos/build_zip.sh — Mac portable .app via ditto so bundle metadata survives. installer.iss now adds: Quick Launch task (opt-in, legacy Win 7), App Paths registry entry (Win+R "DataTools" works), SetupIconFile, UninstallDisplayIcon, AppSupportURL, AppUpdatesURL. CI workflow uploads installer + portable per platform and attaches both to GitHub Releases on tag push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
.github/workflows/build.yml
vendored
71
.github/workflows/build.yml
vendored
@@ -1,8 +1,18 @@
|
||||
name: Build installers
|
||||
|
||||
# Triggers:
|
||||
# * Tag push (v*) → produces installers, attaches to a GitHub Release.
|
||||
# * Manual dispatch → produces installers as workflow artifacts only.
|
||||
# * Tag push (v*) → produces installers + portable zips, attaches them
|
||||
# to a GitHub Release.
|
||||
# * Manual dispatch → uploads everything as workflow artifacts only.
|
||||
#
|
||||
# Outputs per platform (downloadable by buyers):
|
||||
# * macOS: .dmg installer + portable .zip (signed .app inside).
|
||||
# * Windows: .exe installer + portable .zip (no-install).
|
||||
# * Linux: .AppImage (already portable; no separate zip).
|
||||
#
|
||||
# Self-contained: every artifact ships its own Python interpreter + every
|
||||
# runtime dep through PyInstaller. No pre/post install steps on the
|
||||
# buyer's machine.
|
||||
#
|
||||
# What this workflow doesn't do (yet):
|
||||
# * Code signing (Mac Developer ID, Windows code-signing cert).
|
||||
@@ -29,14 +39,17 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
artifact_name: DataTools-mac.dmg
|
||||
artifact_path: dist/DataTools-*-mac.dmg
|
||||
platform: mac
|
||||
installer_glob: dist/DataTools-*-mac.dmg
|
||||
portable_glob: dist/DataTools-*-mac-portable.zip
|
||||
- os: windows-latest
|
||||
artifact_name: DataTools-win.exe
|
||||
artifact_path: dist/DataTools-*-win-setup.exe
|
||||
platform: win
|
||||
installer_glob: dist/DataTools-*-win-setup.exe
|
||||
portable_glob: dist/DataTools-*-win-portable.zip
|
||||
- os: ubuntu-latest
|
||||
artifact_name: DataTools-linux.AppImage
|
||||
artifact_path: dist/DataTools-*-linux-x86_64.AppImage
|
||||
platform: linux
|
||||
installer_glob: dist/DataTools-*-linux-x86_64.AppImage
|
||||
portable_glob: '' # AppImage is already a portable single file
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -50,7 +63,7 @@ jobs:
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
pip install pyinstaller pillow
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
@@ -59,15 +72,22 @@ jobs:
|
||||
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: Generate platform icons
|
||||
run: python build/generate_icons.py
|
||||
|
||||
- name: Build PyInstaller bundle
|
||||
run: pyinstaller build/datatools.spec --clean --noconfirm
|
||||
|
||||
# ---- Per-platform packaging ----------------------------------
|
||||
# ---- Per-platform installer packaging ------------------------
|
||||
|
||||
- name: Package macOS DMG
|
||||
- name: Package macOS DMG (installer)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: bash build/macos/build_dmg.sh "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Package macOS portable .zip
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: bash build/macos/build_zip.sh "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Install Inno Setup (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install innosetup --no-progress -y
|
||||
@@ -78,6 +98,10 @@ jobs:
|
||||
run: |
|
||||
iscc /DAppVersion=${{ steps.version.outputs.version }} build\installer.iss
|
||||
|
||||
- name: Package Windows portable .zip
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: python build/build_portable_zip.py win ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Install AppImage tooling (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -92,17 +116,32 @@ jobs:
|
||||
|
||||
# ---- Upload + release ----------------------------------------
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: ${{ matrix.artifact_path }}
|
||||
name: DataTools-${{ matrix.platform }}-installer
|
||||
path: ${{ matrix.installer_glob }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach to Release (tag push only)
|
||||
- name: Upload portable artifact
|
||||
if: matrix.portable_glob != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DataTools-${{ matrix.platform }}-portable
|
||||
path: ${{ matrix.portable_glob }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach installer to Release (tag push only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.artifact_path }}
|
||||
files: ${{ matrix.installer_glob }}
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Attach portable to Release (tag push only)
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.portable_glob != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.portable_glob }}
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,6 +11,11 @@ dist/
|
||||
build/build/
|
||||
build/__pycache__/
|
||||
build/dist/
|
||||
# Generated by build/generate_icons.py from src/gui/assets/datatools_icon_256.png.
|
||||
# Build artifacts, not source — regenerated each CI run.
|
||||
build/icon.ico
|
||||
build/icon.icns
|
||||
build/icon.png
|
||||
.pytest_cache/
|
||||
|
||||
# Claude Code agent worktrees + local settings
|
||||
|
||||
@@ -19,23 +19,53 @@ build/
|
||||
│ Mac .app bundle config. Reads the version
|
||||
│ from src/__init__.py.
|
||||
├── installer.iss Inno Setup script — Windows .exe installer.
|
||||
│ Adds Start Menu + Desktop + App Paths entries.
|
||||
├── generate_icons.py Builds icon.ico / icon.icns / icon.png from
|
||||
│ src/gui/assets/datatools_icon_256.png. Run
|
||||
│ once before pyinstaller (CI does this).
|
||||
├── build_portable_zip.py Cross-platform: zips dist/DataTools/ into a
|
||||
│ no-install portable download. Used by the
|
||||
│ Windows + Linux portable artifacts.
|
||||
├── macos/
|
||||
│ └── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a
|
||||
│ drag-to-/Applications layout.
|
||||
│ ├── build_dmg.sh Wraps dist/DataTools.app into a .dmg with a
|
||||
│ │ drag-to-/Applications layout (installer).
|
||||
│ └── build_zip.sh Wraps dist/DataTools.app into a portable
|
||||
│ .zip via ditto (preserves bundle metadata).
|
||||
├── 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).
|
||||
├── icon.{ico,icns,png} Generated by generate_icons.py — gitignored.
|
||||
└── README.md this file
|
||||
```
|
||||
|
||||
## Distribution outputs per platform
|
||||
|
||||
Each CI run produces two downloads per platform — an installer for
|
||||
buyers who want shortcuts wired automatically, and a portable .zip
|
||||
for buyers (or IT-locked-down machines) that can't run installers:
|
||||
|
||||
| Platform | Installer | Portable |
|
||||
|----------|----------------------------------------|------------------------------------------------|
|
||||
| macOS | `DataTools-<ver>-mac.dmg` | `DataTools-<ver>-mac-portable.zip` (ditto .app)|
|
||||
| Windows | `DataTools-<ver>-win-setup.exe` | `DataTools-<ver>-win-portable.zip` |
|
||||
| Linux | `DataTools-<ver>-linux-x86_64.AppImage`| (the AppImage IS the portable) |
|
||||
|
||||
All six outputs are self-contained: every dependency (Python, pandas,
|
||||
streamlit, pdfplumber, the lot) is frozen into the bundle. The buyer
|
||||
does not need to install Python, pip, or anything else first.
|
||||
|
||||
## Easy-launch surface
|
||||
|
||||
| Affordance | Windows | macOS |
|
||||
|------------------|--------------------------------------------------|------------------------------------------------------|
|
||||
| Desktop shortcut | Inno Setup `desktopicon` task (checked default) | The .app bundle in /Applications is the icon |
|
||||
| App menu | Start Menu → DataTools (always installed) | Launchpad + Spotlight (auto from /Applications) |
|
||||
| Taskbar / Dock | User pins manually (OS forbids programmatic pin) | User pins manually after first launch |
|
||||
| Run from terminal| `DataTools` (registered via App Paths) | `open -a DataTools` (auto from .app bundle) |
|
||||
|
||||
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
|
||||
@@ -43,12 +73,46 @@ the resulting installers to a GitHub Release. Manual
|
||||
|
||||
## Releasing
|
||||
|
||||
### Single-command local build (recommended for one-developer workflow)
|
||||
|
||||
PyInstaller can't cross-compile, so a single machine produces one
|
||||
platform's packages. Run this on each target OS:
|
||||
|
||||
```bash
|
||||
# One-time setup per machine:
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller pillow
|
||||
# Windows only: install Inno Setup from https://jrsoftware.org/isdl.php
|
||||
# Linux only: drop appimagetool onto PATH (see preflight output)
|
||||
|
||||
# Build everything for the current OS:
|
||||
python build/make_release.py
|
||||
```
|
||||
|
||||
Outputs land in `dist/`:
|
||||
- Windows host → `DataTools-<ver>-win-setup.exe` + `DataTools-<ver>-win-portable.zip`
|
||||
- macOS host → `DataTools-<ver>-mac.dmg` + `DataTools-<ver>-mac-portable.zip`
|
||||
- Linux host → `DataTools-<ver>-linux-x86_64.AppImage`
|
||||
|
||||
Useful flags:
|
||||
|
||||
```bash
|
||||
python build/make_release.py --preflight # check tooling, build nothing
|
||||
python build/make_release.py --clean # wipe dist/ first
|
||||
python build/make_release.py --skip-installer # just the portable zip
|
||||
python build/make_release.py --skip-portable # just the installer
|
||||
```
|
||||
|
||||
### CI build (push tag → GitHub Release)
|
||||
|
||||
If you have CI runners for all three OSes:
|
||||
|
||||
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).
|
||||
4. CI builds all three platforms and creates a Release with the
|
||||
installers + portable zips attached.
|
||||
5. Mirror the Release assets to Gumroad (manual until v2).
|
||||
|
||||
## Signing (Phase 2 — needs accounts/credentials)
|
||||
|
||||
|
||||
69
build/build_portable_zip.py
Normal file
69
build/build_portable_zip.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Wrap the PyInstaller folder build into a portable .zip.
|
||||
|
||||
Self-contained download: unzip → double-click the launcher → app runs.
|
||||
No installer, no Python install, no admin rights required.
|
||||
|
||||
Usage:
|
||||
python build/build_portable_zip.py <platform> <version>
|
||||
|
||||
Where ``platform`` is one of ``win`` / ``mac`` / ``linux``. The
|
||||
script just produces a generic ``dist/DataTools/`` zip; on macOS the
|
||||
preferred portable format is the ``ditto``-wrapped .app — see
|
||||
``build/macos/build_zip.sh`` for that flow. This helper exists mainly
|
||||
for Windows + Linux, where there's no .app bundle to wrap.
|
||||
|
||||
Output:
|
||||
dist/DataTools-<version>-<platform>-portable.zip
|
||||
|
||||
The zip root is the ``DataTools/`` folder so an unzip produces a
|
||||
self-contained dir the user can drop anywhere (Desktop, USB stick,
|
||||
network share). On Windows, the launcher is ``DataTools.exe`` inside
|
||||
that folder; on Linux, ``DataTools``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
DIST_DIR = REPO / "dist"
|
||||
BUNDLE_DIR = DIST_DIR / "DataTools"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
sys.stderr.write(
|
||||
"usage: python build/build_portable_zip.py <platform> <version>\n"
|
||||
)
|
||||
return 2
|
||||
platform = sys.argv[1]
|
||||
version = sys.argv[2]
|
||||
|
||||
if not BUNDLE_DIR.is_dir():
|
||||
sys.stderr.write(
|
||||
f"Bundle dir not found at {BUNDLE_DIR}.\n"
|
||||
"Run ``pyinstaller build/datatools.spec --clean --noconfirm`` first.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
out_stem = DIST_DIR / f"DataTools-{version}-{platform}-portable"
|
||||
# ``make_archive`` takes a base name (no extension) and produces
|
||||
# ``<base>.zip``. ``root_dir`` = parent of what we want compressed,
|
||||
# ``base_dir`` = the folder name inside the archive root. This
|
||||
# combo yields a single top-level ``DataTools/`` directory inside
|
||||
# the .zip rather than dumping its contents loose.
|
||||
archive = shutil.make_archive(
|
||||
base_name=str(out_stem),
|
||||
format="zip",
|
||||
root_dir=str(DIST_DIR),
|
||||
base_dir="DataTools",
|
||||
)
|
||||
size_mb = Path(archive).stat().st_size / (1024 * 1024)
|
||||
print(f"wrote {archive} ({size_mb:.1f} MB)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
78
build/generate_icons.py
Normal file
78
build/generate_icons.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Generate platform-specific app icons from the source PNG asset.
|
||||
|
||||
Outputs:
|
||||
build/icon.ico Windows multi-resolution icon (16..256 px sizes).
|
||||
build/icon.icns macOS icon bundle (16..1024 px scaled tiers).
|
||||
build/icon.png Plain 256x256 PNG used by the Linux AppImage.
|
||||
|
||||
Source: ``src/gui/assets/datatools_icon_256.png`` (the same icon
|
||||
``st.set_page_config`` uses, so the installer / Dock / Taskbar match
|
||||
the in-app tab favicon).
|
||||
|
||||
Run manually:
|
||||
python build/generate_icons.py
|
||||
|
||||
CI runs this automatically before invoking PyInstaller (see
|
||||
``.github/workflows/build.yml``). Both files are .gitignored — they
|
||||
are build artifacts derived from the committed PNG.
|
||||
|
||||
Self-contained: pulls only Pillow (already a transitive dep of
|
||||
``pdfplumber``) so no extra installs are required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# Repo layout: this script lives at <REPO>/build/. The source PNG is at
|
||||
# <REPO>/src/gui/assets/datatools_icon_256.png.
|
||||
BUILD_DIR = Path(__file__).resolve().parent
|
||||
REPO = BUILD_DIR.parent
|
||||
SOURCE_PNG = REPO / "src" / "gui" / "assets" / "datatools_icon_256.png"
|
||||
|
||||
# Windows ICO needs every size the OS might render at: taskbar (16/24),
|
||||
# Start Menu (32/48), tile (64/128), shell properties dialog (256).
|
||||
ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
||||
(128, 128), (256, 256)]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SOURCE_PNG.exists():
|
||||
sys.stderr.write(
|
||||
f"Source icon not found at {SOURCE_PNG}.\n"
|
||||
"Add a 256x256 (or larger) RGBA PNG there and re-run.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
src = Image.open(SOURCE_PNG).convert("RGBA")
|
||||
if src.size[0] < 256 or src.size[1] < 256:
|
||||
sys.stderr.write(
|
||||
f"Source icon is {src.size}; recommend 256x256 or larger "
|
||||
"so downscaled tiers look crisp.\n"
|
||||
)
|
||||
|
||||
ico_path = BUILD_DIR / "icon.ico"
|
||||
src.save(ico_path, format="ICO", sizes=ICO_SIZES)
|
||||
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes)")
|
||||
|
||||
icns_path = BUILD_DIR / "icon.icns"
|
||||
# Pillow's ICNS writer derives the per-tier sizes from the source
|
||||
# image; passing a 256x256 source yields ic07..ic12 entries which
|
||||
# cover Finder, Dock, and the Get Info panel.
|
||||
src.save(icns_path, format="ICNS")
|
||||
print(f"wrote {icns_path} ({icns_path.stat().st_size:,} bytes)")
|
||||
|
||||
# AppImage uses a plain PNG for its desktop entry. Copy the source
|
||||
# so the AppImage build script doesn't have to know the asset path.
|
||||
png_path = BUILD_DIR / "icon.png"
|
||||
src.save(png_path, format="PNG")
|
||||
print(f"wrote {png_path} ({png_path.stat().st_size:,} bytes)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,11 +1,26 @@
|
||||
; Inno Setup script for DataTools — Windows installer.
|
||||
;
|
||||
; Compile from the repo root:
|
||||
; iscc /DAppVersion=1.0.0 build\installer.iss
|
||||
; iscc /DAppVersion=3.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.
|
||||
;
|
||||
; What this installer wires up (covers the "easy launch" surface):
|
||||
; * Start Menu group: Start → DataTools → DataTools / Uninstall
|
||||
; * Desktop shortcut: optional, checked by default during install
|
||||
; * Quick Launch: optional, off by default (legacy Win 7 + power
|
||||
; users who keep the bar enabled). Windows 10/11
|
||||
; users pin to taskbar manually via right-click —
|
||||
; OS security policy forbids programmatic pinning.
|
||||
; * App Paths entry: so ``DataTools`` typed into Win+R / cmd works.
|
||||
;
|
||||
; Self-contained: the installer contains a frozen PyInstaller bundle
|
||||
; (Python + every runtime dep). No pre-install or post-install steps
|
||||
; on the buyer's machine. UAC is NOT required because we install
|
||||
; per-user by default; the prompt only fires if the buyer asks for an
|
||||
; all-users install.
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.0.0-dev"
|
||||
@@ -18,11 +33,15 @@ AppVersion={#AppVersion}
|
||||
AppVerName=DataTools {#AppVersion}
|
||||
AppPublisher=DataTools
|
||||
AppPublisherURL=https://datatools.app
|
||||
AppSupportURL=https://datatools.app/support
|
||||
AppUpdatesURL=https://datatools.app/releases
|
||||
DefaultDirName={autopf}\DataTools
|
||||
DefaultGroupName=DataTools
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=DataTools-{#AppVersion}-win-setup
|
||||
SetupIconFile=icon.ico
|
||||
UninstallDisplayIcon={app}\DataTools.exe
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
@@ -30,20 +49,37 @@ ArchitecturesInstallIn64BitMode=x64
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
; Allow per-user install (no UAC prompt) when admin isn't available.
|
||||
; Buyers without admin rights can still install without IT involvement.
|
||||
|
||||
ChangesAssociations=no
|
||||
CloseApplications=force
|
||||
RestartApplications=no
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"
|
||||
Name: "quicklaunchicon"; Description: "Create a &Quick Launch shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked; OnlyBelowVersion: 6.1
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\DataTools\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\DataTools"; Filename: "{app}\DataTools.exe"
|
||||
; Start Menu entries — created unconditionally so the app is always
|
||||
; discoverable via Start search.
|
||||
Name: "{group}\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe"
|
||||
Name: "{group}\Uninstall DataTools"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\DataTools"; Filename: "{app}\DataTools.exe"; Tasks: desktopicon
|
||||
; Desktop shortcut — opt-in via the Tasks page.
|
||||
Name: "{autodesktop}\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe"; Tasks: desktopicon
|
||||
; Quick Launch (legacy) — only relevant on Win 7 and older.
|
||||
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\DataTools"; Filename: "{app}\DataTools.exe"; IconFilename: "{app}\DataTools.exe"; Tasks: quicklaunchicon
|
||||
|
||||
[Registry]
|
||||
; App Paths — lets the buyer launch from Win+R or cmd with just
|
||||
; "DataTools" instead of a full path. Per-user hive so the per-user
|
||||
; install path doesn't need admin to register.
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\App Paths\DataTools.exe"; ValueType: string; ValueName: ""; ValueData: "{app}\DataTools.exe"; Flags: uninsdeletekey
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\DataTools.exe"; Description: "Launch DataTools"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
38
build/macos/build_zip.sh
Executable file
38
build/macos/build_zip.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrap dist/DataTools.app into a no-install portable .zip.
|
||||
#
|
||||
# Usage:
|
||||
# bash build/macos/build_zip.sh <version>
|
||||
#
|
||||
# Why a portable .zip in addition to the .dmg:
|
||||
# * Buyers who don't want an installer can unzip and double-click the
|
||||
# .app directly — no drag-to-/Applications step, no installer
|
||||
# chrome. Self-contained: the .app holds Python + every dep.
|
||||
# * IT-locked-down machines often block .dmg auto-mount but allow
|
||||
# .zip download + extraction.
|
||||
#
|
||||
# Run after ``pyinstaller build/datatools.spec --clean --noconfirm``
|
||||
# has produced ``dist/DataTools.app``. Output goes to
|
||||
# ``dist/DataTools-<version>-mac-portable.zip``.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.0.0-dev}"
|
||||
APP="dist/DataTools.app"
|
||||
ZIP="dist/DataTools-${VERSION}-mac-portable.zip"
|
||||
|
||||
if [[ ! -d "$APP" ]]; then
|
||||
echo "Error: $APP not found. Run pyinstaller build/datatools.spec first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ``ditto`` preserves the .app bundle's extended attributes and
|
||||
# resource forks (a plain ``zip`` strips them and can break code
|
||||
# signatures + Info.plist resolution on the buyer's machine).
|
||||
#
|
||||
# --sequesterRsrc keeps the AppleDouble metadata inside the archive
|
||||
# rather than as parallel ._ files on disk after extraction.
|
||||
rm -f "$ZIP"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
|
||||
|
||||
echo "Built $ZIP ($(du -h "$ZIP" | cut -f1))"
|
||||
348
build/make_release.py
Normal file
348
build/make_release.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""Single-command release builder for DataTools.
|
||||
|
||||
PyInstaller can't cross-compile — to produce a Windows .exe you run
|
||||
this on Windows, for a Mac .dmg you run it on macOS, for a Linux
|
||||
AppImage you run it on Linux. One script, one OS at a time.
|
||||
|
||||
What this script does (in order):
|
||||
1. Preflight — checks PyInstaller, Pillow, and the platform's
|
||||
packager (Inno Setup on Win / hdiutil + ditto on Mac /
|
||||
appimagetool on Linux) are reachable. Bails with install
|
||||
instructions if anything is missing.
|
||||
2. Generates icon.ico / icon.icns / icon.png from the PNG asset.
|
||||
3. Runs PyInstaller against build/datatools.spec.
|
||||
4. Wraps the PyInstaller output into:
|
||||
* Windows: DataTools-<ver>-win-setup.exe (Inno Setup)
|
||||
+ DataTools-<ver>-win-portable.zip
|
||||
* macOS: DataTools-<ver>-mac.dmg
|
||||
+ DataTools-<ver>-mac-portable.zip
|
||||
* Linux: DataTools-<ver>-linux-x86_64.AppImage
|
||||
5. Prints what landed in dist/ and the byte sizes.
|
||||
|
||||
Usage:
|
||||
python build/make_release.py # build everything for this OS
|
||||
python build/make_release.py --preflight # check tooling, don't build
|
||||
python build/make_release.py --skip-installer # only the portable zip
|
||||
python build/make_release.py --skip-portable # only the installer
|
||||
python build/make_release.py --clean # wipe dist/ first
|
||||
|
||||
Run from the repo root or from build/ — either works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
BUILD = REPO / "build"
|
||||
DIST = REPO / "dist"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output helpers — colourless so logs stay readable in any terminal/CI tail.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _step(msg: str) -> None:
|
||||
print(f"\n==> {msg}", flush=True)
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f" ok: {msg}", flush=True)
|
||||
|
||||
|
||||
def _warn(msg: str) -> None:
|
||||
print(f" warn: {msg}", flush=True)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(f" ERROR: {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, env: dict | None = None) -> None:
|
||||
"""Run *cmd*, stream output, exit on failure with a useful banner."""
|
||||
printable = " ".join(map(str, cmd))
|
||||
print(f" $ {printable}", flush=True)
|
||||
try:
|
||||
subprocess.run(cmd, check=True, cwd=cwd or REPO, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
_err(f"command failed (exit {e.returncode}): {printable}")
|
||||
sys.exit(e.returncode)
|
||||
except FileNotFoundError:
|
||||
_err(f"command not found: {cmd[0]}")
|
||||
sys.exit(127)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_platform() -> str:
|
||||
"""Return ``win`` / ``mac`` / ``linux`` based on sys.platform."""
|
||||
p = sys.platform
|
||||
if p.startswith("win"):
|
||||
return "win"
|
||||
if p == "darwin":
|
||||
return "mac"
|
||||
if p.startswith("linux"):
|
||||
return "linux"
|
||||
_err(f"unsupported platform {p!r}; this script handles win/mac/linux only.")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version — single source of truth in src/__init__.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
init_py = (REPO / "src" / "__init__.py").read_text(encoding="utf-8")
|
||||
m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', init_py)
|
||||
if not m:
|
||||
_err("could not parse __version__ from src/__init__.py")
|
||||
sys.exit(1)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight — check tooling before doing anything destructive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _have_module(name: str) -> bool:
|
||||
try:
|
||||
__import__(name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _have_command(name: str) -> bool:
|
||||
return shutil.which(name) is not None
|
||||
|
||||
|
||||
# Per-platform install hints. The error messages quote these so a buyer
|
||||
# building from source isn't left guessing what to install next.
|
||||
_INSTALL_HINTS = {
|
||||
"pyinstaller": "pip install pyinstaller",
|
||||
"pil": "pip install pillow",
|
||||
"iscc": "Inno Setup (Windows): https://jrsoftware.org/isdl.php — install, then re-open the shell so iscc lands on PATH.",
|
||||
"hdiutil": "ships with macOS — if it's missing your Mac install is broken.",
|
||||
"ditto": "ships with macOS — if it's missing your Mac install is broken.",
|
||||
"appimagetool": "Linux: download appimagetool-x86_64.AppImage from https://github.com/AppImage/AppImageKit/releases, chmod +x, drop on PATH.",
|
||||
}
|
||||
|
||||
|
||||
def preflight(target: str) -> None:
|
||||
"""Verify every tool the target build needs is reachable; exit if not."""
|
||||
_step(f"preflight ({target})")
|
||||
|
||||
missing: list[tuple[str, str]] = []
|
||||
|
||||
# Python-side deps — same on every platform. The ``_INSTALL_HINTS``
|
||||
# lookup uses lowercase keys so module name capitalization doesn't
|
||||
# need to match.
|
||||
for mod in ("PyInstaller", "PIL"):
|
||||
if not _have_module(mod):
|
||||
hint = _INSTALL_HINTS.get(mod.lower(), f"pip install {mod}")
|
||||
missing.append((mod.lower(), hint))
|
||||
else:
|
||||
_ok(f"{mod} importable")
|
||||
|
||||
# PyInstaller's CLI must also be reachable as a binary, not just as
|
||||
# an importable module — the spec is invoked via the ``pyinstaller``
|
||||
# command. ``python -m PyInstaller`` is a fine fallback so don't
|
||||
# hard-fail if only the CLI binary is missing.
|
||||
if _have_command("pyinstaller"):
|
||||
_ok("pyinstaller on PATH")
|
||||
else:
|
||||
_warn("pyinstaller binary not on PATH — will fall back to `python -m PyInstaller`")
|
||||
|
||||
# Platform-specific packagers.
|
||||
if target == "win":
|
||||
if _have_command("iscc"):
|
||||
_ok("Inno Setup (iscc) on PATH")
|
||||
else:
|
||||
missing.append(("iscc", _INSTALL_HINTS["iscc"]))
|
||||
elif target == "mac":
|
||||
for tool in ("hdiutil", "ditto"):
|
||||
if _have_command(tool):
|
||||
_ok(f"{tool} on PATH")
|
||||
else:
|
||||
missing.append((tool, _INSTALL_HINTS[tool]))
|
||||
elif target == "linux":
|
||||
if _have_command("appimagetool"):
|
||||
_ok("appimagetool on PATH")
|
||||
else:
|
||||
missing.append(("appimagetool", _INSTALL_HINTS["appimagetool"]))
|
||||
|
||||
if missing:
|
||||
_err("missing prerequisites:")
|
||||
for name, hint in missing:
|
||||
print(f" - {name}: {hint}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
_ok("all prerequisites present")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def step_generate_icons() -> None:
|
||||
_step("generate icons")
|
||||
_run([sys.executable, str(BUILD / "generate_icons.py")])
|
||||
|
||||
|
||||
def step_pyinstaller(clean: bool) -> None:
|
||||
_step("pyinstaller bundle")
|
||||
# Use ``python -m PyInstaller`` so we don't depend on the binary
|
||||
# being on PATH (Windows users frequently see this — pip's
|
||||
# Scripts/ dir isn't auto-added).
|
||||
cmd = [sys.executable, "-m", "PyInstaller",
|
||||
str(BUILD / "datatools.spec"),
|
||||
"--noconfirm"]
|
||||
if clean:
|
||||
cmd.append("--clean")
|
||||
_run(cmd)
|
||||
|
||||
|
||||
def step_package_win(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
if do_installer:
|
||||
_step("Windows installer (Inno Setup)")
|
||||
_run(["iscc", f"/DAppVersion={version}", str(BUILD / "installer.iss")])
|
||||
out.append(DIST / f"DataTools-{version}-win-setup.exe")
|
||||
if do_portable:
|
||||
_step("Windows portable .zip")
|
||||
_run([sys.executable, str(BUILD / "build_portable_zip.py"), "win", version])
|
||||
out.append(DIST / f"DataTools-{version}-win-portable.zip")
|
||||
return out
|
||||
|
||||
|
||||
def step_package_mac(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
if do_installer:
|
||||
_step("macOS DMG (installer)")
|
||||
_run(["bash", str(BUILD / "macos" / "build_dmg.sh"), version])
|
||||
out.append(DIST / f"DataTools-{version}-mac.dmg")
|
||||
if do_portable:
|
||||
_step("macOS portable .zip")
|
||||
_run(["bash", str(BUILD / "macos" / "build_zip.sh"), version])
|
||||
out.append(DIST / f"DataTools-{version}-mac-portable.zip")
|
||||
return out
|
||||
|
||||
|
||||
def step_package_linux(version: str, do_installer: bool, do_portable: bool) -> list[Path]:
|
||||
# On Linux the AppImage IS the portable. We ignore the two flags
|
||||
# and always produce the single file — splitting wouldn't add
|
||||
# value.
|
||||
if not (do_installer or do_portable):
|
||||
return []
|
||||
_step("Linux AppImage")
|
||||
_run(["bash", str(BUILD / "appimage" / "build.sh"), version])
|
||||
return [DIST / f"DataTools-{version}-linux-x86_64.AppImage"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _summarise(outputs: list[Path]) -> None:
|
||||
_step("done — outputs")
|
||||
if not outputs:
|
||||
_warn("no files produced (everything skipped via flags)")
|
||||
return
|
||||
for p in outputs:
|
||||
if p.exists():
|
||||
size_mb = p.stat().st_size / (1024 * 1024)
|
||||
print(f" {p.relative_to(REPO)} ({size_mb:.1f} MB)")
|
||||
else:
|
||||
_warn(f"expected output missing: {p.relative_to(REPO)}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="make_release.py",
|
||||
description=(
|
||||
"Build the installer + portable zip for the current OS. "
|
||||
"Cross-compilation isn't supported by PyInstaller — run "
|
||||
"this once per platform you want to target."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform", choices=("auto", "win", "mac", "linux"), default="auto",
|
||||
help="Override OS detection (mostly for testing). Default: auto.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preflight", action="store_true",
|
||||
help="Check tooling and exit without building.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean", action="store_true",
|
||||
help="Wipe dist/ before building.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-installer", action="store_true",
|
||||
help="Don't build the OS installer (.exe / .dmg).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-portable", action="store_true",
|
||||
help="Don't build the portable .zip.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
target = _detect_platform() if args.platform == "auto" else args.platform
|
||||
version = _read_version()
|
||||
do_installer = not args.skip_installer
|
||||
do_portable = not args.skip_portable
|
||||
|
||||
print(f"DataTools release builder")
|
||||
print(f" target: {target} (host: {platform.platform()})")
|
||||
print(f" version: {version}")
|
||||
print(f" installer: {'yes' if do_installer else 'no'}")
|
||||
print(f" portable: {'yes' if do_portable else 'no'}")
|
||||
print(f" dist dir: {DIST}")
|
||||
|
||||
if target != _detect_platform():
|
||||
_warn(
|
||||
f"--platform {target} but host is {_detect_platform()}. "
|
||||
"PyInstaller can't cross-compile — the bundle will be for "
|
||||
"the HOST, only the packaging step will follow your override. "
|
||||
"Useful only for testing the packager paths."
|
||||
)
|
||||
|
||||
preflight(target)
|
||||
if args.preflight:
|
||||
return 0
|
||||
|
||||
if args.clean and DIST.exists():
|
||||
_step(f"cleaning {DIST}")
|
||||
shutil.rmtree(DIST)
|
||||
|
||||
step_generate_icons()
|
||||
step_pyinstaller(clean=args.clean)
|
||||
|
||||
if target == "win":
|
||||
outputs = step_package_win(version, do_installer, do_portable)
|
||||
elif target == "mac":
|
||||
outputs = step_package_mac(version, do_installer, do_portable)
|
||||
else:
|
||||
outputs = step_package_linux(version, do_installer, do_portable)
|
||||
|
||||
_summarise(outputs)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user