release: the curated release package + a tag-triggered workflow
release / release (push) Waiting to run

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-<version>.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.
This commit is contained in:
2026-10-20 23:29:44 +03:00
parent 5cea89cc66
commit 9ee9c85b09
6 changed files with 302 additions and 3 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""The release package (``tools/build_release.py`` → simple-<ver>.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()