From 9ee9c85b099e67c18f55016295a3fc7ec862e804 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: Tue, 20 Oct 2026 23:29:44 +0300 Subject: [PATCH] release: the curated release package + a tag-triggered workflow The READMEs now point users at a release package instead of a clone; this adds the machinery behind it. - tools/build_release.py builds dist/simple-.zip: the runtime tree (the server package incl. its bundled MCC/MNC list, the PWA runtime files, docs/, tools/, the setup/start scripts, pyproject, requirements-pysim.txt, the READMEs, LICENSE and THIRD-PARTY-NOTICES) under a single top-level directory, with no tests, no frontend sources/build config, no bytecode and no git metadata. The version comes from server.py; --expect-version guards a mislabeled tag. - .github/workflows/release.yml runs both test suites, builds the package and publishes it on a v* tag (workflow_dispatch for an existing tag); a re-run uploads with --clobber. pysim is checked out next to the repo for the Python suite and is NOT bundled. - tests/test_release_package.py pins the package's shape: the runtime files, the absence of the development files, and that sw.js's precache list is fully covered - so a new asset cannot silently go missing. - dist/ is gitignored; the READMEs name the artifact. Verified: 997 Python + 953 frontend green; the built zip (53 files, ~0.95 MB) was unpacked in a clean directory and its setup.sh installed it into a fresh venv - the console script runs and the installed server resolves web_dir to the unpacked frontend/ (index.html, sw.js, uicc_files.json present). The workflow YAML parses; GitHub-side execution is unverified until the first tag is pushed. --- .github/workflows/release.yml | 74 ++++++++++++++++++++++ .gitignore | 1 + README.md | 2 +- README_RUS.md | 4 +- tests/test_release_package.py | 115 ++++++++++++++++++++++++++++++++++ tools/build_release.py | 109 ++++++++++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_release_package.py create mode 100644 tools/build_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0e3cf21 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: release + +# Builds the release package from the tagged source and publishes it. +# The package is a snapshot of the tree without the development-only files +# (tools/build_release.py); pysim is not bundled - setup installs it from +# upstream. Trigger: push a v* tag (or run manually for an existing tag). + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to package and publish (e.g. v3.31.0)' + required: true + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out the tagged source + uses: actions/checkout@v4 + with: + path: simple + ref: ${{ github.event.inputs.tag || github.ref }} + + # The Python tests expect a pysim checkout as a sibling of the repo + # (tests/*.py prepend /../pysim to sys.path). + - name: Check out pysim next to the repo + uses: actions/checkout@v4 + with: + repository: osmocom/pysim + path: pysim + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pysim and the test dependencies + working-directory: simple + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-pysim.txt + python -m pip install --no-deps ../pysim + + - name: Python suite + working-directory: simple + run: python -m unittest discover -s tests + + - name: Frontend suite + working-directory: simple/frontend + run: node --test + + - name: Build the release package + working-directory: simple + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + python tools/build_release.py --expect-version "${TAG#v}" --out dist + + - name: Publish the release + working-directory: simple + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + ZIP="dist/simple-${TAG#v}.zip" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$ZIP" --clobber + else + gh release create "$TAG" "$ZIP" --title "$TAG" --generate-notes + fi diff --git a/.gitignore b/.gitignore index 3ba1aad..d4c066d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ build/ AGENTS.md simple_lastrun*.log +dist/ diff --git a/README.md b/README.md index 844f9e4..4c68b93 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus **1. Download the release package (recommended).** From the [Releases](https://github.com/anttro/simple/releases) page take the latest package -(ZIP), unzip it anywhere and run the setup script once: +(`simple-.zip`), unzip it anywhere and run the setup script once: ```sh ./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server diff --git a/README_RUS.md b/README_RUS.md index 273e6f6..adfecaa 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -18,8 +18,8 @@ SIMple — инструментарий для специалистов по UIC **Полная установка (PWA + сервер карт)** — два способа: **1. Скачать релизный пакет (рекомендуется).** На странице -[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет (ZIP), -распакуйте в любое место и один раз запустите setup: +[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет +(`simple-.zip`), распакуйте в любое место и один раз запустите setup: ```sh ./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер diff --git a/tests/test_release_package.py b/tests/test_release_package.py new file mode 100644 index 0000000..8af8ce6 --- /dev/null +++ b/tests/test_release_package.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""The release package (``tools/build_release.py`` → simple-.zip). + +The zip is the READMEs' "download the release package" path. It must carry +everything needed to run the app and none of the development-only files (the +tests, the frontend sources/build configuration, git metadata), and its PWA +files must cover what the service worker precaches - otherwise a newly added +asset would silently be missing from a release. +""" + +import importlib.util +import re +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / 'tools' / 'build_release.py' +SERVER = REPO / 'pysim_simple_server' / 'server.py' + + +def _load_builder(): + spec = importlib.util.spec_from_file_location('build_release', SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _version(): + m = re.search(r"^VERSION = '([^']+)'$", SERVER.read_text(encoding='utf-8'), re.M) + return m.group(1) + + +def _sw_precache(): + """The PWA files sw.js precaches (the shell a release must be able to serve).""" + text = (REPO / 'frontend' / 'sw.js').read_text(encoding='utf-8') + m = re.search(r'const URLS = \[(.*?)\];', text, re.S) + return re.findall(r"'([^']+)'", m.group(1)) + + +class ReleasePackageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.builder = _load_builder() + cls.version = _version() + cls.tmp = tempfile.TemporaryDirectory() + cls.zip_path = cls.builder.build(cls.tmp.name) + with zipfile.ZipFile(cls.zip_path) as z: + cls.names = z.namelist() + cls.root = 'simple-%s/' % cls.version + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_the_package_is_named_after_the_server_version(self): + self.assertEqual(self.zip_path.name, 'simple-%s.zip' % self.version) + + def test_a_single_top_level_directory(self): + self.assertTrue(all(n.startswith(self.root) for n in self.names), 'stray top-level entries') + self.assertIn(self.root + 'pyproject.toml', self.names) + + def test_the_runtime_files_are_present(self): + for f in ('pysim_simple_server/server.py', + 'pysim_simple_server/__main__.py', + 'pysim_simple_server/data/mcc-mnc-list.json', + 'pysim_simple_server/data/mcc-mnc-list.LICENSE', + 'setup.sh', 'setup.bat', 'start.sh', 'start.bat', + 'pyproject.toml', 'requirements-pysim.txt', + 'README.md', 'README_RUS.md', 'LICENSE', 'THIRD-PARTY-NOTICES', + 'docs/api.md'): + self.assertIn(self.root + f, self.names) + + def test_every_frontend_runtime_file_is_present(self): + for f in self.builder.FRONTEND_FILES: + self.assertIn(self.root + 'frontend/' + f, self.names, f) + + def test_the_service_worker_precache_is_covered(self): + for f in _sw_precache(): + self.assertIn(self.root + 'frontend/' + f, self.names, + 'sw.js precaches %s but the package lacks it' % f) + + def test_no_development_files(self): + for n in self.names: + rel = n[len(self.root):] + self.assertFalse(rel.startswith('tests/'), n) + self.assertFalse(rel.startswith('frontend/src/'), n) + self.assertFalse(rel.startswith('frontend/tests/'), n) + for dev in ('frontend/package.json', 'frontend/package-lock.json', + 'frontend/tailwind.config.js', 'frontend/postcss.config.js'): + self.assertNotEqual(rel, dev, n) + self.assertNotIn('node_modules/', rel, n) + self.assertNotIn('.git/', rel, n) + self.assertNotIn('__pycache__', rel, n) + self.assertFalse(rel.endswith('.pyc'), n) + + def test_no_bytecode_or_temp_files(self): + basenames = [n.rsplit('/', 1)[-1] for n in self.names] + self.assertNotIn('.DS_Store', basenames) + self.assertFalse(any(b.endswith('~') for b in basenames)) + + def test_expect_version_guard(self): + # CI passes the tag without "v"; a mismatch must fail the build before + # anything is published. + r = subprocess.run([sys.executable, str(SCRIPT), '--expect-version', '0.0.0', + '--out', self.tmp.name], capture_output=True, text=True) + self.assertNotEqual(r.returncode, 0) + self.assertIn('version mismatch', r.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/build_release.py b/tools/build_release.py new file mode 100644 index 0000000..e19f94f --- /dev/null +++ b/tools/build_release.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Build the release package: ``dist/simple-.zip``. + +The package is a snapshot of the tagged source with the development-only files +removed - no git history, no tests, no frontend sources or build configuration. +It is what the READMEs' "download the release package" path installs; pysim is +NOT included (``setup.sh`` / ``setup.bat`` fetch it from its upstream +repository). + +Usage: + python tools/build_release.py [--out DIR] [--expect-version X.Y.Z] [--list] +""" + +import argparse +import re +import sys +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# The PWA's runtime files: everything ``sw.js`` precaches, plus the service +# worker itself. tests/test_release_package.py cross-checks this list against +# sw.js, so a newly precached file cannot silently go missing from a package. +FRONTEND_FILES = ( + 'index.html', 'style.css', 'sw.js', 'help.html', 'help-ru.html', + 'uicc_files.json', 'manifest.json', 'favicon.svg', + 'icon-192.png', 'icon-512.png', + 'sim.png', 'sim.svg', 'sim_anim.svg', 'nosim.svg', + 'aes-bundle.js', 'des-bundle.js', +) + +# Everything a user needs (and the source of what the package ships - the GPL +# "corresponding source" is also the public repo at the same tag). +PACKAGE_ENTRIES = ( + 'pysim_simple_server', + 'docs', + 'tools', + 'setup.sh', 'setup.bat', 'start.sh', 'start.bat', + 'pyproject.toml', 'requirements-pysim.txt', + 'README.md', 'README_RUS.md', + 'LICENSE', 'THIRD-PARTY-NOTICES', +) + + +def version(): + """The server's VERSION - the single source of truth for the package name.""" + text = (REPO / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8') + m = re.search(r"^VERSION = '([^']+)'$", text, re.M) + if not m: + sys.exit('error: VERSION not found in pysim_simple_server/server.py') + return m.group(1) + + +def _iter_files(src): + """The files to package from a PACKAGE_ENTRIES path (no bytecode, no caches).""" + if src.is_file(): + yield src + return + for p in sorted(src.rglob('*')): + if p.is_dir() or '__pycache__' in p.parts or p.suffix == '.pyc': + continue + yield p + + +def build(out_dir): + """Write the package and return its path.""" + ver = version() + name = 'simple-%s' % ver + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / (name + '.zip') + with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z: + for entry in PACKAGE_ENTRIES: + src = REPO / entry + if not src.exists(): + sys.exit('error: missing %s' % entry) + for p in _iter_files(src): + z.write(p, '%s/%s' % (name, p.relative_to(REPO).as_posix())) + for f in FRONTEND_FILES: + src = REPO / 'frontend' / f + if not src.is_file(): + sys.exit('error: missing frontend/%s' % f) + z.write(src, '%s/frontend/%s' % (name, f)) + return out + + +def main(): + ap = argparse.ArgumentParser(description='Build the SIMple release package.') + ap.add_argument('--out', default=str(REPO / 'dist'), help='output directory (default: /dist)') + ap.add_argument('--expect-version', metavar='VER', + help='fail unless the source VERSION matches (CI: the tag without "v")') + ap.add_argument('--list', action='store_true', help='print the archive contents') + args = ap.parse_args() + + if args.expect_version and version() != args.expect_version: + sys.exit('error: version mismatch: source says %s, expected %s' + % (version(), args.expect_version)) + out = build(args.out) + with zipfile.ZipFile(out) as z: + names = z.namelist() + if args.list: + for n in names: + print(n) + print('%s: %d files, %d bytes' % (out, len(names), out.stat().st_size)) + + +if __name__ == '__main__': + main()