Skip to content

Commit 1a44a78

Browse files
authored
Merge pull request #2 from audiohacking/copilot/add-github-action-build-dmg
Fix CI failures: pyright errors in job_executors + Darwin MPS policy test
2 parents 0a8f7bd + 23cb991 commit 1a44a78

File tree

4 files changed

+254
-13
lines changed

4 files changed

+254
-13
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
name: Build macOS DMG
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write # required to upload assets to GitHub Releases
10+
11+
jobs:
12+
build-mac-dmg:
13+
name: Build macOS DMG
14+
runs-on: macos-latest
15+
16+
steps:
17+
- name: Checkout repository
18+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
19+
20+
- name: Set up pnpm
21+
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
22+
23+
- name: Set up Node.js
24+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
25+
with:
26+
node-version: 24
27+
cache: pnpm
28+
cache-dependency-path: pnpm-lock.yaml
29+
30+
- name: Set up uv
31+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
32+
with:
33+
version: "latest"
34+
enable-cache: true
35+
cache-dependency-glob: "backend/uv.lock"
36+
37+
- name: Install Node dependencies
38+
run: pnpm install --frozen-lockfile
39+
40+
- name: Cache embedded Python environment
41+
id: python-cache
42+
uses: actions/cache@v4
43+
with:
44+
path: python-embed
45+
key: python-embed-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/uv.lock', 'backend/.python-version') }}
46+
47+
- name: Prepare embedded Python
48+
if: steps.python-cache.outputs.cache-hit != 'true'
49+
run: bash scripts/prepare-python.sh
50+
51+
- name: Generate Python dependency hash
52+
run: shasum -a 256 backend/uv.lock | awk '{print $1}' > python-deps-hash.txt
53+
54+
- name: Build frontend
55+
run: pnpm run build:frontend
56+
57+
- name: Build macOS app bundle (unsigned)
58+
# Build the unpacked .app only — electron-builder signing is disabled entirely.
59+
# Ad-hoc signing is applied in the next step via build/macos/codesign.sh.
60+
id: build-app
61+
env:
62+
CSC_IDENTITY_AUTO_DISCOVERY: "false"
63+
run: |
64+
pnpm exec electron-builder --mac --dir \
65+
--config.mac.notarize=false \
66+
--config.publish.owner=${{ github.repository_owner }} \
67+
--config.publish.repo=${{ github.event.repository.name }}
68+
69+
APP_PATH=$(find release/mac-* -name "*.app" -maxdepth 1 | head -1)
70+
if [ -z "$APP_PATH" ]; then
71+
echo "Error: No .app bundle found in release/"
72+
exit 1
73+
fi
74+
echo "app_path=$APP_PATH" >> "$GITHUB_OUTPUT"
75+
76+
- name: Ad-hoc code sign the app bundle
77+
# Signs all Mach-O binaries (Python natives, Electron frameworks, executables)
78+
# and the app bundle using identity "-" (ad-hoc / self-signed).
79+
# No Apple ID, no certificate, no notarization required.
80+
# To use a real Developer ID certificate, set the MACOS_SIGNING_IDENTITY secret.
81+
run: |
82+
chmod +x build/macos/codesign.sh
83+
./build/macos/codesign.sh "${{ steps.build-app.outputs.app_path }}"
84+
env:
85+
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY || '-' }}
86+
87+
- name: Create DMG from signed app
88+
# Packages the signed .app into a DMG using the electron-builder.yml layout.
89+
# --prepackaged skips the build phase; electron-builder only creates the DMG.
90+
# CSC_IDENTITY_AUTO_DISCOVERY=false prevents any re-signing attempt.
91+
env:
92+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
93+
CSC_IDENTITY_AUTO_DISCOVERY: "false"
94+
run: |
95+
PUBLISH_MODE="never"
96+
if [ "${{ github.event_name }}" = "release" ]; then
97+
PUBLISH_MODE="always"
98+
fi
99+
100+
pnpm exec electron-builder --mac dmg \
101+
--prepackaged "${{ steps.build-app.outputs.app_path }}" \
102+
--config.mac.notarize=false \
103+
--config.publish.owner=${{ github.repository_owner }} \
104+
--config.publish.repo=${{ github.event.repository.name }} \
105+
--publish "$PUBLISH_MODE"
106+
107+
- name: Upload DMG artifact
108+
uses: actions/upload-artifact@v4
109+
with:
110+
name: macos-dmg
111+
path: release/*.dmg
112+
if-no-files-found: error
113+
retention-days: 30

backend/handlers/job_executors.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import TYPE_CHECKING, Any
6+
from typing import TYPE_CHECKING, Any, Literal, cast
77

88
from api_types import (
99
GenerateImageRequest,
1010
GenerateVideoRequest,
11+
VideoCameraMotion,
1112
)
1213
from state.job_queue import QueueJob
1314

@@ -23,11 +24,14 @@ def _str(params: dict[str, Any], key: str, default: str = "") -> str:
2324
return str(v) if v is not None else default
2425

2526

26-
def _bool(params: dict[str, Any], key: str, default: bool = False) -> bool:
27-
v = params.get(key, default)
28-
if isinstance(v, bool):
29-
return v
30-
return str(v).lower() in ("1", "true", "yes", "on")
27+
def _camera_motion(params: dict[str, Any]) -> VideoCameraMotion:
28+
"""Return the cameraMotion param, defaulting to 'none'. Cast is safe: values come from validated queue jobs."""
29+
return cast(VideoCameraMotion, _str(params, "cameraMotion", "none"))
30+
31+
32+
def _aspect_ratio(params: dict[str, Any]) -> Literal["16:9", "9:16"]:
33+
"""Return the aspectRatio param, defaulting to '16:9'. Cast is safe: values come from validated queue jobs."""
34+
return cast(Literal["16:9", "9:16"], _str(params, "aspectRatio", "16:9"))
3135

3236

3337
def _int(params: dict[str, Any], key: str, default: int = 0) -> int:
@@ -65,8 +69,8 @@ def _execute_video(self, job: QueueJob) -> list[str]:
6569
duration=_str(p, "duration", "5"),
6670
fps=_str(p, "fps", "24"),
6771
audio=_str(p, "audio", "false"),
68-
cameraMotion=_str(p, "cameraMotion", "none"),
69-
aspectRatio=_str(p, "aspectRatio", "16:9"),
72+
cameraMotion=_camera_motion(p),
73+
aspectRatio=_aspect_ratio(p),
7074
model=job.model,
7175
negativePrompt=_str(p, "negativePrompt"),
7276
)
@@ -123,8 +127,8 @@ def execute(self, job: QueueJob) -> list[str]:
123127
duration=_str(p, "duration", "5"),
124128
fps=_str(p, "fps", "24"),
125129
audio=_str(p, "audio", "false"),
126-
cameraMotion=_str(p, "cameraMotion", "none"),
127-
aspectRatio=_str(p, "aspectRatio", "16:9"),
130+
cameraMotion=_camera_motion(p),
131+
aspectRatio=_aspect_ratio(p),
128132
model=job.model,
129133
negativePrompt=_str(p, "negativePrompt"),
130134
)

backend/tests/test_runtime_policy_decision.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from runtime_config.runtime_policy import decide_force_api_generations
66

77

8-
def test_darwin_always_forces_api() -> None:
9-
assert decide_force_api_generations(system="Darwin", cuda_available=True, vram_gb=24) is True
10-
assert decide_force_api_generations(system="Darwin", cuda_available=False, vram_gb=None) is True
8+
def test_darwin_allows_local_mps_generation() -> None:
9+
# Darwin supports MPS (Apple Silicon GPU), so local generation is allowed.
10+
assert decide_force_api_generations(system="Darwin", cuda_available=True, vram_gb=24) is False
11+
assert decide_force_api_generations(system="Darwin", cuda_available=False, vram_gb=None) is False
1112

1213

1314
def test_windows_without_cuda_forces_api() -> None:

build/macos/codesign.sh

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env bash
2+
# build/macos/codesign.sh
3+
# Ad-hoc code signing for LTX Desktop macOS app bundle.
4+
#
5+
# No Apple Developer account or Apple ID is required. By default this script
6+
# uses ad-hoc signing (identity "-"), which prevents the "app is damaged" Gatekeeper
7+
# warning without needing a paid Apple Developer certificate.
8+
#
9+
# For production releases with a real Developer ID, set the MACOS_SIGNING_IDENTITY
10+
# environment variable to your certificate name (e.g. "Developer ID Application: …").
11+
#
12+
# Adapted from: https://github.com/audiohacking/AceForge/blob/main/build/macos/codesign.sh
13+
# Original reference: https://github.com/dylanwh/lilguy/blob/main/macos/build.sh
14+
#
15+
# Usage:
16+
# bash build/macos/codesign.sh <path-to-app.bundle>
17+
18+
set -euo pipefail
19+
20+
APP_PATH="${1:?Usage: $0 <path-to-app.bundle>}"
21+
SIGNING_IDENTITY="${MACOS_SIGNING_IDENTITY:--}" # Default: ad-hoc ("-")
22+
ENTITLEMENTS_PATH="resources/entitlements.mac.plist"
23+
24+
echo "=================================================="
25+
echo " LTX Desktop macOS Code Signing"
26+
echo "=================================================="
27+
echo " App: $APP_PATH"
28+
echo " Identity: $SIGNING_IDENTITY"
29+
echo " Entitlements: $ENTITLEMENTS_PATH"
30+
echo ""
31+
32+
# ── Pre-flight checks ────────────────────────────────────────────────────────
33+
if [ ! -d "$APP_PATH" ]; then
34+
echo "Error: App bundle not found at $APP_PATH"
35+
exit 1
36+
fi
37+
38+
if [ ! -f "$ENTITLEMENTS_PATH" ]; then
39+
echo "Error: Entitlements file not found at $ENTITLEMENTS_PATH"
40+
echo " Run this script from the project root."
41+
exit 1
42+
fi
43+
44+
# ── Signing helper ────────────────────────────────────────────────────────────
45+
# Ad-hoc signing ("-") does not support --timestamp (requires a CA).
46+
sign_target() {
47+
local target="$1"
48+
echo " Signing: $(basename "$target")"
49+
50+
if [ "$SIGNING_IDENTITY" = "-" ]; then
51+
xcrun codesign \
52+
--sign "$SIGNING_IDENTITY" \
53+
--force \
54+
--options runtime \
55+
--entitlements "$ENTITLEMENTS_PATH" \
56+
--deep \
57+
"$target"
58+
else
59+
xcrun codesign \
60+
--sign "$SIGNING_IDENTITY" \
61+
--force \
62+
--options runtime \
63+
--entitlements "$ENTITLEMENTS_PATH" \
64+
--deep \
65+
--timestamp \
66+
"$target"
67+
fi
68+
69+
echo " ✓ Signed: $(basename "$target")"
70+
}
71+
72+
# ── Step 1: Sign bundled Python native libraries ──────────────────────────────
73+
# Sign leaf Mach-O binaries first so the bundle signature remains valid.
74+
# The Python embed directory contains .dylib/.so native extensions.
75+
echo "Step 1: Signing bundled Python native libraries..."
76+
PYTHON_DIR="$APP_PATH/Contents/Resources/python"
77+
if [ -d "$PYTHON_DIR" ]; then
78+
find "$PYTHON_DIR" -type f \( -name "*.dylib" -o -name "*.so" \) -print0 | \
79+
while IFS= read -r -d '' lib; do
80+
sign_target "$lib" || true # keep going on individual failures
81+
done
82+
echo " Python native libraries signed."
83+
else
84+
echo " No bundled Python directory found at Contents/Resources/python — skipping."
85+
fi
86+
87+
# ── Step 2: Sign Electron framework libraries ────────────────────────────────
88+
echo ""
89+
echo "Step 2: Signing Electron framework libraries..."
90+
if [ -d "$APP_PATH/Contents/Frameworks" ]; then
91+
find "$APP_PATH/Contents/Frameworks" -type f \( -name "*.dylib" -o -name "*.so" \) -print0 | \
92+
while IFS= read -r -d '' f; do
93+
sign_target "$f" || true
94+
done
95+
fi
96+
97+
# ── Step 3: Sign main executables ────────────────────────────────────────────
98+
echo ""
99+
echo "Step 3: Signing main executables..."
100+
for exe in "$APP_PATH/Contents/MacOS/"*; do
101+
if [ -f "$exe" ] && [ -x "$exe" ]; then
102+
sign_target "$exe"
103+
fi
104+
done
105+
106+
# ── Step 4: Sign the whole app bundle ────────────────────────────────────────
107+
echo ""
108+
echo "Step 4: Signing app bundle..."
109+
sign_target "$APP_PATH"
110+
111+
# ── Verification ─────────────────────────────────────────────────────────────
112+
echo ""
113+
echo "Verification:"
114+
xcrun codesign --verify --deep --strict --verbose=2 "$APP_PATH" 2>&1 || true
115+
116+
echo ""
117+
echo "Signature info:"
118+
xcrun codesign -dv "$APP_PATH" 2>&1 || true
119+
120+
echo ""
121+
echo "=================================================="
122+
echo " ✓ Code signing complete!"
123+
echo "=================================================="

0 commit comments

Comments
 (0)