#!/usr/bin/python3 -OO # Copyright 2008-2026 by The SABnzbd-Team (sabnzbd.org) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import glob import importlib.util import re import sys import os import tempfile import time import shutil import subprocess import tarfile import urllib.request import urllib.error import configobj import markdown import packaging.version import xml.etree.ElementTree as ET from common import ( RELEASE_VERSION, RELEASE_VERSION_TUPLE, VERSION_FILE, RELEASE_README, RELEASE_NAME, RELEASE_MACOS, RELEASE_WIN_BIN, RELEASE_WIN_INSTALLER, ON_GITHUB_ACTIONS, RELEASE_THIS, RELEASE_SRC, EXTRA_FILES, EXTRA_FOLDERS, APPDATA_FILE, RELEASE_VERSION_BASE, pe_has_authenticode_signature, ) # Support functions def safe_remove(path): """Remove file without errors if the file doesn't exist Can also handle folders """ if os.path.exists(path): if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) def delete_files_glob(glob_pattern: str, allow_no_matches: bool = False): """Delete one file or set of files from wild-card spec. We expect to match at least 1 file, to force expected behavior""" if files_to_remove := glob.glob(glob_pattern): for path in files_to_remove: if os.path.exists(path): os.remove(path) else: if not allow_no_matches: raise FileNotFoundError(f"No files found that match '{glob_pattern}'") def run_external_command(command: list[str], print_output: bool = True, **kwargs): """Wrapper to ease the use of calling external programs""" process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs) output, _ = process.communicate() ret = process.wait() if (output and print_output) or ret != 0: print(output) if ret != 0: raise RuntimeError("Command returned non-zero exit code %s!" % ret) return output def run_git_command(parms): """Run git command, raise error if it failed""" return run_external_command(["git", *parms]) def patch_version_file(release_name): """Patch in the Git commit hash, but only when this is an unmodified checkout """ git_output = run_git_command(["log", "-1"]) for line in git_output.split("\n"): if "commit " in line: commit = line.split(" ")[1].strip() break else: raise TypeError("Commit hash not found") with open(VERSION_FILE, "r") as ver: version_file = ver.read() version_file = re.sub(r'__baseline__\s*=\s*"[^"]*"', '__baseline__ = "%s"' % commit, version_file) version_file = re.sub(r'__version__\s*=\s*"[^"]*"', '__version__ = "%s"' % release_name, version_file) with open(VERSION_FILE, "w") as ver: ver.write(version_file) def verify_appdata(): """Verify that appdata file is updated""" if not isinstance( ET.parse(APPDATA_FILE).find(f"./releases/release[@version='{RELEASE_VERSION_BASE}']"), ET.Element, ): release_missing = f"Could not find {RELEASE_VERSION_BASE} in {APPDATA_FILE}" if RELEASE_THIS: raise RuntimeError(release_missing) elif ON_GITHUB_ACTIONS: print(f"::warning file={APPDATA_FILE},title=Missing release::{release_missing}") else: print(release_missing) def build_readme_html(output_path: str): """Convert the release notes Markdown to a styled HTML file.""" with open(RELEASE_README, "r", encoding="utf-8") as f: readme_html = markdown.markdown(f.read(), extensions=["nl2br"]) # Linkify bare URLs not already inside an HTML attribute readme_html = re.sub( r'(?"\']+', lambda m: f'{m.group()}', readme_html, ) with open(output_path, "w", encoding="utf-8") as f: f.write( f'' f"SABnzbd {RELEASE_VERSION} Release Notes" f'' f"" f"{readme_html}" ) def test_macos_min_version(binary_path: str): # Skip check if nothing was set if macos_min_version := os.environ.get("MACOSX_DEPLOYMENT_TARGET"): # Skip any arm64 specific files if "arm64" in binary_path: print(f"Skipping arm64 binary {binary_path}") return # Check minimum macOS version is at least mac OS10.13 # We only check the x86_64 since for arm64 it's always macOS 11+ print(f"Checking if binary supports macOS {macos_min_version} and above: {binary_path}") otool_output = run_external_command( [ "otool", "-arch", "x86_64", "-l", binary_path, ], print_output=False, ) # Parse the output for LC_BUILD_VERSION minos # The output is very large, so that's why we enumerate over it req_version = packaging.version.parse(macos_min_version) bin_version = None lines = otool_output.split("\n") for line_nr, line in enumerate(lines): if "LC_VERSION_MIN_MACOSX" in line: # Display the version in the next lines bin_version = packaging.version.parse(lines[line_nr + 2].split()[1]) elif "minos" in line: bin_version = packaging.version.parse(line.split()[1]) if bin_version and bin_version > req_version: raise ValueError(f"{binary_path} requires {bin_version}, we want {req_version}") else: # We got the information we need break else: print(lines) raise RuntimeError(f"Could not determine minimum macOS version for {binary_path}") else: print("Skipping macOS version check, MACOSX_DEPLOYMENT_TARGET not set") def test_sab_binary(binary_path: str): """Wrapper to have a simple start-up test for the binary""" with tempfile.TemporaryDirectory() as config_dir: sabnzbd_process = subprocess.Popen( [binary_path, "--browser", "0", "--logging", "2", "--config", config_dir], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) # Wait for SAB to respond base_url = "http://127.0.0.1:8080/" for _ in range(30): try: urllib.request.urlopen(base_url, timeout=1).read() break except Exception: time.sleep(1) else: # Print console output and give some time to print print(sabnzbd_process.stdout.read()) time.sleep(1) raise urllib.error.URLError("Could not connect to SABnzbd") # Open a number of API calls and pages, to see if we are really up pages_to_test = [ "", "wizard", "config", "config/server", "config/categories", "config/scheduling", "config/rss", "config/general", "config/folders", "config/switches", "config/sorting", "config/notify", "config/special", "api?mode=version", ] for url in pages_to_test: print("Testing: %s%s" % (base_url, url)) if b"500 Internal Server Error" in urllib.request.urlopen(base_url + url, timeout=1).read(): raise RuntimeError("Crash in %s" % url) # Parse API-key so we can do a graceful shutdown sab_config = configobj.ConfigObj(os.path.join(config_dir, "sabnzbd.ini")) urllib.request.urlopen(base_url + "api?mode=shutdown&apikey=" + sab_config["misc"]["api_key"], timeout=10) sabnzbd_process.wait() # Print logs for verification with open(os.path.join(config_dir, "logs", "sabnzbd.log"), "r") as log_file: # Wait after printing so the output is nicely displayed in case of problems print(log_text := log_file.read()) time.sleep(5) # Make sure no extra errors/warnings were reported if "ERROR" in log_text or "WARNING" in log_text: raise RuntimeError("Warning or error reported during execution") if __name__ == "__main__": # Was any option supplied? if len(sys.argv) < 2: raise TypeError("Please specify what to do") # Make sure we are in the src folder if not os.path.exists("builder"): raise FileNotFoundError("Run from the main SABnzbd source folder: python builder/package.py") # Check if we have the needed certificates if importlib.util.find_spec("certifi") is None: raise FileNotFoundError("Need certifi module") # Check if we have correct appdata file verify_appdata() # Patch release file patch_version_file(RELEASE_VERSION) # Convert release notes to HTML for the installer finish page safe_remove("README.html") build_readme_html("README.html") # Compile translations if not os.path.exists("locale"): run_external_command([sys.executable, "tools/make_mo.py"]) # Check again if translations exist, fail otherwise if not os.path.exists("locale"): raise FileNotFoundError("Failed to compile language files") if "binary" in sys.argv: # Must be run on Windows if sys.platform != "win32": raise RuntimeError("Binary should be created on Windows") # Make sure we remove any existing build-folders safe_remove("build") safe_remove("dist") # Remove any leftovers safe_remove(RELEASE_NAME) safe_remove(RELEASE_WIN_BIN) # Run PyInstaller and check output shutil.copyfile("builder/SABnzbd.spec", "SABnzbd.spec") run_external_command([sys.executable, "-O", "-m", "PyInstaller", "SABnzbd.spec"]) shutil.copytree("dist/SABnzbd-console", "dist/SABnzbd", dirs_exist_ok=True) safe_remove("dist/SABnzbd-console") # Remove unwanted DLL's shutil.rmtree("dist/SABnzbd/Pythonwin") delete_files_glob("dist/SABnzbd/api-ms-win*.dll", allow_no_matches=True) delete_files_glob("dist/SABnzbd/ucrtbase.dll", allow_no_matches=True) # Test the release test_sab_binary("dist/SABnzbd/SABnzbd.exe") # Create the archive run_external_command(["win/7zip/7za.exe", "a", RELEASE_WIN_BIN, "SABnzbd"], cwd="dist") shutil.move(f"dist/{RELEASE_WIN_BIN}", RELEASE_WIN_BIN) if "installer" in sys.argv: # Check if we have the dist folder if not os.path.exists("dist/SABnzbd/SABnzbd.exe"): raise FileNotFoundError("SABnzbd executable not found, run binary creation first") # Check if we have a signed version if os.path.exists(f"signed/{RELEASE_WIN_BIN}"): print("Using signed version of SABnzbd binaries") safe_remove("dist/SABnzbd") run_external_command(["win/7zip/7za.exe", "x", "-odist", f"signed/{RELEASE_WIN_BIN}"]) # Make sure it exists if not os.path.exists("dist/SABnzbd/SABnzbd.exe"): raise FileNotFoundError("SABnzbd executable not found, signed zip extraction failed") # Make sure the executables are actually signed for exe_to_check in ("dist/SABnzbd/SABnzbd.exe", "dist/SABnzbd/SABnzbd-console.exe"): if not pe_has_authenticode_signature(exe_to_check): raise RuntimeError("%s is not signed!" % exe_to_check) print("%s has an Authenticode signature" % exe_to_check) elif RELEASE_THIS: raise FileNotFoundError("Signed SABnzbd executable not found, required for release!") else: print("Using unsigned version of SABnzbd binaries") # Run NSIS to build installer run_external_command( [ "makensis.exe", "/V3", "/NOCD", # No not change working directory "/DSAB_VERSION=%s" % RELEASE_VERSION, "/DSAB_VERSIONKEY=%s" % ".".join(map(str, RELEASE_VERSION_TUPLE)), "/DSAB_FILE=%s" % RELEASE_WIN_INSTALLER, "builder/win/NSIS_Installer.nsi", ] ) if "app" in sys.argv: # Must be run on macOS if sys.platform != "darwin": raise RuntimeError("App should be created on macOS") # Who will sign and notarize this? authority = os.environ.get("SIGNING_AUTH") notarization_user = os.environ.get("NOTARIZATION_USER") notarization_pass = os.environ.get("NOTARIZATION_PASS") # We need to sign all the included binaries before packaging them # Otherwise the signature of the main application becomes invalid if authority: files_to_sign = [ "macos/par2/par2", "macos/unrar/unrar", "macos/unrar/arm64/unrar", "macos/7zip/7zz", ] for file_to_sign in files_to_sign: # Make sure it supports the macOS versions we want first test_macos_min_version(file_to_sign) # Then sign in print("Signing %s with hardened runtime" % file_to_sign) run_external_command( [ "codesign", "--deep", "--force", "--timestamp", "--options", "runtime", "--entitlements", "builder/macos/entitlements.plist", "-s", authority, file_to_sign, ], print_output=False, ) print("Signed %s!" % file_to_sign) # Run PyInstaller and check output shutil.copyfile("builder/SABnzbd.spec", "SABnzbd.spec") run_external_command([sys.executable, "-O", "-m", "PyInstaller", "SABnzbd.spec"]) # Make sure we created a fully universal2 release when releasing or during CI # PyInstaller also includes a builtin check for this, but we double-check every executable if RELEASE_THIS or ON_GITHUB_ACTIONS: for bin_to_check in glob.glob("dist/SABnzbd.app/**/*.so", recursive=True): print("Checking if binary is universal2: %s" % bin_to_check) file_output = run_external_command(["file", bin_to_check], print_output=False) # Make sure we have both arm64 and x86 if not ("x86_64" in file_output and "arm64" in file_output): raise RuntimeError("Non-universal2 binary found!") # Make sure it supports the macOS versions we want test_macos_min_version(bin_to_check) # Only continue if we can sign if authority: # We use PyInstaller to sign the main SABnzbd executable and the SABnzbd.app files_already_signed = [ "dist/SABnzbd.app/Contents/MacOS/SABnzbd", "dist/SABnzbd.app", ] for file_to_check in files_already_signed: print("Checking signature of %s" % file_to_check) sign_result = run_external_command( [ "codesign", "-dv", "-r-", file_to_check, ], print_output=False, ) + run_external_command( [ "codesign", "--verify", "--deep", file_to_check, ], print_output=False, ) if authority not in sign_result or "adhoc" in sign_result or "invalid" in sign_result: raise RuntimeError("Signature of %s seems invalid!" % file_to_check) # Always notarize, as newer macOS versions don't allow any code without it if notarization_user and notarization_pass: # Prepare zip to upload to notarization service print("Creating zip to send to Apple notarization service") # We need to use ditto, otherwise the signature gets lost! notarization_zip = RELEASE_NAME + ".zip" run_external_command( ["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", "dist/SABnzbd.app", notarization_zip] ) # Upload to Apple print("Sending zip to Apple notarization service") upload_result = run_external_command( [ "xcrun", "notarytool", "submit", notarization_zip, "--apple-id", notarization_user, "--team-id", authority, "--password", notarization_pass, "--wait", ], ) # Check if success if "status: accepted" not in upload_result.lower(): raise RuntimeError("Failed to notarize..") # Staple the notarization! print("Approved! Stapling the result to the app") run_external_command(["xcrun", "stapler", "staple", "dist/SABnzbd.app"]) else: print("Notarization skipped, NOTARIZATION_USER or NOTARIZATION_PASS missing.") else: print("Signing skipped, missing SIGNING_AUTH.") # Test the release, as the very last step to not mess with any release code test_sab_binary("dist/SABnzbd.app/Contents/MacOS/SABnzbd") if "dmg" in sys.argv: # Must be run on macOS if sys.platform != "darwin": raise RuntimeError("DMG should be created on macOS") # Only available for macOS import dmgbuild apppath = "dist/SABnzbd.app" readmepath = os.path.join(apppath, "Contents/Resources/README.html") print("Building DMG") dmgbuild.build_dmg( filename=RELEASE_MACOS, volume_name=RELEASE_NAME, settings={ "files": [apppath, readmepath], "symlinks": {"Applications": "/Applications"}, "icon": "builder/macos/image/sabnzbdplus.icns", "background": "builder/macos/image/sabnzbd_new_bg.png", "icon_locations": { os.path.basename(readmepath): (70, 160), os.path.basename(apppath): (295, 220), "Applications": (510, 220), }, "window_rect": ((100, 100), (660, 360)), "sidebar_width": 0, "grid_spacing": 50, "icon_size": 64, }, callback=lambda info: print("dmgbuild", info), ) if authority := os.environ.get("SIGNING_AUTH"): print("Signing DMG") run_external_command(["codesign", "-f", "-i", "org.sabnzbd.sabnzbd", "-s", authority, RELEASE_MACOS]) print("Signed!") else: print("Signing skipped, missing SIGNING_AUTH.") if "source" in sys.argv: # Prepare Source distribution package. # We assume the sources are freshly cloned from the repo # Make sure all source files are Unix format src_folder = "srcdist" safe_remove(src_folder) os.mkdir(src_folder) # Remove any leftovers safe_remove(RELEASE_SRC) # Add extra files and folders need for source dist EXTRA_FOLDERS.extend(["sabnzbd/", "po/", "linux/", "tools/", "tests/"]) EXTRA_FILES.extend(["SABnzbd.py", "requirements.txt"]) # Copy all folders and files to the new folder for source_folder in EXTRA_FOLDERS: shutil.copytree(source_folder, os.path.join(src_folder, source_folder), dirs_exist_ok=True) # Copy all files for source_file in EXTRA_FILES: shutil.copyfile(source_file, os.path.join(src_folder, source_file)) # Make sure all line-endings are correct # Skip test data directories which contain binary files with misleading extensions for input_filename in glob.glob("%s/**/*.*" % src_folder, recursive=True): if os.path.join("tests", "data", "") in input_filename: continue base, ext = os.path.splitext(input_filename) if ext.lower() not in (".py", ".txt", ".css", ".js", ".tmpl", ".sh", ".cmd"): continue print(input_filename) with open(input_filename, "rb") as input_data: data = input_data.read() data = data.replace(b"\r", b"") with open(input_filename, "wb") as output_data: output_data.write(data) # Create tar.gz file for source distro with tarfile.open(RELEASE_SRC, "w:gz") as tar_output: for root, dirs, files in os.walk(src_folder): for _file in files: input_path = os.path.join(root, _file) if sys.platform == "win32": tar_path = input_path.replace("srcdist\\", RELEASE_NAME + "/").replace("\\", "/") else: tar_path = input_path.replace("srcdist/", RELEASE_NAME + "/") tarinfo = tar_output.gettarinfo(input_path, tar_path) tarinfo.uid = 0 tarinfo.gid = 0 if _file in ("SABnzbd.py", "Sample-PostProc.sh", "make_mo.py", "msgfmt.py"): # Force Linux/macOS scripts as executable tarinfo.mode = 0o755 else: tarinfo.mode = 0o644 with open(input_path, "rb") as f: tar_output.addfile(tarinfo, f) # Remove source folder safe_remove(src_folder) # Reset! run_git_command(["reset", "--hard"]) run_git_command(["clean", "-f"])