diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 87e2411..441a518 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,9 +1,11 @@ -name: Release Binaries & Installers +name: Release Binaries & Packages on: push: tags: - 'v*' + release: + types: [published] workflow_dispatch: inputs: tag: @@ -12,8 +14,8 @@ on: default: 'v3.0.0' jobs: - release-linux: - name: Build & Release Linux Binaries + release: + name: Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) runs-on: ubuntu-latest steps: - name: Checkout Code @@ -21,27 +23,27 @@ jobs: with: fetch-depth: 0 - - name: Install System and Python Build Dependencies + - name: Install System and Cross-Compilation Build Dependencies run: | if command -v apt-get >/dev/null 2>&1; then - echo "[*] Installing Python shared libraries and headers for PyInstaller..." + echo "[*] Installing Python shared libraries, headers, and build dependencies..." SUDO="" if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then SUDO="sudo" fi - $SUDO apt-get update && $SUDO apt-get install -y --no-install-recommends python3-dev libpython3-dev python3-all-dev "libpython3.*" || true + $SUDO apt-get update && $SUDO apt-get install -y --no-install-recommends \ + python3-dev libpython3-dev python3-all-dev "libpython3.*" \ + innosetup wine wine64 || true fi python3 compilation/ensure_pip.py python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true - python3 -m pip install pyinstaller -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install pyinstaller -r compilation/requirements.txt + python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt - - - - name: Compile Standalone Linux Binaries + - name: Compile Standalone Binaries (Linux & Windows) run: | - python3 compilation/package_dist.py --target linux + python3 compilation/package_dist.py --target all - - name: Publish Linux Release Assets + - name: Publish Unified Release Assets env: GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }} GITEA_SERVER_URL: ${{ github.server_url }} @@ -49,121 +51,3 @@ jobs: GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }} run: | python3 compilation/upload_release.py --skip-build - - release-windows: - name: Build & Release Windows Binaries & Installers - # Note: Requires a registered Gitea Act Runner with label 'windows-latest' - runs-on: windows-latest - steps: - - name: Checkout Repository - shell: powershell - run: | - $server = "${{ github.server_url }}" - $token = "${{ secrets.TAG_TOKEN || github.token }}" - $repo = "${{ github.repository }}" - $sha = "${{ github.sha }}" - $cleanUrl = $server -replace "^https?://", "" - $authUrl = "https://${token}@${cleanUrl}/${repo}.git" - - # Discover and append Git install paths to PATH if missing - $gitCandidatePaths = @( - "C:\Program Files\Git\cmd", - "C:\Program Files\Git\bin", - "C:\Program Files (x86)\Git\cmd", - "C:\Program Files (x86)\Git\bin", - "C:\ProgramData\chocolatey\bin", - "$env:LOCALAPPDATA\Programs\Git\cmd" - ) - foreach ($p in $gitCandidatePaths) { - if ((Test-Path $p) -and ($env:PATH -notlike "*$p*")) { - $env:PATH = "$p;$env:PATH" - } - } - - $gitCmd = Get-Command "git" -ErrorAction SilentlyContinue - if ($gitCmd) { - Write-Host "[+] Found Git at $($gitCmd.Source)" - if (-not (Test-Path ".git")) { - & git init - & git remote add origin $authUrl - } else { - & git remote set-url origin $authUrl - } - & git fetch --depth 1 origin $sha - & git checkout -f FETCH_HEAD - } else { - Write-Host "[!] Git binary not found in PATH. Downloading repository archive via Gitea API..." - $zipUrl = "$server/api/v1/repos/$repo/archive/$sha.zip" - $headers = @{ "Authorization" = "token $token" } - $zipPath = "$env:TEMP\repo_$sha.zip" - $extractPath = "$env:TEMP\repo_extract_$sha" - Invoke-RestMethod -Uri $zipUrl -Headers $headers -OutFile $zipPath -ErrorAction Stop - Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force - $inner = Get-ChildItem -Path $extractPath | Where-Object { $_.PSIsContainer } | Select-Object -First 1 - if ($inner) { - Copy-Item -Path "$($inner.FullName)\*" -Destination "." -Recurse -Force - } else { - Copy-Item -Path "$extractPath\*" -Destination "." -Recurse -Force - } - Remove-Item -Path $zipPath, $extractPath -Recurse -Force -ErrorAction SilentlyContinue - Write-Host "[+] Repository successfully extracted from ZIP archive." - } - - - name: Install Dependencies - shell: powershell - run: | - $py = "python" - if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - if (Get-Command "py" -ErrorAction SilentlyContinue) { - $py = "py -3.12" - } - } - & $py -m pip install --upgrade pip - & $py -m pip install pyinstaller cryptography -r compilation/requirements.txt - - - name: Compile Standalone Windows Binaries - shell: powershell - run: | - $py = "python" - if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - if (Get-Command "py" -ErrorAction SilentlyContinue) { - $py = "py -3.12" - } - } - & $py compilation/package_dist.py --target windows - - - name: Compile Inno Setup Installers - shell: powershell - run: | - $iscc = $null - if (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") { - $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" - } elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") { - $iscc = "C:\Program Files\Inno Setup 6\ISCC.exe" - } elseif (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue) { - $iscc = "ISCC.exe" - } - - if ($iscc) { - Write-Host "[*] Compiling Windows Inno Setup installers using $iscc..." - & $iscc compilation/installer_client.iss - & $iscc compilation/installer_server.iss - } else { - Write-Host "[!] Inno Setup compiler (ISCC.exe) not found on runner host. Skipping installer compilation." - } - - - name: Publish Windows Release Assets - shell: powershell - env: - GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }} - GITEA_SERVER_URL: ${{ github.server_url }} - GITEA_REPOSITORY: ${{ github.repository }} - GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }} - run: | - $py = "python" - if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - if (Get-Command "py" -ErrorAction SilentlyContinue) { - $py = "py -3.12" - } - } - & $py compilation/upload_release.py --skip-build diff --git a/compilation/package_dist.py b/compilation/package_dist.py index 25e7645..bd4285b 100644 --- a/compilation/package_dist.py +++ b/compilation/package_dist.py @@ -12,6 +12,7 @@ SRC_DIR = os.path.join(ROOT_DIR, "src") DIST_DIR = os.path.join(ROOT_DIR, "dist") BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp") + def clean_and_prep(): if os.path.exists(DIST_DIR): shutil.rmtree(DIST_DIR) @@ -20,6 +21,22 @@ def clean_and_prep(): shutil.rmtree(BUILD_TEMP) os.makedirs(BUILD_TEMP, exist_ok=True) + +def calculate_sha256(filepath): + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest() + + +def write_checksum_file(filename, digest, binary_filename): + out_path = os.path.join(DIST_DIR, filename) + with open(out_path, "w", encoding="utf-8") as f: + f.write(f"{digest} {binary_filename}\n") + print(f"[+] Generated checksum file: {filename} ({digest[:16]}...)") + + def build_pyinstaller_binary(script_path, binary_name): print(f"[*] Compiling {binary_name} with PyInstaller...") cmd = [ @@ -38,25 +55,15 @@ def build_pyinstaller_binary(script_path, binary_name): raise RuntimeError(f"Failed to build {binary_name}") print(f"[+] Successfully compiled {binary_name}") -def calculate_sha256(filepath): - h = hashlib.sha256() - with open(filepath, "rb") as f: - while chunk := f.read(65536): - h.update(chunk) - return h.hexdigest() - -def write_checksum_file(filename, digest, binary_filename): - out_path = os.path.join(DIST_DIR, filename) - with open(out_path, "w", encoding="utf-8") as f: - f.write(f"{digest} {binary_filename}\n") - print(f"[+] Generated checksum file: {filename} ({digest[:16]}...)") def build_linux_zipapp_fallback(): print("[*] Packaging Linux standalone zipapp fallback binaries...") # Linux Client zipapp app_dir = os.path.join(BUILD_TEMP, "linux_app") os.makedirs(app_dir, exist_ok=True) - shutil.copy(os.path.join(SRC_DIR, "Linux_Client.py"), os.path.join(app_dir, "Linux_Client.py")) + for f in os.listdir(SRC_DIR): + if f.endswith(".py"): + shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_dir, f)) client_out = os.path.join(DIST_DIR, "Linux_Client.bin") zipapp.create_archive( source=app_dir, @@ -67,7 +74,9 @@ def build_linux_zipapp_fallback(): # Server zipapp srv_dir = os.path.join(BUILD_TEMP, "linux_srv") os.makedirs(srv_dir, exist_ok=True) - shutil.copy(os.path.join(SRC_DIR, "Server.py"), os.path.join(srv_dir, "Server.py")) + for f in os.listdir(SRC_DIR): + if f.endswith(".py"): + shutil.copy(os.path.join(SRC_DIR, f), os.path.join(srv_dir, f)) server_out = os.path.join(DIST_DIR, "Server.bin") zipapp.create_archive( source=srv_dir, @@ -76,13 +85,109 @@ def build_linux_zipapp_fallback(): main="Server:main" ) + +def create_windows_exe_zipapp(script_name, exe_name): + """ + Cross-compilation helper: Creates a standalone Windows .exe by prepending + the standard Python Windows 64-bit PE launcher stub (t64.exe) to a compiled zipapp archive. + """ + app_temp_dir = os.path.join(BUILD_TEMP, f"win_app_{exe_name}") + os.makedirs(app_temp_dir, exist_ok=True) + + for f in os.listdir(SRC_DIR): + if f.endswith(".py"): + shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_temp_dir, f)) + + module_name = os.path.splitext(script_name)[0] + zipapp_temp = os.path.join(BUILD_TEMP, f"{exe_name}.zip") + zipapp.create_archive( + source=app_temp_dir, + target=zipapp_temp, + interpreter=None, + main=f"{module_name}:main" + ) + + stub_bytes = b"" + try: + import pip._vendor.distlib.resources as distlib_res + finder = distlib_res.finder("pip._vendor.distlib") + res = finder.find("t64.exe") + if res: + stub_bytes = res.bytes + except Exception: + pass + + if not stub_bytes: + try: + import distlib.resources as distlib_res + finder = distlib_res.finder("distlib") + res = finder.find("t64.exe") + if res: + stub_bytes = res.bytes + except Exception: + pass + + out_exe_path = os.path.join(DIST_DIR, f"{exe_name}.exe") + with open(out_exe_path, "wb") as out_f: + if stub_bytes: + out_f.write(stub_bytes) + with open(zipapp_temp, "rb") as in_f: + out_f.write(in_f.read()) + print(f"[+] Successfully cross-compiled Windows PE executable: {exe_name}.exe (PE Stub attached: {bool(stub_bytes)})") + + +def compile_inno_setup_installers(): + """Compiles Inno Setup installers if iscc or innosetup wrapper is available.""" + iscc_cmd = None + if shutil.which("iscc"): + iscc_cmd = ["iscc"] + elif shutil.which("innosetup"): + iscc_cmd = ["innosetup"] + elif os.path.exists("C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe"): + iscc_cmd = ["C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe"] + elif os.path.exists("C:\\Program Files\\Inno Setup 6\\ISCC.exe"): + iscc_cmd = ["C:\\Program Files\\Inno Setup 6\\ISCC.exe"] + elif shutil.which("wine"): + wine_iscc = os.path.expanduser("~/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe") + if os.path.exists(wine_iscc): + iscc_cmd = ["wine", wine_iscc] + + if not iscc_cmd: + print("[!] Note: Inno Setup compiler (iscc / innosetup) not available on host. Skipping installer .exe creation.") + return False + + for iss in ["installer_client.iss", "installer_server.iss"]: + iss_path = os.path.join(ROOT_DIR, "compilation", iss) + if os.path.exists(iss_path): + print(f"[*] Compiling Inno Setup script: {iss}...") + try: + subprocess.run(iscc_cmd + [iss_path], check=True) + print(f"[+] Successfully compiled {iss}") + except Exception as e: + print(f"[!] Warning: Failed to compile {iss} with {iscc_cmd}: {e}") + return True + + def build_windows(): print("[*] Compiling Windows standalone executables...") - win_client_script = os.path.join(SRC_DIR, "Win_Client.py") - build_pyinstaller_binary(win_client_script, "Win_Client") + is_windows_host = platform.system() == "Windows" - server_script = os.path.join(SRC_DIR, "Server.py") - build_pyinstaller_binary(server_script, "Server") + if is_windows_host: + win_client_script = os.path.join(SRC_DIR, "Win_Client.py") + server_script = os.path.join(SRC_DIR, "Server.py") + try: + build_pyinstaller_binary(win_client_script, "Win_Client") + build_pyinstaller_binary(server_script, "Server") + except Exception as e: + print(f"[!] PyInstaller on Windows failed ({e}). Falling back to PE launcher stub packaging...") + create_windows_exe_zipapp("Win_Client.py", "Win_Client") + create_windows_exe_zipapp("Server.py", "Server") + else: + print("[*] Cross-compiling Windows standalone executables on Linux...") + create_windows_exe_zipapp("Win_Client.py", "Win_Client") + create_windows_exe_zipapp("Server.py", "Server") + + compile_inno_setup_installers() client_bin = os.path.join(DIST_DIR, "Win_Client.exe") server_bin = os.path.join(DIST_DIR, "Server.exe") @@ -103,10 +208,18 @@ def build_windows(): else: print(f"[!] Warning: Expected {server_bin} was not found.") + # Inno Setup output checksums if present + for inst in ["LOGAR-Client-Setup.exe", "LOGAR-Server-Setup.exe"]: + inst_path = os.path.join(DIST_DIR, inst) + if os.path.exists(inst_path): + inst_hash = calculate_sha256(inst_path) + sums.append(f"{inst_hash} {inst}") + sums_path = os.path.join(DIST_DIR, "SHA256SUMS_windows.txt") with open(sums_path, "w", encoding="utf-8") as f: f.write("\n".join(sums) + "\n") + def build_linux(): print("[*] Compiling Linux standalone binaries...") is_linux_host = platform.system() == "Linux" @@ -164,12 +277,29 @@ def build_linux(): with open(sums_path, "w", encoding="utf-8") as f: f.write("\n".join(sums) + "\n") + +def build_all(): + print("[*] Building standalone distribution artifacts for both Linux and Windows...") + build_linux() + build_windows() + + # Consolidated master checksums file + all_sums = [] + for f in sorted(os.listdir(DIST_DIR)): + fpath = os.path.join(DIST_DIR, f) + if os.path.isfile(fpath) and not f.endswith(".txt") and not "sha256sum" in f: + h = calculate_sha256(fpath) + all_sums.append(f"{h} {f}") + with open(os.path.join(DIST_DIR, "SHA256SUMS.txt"), "w", encoding="utf-8") as f: + f.write("\n".join(all_sums) + "\n") + + def main(target=None): if target is None: parser = argparse.ArgumentParser(description="LOGAR Standalone Binary Compiler & Packager") parser.add_argument( "--target", "-t", - choices=["windows", "win", "linux", "auto"], + choices=["all", "windows", "win", "linux", "auto"], default="auto", help="Target platform to compile binaries for (default: auto-detect)" ) @@ -177,19 +307,21 @@ def main(target=None): target = args.target if target == "auto": - target = "windows" if platform.system() == "Windows" else "linux" + target = "all" elif target == "win": target = "windows" print("=" * 60) - print(" LOGAR Binary Packaging") + print(" LOGAR Binary Packaging & Cross-Compilation") print(f" Host Platform: {platform.system()} ({platform.machine()})") print(f" Target Platform: {target.upper()}") print("=" * 60) clean_and_prep() - if target == "windows": + if target == "all": + build_all() + elif target == "windows": build_windows() elif target == "linux": build_linux() @@ -206,5 +338,6 @@ def main(target=None): print(f" - {f} ({sz / (1024*1024):.2f} MB)" if sz > 1024*1024 else f" - {f} ({sz} bytes)") print("=" * 60) + if __name__ == "__main__": main() diff --git a/compilation/requirements.txt b/compilation/requirements.txt index 076e5f3..a39a74f 100644 --- a/compilation/requirements.txt +++ b/compilation/requirements.txt @@ -5,4 +5,5 @@ fastapi>=0.110.0 uvicorn>=0.28.0 websockets>=11.0.0 pydantic>=2.6.0 +distlib>=0.3.8 pywin32>=306; sys_platform == "win32"