From c5373aa9e01d0bc84ef4ef32f2cd7f121adfacdb Mon Sep 17 00:00:00 2001 From: Golumpa Date: Wed, 17 Jun 2026 14:54:25 +0100 Subject: [PATCH] Add PyInstaller release workflow --- .github/workflows/release.yml | 166 ++++++++++++++++++ docs/packaging.md | 45 +++++ packaging/NTE History Exporter.spec | 47 +++++ pyproject.toml | 3 + src/nte_history_exporter/cli.py | 5 +- .../live_capture/libpcap.py | 3 +- src/nte_history_exporter/mappings.py | 3 +- tests/test_boundary_export.py | 24 +++ tools/build-windows.ps1 | 27 +++ 9 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/packaging.md create mode 100644 packaging/NTE History Exporter.spec create mode 100644 tools/build-windows.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9e29a1a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release + +on: + workflow_dispatch: + push: + branches: [main] + +permissions: + contents: write + +jobs: + build-windows: + name: Build Windows + if: github.ref == 'refs/heads/main' + runs-on: windows-latest + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Get Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[build]" + + - name: Test + run: python -m unittest discover -s tests + + - name: Build + run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec" + + - name: Smoke Test + run: .\dist\nte-history-exporter.exe --help + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: windows + path: dist/nte-history-exporter.exe + + build-linux: + name: Build Linux + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Install libpcap + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Get Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[build]" + + - name: Test + run: python -m unittest discover -s tests + + - name: Build + run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec" + + - name: Smoke Test + run: ./dist/nte-history-exporter --help + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: linux + path: dist/nte-history-exporter + + build-macos: + name: Build Macos + if: github.ref == 'refs/heads/main' + runs-on: macos-15-intel + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Install libpcap + run: brew update && brew install libpcap + + - name: Get Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[build]" + + - name: Test + run: python -m unittest discover -s tests + + - name: Build + run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec" + + - name: Smoke Test + run: ./dist/nte-history-exporter --help + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: macos + path: dist/nte-history-exporter + + release: + name: Release + if: github.ref == 'refs/heads/main' + needs: [build-windows, build-linux, build-macos] + runs-on: ubuntu-latest + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Get package version + id: package-version + run: | + version="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Download Artifacts + uses: actions/download-artifact@v4 + + - name: Rename + run: | + version="${{ steps.package-version.outputs.version }}" + cp windows/nte-history-exporter.exe nte-history-exporter.exe + zip nte-history-exporter-v${version}-x86_64-pc-windows-msvc.zip nte-history-exporter.exe + cp linux/nte-history-exporter nte-history-exporter + chmod +x nte-history-exporter + cp nte-history-exporter nte-history-exporter-linux + zip nte-history-exporter-v${version}-x86_64-unknown-linux-gnu.zip nte-history-exporter + cp macos/nte-history-exporter nte-history-exporter + chmod +x nte-history-exporter + cp nte-history-exporter nte-history-exporter-macos + zip nte-history-exporter-v${version}-x86_64-apple-darwin.zip nte-history-exporter + + - name: Release + uses: marvinpinto/action-automatic-releases@latest + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + automatic_release_tag: v${{ steps.package-version.outputs.version }} + prerelease: false + title: v${{ steps.package-version.outputs.version }} Release + files: | + nte-history-exporter.exe + nte-history-exporter-linux + nte-history-exporter-macos + nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-pc-windows-msvc.zip + nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-unknown-linux-gnu.zip + nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-apple-darwin.zip diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..b3baac3 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,45 @@ +# Packaging + +## Windows local build + +Build the Windows console executable with: + +```powershell +.\tools\build-windows.ps1 +``` + +The output is: + +```text +dist\nte-history-exporter.exe +``` + +Run live capture from an elevated PowerShell prompt: + +```powershell +.\dist\nte-history-exporter.exe --live +``` + +## Packet capture runtime + +Windows live capture defaults to `--capture-backend auto`, which tries an installed system Npcap runtime first, then the built-in raw-socket backend. If `--capture-backend libpcap` is specified, raw fallback is disabled. If `--capture-backend raw` is specified, only the Windows raw-socket backend is used. + +Npcap is Windows-only; Linux and macOS use the system libpcap runtime. + +The GitHub Actions release does not download or bundle Npcap. The Windows executable can use a user's installed Npcap runtime when present, then falls back to raw sockets. A future pktmon backend can provide the same kind of no-extra-install Windows path that stardb-exporter uses. + +Npcap is not committed or bundled by default. Windows users who want the Npcap backend should install Npcap separately. Users who do not install Npcap can still use the Windows raw-socket fallback. + +## Cross-platform CI shape + +Use one GitHub Actions job per OS. PyInstaller must build on the target platform, so Windows produces `.exe`, macOS produces a macOS binary, and Linux produces a Linux binary. + +This repository has a manual release workflow at: + +```text +.github\workflows\release.yml +``` + +It builds Windows, Linux, and macOS artifacts, then publishes a `v` GitHub release with raw binaries and versioned zip files. + +Linux runners install the libpcap runtime for parity with live-capture usage, but the build itself uses `ctypes` and does not link against libpcap at package time. diff --git a/packaging/NTE History Exporter.spec b/packaging/NTE History Exporter.spec new file mode 100644 index 0000000..3797db2 --- /dev/null +++ b/packaging/NTE History Exporter.spec @@ -0,0 +1,47 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path + + +block_cipher = None +root = Path(SPECPATH).parent + + +a = Analysis( + [str(root / "src" / "nte_history_exporter" / "cli.py")], + pathex=[str(root / "src")], + binaries=[], + datas=[(str(root / "mappings"), "mappings")], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + 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, + a.binaries, + a.zipfiles, + a.datas, + [], + name="nte-history-exporter", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/pyproject.toml b/pyproject.toml index d9f6034..24e2f3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,9 @@ description = "Cross-platform Neverness to Everness pull-history exporter." requires-python = ">=3.10" dependencies = [] +[project.optional-dependencies] +build = ["pyinstaller>=6.0"] + [project.scripts] nte-history-exporter = "nte_history_exporter.cli:main" diff --git a/src/nte_history_exporter/cli.py b/src/nte_history_exporter/cli.py index 426b564..be9a8e2 100644 --- a/src/nte_history_exporter/cli.py +++ b/src/nte_history_exporter/cli.py @@ -24,7 +24,10 @@ def build_parser() -> argparse.ArgumentParser: "--capture-backend", choices=["auto", "libpcap", "raw"], default="auto", - help="live capture backend; auto prefers Npcap/libpcap and falls back to raw sockets on Windows", + help=( + "live capture backend; auto prefers Npcap/libpcap and falls back to raw sockets on Windows " + "(default: %(default)s)" + ), ) parser.add_argument("--copy-clipboard", action="store_true", help="copy a single live export to clipboard") parser.add_argument("--debug", action="store_true", help="also write research CSVs next to the JSON exports") diff --git a/src/nte_history_exporter/live_capture/libpcap.py b/src/nte_history_exporter/live_capture/libpcap.py index ba78f06..1ee2acf 100644 --- a/src/nte_history_exporter/live_capture/libpcap.py +++ b/src/nte_history_exporter/live_capture/libpcap.py @@ -103,7 +103,8 @@ def _load_library() -> ctypes.CDLL: npcap_dir = _windows_npcap_directory() if npcap_dir.is_dir() and hasattr(os, "add_dll_directory"): dll_directory = os.add_dll_directory(str(npcap_dir)) - candidates.extend([str(npcap_dir / "wpcap.dll"), "wpcap.dll"]) + candidates.append(str(npcap_dir / "wpcap.dll")) + candidates.append("wpcap.dll") elif sys.platform == "darwin": candidates.extend( [ diff --git a/src/nte_history_exporter/mappings.py b/src/nte_history_exporter/mappings.py index 2ecec43..27213a1 100644 --- a/src/nte_history_exporter/mappings.py +++ b/src/nte_history_exporter/mappings.py @@ -1,10 +1,11 @@ from __future__ import annotations import json +import sys from pathlib import Path from typing import Any -PROJECT_ROOT = Path(__file__).resolve().parents[2] +PROJECT_ROOT = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[2])) MAPPINGS_DIR = PROJECT_ROOT / "mappings" diff --git a/tests/test_boundary_export.py b/tests/test_boundary_export.py index ac1a184..4ff0cb1 100644 --- a/tests/test_boundary_export.py +++ b/tests/test_boundary_export.py @@ -38,6 +38,7 @@ from nte_history_exporter.live_capture.libpcap import ( DLT_LOOP, DLT_RAW, _extract_ipv4_frame, + _load_library, LibpcapUnavailable, ) from nte_history_exporter.live_capture.windows_raw import parse_ipv4_packet @@ -138,6 +139,29 @@ class BoundaryExportTests(unittest.TestCase): raw_capture.assert_not_called() + def test_windows_libpcap_load_prefers_system_then_path(self): + system_dir = Path("C:/Windows/System32/Npcap") + loaded = Mock() + with ( + patch("nte_history_exporter.live_capture.libpcap.sys.platform", "win32"), + patch("nte_history_exporter.live_capture.libpcap._windows_npcap_directory", return_value=system_dir), + patch("nte_history_exporter.live_capture.libpcap.Path.is_dir", return_value=True), + patch("nte_history_exporter.live_capture.libpcap.os.add_dll_directory", return_value=Mock()), + patch( + "nte_history_exporter.live_capture.libpcap.ctypes.CDLL", + side_effect=[OSError("system missing"), loaded], + ) as cdll, + ): + self.assertIs(_load_library(), loaded) + + self.assertEqual( + [call.args[0] for call in cdll.call_args_list], + [ + str(system_dir / "wpcap.dll"), + "wpcap.dll", + ], + ) + def test_libpcap_link_layers_extract_ipv4_packets(self): ip_packet = bytes.fromhex("4500001c0000000040110000c0000201c6336402") + bytes(8) ethernet = bytes(12) + bytes.fromhex("0800") + ip_packet diff --git a/tools/build-windows.ps1 b/tools/build-windows.ps1 new file mode 100644 index 0000000..44e6225 --- /dev/null +++ b/tools/build-windows.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference = 'Stop' + +Set-Location (Split-Path -Parent $PSScriptRoot) + +$python = Join-Path $PWD '.venv\Scripts\python.exe' +if (-not (Test-Path $python)) { + python -m venv .venv +} + +& $python -m pip install --upgrade pip +& $python -m pip install -e '.[build]' + +& $python -m PyInstaller --clean --noconfirm 'packaging\NTE History Exporter.spec' +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller failed with exit code $LASTEXITCODE" +} + +$exe = Join-Path $PWD 'dist\nte-history-exporter.exe' +if (-not (Test-Path $exe)) { + throw "Expected build output was not created: $exe" +} + +Write-Host "Built $exe" +& $exe --help +if ($LASTEXITCODE -ne 0) { + throw "Built executable smoke test failed with exit code $LASTEXITCODE" +}