release: the curated release package + a tag-triggered workflow
release / release (push) Waiting to run
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:
@@ -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 <repo>/../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
|
||||||
@@ -6,3 +6,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
simple_lastrun*.log
|
simple_lastrun*.log
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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
|
**1. Download the release package (recommended).** From the
|
||||||
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
||||||
(ZIP), unzip it anywhere and run the setup script once:
|
(`simple-<version>.zip`), unzip it anywhere and run the setup script once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
||||||
|
|||||||
+2
-2
@@ -18,8 +18,8 @@ SIMple — инструментарий для специалистов по UIC
|
|||||||
**Полная установка (PWA + сервер карт)** — два способа:
|
**Полная установка (PWA + сервер карт)** — два способа:
|
||||||
|
|
||||||
**1. Скачать релизный пакет (рекомендуется).** На странице
|
**1. Скачать релизный пакет (рекомендуется).** На странице
|
||||||
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет (ZIP),
|
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
|
||||||
распакуйте в любое место и один раз запустите setup:
|
(`simple-<version>.zip`), распакуйте в любое место и один раз запустите setup:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the release package: ``dist/simple-<version>.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: <repo>/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()
|
||||||
Reference in New Issue
Block a user