Skip to content

win-* branches: publish beta tag to Docker Hub and GHCR #993

win-* branches: publish beta tag to Docker Hub and GHCR

win-* branches: publish beta tag to Docker Hub and GHCR #993

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 }}