diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b76fcd9..f846ae5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release Shippables +name: Release Binaries on: push: @@ -18,11 +18,16 @@ jobs: with: python-version: '3.12' - - name: Assemble Shippables + - name: Install Build Dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller -r requirements.txt + + - name: Compile Standalone Binaries run: | python package_dist.py - - name: Publish Release + - name: Publish Release (Binaries Only) uses: https://gitea.com/actions/gitea-release-action@v1 with: files: | diff --git a/package_dist.py b/package_dist.py index 4e33944..7104abc 100644 --- a/package_dist.py +++ b/package_dist.py @@ -1,35 +1,63 @@ import os +import sys import shutil -import tarfile -import zipfile +import zipapp import hashlib +import platform +import subprocess -DIST_DIR = "dist" -OUT_DIR = "out" +DIST_DIR = os.path.abspath("dist") +OUT_DIR = os.path.abspath("out") +BUILD_TEMP = os.path.abspath("build_temp") -def make_zip(source_dir, output_zip): - with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf: - for root, _, files in os.walk(source_dir): - for file in files: - full_path = os.path.join(root, file) - rel_path = os.path.relpath(full_path, source_dir) - zf.write(full_path, rel_path) +def clean_and_prep(): + if os.path.exists(DIST_DIR): + shutil.rmtree(DIST_DIR) + os.makedirs(DIST_DIR, exist_ok=True) + if os.path.exists(BUILD_TEMP): + shutil.rmtree(BUILD_TEMP) + os.makedirs(BUILD_TEMP, exist_ok=True) -def make_tar_gz(source_dir, output_tar): - with tarfile.open(output_tar, "w:gz") as tf: - for root, _, files in os.walk(source_dir): - for file in files: - full_path = os.path.join(root, file) - rel_path = os.path.relpath(full_path, source_dir) - tf.add(full_path, arcname=rel_path) +def build_pyinstaller_binary(script_path, binary_name): + print(f"[*] Compiling {binary_name} with PyInstaller...") + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onefile", + "--clean", + "--distpath", DIST_DIR, + "--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"), + "--specpath", os.path.join(BUILD_TEMP, f"spec_{binary_name}"), + "--name", binary_name, + script_path + ] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + print(f"[!] Compilation error for {binary_name}:\n{res.stderr}") + raise RuntimeError(f"Failed to build {binary_name}") + print(f"[+] Successfully compiled {binary_name}") -def generate_checksums(dist_dir): - checksum_file = os.path.join(dist_dir, "SHA256SUMS.txt") +def build_linux_zipapp_binary(): + print("[*] Packaging Linux_Client.bin executable binary...") + app_dir = os.path.join(BUILD_TEMP, "linux_app") + os.makedirs(app_dir, exist_ok=True) + shutil.copy(os.path.join(OUT_DIR, "linux_client", "Linux_Client.py"), os.path.join(app_dir, "Linux_Client.py")) + + bin_output = os.path.join(DIST_DIR, "Linux_Client.bin") + zipapp.create_archive( + source=app_dir, + target=bin_output, + interpreter="/usr/bin/env python3", + main="Linux_Client:main" + ) + print(f"[+] Successfully generated {bin_output}") + +def generate_checksums(): + checksum_file = os.path.join(DIST_DIR, "SHA256SUMS.txt") lines = [] - for fname in sorted(os.listdir(dist_dir)): + for fname in sorted(os.listdir(DIST_DIR)): if fname == "SHA256SUMS.txt": continue - fpath = os.path.join(dist_dir, fname) + fpath = os.path.join(DIST_DIR, fname) if os.path.isfile(fpath): with open(fpath, "rb") as f: digest = hashlib.sha256(f.read()).hexdigest() @@ -38,29 +66,45 @@ def generate_checksums(dist_dir): f.write("\n".join(lines) + "\n") def main(): - os.makedirs(DIST_DIR, exist_ok=True) + print("=" * 60) + print(" LOGAR Binary Packaging (Binaries Only)") + print(f" Platform: {platform.system()} ({platform.machine()})") + print("=" * 60) - server_src = os.path.join(OUT_DIR, "server") - win_src = os.path.join(OUT_DIR, "win_client") - linux_src = os.path.join(OUT_DIR, "linux_client") + clean_and_prep() - print("[*] Packaging Server shippable...") - make_tar_gz(server_src, os.path.join(DIST_DIR, "logar-server.tar.gz")) - make_zip(server_src, os.path.join(DIST_DIR, "logar-server.zip")) + is_windows = platform.system() == "Windows" - print("[*] Packaging Windows Client shippable...") - make_zip(win_src, os.path.join(DIST_DIR, "logar-win-client.zip")) + if is_windows: + # Build Windows client executable + win_client_script = os.path.join(OUT_DIR, "win_client", "Win_Client.py") + build_pyinstaller_binary(win_client_script, "Win_Client") - print("[*] Packaging Linux Client shippable...") - make_tar_gz(linux_src, os.path.join(DIST_DIR, "logar-linux-client.tar.gz")) + # Build Windows server executable + server_script = os.path.join(OUT_DIR, "server", "Server.py") + build_pyinstaller_binary(server_script, "Server") - print("[*] Generating SHA-256 checksums...") - generate_checksums(DIST_DIR) + # Build Linux client standalone binary + build_linux_zipapp_binary() + else: + # On Linux runner: Build native Linux binaries + linux_client_script = os.path.join(OUT_DIR, "linux_client", "Linux_Client.py") + build_pyinstaller_binary(linux_client_script, "Linux_Client.bin") - print(f"[+] All release artifacts assembled in '{DIST_DIR}/':") + server_script = os.path.join(OUT_DIR, "server", "Server.py") + build_pyinstaller_binary(server_script, "Server.bin") + + generate_checksums() + + # Clean temporary build directory + if os.path.exists(BUILD_TEMP): + shutil.rmtree(BUILD_TEMP, ignore_errors=True) + + print("\n[+] Binary shipping artifacts assembled in 'dist/':") for f in os.listdir(DIST_DIR): sz = os.path.getsize(os.path.join(DIST_DIR, f)) - print(f" - {f} ({sz} bytes)") + 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/upload_release.py b/upload_release.py new file mode 100644 index 0000000..8997862 --- /dev/null +++ b/upload_release.py @@ -0,0 +1,98 @@ +import os +import sys +import json +import argparse +import urllib.request +import urllib.parse +import mimetypes +import package_dist + +DEFAULT_GITEA_URL = "https://gitea.eibl.tech" +DEFAULT_REPO = "me0nline/LOGAR" + +def upload_file_to_release(base_url, repo, release_id, token, file_path): + filename = os.path.basename(file_path) + url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets?name={urllib.parse.quote(filename)}" + + with open(file_path, "rb") as f: + file_bytes = f.read() + + req = urllib.request.Request(url, data=file_bytes, method="POST") + req.add_header("Authorization", f"token {token}") + req.add_header("Content-Type", "application/octet-stream") + req.add_header("Accept", "application/json") + + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode("utf-8")) + print(f"[+] Attached {filename} ({len(file_bytes)} bytes) to release.") + return data + except urllib.error.HTTPError as e: + err = e.read().decode("utf-8", errors="ignore") + print(f"[!] Error uploading {filename}: HTTP {e.code} - {err}") + return None + +def create_or_get_release(base_url, repo, tag, token, title=None, notes=None): + url = f"{base_url}/api/v1/repos/{repo}/releases" + headers = { + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json" + } + + # Check if release exists + check_url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}" + check_req = urllib.request.Request(check_url, headers={"Authorization": f"token {token}"}) + try: + with urllib.request.urlopen(check_req) as resp: + existing = json.loads(resp.read().decode("utf-8")) + print(f"[*] Found existing release for tag {tag} (ID: {existing['id']})") + return existing["id"] + except urllib.error.HTTPError: + pass + + # Create new release + payload = { + "tag_name": tag, + "name": title or f"LOGAR Release {tag}", + "body": notes or f"Automated binary release for {tag}.", + "draft": False, + "prerelease": False + } + req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST") + with urllib.request.urlopen(req) as resp: + created = json.loads(resp.read().decode("utf-8")) + print(f"[+] Created release {tag} (ID: {created['id']})") + return created["id"] + +def main(): + parser = argparse.ArgumentParser(description="Upload LOGAR compiled binaries directly to Gitea Release") + parser.add_argument("--tag", required=True, help="Release tag name (e.g. v1.0.0)") + parser.add_argument("--token", required=True, help="Gitea Personal Access Token") + parser.add_argument("--url", default=DEFAULT_GITEA_URL, help="Base Gitea instance URL") + parser.add_argument("--repo", default=DEFAULT_REPO, help="Repository owner/name") + parser.add_argument("--skip-build", action="store_true", help="Skip running package_dist.py before upload") + args = parser.parse_args() + + if not args.skip_build: + print("[*] Assembling compiled binaries...") + package_dist.main() + + dist_dir = os.path.abspath("dist") + if not os.path.exists(dist_dir) or not os.listdir(dist_dir): + print("[!] No binaries found in dist/. Run package_dist.py first.") + sys.exit(1) + + print(f"[*] Connecting to Gitea: {args.url} (repo: {args.repo})...") + release_id = create_or_get_release(args.url, args.repo, args.tag, args.token) + + print(f"[*] Uploading binary assets from '{dist_dir}'...") + for f in sorted(os.listdir(dist_dir)): + fpath = os.path.join(dist_dir, f) + if os.path.isfile(fpath): + upload_file_to_release(args.url, args.repo, release_id, args.token, fpath) + + print(f"\n[+] Release successfully published with binary assets: {args.url}/{args.repo}/releases/tag/{args.tag}") + +if __name__ == "__main__": + main()