win-* branches: publish beta tag to Docker Hub and GHCR #993
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: macOS Installer Build (ARM arm64) | |
| # Add permissions needed for creating releases | |
| permissions: | |
| contents: write | |
| on: | |
| push: | |
| branches: | |
| - '*' # This will trigger on any branch push | |
| - 'B-*' # Explicit beta branch pattern | |
| tags: | |
| - "*" # This will trigger on any tag push | |
| pull_request: | |
| branches: | |
| - main | |
| jobs: | |
| build-macos-arm-installer: | |
| name: Build macOS ARM Installer | |
| runs-on: macos-14 # Specifically requesting macOS 14 runner which has ARM support | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v3 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v4 | |
| with: | |
| python-version: '3.12' | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt | |
| pip install py2app==0.28.6 "pyinstaller>=6.1.0,<7" rumps pyobjc-framework-ServiceManagement | |
| - name: Extract metadata | |
| id: meta | |
| run: | | |
| if [[ "${{ github.ref }}" == refs/tags/* ]]; then | |
| echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT | |
| echo "IS_TAG=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "VERSION=$(cat version.txt)" >> $GITHUB_OUTPUT | |
| echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT | |
| echo "IS_TAG=false" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Create app_launcher.py | |
| run: | | |
| cat > app_launcher.py << 'EOF' | |
| #!/usr/bin/env python3 | |
| import os | |
| import sys | |
| import logging | |
| import shutil | |
| import json | |
| import traceback | |
| import time | |
| from datetime import datetime | |
| # Setup base paths before anything else - Use a location that doesn't require admin privileges | |
| bundle_dir = os.path.dirname(os.path.abspath(sys.argv[0])) | |
| if hasattr(sys, "_MEIPASS"): | |
| bundle_dir = sys._MEIPASS | |
| app_name = "Huntarr" | |
| home = os.path.expanduser("~") | |
| if os.path.exists(os.path.join(bundle_dir, "Contents", "Resources")): | |
| resources_dir = os.path.join(bundle_dir, "Contents", "Resources") | |
| config_dir = os.path.join(resources_dir, "config") | |
| else: | |
| config_dir = os.path.join(home, "Documents", app_name, "config") | |
| log_dir = os.path.join(config_dir, "logs") | |
| # Create config and logs dirs first so we can write error log there (not on Desktop) | |
| try: | |
| os.makedirs(config_dir, exist_ok=True) | |
| os.makedirs(log_dir, exist_ok=True) | |
| except Exception: | |
| pass | |
| error_log = os.path.join(log_dir, "huntarr_error.log") | |
| try: | |
| with open(error_log, "a") as f: | |
| f.write(f"\n[{datetime.now().isoformat()}] Starting app_launcher.py\n") | |
| f.write(f"Config: {config_dir}\n") | |
| except Exception: | |
| pass | |
| # Create remaining essential directories | |
| for dir_path in [ | |
| os.path.join(config_dir, "settings"), | |
| os.path.join(config_dir, "stateful"), | |
| os.path.join(config_dir, "user"), | |
| os.path.join(config_dir, "scheduler"), | |
| ]: | |
| try: | |
| os.makedirs(dir_path, exist_ok=True) | |
| with open(error_log, "a") as f: | |
| f.write(f"Created directory: {dir_path}\n") | |
| except Exception as e: | |
| try: | |
| with open(error_log, "a") as f: | |
| f.write(f"Error creating directory {dir_path}: {str(e)}\n") | |
| except Exception: | |
| pass | |
| # Configure logging | |
| try: | |
| logging.basicConfig( | |
| level=logging.DEBUG, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| handlers=[ | |
| logging.FileHandler(os.path.join(log_dir, "huntarr.log")), | |
| logging.StreamHandler() | |
| ] | |
| ) | |
| logger = logging.getLogger("Huntarr") | |
| logger.setLevel(logging.DEBUG) | |
| for handler in logger.handlers: | |
| handler.setLevel(logging.DEBUG) | |
| logging.getLogger('werkzeug').setLevel(logging.WARNING) | |
| with open(error_log, "a") as f: | |
| f.write("Logging system initialized\n") | |
| except Exception as e: | |
| try: | |
| with open(error_log, "a") as f: | |
| f.write(f"Error setting up logging: {str(e)}\n") | |
| except Exception: | |
| pass | |
| raise | |
| # Create necessary default config files if they don't exist | |
| try: | |
| # Create default scheduler file | |
| scheduler_dir = os.path.join(config_dir, "scheduler") | |
| scheduler_file = os.path.join(scheduler_dir, "schedule.json") | |
| if not os.path.exists(scheduler_file): | |
| default_schedule = { | |
| "global": [], | |
| "sonarr": [], | |
| "radarr": [], | |
| "lidarr": [], | |
| "readarr": [] | |
| } | |
| with open(scheduler_file, "w") as f: | |
| json.dump(default_schedule, f, indent=2) | |
| logger.debug(f"Created default scheduler file at {scheduler_file}") | |
| # Create default general.json with appropriate timeouts | |
| general_file = os.path.join(config_dir, "settings", "general.json") | |
| if not os.path.exists(general_file): | |
| default_general = { | |
| "api_timeout": 120, | |
| "command_wait_delay": 1, | |
| "command_wait_attempts": 600, | |
| "log_level": "DEBUG" | |
| } | |
| with open(general_file, "w") as f: | |
| json.dump(default_general, f, indent=2) | |
| logger.debug(f"Created default general settings at {general_file}") | |
| except Exception as e: | |
| logger.exception(f"Error creating default config files: {str(e)}") | |
| try: | |
| # Set environment variables to mimic Docker container | |
| os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
| os.environ["FLASK_ENV"] = "production" | |
| # Create a file to record the config location for other processes (in Huntarr config, not Desktop) | |
| config_location_file = os.path.join(config_dir, "config_location.txt") | |
| with open(config_location_file, "w") as f: | |
| f.write(config_dir) | |
| # Make sure we have write permissions to the config directory | |
| test_file_path = os.path.join(config_dir, "write_test.txt") | |
| try: | |
| with open(test_file_path, "w") as f: | |
| f.write("Permission test") | |
| os.remove(test_file_path) | |
| logger.debug(f"Confirmed write permissions to {config_dir}") | |
| except Exception as perm_error: | |
| logger.error(f"No write permission to {config_dir}: {str(perm_error)}") | |
| # Try to use a temporary directory if we can't write to our preferred locations | |
| import tempfile | |
| temp_config_dir = os.path.join(tempfile.gettempdir(), f"huntarr_{int(time.time())}") | |
| os.makedirs(temp_config_dir, exist_ok=True) | |
| config_dir = temp_config_dir | |
| log_dir = os.path.join(config_dir, "logs") | |
| os.makedirs(log_dir, exist_ok=True) | |
| error_log = os.path.join(log_dir, "huntarr_error.log") | |
| os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
| logger.warning(f"Switched to temporary directory: {config_dir}") | |
| with open(config_location_file, "w") as f: | |
| f.write(config_dir) | |
| # Log environment information for debugging | |
| logger.debug(f"Python version: {sys.version}") | |
| logger.debug(f"Python executable: {sys.executable}") | |
| logger.debug(f"Current working directory: {os.getcwd()}") | |
| logger.debug(f"HUNTARR_CONFIG_DIR = {os.environ.get('HUNTARR_CONFIG_DIR')}") | |
| # List all environment variables for debugging | |
| logger.debug("Environment variables:") | |
| for key, value in sorted(os.environ.items()): | |
| logger.debug(f" {key} = {value}") | |
| # Check if running in PyInstaller bundle | |
| if hasattr(sys, "_MEIPASS"): | |
| logger.debug(f"Running in PyInstaller bundle: {sys._MEIPASS}") | |
| bundle_dir = sys._MEIPASS | |
| os.chdir(bundle_dir) | |
| logger.debug(f"Changed working directory to: {os.getcwd()}") | |
| # List bundle contents for debugging | |
| logger.debug("Bundle contents:") | |
| for root, dirs, files in os.walk(bundle_dir, topdown=True, followlinks=False): | |
| rel_path = os.path.relpath(root, bundle_dir) | |
| if rel_path == ".": | |
| rel_path = "" | |
| logger.debug(f" Directory: {rel_path}") | |
| for file in files: | |
| logger.debug(f" {os.path.join(rel_path, file)}") | |
| # Import main module with debug info | |
| logger.debug("Attempting to import main module...") | |
| try: | |
| sys.path.insert(0, os.getcwd()) | |
| import main | |
| logger.debug("Main module imported successfully") | |
| except ImportError as e: | |
| logger.error(f"Failed to import main: {str(e)}") | |
| raise | |
| # Open default browser after a short delay so user sees the app is running | |
| def open_browser(): | |
| time.sleep(3) | |
| try: | |
| import webbrowser | |
| webbrowser.open('http://127.0.0.1:9705') | |
| except Exception as e: | |
| logger.debug(f"Could not open browser: {e}") | |
| import threading | |
| threading.Thread(target=open_browser, daemon=True).start() | |
| # On macOS run server in a thread and show menu bar icon (Open Huntarr, Quit, Open at Login) | |
| if sys.platform == 'darwin': | |
| logger.debug("Starting main application in background thread (macOS menu bar)") | |
| server_thread = threading.Thread(target=main.main, daemon=False) | |
| server_thread.start() | |
| try: | |
| from src.primary.macos_menubar import run_menubar | |
| run_menubar() | |
| finally: | |
| server_thread.join(timeout=10) | |
| else: | |
| logger.debug("Starting main application") | |
| main.main() | |
| except Exception as e: | |
| logger.exception(f"Fatal error in app_launcher: {str(e)}") | |
| try: | |
| with open(error_log, "a") as f: | |
| f.write(f"\n[{datetime.now().isoformat()}] FATAL ERROR: {str(e)}\n") | |
| f.write("\nTraceback:\n") | |
| traceback.print_exc(file=f) | |
| f.write("\n\nSystem Information:\n") | |
| f.write(f"Python version: {sys.version}\n") | |
| f.write(f"Python path: {sys.path}\n") | |
| f.write(f"Working directory: {os.getcwd()}\n") | |
| try: | |
| f.write("\nDirectory contents:\n") | |
| for item in os.listdir(os.getcwd()): | |
| f.write(f" {item}\n") | |
| except Exception as dir_err: | |
| f.write(f"Error listing directory: {str(dir_err)}\n") | |
| except Exception: | |
| pass | |
| EOF | |
| - name: Create runtime hook | |
| run: | | |
| mkdir -p hooks | |
| cat > hooks/runtime_hook.py << 'EOF' | |
| import os | |
| import sys | |
| # Set up app environment variables | |
| os.environ["FLASK_ENV"] = "production" | |
| # Setup config directory path in user's home | |
| home = os.path.expanduser("~") | |
| config_dir = os.path.join(home, "Library", "Application Support", "Huntarr", "config") | |
| os.environ["HUNTARR_CONFIG_DIR"] = config_dir | |
| # Add bundle resources directory to path | |
| if ".app" in sys.executable: | |
| bundle_dir = os.path.abspath(os.path.dirname(sys.executable)) | |
| resources_dir = os.path.abspath(os.path.join(bundle_dir, "..", "Resources")) | |
| if resources_dir not in sys.path: | |
| sys.path.insert(0, resources_dir) | |
| EOF | |
| - name: Create icon | |
| run: | | |
| mkdir -p icon.iconset | |
| # Use existing PNGs from repo (sips may not handle .ico on all macOS) | |
| for size in 16 32 64 128 256 512; do | |
| src="frontend/static/logo/${size}.png" | |
| if [ -f "$src" ]; then | |
| cp "$src" "icon.iconset/icon_${size}x${size}.png" | |
| else | |
| sips -s format png frontend/static/logo/huntarr.ico --out "icon.iconset/icon_${size}x${size}.png" --resampleWidth $size 2>/dev/null || true | |
| fi | |
| done | |
| # Create @2x variants from next size up | |
| [ -f icon.iconset/icon_32x32.png ] && cp icon.iconset/icon_32x32.png icon.iconset/icon_16x16@2x.png | |
| [ -f icon.iconset/icon_64x64.png ] && cp icon.iconset/icon_64x64.png icon.iconset/icon_32x32@2x.png | |
| [ -f icon.iconset/icon_128x128.png ] && cp icon.iconset/icon_128x128.png icon.iconset/icon_64x64@2x.png | |
| [ -f icon.iconset/icon_256x256.png ] && cp icon.iconset/icon_256x256.png icon.iconset/icon_128x128@2x.png | |
| [ -f icon.iconset/icon_512x512.png ] && cp icon.iconset/icon_512x512.png icon.iconset/icon_256x256@2x.png | |
| iconutil -c icns icon.iconset -o frontend/static/logo/huntarr.icns | |
| if [ ! -f frontend/static/logo/huntarr.icns ]; then | |
| cp /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns frontend/static/logo/huntarr.icns | |
| fi | |
| - name: Create PyInstaller spec | |
| run: | | |
| cat > Huntarr.spec << 'SPECEOF' | |
| # -*- mode: python ; coding: utf-8 -*- | |
| import os | |
| import sys | |
| from PyInstaller.building.api import PYZ, EXE, COLLECT | |
| from PyInstaller.building.build_main import Analysis | |
| from PyInstaller.building.datastruct import Tree | |
| # BUNDLE is macOS-only (wraps COLLECT into .app); location varies by PyInstaller version | |
| if sys.platform != 'darwin': | |
| raise SystemExit('This spec is for macOS only.') | |
| try: | |
| from PyInstaller.building.api import BUNDLE | |
| except ImportError: | |
| try: | |
| from PyInstaller.building.osx import BUNDLE | |
| except ImportError: | |
| from PyInstaller.utils.osx import BUNDLE | |
| block_cipher = None | |
| # Add apprise data files to fix attachment directory error | |
| datas = [ | |
| ('frontend', 'frontend'), | |
| ('version.txt', '.'), | |
| ('README.md', '.'), | |
| ('LICENSE', '.'), | |
| ('src', 'src'), | |
| ] | |
| # Add apprise data files | |
| try: | |
| import apprise | |
| apprise_path = os.path.dirname(apprise.__file__) | |
| apprise_attachment_path = os.path.join(apprise_path, 'attachment') | |
| apprise_plugins_path = os.path.join(apprise_path, 'plugins') | |
| apprise_config_path = os.path.join(apprise_path, 'config') | |
| if os.path.exists(apprise_attachment_path): | |
| datas.append((apprise_attachment_path, 'apprise/attachment')) | |
| if os.path.exists(apprise_plugins_path): | |
| datas.append((apprise_plugins_path, 'apprise/plugins')) | |
| if os.path.exists(apprise_config_path): | |
| datas.append((apprise_config_path, 'apprise/config')) | |
| except ImportError: | |
| print("Warning: apprise not found, skipping apprise data files") | |
| a = Analysis( | |
| ['app_launcher.py'], | |
| pathex=['.'], | |
| binaries=[], | |
| datas=datas, | |
| hiddenimports=[ | |
| 'flask', | |
| 'flask.json', | |
| 'requests', | |
| 'waitress', | |
| 'bcrypt', | |
| 'qrcode', | |
| 'PIL', | |
| 'pyotp', | |
| 'qrcode.image.pil', | |
| 'routes', | |
| 'main', | |
| # macOS menu bar (status bar) icon | |
| 'rumps', | |
| 'Foundation', | |
| 'AppKit', | |
| 'ServiceManagement', | |
| 'objc', | |
| # Apprise notification support | |
| 'apprise', | |
| 'apprise.common', | |
| 'apprise.conversion', | |
| 'apprise.decorators', | |
| 'apprise.locale', | |
| 'apprise.logger', | |
| 'apprise.manager', | |
| 'apprise.utils', | |
| 'apprise.URLBase', | |
| 'apprise.AppriseAsset', | |
| 'apprise.AppriseAttachment', | |
| 'apprise.AppriseConfig', | |
| 'apprise.cli', | |
| 'apprise.config', | |
| 'apprise.attachment', | |
| 'apprise.plugins', | |
| 'markdown', | |
| 'yaml', | |
| 'cryptography', | |
| ], | |
| hookspath=['hooks'], | |
| hooksconfig={}, | |
| runtime_hooks=['hooks/runtime_hook.py'], | |
| excludes=[], | |
| win_no_prefer_redirects=False, | |
| win_private_assemblies=False, | |
| cipher=block_cipher, | |
| noarchive=False, | |
| ) | |
| pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) | |
| exe = EXE( | |
| pyz, | |
| a.scripts, | |
| [], | |
| exclude_binaries=True, | |
| name='Huntarr', | |
| debug=False, | |
| bootloader_ignore_signals=False, | |
| strip=False, | |
| upx=True, | |
| console=True, | |
| disable_windowed_traceback=False, | |
| argv_emulation=True, | |
| target_arch='arm64', | |
| codesign_identity=None, | |
| entitlements_file=None, | |
| icon='frontend/static/logo/huntarr.icns', | |
| ) | |
| coll = COLLECT( | |
| exe, | |
| a.binaries, | |
| a.zipfiles, | |
| a.datas, | |
| strip=False, | |
| upx=True, | |
| upx_exclude=[], | |
| name='Huntarr', | |
| ) | |
| app = BUNDLE( | |
| coll, | |
| name='Huntarr.app', | |
| icon='frontend/static/logo/huntarr.icns', | |
| bundle_identifier='io.huntarr.app', | |
| info_plist={ | |
| 'CFBundleShortVersionString': '${{ steps.meta.outputs.VERSION }}', | |
| 'CFBundleVersion': '${{ steps.meta.outputs.VERSION }}', | |
| 'NSHighResolutionCapable': True, | |
| 'NSRequiresAquaSystemAppearance': False, | |
| 'LSUIElement': False, | |
| 'LSEnvironment': { | |
| 'HUNTARR_CONFIG_DIR': '~/Library/Application Support/Huntarr/config', | |
| 'PYTHONPATH': '@executable_path/../Resources', | |
| }, | |
| 'CFBundleDocumentTypes': [], | |
| 'NSPrincipalClass': 'NSApplication', | |
| }, | |
| ) | |
| SPECEOF | |
| - name: Build macOS app bundle | |
| run: python -m PyInstaller Huntarr.spec --clean | |
| - name: Create PKG installer | |
| run: | | |
| # Create a simple postinstall script | |
| mkdir -p scripts | |
| cat > scripts/postinstall << 'EOF' | |
| #!/bin/bash | |
| # Create config directory in user's Application Support | |
| mkdir -p "$HOME/Library/Application Support/Huntarr/config" | |
| # Logs are now stored in database only | |
| # Set permissions | |
| chmod -R 755 "$HOME/Library/Application Support/Huntarr" | |
| exit 0 | |
| EOF | |
| chmod +x scripts/postinstall | |
| # Create PKG installer | |
| version="${{ steps.meta.outputs.VERSION }}" | |
| branch="${{ steps.meta.outputs.BRANCH }}" | |
| if [[ "${{ steps.meta.outputs.IS_TAG }}" == "true" ]]; then | |
| pkg_name="Huntarr-${version}-mac-arm64.pkg" | |
| else | |
| if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then | |
| pkg_name="Huntarr-${version}-mac-main-arm64.pkg" | |
| elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then | |
| pkg_name="Huntarr-${version}-mac-dev-arm64.pkg" | |
| else | |
| # Sanitize branch name by replacing slashes with hyphens | |
| branch_safe=$(echo "${branch}" | tr '/' '-') | |
| pkg_name="Huntarr-${version}-mac-${branch_safe}-arm64.pkg" | |
| fi | |
| fi | |
| pkgbuild --root dist/ \ | |
| --scripts scripts/ \ | |
| --identifier io.huntarr.app \ | |
| --version ${version} \ | |
| --install-location /Applications \ | |
| ${pkg_name} | |
| - name: Create macOS run instructions | |
| run: | | |
| cat > Huntarr-macOS-README.txt << 'EOF' | |
| Huntarr macOS (Apple Silicon) | |
| INSTALLATION | |
| ------------- | |
| When you run the installer, macOS may show "Huntarr cannot be opened" or ask you to move to Trash or hit Done. Hit DONE (do not move to Trash). | |
| Then: | |
| 1. Go to System Settings (or System Preferences) → Privacy and Security. | |
| 2. Under Security, find the message about Huntarr being blocked. Click "Allow" or "Open Anyway" for Huntarr. | |
| 3. When the install prompt appears again, click "Open Anyway" again. Huntarr will then install. | |
| After installing, open Huntarr from Applications. The app will start the server and open your browser at http://127.0.0.1:9705 after a few seconds. | |
| If nothing appears: | |
| - Check Huntarr logs: ~/Documents/Huntarr/config/logs/huntarr_error.log (or ~/Library/Application Support/Huntarr/config/logs/) | |
| - Open http://127.0.0.1:9705 in your browser manually; the server may already be running. | |
| - Config and logs: ~/Documents/Huntarr/config or ~/Library/Application Support/Huntarr/config | |
| EOF | |
| - name: Upload installer as artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: huntarr-macos-arm64-installer | |
| path: | | |
| *.pkg | |
| Huntarr-macOS-README.txt | |
| retention-days: 30 | |
| - name: Upload to release | |
| if: steps.meta.outputs.IS_TAG == 'true' | |
| uses: softprops/action-gh-release@v1 | |
| with: | |
| files: '*.pkg' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |