Skip to content

feat: Update test for mid-quality rating to reflect correct expected … #33

feat: Update test for mid-quality rating to reflect correct expected …

feat: Update test for mid-quality rating to reflect correct expected … #33

Workflow file for this run

name: Build Desktop Binaries
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.os }} PyInstaller build
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
python: '3.12'
artifact_name: PhotoSort-Windows-x64
onnx_runtime: onnxruntime
- os: windows-latest
python: '3.12'
artifact_name: PhotoSort-Windows-x64-CUDA
onnx_runtime: onnxruntime-gpu
- os: macos-14 # Apple Silicon
python: '3.12'
artifact_name: PhotoSort-macOS-AppleSilicon
env:
PIP_DISABLE_PIP_VERSION_CHECK: '1'
PIP_NO_PYTHON_VERSION_WARNING: '1'
PIP_PROGRESS_BAR: 'off'
QT_QPA_PLATFORM: offscreen
# Enable file logging in packaged app for troubleshooting
PHOTOSORT_ENABLE_FILE_LOGGING: 'true'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Generate version module
shell: bash
# Write src/core/build_info.py VERSION from tag (strip leading 'v') or short SHA for ad-hoc builds
run: |
python - <<'PY'
import os, re, pathlib
ref = os.getenv('GITHUB_REF', '') or ''
sha = (os.getenv('GITHUB_SHA', '') or '')[:7]
m = re.match(r'refs/tags/v?(.*)$', ref)
if m and m.group(1):
version = m.group(1)
else:
version = f'dev-{sha}' if sha else 'dev'
path = pathlib.Path('src') / 'core' / 'build_info.py'
path.parent.mkdir(parents=True, exist_ok=True)
content = '# File auto-generated by CI. Do not edit.\nVERSION = ' + repr(version) + '\n'
path.write_text(content, encoding='utf-8')
print('Wrote', path, 'with version:', version)
PY
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Python deps
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# Override ONNX Runtime with matrix-specified package
if [ "$RUNNER_OS" = "Windows" ]; then
pip install --force-reinstall ${{ matrix.onnx_runtime }}
fi
# Build tools
pip install pyinstaller
- name: Generate app icons (platform-specific)
shell: bash
run: |
# Use committed assets/app_icon.png
mkdir -p assets
if [ "$RUNNER_OS" = "macOS" ]; then
# Always regenerate .icns in CI for consistency
mkdir -p build/icons/PhotoSort.iconset
python - <<'PY'
import os
from PIL import Image
base = Image.open('assets/app_icon.png')
sizes = {
'icon_16x16.png': 16,
'icon_16x16@2x.png': 32,
'icon_32x32.png': 32,
'icon_32x32@2x.png': 64,
'icon_128x128.png': 128,
'icon_128x128@2x.png': 256,
'icon_256x256.png': 256,
'icon_256x256@2x.png': 512,
'icon_512x512.png': 512,
'icon_512x512@2x.png': 1024,
}
os.makedirs('build/icons/PhotoSort.iconset', exist_ok=True)
for name, sz in sizes.items():
# Use modern Pillow API
base.resize((sz, sz), Image.Resampling.LANCZOS).save(os.path.join('build/icons/PhotoSort.iconset', name))
PY
iconutil -c icns build/icons/PhotoSort.iconset -o assets/photosort.icns
fi
- name: Ensure models dir exists
shell: bash
run: |
# Create empty models directory - users will download ONNX models separately
# This ensures the directory structure exists in the packaged app
mkdir -p models
- name: Install pyexiv2 dependencies (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
# Install Homebrew dependencies required by pyexiv2
# Use || true to avoid warnings if already installed
brew install brotli inih gettext || true
- name: Build with PyInstaller (Windows)
if: runner.os == 'Windows'
run: |
pyinstaller -w --onefile -n PhotoSort `
--icon assets/app_icon.ico `
--paths src `
--hidden-import core.build_info `
--hidden-import mediapipe `
--add-data "src/ui/dark_theme.qss;." `
--add-data "assets/app_icon.ico;." `
--add-data "assets/app_icon.png;." `
--hidden-import pyexiv2 `
--hidden-import PyQt6.QtCore `
--hidden-import PyQt6.QtGui `
--hidden-import PyQt6.QtWidgets `
--hidden-import rawpy `
--hidden-import cv2 `
--hidden-import onnxruntime `
--hidden-import torchvision `
--hidden-import torch `
--hidden-import sklearn `
--hidden-import sentence_transformers `
--collect-data mediapipe `
--collect-data pyiqa `
--add-data "models;models" `
--runtime-hook runtime_hook.py `
src/main.py
- name: Collect artifact (Windows .exe)
if: runner.os == 'Windows'
shell: pwsh
run: |
$exe = "${{ matrix.artifact_name }}.exe"
Copy-Item "dist\PhotoSort.exe" $exe -Force
- name: Test executable (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$exe = "${{ matrix.artifact_name }}.exe"
# Basic smoke test - check if executable exists and has valid PE header
if (-not (Test-Path $exe)) {
Write-Error "Executable not found: $exe"
exit 1
}
$size = (Get-Item $exe).Length
Write-Output "Executable size: $([math]::Round($size/1MB, 2)) MB"
# Verify PE header
$bytes = [System.IO.File]::ReadAllBytes($exe)
if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) {
Write-Error "Invalid PE header"
exit 1
}
Write-Output "✓ Windows executable validated successfully"
- name: Generate SHA256 checksum (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$exe = "${{ matrix.artifact_name }}.exe"
$hash = (Get-FileHash -Algorithm SHA256 $exe).Hash.ToLower()
"$hash $exe" | Out-File -Encoding ascii "$exe.sha256"
- name: Build with PyInstaller (macOS)
if: runner.os == 'macOS'
run: |
# Determine Homebrew prefix (different for Intel vs Apple Silicon)
if [ -d "/opt/homebrew" ]; then
BREW_PREFIX="/opt/homebrew"
else
BREW_PREFIX="/usr/local"
fi
pyinstaller -w --name PhotoSort \
--icon assets/photosort.icns \
--paths src \
--hidden-import core.build_info \
--hidden-import mediapipe \
--add-data src/ui/dark_theme.qss:. \
--add-data assets/app_icon.ico:. \
--add-data assets/app_icon.png:. \
--hidden-import PyQt6.QtCore \
--hidden-import PyQt6.QtGui \
--hidden-import PyQt6.QtWidgets \
--hidden-import rawpy \
--hidden-import pyexiv2 \
--collect-binaries pyexiv2 \
--copy-metadata pyexiv2 \
--add-binary "${BREW_PREFIX}/opt/brotli/lib/libbrotlicommon.1.dylib:." \
--add-binary "${BREW_PREFIX}/opt/brotli/lib/libbrotlidec.1.dylib:." \
--add-binary "${BREW_PREFIX}/opt/brotli/lib/libbrotlienc.1.dylib:." \
--add-binary "${BREW_PREFIX}/opt/inih/lib/libinih.0.dylib:." \
--add-binary "${BREW_PREFIX}/opt/inih/lib/libINIReader.0.dylib:." \
--add-binary "${BREW_PREFIX}/opt/gettext/lib/libintl.8.dylib:." \
--hidden-import cv2 \
--hidden-import onnxruntime \
--hidden-import torchvision \
--hidden-import torch \
--hidden-import sklearn \
--hidden-import sentence_transformers \
--collect-data mediapipe \
--collect-data pyiqa \
--add-data models:models \
src/main.py
- name: Test app bundle (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
APP_PATH="dist/PhotoSort.app"
if [ ! -d "$APP_PATH" ]; then
echo "PhotoSort.app not found in dist" >&2
exit 1
fi
# Check app bundle structure
if [ ! -f "$APP_PATH/Contents/Info.plist" ]; then
echo "Invalid app bundle: Info.plist missing" >&2
exit 1
fi
if [ ! -f "$APP_PATH/Contents/MacOS/PhotoSort" ]; then
echo "Invalid app bundle: executable missing" >&2
exit 1
fi
# Check executable permissions
if [ ! -x "$APP_PATH/Contents/MacOS/PhotoSort" ]; then
echo "Executable is not executable" >&2
exit 1
fi
# Get app size
SIZE=$(du -sh "$APP_PATH" | cut -f1)
echo "App bundle size: $SIZE"
echo "✓ macOS app bundle validated successfully"
- name: Package artifact (macOS .dmg)
if: runner.os == 'macOS'
shell: bash
run: |
DMG="${{ matrix.artifact_name }}.dmg"
APP_PATH="dist/PhotoSort.app"
STAGING_DIR="dmg_staging"
if [ ! -d "$APP_PATH" ]; then
echo "PhotoSort.app not found in dist" >&2
exit 1
fi
# Install create-dmg for professional DMG creation
brew install create-dmg
# Create staging directory with app and Applications symlink
mkdir -p "$STAGING_DIR"
cp -R "$APP_PATH" "$STAGING_DIR/"
# Use create-dmg for professional appearance
# Note: create-dmg may auto-rename output, so we capture actual filename
create-dmg \
--volname "PhotoSort" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "PhotoSort.app" 150 190 \
--hide-extension "PhotoSort.app" \
--app-drop-link 450 190 \
"$DMG" \
"$STAGING_DIR" \
|| true # create-dmg exits with 2 on success sometimes
# Find the actual DMG file created (create-dmg might rename it)
ACTUAL_DMG=$(ls -t *.dmg 2>/dev/null | head -1)
if [ -z "$ACTUAL_DMG" ]; then
echo "No DMG file found after creation" >&2
exit 1
fi
# Rename to expected name if different
if [ "$ACTUAL_DMG" != "$DMG" ]; then
echo "Renaming $ACTUAL_DMG to $DMG"
mv "$ACTUAL_DMG" "$DMG"
fi
# Verify final DMG exists
if [ ! -f "$DMG" ]; then
echo "DMG not found: $DMG" >&2
exit 1
fi
echo "Created DMG: $DMG ($(du -h "$DMG" | cut -f1))"
# Clean up staging directory
rm -rf "$STAGING_DIR"
- name: Generate SHA256 checksum (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
DMG="${{ matrix.artifact_name }}.dmg"
shasum -a 256 "$DMG" > "$DMG.sha256"
- name: Archive artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}.*
retention-days: 14
release:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten artifacts for release
shell: bash
run: |
# Flatten nested artifact structure
mkdir -p release_files
find artifacts -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.sha256" \) -exec cp {} release_files/ \;
echo "Release files:"
ls -lh release_files/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release_files/*
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}