From e00a96b918db44487c565cebcc081ef877566b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 21 Oct 2026 00:17:06 +0300 Subject: [PATCH] Release a Windows-friendly runtime zip (v2.7.12) The releases carried the wheel and the sdist; the only zip was GitHub's source archive of the whole repository (tests, CI, tools). A Windows user just wants to extract a folder and double-click start.bat. - scripts/build_release_zip.py builds simtrace-analyser-.zip with a single top-level directory: the two Python packages, the PWA runtime files (frontend/tests and package.json stay out), the four launchers, the udev rule, the READMEs and the screenshot. The .bat entries are written with CRLF line endings (cmd.exe mishandles bare-LF labels/goto), the .sh files keep their executable bit; LICENSE is included automatically once the repo has one. - tests/test_release_zip.py pins the archive shape, the exclusions, the sw.js PRECACHE drift (every precached asset must travel), the CRLF/LF line endings and start.sh's mode. - the release workflow builds the zip, checks it carries start.bat and the PWA, uploads it with the run and attaches it to the release. - READMEs (EN/RU) and the release-notes template offer the zip as the Windows-friendly option; version 2.7.12, PWA cache v94. Verified: the built zip (34 entries, 658 KB compressed) extracted and started on Linux serves the same index.html; 553 Python + 80 Node tests. --- .github/release-notes.md | 6 ++ .github/workflows/release.yml | 18 ++++- README.md | 10 ++- README_RUS.md | 10 ++- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- scripts/build_release_zip.py | 132 ++++++++++++++++++++++++++++++++++ simtrace_analyser/version.py | 2 +- tests/test_pcap_import.py | 2 +- tests/test_release_zip.py | 104 +++++++++++++++++++++++++++ 11 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 scripts/build_release_zip.py create mode 100644 tests/test_release_zip.py diff --git a/.github/release-notes.md b/.github/release-notes.md index 6cb4c29..99b1a9e 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -12,3 +12,9 @@ virtual environment first (`python3 -m venv .venv && . .venv/bin/activate`). The source archive is the same tree a clone would have: unzip it and run `./start.sh` (Linux/macOS) or `start.bat` (Windows). Direct SIMtrace2 capture on Linux additionally needs the attached `70-simtrace2-pysniff.rules` udev rule. + +**Runtime zip (Windows-friendly, no installer)** — `simtrace-analyser-*.zip` +is the runtime tree only (packages, PWA, launchers, docs — no tests, no git): +extract it anywhere and run `start.bat` (Windows) or `./start.sh` +(Linux/macOS). Python 3 has to be on `PATH`; `pip install pyusb` is only +needed for direct SIMtrace2 capture. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12937b8..b881ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,22 @@ jobs: - name: Build the sdist and the wheel run: python -m build --sdist --wheel + - name: Build the runtime zip + run: python scripts/build_release_zip.py --output dist + + - name: The runtime zip is complete + run: | + python - <<'PY' + import glob + import zipfile + archive = glob.glob('dist/*.zip')[0] + names = zipfile.ZipFile(archive).namelist() + for required in ('start.bat', 'frontend/index.html'): + assert any(n.endswith('/' + required) for n in names), \ + '%s is missing from %s' % (required, archive) + print('%s: %d entries, launchers and PWA bundled' % (archive, len(names))) + PY + - name: The wheel bundles the PWA run: | python - <<'PY' @@ -96,4 +112,4 @@ jobs: --title "$GITHUB_REF_NAME" \ --generate-notes \ --notes-file .github/release-notes.md \ - dist/*.whl dist/*.tar.gz 70-simtrace2-pysniff.rules + dist/*.whl dist/*.tar.gz dist/*.zip 70-simtrace2-pysniff.rules diff --git a/README.md b/README.md index 1155a15..9f3225f 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Everything is on the **Wheel** (the PWA is inside the package): ```sh -pip install ./simtrace_analyser-2.7.11-py3-none-any.whl +pip install ./simtrace_analyser-2.7.12-py3-none-any.whl # then run (the UI is served at http://127.0.0.1:8081/): simtrace-analyser-server --capture gsmtap @@ -119,7 +119,7 @@ Fedora), install into a virtual environment first: shown there): ```sh -pip install https://github.com/anttro/simtrace-analyser/releases/download/v2.7.11/simtrace_analyser-2.7.11-py3-none-any.whl +pip install https://github.com/anttro/simtrace-analyser/releases/download/v2.7.12/simtrace_analyser-2.7.12-py3-none-any.whl ``` With the GitHub CLI, download the latest wheel without knowing the version: @@ -133,6 +133,12 @@ pip install ./simtrace_analyser-*.whl without the git history: unzip, `cd` into it and use the launchers below (`./start.sh` on Linux/macOS, `start.bat` on Windows). +**Zip (Windows-friendly, no installer)** — `simtrace-analyser-2.7.12.zip` is +the runtime tree only (packages, PWA, launchers, docs; no tests, no git): +download it, extract it anywhere and run `start.bat` (Windows) or +`./start.sh` (Linux/macOS). Python 3 has to be on `PATH`; `pip install pyusb` +is only needed for direct SIMtrace2 capture. + ### From a git clone (development) ```sh diff --git a/README_RUS.md b/README_RUS.md index 67e384a..7031129 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -111,7 +111,7 @@ git) или склонировать репозиторий для разраб **Wheel** (PWA внутри пакета): ```sh -pip install ./simtrace_analyser-2.7.11-py3-none-any.whl +pip install ./simtrace_analyser-2.7.12-py3-none-any.whl # затем запуск (интерфейс отдаётся на http://127.0.0.1:8081/): simtrace-analyser-server --capture gsmtap @@ -125,7 +125,7 @@ Fedora), сначала создайте виртуальное окружени указанную там): ```sh -pip install https://github.com/anttro/simtrace-analyser/releases/download/v2.7.11/simtrace_analyser-2.7.11-py3-none-any.whl +pip install https://github.com/anttro/simtrace-analyser/releases/download/v2.7.12/simtrace_analyser-2.7.12-py3-none-any.whl ``` С GitHub CLI можно скачать wheel последнего релиза, не зная версии: @@ -139,6 +139,12 @@ pip install ./simtrace_analyser-*.whl дерево без истории git: распакуйте, перейдите в каталог и используйте скрипты запуска ниже (`./start.sh` в Linux/macOS, `start.bat` в Windows). +**Zip (для Windows, без установки)** — `simtrace-analyser-2.7.12.zip` +содержит только рабочее дерево (пакеты, PWA, скрипты запуска, документацию; +без тестов и git): скачайте, распакуйте в любое место и запустите `start.bat` +(Windows) или `./start.sh` (Linux/macOS). Python 3 должен быть в `PATH`; +`pip install pyusb` нужен только для прямого захвата с SIMtrace2. + ### Из клона репозитория (для разработки) ```sh diff --git a/frontend/index.html b/frontend/index.html index 4c4d4a2..d2abf88 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3389,7 +3389,7 @@ function onSearchChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.7.11'; +const PWA_VERSION = '2.7.12'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index aa5b193..847c88a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v93'; +const CACHE = 'simtrace-analyser-v94'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index a6a774a..4fbee5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simtrace-analyser" -version = "2.7.11" +version = "2.7.12" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/scripts/build_release_zip.py b/scripts/build_release_zip.py new file mode 100644 index 0000000..c69b40c --- /dev/null +++ b/scripts/build_release_zip.py @@ -0,0 +1,132 @@ +"""Build the ready-to-run runtime zip for a GitHub release. + +The wheel needs ``pip`` and the GitHub source archive carries the whole +repository (tests, CI, the gitignored ``tools/``). This zip is the runtime +tree only: extract it anywhere, run ``start.bat`` on Windows or +``./start.sh`` elsewhere, and the server serves the bundled PWA. Python 3 +must be on ``PATH``; PyUSB is only needed for direct hardware capture. + +The build is deliberately simple - the explicit include lists below are the +whole manifest, so nothing from ``tests/``, ``.github/``, ``pyproject.toml`` +or a build directory can leak into the archive by accident. +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +# The script lives outside the packages; import the version from the checkout. +sys.path.insert(0, str(REPO)) + +from simtrace_analyser.version import __version__ # noqa: E402 + +# The Python packages (all modules, no caches). +PACKAGES = ('simtrace_analyser', 'simtrace2_pysniff') + +# The PWA runtime files; ``tests/`` and ``package.json`` are dev-only. +FRONTEND = 'frontend' +FRONTEND_EXCLUDE = ('tests', 'package.json') + +# Repo-root files that make the extracted tree usable. LICENSE is included +# once the repository has one. +ROOT_FILES = ( + 'start.sh', + 'start.bat', + 'sniff-start.sh', + 'sniff-start.bat', + '70-simtrace2-pysniff.rules', + 'README.md', + 'README_RUS.md', + 'frontend.png', + 'LICENSE', +) + +# Skip editor/byte-code droppings wherever they appear. +SKIP_DIRS = ('__pycache__',) +SKIP_NAMES = ('.DS_Store',) +SKIP_SUFFIXES = ('.pyc',) + + +def _files(root): + """Yield the regular files under *root*, sorted and cache-free.""" + for path in sorted(root.rglob('*')): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.name in SKIP_NAMES or path.suffix in SKIP_SUFFIXES: + continue + yield path + + +def _write_bat(zf, path, arcname): + """Write a .bat entry with CRLF line endings. + + The checkout stores the batch files with LF, and cmd.exe mishandles a + batch file whose lines end in a bare LF (labels and ``goto`` in + particular), so the archive gets the Windows form. The source files + stay untouched. ``writestr`` does not copy the file mode, so set it. + """ + data = path.read_bytes().replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') + info = zipfile.ZipInfo.from_file(path, arcname) + info.external_attr = 0o644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, data) + + +def build_zip(output_dir, version=None): + """Write ``simtrace-analyser-.zip`` into *output_dir*. + + Returns the archive path. The zip has a single top-level directory + ``simtrace-analyser-/`` holding the runtime tree. + """ + version = version or __version__ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + archive = output_dir / ('simtrace-analyser-%s.zip' % version) + top = 'simtrace-analyser-%s' % version + + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as zf: + for package in PACKAGES: + for path in _files(REPO / package): + zf.write(path, '%s/%s' % (top, path.relative_to(REPO).as_posix())) + + for path in _files(REPO / FRONTEND): + rel = path.relative_to(REPO / FRONTEND) + if rel.parts[0] in FRONTEND_EXCLUDE: + continue + zf.write(path, '%s/%s' % (top, path.relative_to(REPO).as_posix())) + + for name in ROOT_FILES: + path = REPO / name + if not path.is_file(): + # LICENSE is not in the repository yet; include it once it is. + continue + arcname = '%s/%s' % (top, name) + if path.suffix == '.bat': + _write_bat(zf, path, arcname) + else: + zf.write(path, arcname) + + return archive + + +def main(argv=None): + parser = argparse.ArgumentParser( + description='Build the runtime zip attached to a release') + parser.add_argument('--output', default='dist', metavar='DIR', + help='directory for the archive (default: dist)') + args = parser.parse_args(argv) + + archive = build_zip(args.output) + with zipfile.ZipFile(archive) as zf: + entries = len(zf.namelist()) + print(archive) + print('%d entries' % entries) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index fb33f33..e809820 100644 --- a/simtrace_analyser/version.py +++ b/simtrace_analyser/version.py @@ -1,3 +1,3 @@ """Single source of truth for the SIMtrace Analyser version.""" -__version__ = '2.7.11' +__version__ = '2.7.12' diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 831a9ce..07aedd9 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '2.7.11') + self.assertEqual(__version__, '2.7.12') def test_status_includes_version(self): import os diff --git a/tests/test_release_zip.py b/tests/test_release_zip.py new file mode 100644 index 0000000..978aa5a --- /dev/null +++ b/tests/test_release_zip.py @@ -0,0 +1,104 @@ +"""Tests for the ready-to-run release zip (scripts/build_release_zip.py).""" + +import importlib.util +import re +import tempfile +import unittest +import zipfile +from pathlib import Path + +from simtrace_analyser.version import __version__ + +REPO = Path(__file__).resolve().parents[1] + +# scripts/ is not a package, so load the builder from its path. +_spec = importlib.util.spec_from_file_location( + 'build_release_zip', REPO / 'scripts' / 'build_release_zip.py') +build_release_zip = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(build_release_zip) + + +class TestReleaseZip(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._tmp = tempfile.TemporaryDirectory() + cls.archive = build_release_zip.build_zip(cls._tmp.name) + with zipfile.ZipFile(cls.archive) as zf: + cls.names = zf.namelist() + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + + def test_name_and_single_top_level_directory(self): + top = 'simtrace-analyser-%s' % __version__ + self.assertEqual(self.archive.name, top + '.zip') + for name in self.names: + self.assertTrue(name.startswith(top + '/'), + '%s is outside %s/' % (name, top)) + + def test_required_entries(self): + top = 'simtrace-analyser-%s' % __version__ + for rel in ( + 'simtrace_analyser/__main__.py', + 'simtrace_analyser/version.py', + 'simtrace2_pysniff/__main__.py', + 'frontend/index.html', + 'frontend/sw.js', + 'frontend/manifest.json', + 'start.sh', + 'start.bat', + 'sniff-start.sh', + 'sniff-start.bat', + '70-simtrace2-pysniff.rules', + 'README.md'): + self.assertIn('%s/%s' % (top, rel), self.names) + + def test_dev_only_entries_are_excluded(self): + for name in self.names: + self.assertNotIn('/tests/', name) + self.assertNotIn('/__pycache__/', name) + self.assertNotIn('.pyc', name) + self.assertNotIn('/.git', name) + self.assertNotIn('/.github/', name) + self.assertNotIn( + 'simtrace-analyser-%s/frontend/package.json' % __version__, + self.names) + self.assertNotIn( + 'simtrace-analyser-%s/pyproject.toml' % __version__, self.names) + + def test_precache_files_are_in_the_zip(self): + # Drift guard: every asset the service worker precaches must travel + # with the PWA, otherwise an offline install fails on a fresh extract. + text = (REPO / 'frontend' / 'sw.js').read_text() + match = re.search(r'PRECACHE\s*=\s*\[(.*?)\]', text, re.S) + self.assertIsNotNone(match, 'no PRECACHE array in frontend/sw.js') + assets = re.findall(r"'([^']+)'", match.group(1)) + self.assertTrue(assets) + top = 'simtrace-analyser-%s' % __version__ + for asset in assets: + self.assertIn('%s/frontend/%s' % (top, asset), self.names, + 'precached %s is missing from the zip' % asset) + + def test_batch_files_use_crlf(self): + with zipfile.ZipFile(self.archive) as zf: + bats = [n for n in self.names if n.endswith('.bat')] + self.assertTrue(bats) + for name in bats: + data = zf.read(name) + self.assertIn(b'\r\n', data, name) + self.assertNotIn(b'\n', data.replace(b'\r\n', b''), + '%s has a bare LF' % name) + + def test_shell_script_is_lf_and_executable(self): + name = 'simtrace-analyser-%s/start.sh' % __version__ + with zipfile.ZipFile(self.archive) as zf: + info = zf.getinfo(name) + data = zf.read(name) + self.assertNotIn(b'\r\n', data) + self.assertTrue(info.external_attr >> 16 & 0o100, + 'start.sh lost its executable bit') + + +if __name__ == '__main__': + unittest.main()