feat: Update test for mid-quality rating to reflect correct expected … #33
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |