-
-
Notifications
You must be signed in to change notification settings - Fork 1
383 lines (340 loc) · 12.6 KB
/
release-build.yml
File metadata and controls
383 lines (340 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
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 `
--paths . `
--hidden-import core.build_info `
--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 `
--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 \
--paths . \
--hidden-import core.build_info \
--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 \
--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 }}
release-notes:
if: startsWith(github.ref, 'refs/tags/')
needs: release
uses: ./.github/workflows/release-notes-generator.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit