From c61c5966f731e800553f0e3c20d8368fd7a24e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 09:55:06 +0000 Subject: [PATCH 1/8] Scaffold pattern-B package: pyproject + binary resolver (WIP) Client-independent groundwork for gomc-rest-python. Adds the package metadata and the platform binary resolver that locates the bundled gomc-rest server. The HTTP layer is delegated to gomc-rest-client; the launch() wiring is intentionally deferred until the in-progress gomc-rest-client update lands. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- pyproject.toml | 33 ++++++++++++++++++ src/gomc_rest/_binaries.py | 60 ++++++++++++++++++++++++++++++++ src/gomc_rest/binaries/README.md | 15 ++++++++ 3 files changed, 108 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/gomc_rest/_binaries.py create mode 100644 src/gomc_rest/binaries/README.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7cf3b94 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "gomc-rest" +version = "0.1.0" +description = "Pattern-B wrapper for gomc-rest: bundles the gomc-rest server binary and auto-launches it, exposing a Python client for Mitsubishi PLCs." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Moge800" }] +keywords = ["mitsubishi", "plc", "mc-protocol", "slmp", "gomc-rest"] + +# HTTP layer is delegated to gomc-rest-client (pattern A). We only add the +# bundled-binary + subprocess lifecycle on top. +dependencies = [ + "gomc-rest-client>=0.1.0", +] + +[project.urls] +Homepage = "https://github.com/Moge800/gomc_rest_python" +"Upstream server" = "https://github.com/Moge800/gomc-rest" +"HTTP client" = "https://github.com/Moge800/gomc_rest_client" + +[tool.hatch.build.targets.wheel] +packages = ["src/gomc_rest"] + +# The platform-specific server binaries live under gomc_rest/binaries/ and are +# shipped as package data. (Per-platform wheels are produced at release time; +# see binaries/README.md.) +[tool.hatch.build.targets.wheel.force-include] +"src/gomc_rest/binaries" = "gomc_rest/binaries" diff --git a/src/gomc_rest/_binaries.py b/src/gomc_rest/_binaries.py new file mode 100644 index 0000000..52aa13d --- /dev/null +++ b/src/gomc_rest/_binaries.py @@ -0,0 +1,60 @@ +"""Locate the bundled gomc-rest server binary for the current platform.""" + +from __future__ import annotations + +import platform +import stat +from pathlib import Path + +_BINARIES_DIR = Path(__file__).parent / "binaries" + +# (system, machine) -> bundled binary file name. +# Machine names are normalised to the keys below before lookup. +_BINARY_NAMES = { + ("windows", "amd64"): "gomc-rest.exe", + ("linux", "amd64"): "gomc-rest-linux-amd64", + ("linux", "arm64"): "gomc-rest-linux-arm64", +} + +_MACHINE_ALIASES = { + "x86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", +} + + +def _normalise_platform() -> tuple[str, str]: + system = platform.system().lower() + machine = _MACHINE_ALIASES.get(platform.machine().lower(), platform.machine().lower()) + return system, machine + + +def binary_path() -> Path: + """Return the path to the bundled server binary for this platform. + + Raises RuntimeError if the platform is unsupported or the binary is missing + from the package (e.g. a sdist install without the platform wheel). + """ + system, machine = _normalise_platform() + name = _BINARY_NAMES.get((system, machine)) + if name is None: + raise RuntimeError( + f"Unsupported platform {system}/{machine}. " + f"gomc-rest ships binaries for: {sorted(_BINARY_NAMES)}." + ) + + path = _BINARIES_DIR / name + if not path.exists(): + raise RuntimeError( + f"Bundled server binary '{name}' was not found at {path}. " + "Install the platform-specific wheel, or place the binary there " + "(see gomc_rest/binaries/README.md)." + ) + + # sdists/VCS checkouts may drop the executable bit. + if system != "windows": + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + return path diff --git a/src/gomc_rest/binaries/README.md b/src/gomc_rest/binaries/README.md new file mode 100644 index 0000000..2eb4e40 --- /dev/null +++ b/src/gomc_rest/binaries/README.md @@ -0,0 +1,15 @@ +# Bundled server binaries + +This directory holds the platform-specific `gomc-rest` server binaries that the +Python package auto-launches (Pattern B): + +| Platform | File name | +| --------------- | ------------------------ | +| Windows (amd64) | `gomc-rest.exe` | +| Linux (amd64) | `gomc-rest-linux-amd64` | +| Linux (arm64) | `gomc-rest-linux-arm64` | + +The binaries are **not committed to git**. They are vendored in at release time +from a pinned [gomc-rest](https://github.com/Moge800/gomc-rest) version, and each +platform wheel ships only its matching binary. `_binaries.py` resolves the +correct file for the current platform at runtime. From c502d5b48b0bdc6ab3783bd9acde2ac5047cee18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 02:15:27 +0000 Subject: [PATCH 2/8] Wire up launch() to gomc-rest-client v0.9.0 API Add the deferred client-dependent layer now that gomc-rest-client is updated: - _server.py: spawn bundled gomc-rest on a free port, wait for /health, verify version support, stop via context manager / atexit. - __init__.py: launch() returning a managed Server; re-export PLCClient and exceptions. - Pin gomc-rest-client>=0.9.0 (requires bundled server v1.3.0+). - README. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- README.md | 56 ++++++++++++++++++++++ pyproject.toml | 2 +- src/gomc_rest/__init__.py | 58 +++++++++++++++++++++++ src/gomc_rest/_server.py | 97 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 src/gomc_rest/__init__.py create mode 100644 src/gomc_rest/_server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c600814 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# gomc-rest (Python) + +Python package for talking to Mitsubishi PLCs via +[gomc-rest](https://github.com/Moge800/gomc-rest) — **Pattern B**: the +`gomc-rest` server binary is bundled and auto-launched as a subprocess, so you +never have to start or distribute the executable yourself. + +The HTTP layer is provided by +[gomc-rest-client](https://github.com/Moge800/gomc_rest_client); this package +adds only the bundled binary and its process lifecycle. + +```text +your Python process +└─ gomc_rest.launch() + ├─ spawns gomc-rest (bundled exe) on a free loopback port ── MC protocol ──▶ PLC + └─ returns a gomc_rest_client.PLCClient pointed at it +``` + +## Install + +```bash +pip install gomc-rest +``` + +## Usage + +```python +import gomc_rest + +with gomc_rest.launch(plc_host="192.168.0.1") as plc: + values = plc.read("D100", 3) + plc.write("D100", [10, 20, 30]) +# the bundled server is stopped automatically on exit +``` + +`launch()` returns a `Server`; using it as a context manager yields a +`PLCClient` (see gomc-rest-client for the full read/write/remote API) and stops +the server on exit. Without `with`, the server is stopped at interpreter exit. + +Pass server flags through `extra_args`: + +```python +with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc: + plc.remote_run() +``` + +## Versions + +This package bundles a pinned `gomc-rest` binary. The bundled server must +satisfy `gomc-rest-client`'s `MINIMUM_SUPPORTED_GOMC_REST_VERSION` +(currently **v1.3.0**); `launch()` verifies this on startup. + +## Status + +Early scaffold. The binaries are vendored at release time (see +`src/gomc_rest/binaries/README.md`) and are not committed to git. diff --git a/pyproject.toml b/pyproject.toml index 7cf3b94..957ad57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ keywords = ["mitsubishi", "plc", "mc-protocol", "slmp", "gomc-rest"] # HTTP layer is delegated to gomc-rest-client (pattern A). We only add the # bundled-binary + subprocess lifecycle on top. dependencies = [ - "gomc-rest-client>=0.1.0", + "gomc-rest-client>=0.9.0", ] [project.urls] diff --git a/src/gomc_rest/__init__.py b/src/gomc_rest/__init__.py new file mode 100644 index 0000000..15c74a9 --- /dev/null +++ b/src/gomc_rest/__init__.py @@ -0,0 +1,58 @@ +"""gomc-rest (Python, Pattern B). + +Bundles the gomc-rest server binary, auto-launches it as a subprocess, and +hands back a PLCClient (from gomc-rest-client) pointed at it. The HTTP layer is +provided entirely by gomc-rest-client; this package only adds binary bundling +and process lifecycle. + + import gomc_rest + + with gomc_rest.launch(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) +""" + +from __future__ import annotations + +from gomc_rest_client import ( + GomcRestBusyError, + GomcRestPLCProtocolError, + PLCClient, +) + +from ._server import Server + +__version__ = "0.1.0" + + +def launch( + plc_host: str = "192.168.0.1", + plc_port: int = 5007, + extra_args: list[str] | None = None, + startup_timeout: float = 10.0, +) -> Server: + """Start the bundled gomc-rest server and return a managed Server. + + Use as a context manager to get the PLCClient and auto-stop the server:: + + with gomc_rest.launch(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) + + ``extra_args`` are passed through to the gomc-rest binary (e.g. + ``["-enable-remote"]`` or ``["-readonly"]``). + """ + return Server( + plc_host=plc_host, + plc_port=plc_port, + extra_args=extra_args, + startup_timeout=startup_timeout, + ) + + +__all__ = [ + "launch", + "Server", + "PLCClient", + "GomcRestBusyError", + "GomcRestPLCProtocolError", + "__version__", +] diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py new file mode 100644 index 0000000..ebb0e6c --- /dev/null +++ b/src/gomc_rest/_server.py @@ -0,0 +1,97 @@ +"""Launch and manage the bundled gomc-rest server as a subprocess (Pattern B).""" + +from __future__ import annotations + +import atexit +import socket +import subprocess +import time + +from gomc_rest_client import PLCClient + +from ._binaries import binary_path + + +def _free_port() -> int: + """Pick a currently-free TCP port on the loopback interface.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class Server: + """Owns a gomc-rest subprocess and a PLCClient pointed at it. + + Prefer the context-manager form:: + + with Server(plc_host="192.168.0.1") as plc: + plc.read("D100", 3) + + When not used as a context manager the subprocess is still terminated at + interpreter exit via atexit. + """ + + def __init__( + self, + plc_host: str = "192.168.0.1", + plc_port: int = 5007, + extra_args: list[str] | None = None, + startup_timeout: float = 10.0, + ) -> None: + self._port = _free_port() + self.base_url = f"http://127.0.0.1:{self._port}" + + args = [ + str(binary_path()), + "-listen", f":{self._port}", + "-host", plc_host, + "-port", str(plc_port), + *(extra_args or []), + ] + self._proc = subprocess.Popen(args) + atexit.register(self.close) + + self.client = PLCClient(self.base_url) + self._wait_until_healthy(startup_timeout) + self._check_version() + + def _wait_until_healthy(self, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._proc.poll() is not None: + raise RuntimeError( + f"gomc-rest exited during startup (code {self._proc.returncode})." + ) + try: + self.client.health() + return + except Exception: + time.sleep(0.1) + self.close() + raise TimeoutError(f"gomc-rest did not become healthy within {timeout}s.") + + def _check_version(self) -> None: + if not self.client.is_supported_version(): + self.close() + raise RuntimeError( + f"Bundled gomc-rest {self.client.version()} is not supported by " + "the installed gomc-rest-client. Update the bundled binary." + ) + + def close(self) -> None: + """Terminate the subprocess. Safe to call more than once.""" + proc = getattr(self, "_proc", None) + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + def __enter__(self) -> PLCClient: + return self.client + + def __exit__(self, *exc) -> None: + self.close() From ee790286ec24bf0a04473ba948a2b1a5942f35a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 02:24:18 +0000 Subject: [PATCH 3/8] Add server_mode: loopback by default, expose REST API when enabled launch()/Server gain server_mode (default False). Default binds the bundled server to 127.0.0.1 so no other app or host can reach the unauthenticated REST API; server_mode=True binds all interfaces so other apps on a trusted network can call it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- README.md | 20 ++++++++++++++++++++ src/gomc_rest/__init__.py | 7 +++++++ src/gomc_rest/_server.py | 7 ++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c600814..e8a120c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,26 @@ with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc.remote_run() ``` +## Network exposure + +By default the bundled server binds to **loopback only** (`127.0.0.1`): your own +process can use it, but no other app or host can reach the REST API. + +Set `server_mode=True` to bind all interfaces so other apps on the network +(e.g. gomc-rest-gui, curl from another machine) can call it: + +```python +server = gomc_rest.launch(plc_host="192.168.0.1", server_mode=True) +print(server.base_url) # other apps connect to http://: +try: + server.client.read("D100", 3) +finally: + server.close() +``` + +The server has no authentication or TLS — only enable `server_mode` on a +trusted network. + ## Versions This package bundles a pinned `gomc-rest` binary. The bundled server must diff --git a/src/gomc_rest/__init__.py b/src/gomc_rest/__init__.py index 15c74a9..fff1372 100644 --- a/src/gomc_rest/__init__.py +++ b/src/gomc_rest/__init__.py @@ -27,6 +27,7 @@ def launch( plc_host: str = "192.168.0.1", plc_port: int = 5007, + server_mode: bool = False, extra_args: list[str] | None = None, startup_timeout: float = 10.0, ) -> Server: @@ -37,12 +38,18 @@ def launch( with gomc_rest.launch(plc_host="192.168.0.1") as plc: plc.read("D100", 3) + By default the server binds to loopback only, so no other app or host can + reach it. Set ``server_mode=True`` to bind all interfaces and let other + apps on the network call the REST API (the server has no auth/TLS — only + expose it on a trusted network). + ``extra_args`` are passed through to the gomc-rest binary (e.g. ``["-enable-remote"]`` or ``["-readonly"]``). """ return Server( plc_host=plc_host, plc_port=plc_port, + server_mode=server_mode, extra_args=extra_args, startup_timeout=startup_timeout, ) diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py index ebb0e6c..dcf1c84 100644 --- a/src/gomc_rest/_server.py +++ b/src/gomc_rest/_server.py @@ -35,15 +35,20 @@ def __init__( self, plc_host: str = "192.168.0.1", plc_port: int = 5007, + server_mode: bool = False, extra_args: list[str] | None = None, startup_timeout: float = 10.0, ) -> None: self._port = _free_port() self.base_url = f"http://127.0.0.1:{self._port}" + # Default: bind to loopback only, so no other app/host can reach the + # (unauthenticated) server. server_mode binds all interfaces so other + # apps on the network can call the REST API. + listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}" args = [ str(binary_path()), - "-listen", f":{self._port}", + "-listen", listen, "-host", plc_host, "-port", str(plc_port), *(extra_args or []), From 20ea2fab23d62f23f772db1124bc620d5ef5f68b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 04:56:48 +0000 Subject: [PATCH 4/8] Generate a per-launch bearer token by default gomc-rest and gomc-rest-client now support token auth. launch()/Server generate a random token per launch, pass it via -token to the bundled server, and set it on the PLCClient. This makes the default effectively script-only: another process on the same host that learns the port still cannot call the API without the token. - token param: None auto-generates; explicit string to share; "" disables. - Expose server.token; re-export GomcRestUnauthorizedError. - Pin gomc-rest-client>=0.10.0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- README.md | 20 ++++++++++++++------ pyproject.toml | 2 +- src/gomc_rest/__init__.py | 18 ++++++++++++++---- src/gomc_rest/_server.py | 16 ++++++++++++---- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e8a120c..16d8b6b 100644 --- a/README.md +++ b/README.md @@ -44,25 +44,33 @@ with gomc_rest.launch(plc_host="192.168.0.1", extra_args=["-enable-remote"]) as plc.remote_run() ``` -## Network exposure +## Access control -By default the bundled server binds to **loopback only** (`127.0.0.1`): your own -process can use it, but no other app or host can reach the REST API. +Two independent layers protect the server, both on by default: + +1. **Per-launch bearer token.** A random token is generated each launch and + required by the server, so even another process on the same host that + discovers the port cannot call the API. It is set automatically on the + returned client and exposed as `server.token`. Pass an explicit `token=` to + share with another app, or `token=""` to disable auth (closed-network use). +2. **Loopback binding.** By default the server binds to `127.0.0.1`, so no + other host can reach it. Set `server_mode=True` to bind all interfaces so other apps on the network -(e.g. gomc-rest-gui, curl from another machine) can call it: +(e.g. gomc-rest-gui, curl from another machine) can call it — give them +`server.token`: ```python server = gomc_rest.launch(plc_host="192.168.0.1", server_mode=True) print(server.base_url) # other apps connect to http://: +print(server.token) # ...with this bearer token try: server.client.read("D100", 3) finally: server.close() ``` -The server has no authentication or TLS — only enable `server_mode` on a -trusted network. +The server has no TLS — only enable `server_mode` on a trusted network. ## Versions diff --git a/pyproject.toml b/pyproject.toml index 957ad57..04e4351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ keywords = ["mitsubishi", "plc", "mc-protocol", "slmp", "gomc-rest"] # HTTP layer is delegated to gomc-rest-client (pattern A). We only add the # bundled-binary + subprocess lifecycle on top. dependencies = [ - "gomc-rest-client>=0.9.0", + "gomc-rest-client>=0.10.0", ] [project.urls] diff --git a/src/gomc_rest/__init__.py b/src/gomc_rest/__init__.py index fff1372..1796172 100644 --- a/src/gomc_rest/__init__.py +++ b/src/gomc_rest/__init__.py @@ -16,6 +16,7 @@ from gomc_rest_client import ( GomcRestBusyError, GomcRestPLCProtocolError, + GomcRestUnauthorizedError, PLCClient, ) @@ -28,6 +29,7 @@ def launch( plc_host: str = "192.168.0.1", plc_port: int = 5007, server_mode: bool = False, + token: str | None = None, extra_args: list[str] | None = None, startup_timeout: float = 10.0, ) -> Server: @@ -38,10 +40,16 @@ def launch( with gomc_rest.launch(plc_host="192.168.0.1") as plc: plc.read("D100", 3) - By default the server binds to loopback only, so no other app or host can - reach it. Set ``server_mode=True`` to bind all interfaces and let other - apps on the network call the REST API (the server has no auth/TLS — only - expose it on a trusted network). + A random bearer token is generated per launch and required by the server, + so even another process on this host that learns the port cannot call the + API. Pass an explicit ``token`` to share with another app, or ``token=""`` + to disable auth (closed-network use). The token is available as + ``server.token``. + + By default the server also binds to loopback only. Set ``server_mode=True`` + to bind all interfaces and let other apps on the network call the REST API + (give them ``server.token``). The server has no TLS — only expose it on a + trusted network. ``extra_args`` are passed through to the gomc-rest binary (e.g. ``["-enable-remote"]`` or ``["-readonly"]``). @@ -50,6 +58,7 @@ def launch( plc_host=plc_host, plc_port=plc_port, server_mode=server_mode, + token=token, extra_args=extra_args, startup_timeout=startup_timeout, ) @@ -61,5 +70,6 @@ def launch( "PLCClient", "GomcRestBusyError", "GomcRestPLCProtocolError", + "GomcRestUnauthorizedError", "__version__", ] diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py index dcf1c84..18db5bd 100644 --- a/src/gomc_rest/_server.py +++ b/src/gomc_rest/_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import atexit +import secrets import socket import subprocess import time @@ -36,27 +37,34 @@ def __init__( plc_host: str = "192.168.0.1", plc_port: int = 5007, server_mode: bool = False, + token: str | None = None, extra_args: list[str] | None = None, startup_timeout: float = 10.0, ) -> None: self._port = _free_port() self.base_url = f"http://127.0.0.1:{self._port}" - # Default: bind to loopback only, so no other app/host can reach the - # (unauthenticated) server. server_mode binds all interfaces so other - # apps on the network can call the REST API. + # A per-launch random token is generated by default, so even another + # process on this host that learns the port cannot call the API without + # it. Pass token="" to disable auth (closed-network use). + self.token = secrets.token_urlsafe(24) if token is None else (token or None) + + # Default: bind to loopback only, so no other host can reach the + # server. server_mode binds all interfaces so other apps on the + # network can call the REST API (give them self.token). listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}" args = [ str(binary_path()), "-listen", listen, "-host", plc_host, "-port", str(plc_port), + *(["-token", self.token] if self.token else []), *(extra_args or []), ] self._proc = subprocess.Popen(args) atexit.register(self.close) - self.client = PLCClient(self.base_url) + self.client = PLCClient(self.base_url, token=self.token) self._wait_until_healthy(startup_timeout) self._check_version() From 0bf8544d53f9f0622f18d1ee78d66961087d40a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 05:02:08 +0000 Subject: [PATCH 5/8] Automate binary bundling: vendor script + release CI - GOMC_REST_VERSION pins the bundled server (v1.3.0, the client's minimum). - scripts/vendor_binaries.py downloads the binaries from the matching gomc-rest GitHub release (all, or one via --only) into the package. - release.yml builds one platform-specific wheel per OS (win_amd64, manylinux2014 x86_64/aarch64), each bundling only its matching binary, and publishes to PyPI via trusted publishing on a v* tag. - binaries/.gitignore keeps downloaded binaries out of git. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- .github/workflows/release.yml | 66 ++++++++++++++++++++++++++++++ GOMC_REST_VERSION | 1 + README.md | 15 +++++-- scripts/vendor_binaries.py | 68 +++++++++++++++++++++++++++++++ src/gomc_rest/binaries/.gitignore | 4 ++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 GOMC_REST_VERSION create mode 100644 scripts/vendor_binaries.py create mode 100644 src/gomc_rest/binaries/.gitignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8434fa7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: release + +# Build one platform-specific wheel per OS (each bundling only its matching +# gomc-rest binary) and publish to PyPI on a version tag. +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build-wheels: + name: wheel (${{ matrix.plat }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + asset: gomc-rest.exe + plat: win_amd64 + - os: ubuntu-latest + asset: gomc-rest-linux-amd64 + plat: manylinux2014_x86_64 + - os: ubuntu-24.04-arm + asset: gomc-rest-linux-arm64 + plat: manylinux2014_aarch64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tooling + run: python -m pip install --upgrade build wheel + + - name: Vendor the matching server binary + run: python scripts/vendor_binaries.py --only ${{ matrix.asset }} + + - name: Build wheel + run: python -m build --wheel + + - name: Re-tag wheel for this platform + shell: bash + run: python -m wheel tags --platform-tag ${{ matrix.plat }} --remove dist/*.whl + + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.plat }} + path: dist/*.whl + + publish: + name: publish to PyPI + needs: build-wheels + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # trusted publishing (OIDC) — no API token needed + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/GOMC_REST_VERSION b/GOMC_REST_VERSION new file mode 100644 index 0000000..18fa8e7 --- /dev/null +++ b/GOMC_REST_VERSION @@ -0,0 +1 @@ +v1.3.0 diff --git a/README.md b/README.md index 16d8b6b..17fccec 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,16 @@ This package bundles a pinned `gomc-rest` binary. The bundled server must satisfy `gomc-rest-client`'s `MINIMUM_SUPPORTED_GOMC_REST_VERSION` (currently **v1.3.0**); `launch()` verifies this on startup. -## Status +## Releasing / bundled binaries -Early scaffold. The binaries are vendored at release time (see -`src/gomc_rest/binaries/README.md`) and are not committed to git. +The bundled server version is pinned in `GOMC_REST_VERSION`. Binaries are not +committed to git; they are fetched from the matching gomc-rest GitHub release. + +- Locally: `python scripts/vendor_binaries.py` downloads all three binaries + into `src/gomc_rest/binaries/`. +- On a `v*` tag, `.github/workflows/release.yml` builds one platform-specific + wheel per OS (each bundling only its matching binary) and publishes to PyPI + via trusted publishing. + +To bump the bundled server, edit `GOMC_REST_VERSION` (keep it >= the client's +`MINIMUM_SUPPORTED_GOMC_REST_VERSION`) and cut a new tag. diff --git a/scripts/vendor_binaries.py b/scripts/vendor_binaries.py new file mode 100644 index 0000000..4439d36 --- /dev/null +++ b/scripts/vendor_binaries.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Download the pinned gomc-rest server binaries into the package. + +The binaries are not committed to git; this script fetches them from the +gomc-rest GitHub release named in the GOMC_REST_VERSION file (override with +the GOMC_REST_VERSION env var) and writes them into src/gomc_rest/binaries/. + +Usage: + python scripts/vendor_binaries.py # all platforms + python scripts/vendor_binaries.py --only gomc-rest-linux-amd64 +""" + +from __future__ import annotations + +import argparse +import os +import sys +import urllib.request +from pathlib import Path + +REPO = "Moge800/gomc-rest" +ASSETS = [ + "gomc-rest.exe", + "gomc-rest-linux-amd64", + "gomc-rest-linux-arm64", +] + +_ROOT = Path(__file__).resolve().parent.parent +_DEST = _ROOT / "src" / "gomc_rest" / "binaries" + + +def _version() -> str: + env = os.environ.get("GOMC_REST_VERSION") + if env: + return env.strip() + return (_ROOT / "GOMC_REST_VERSION").read_text().strip() + + +def _download(version: str, asset: str) -> None: + url = f"https://github.com/{REPO}/releases/download/{version}/{asset}" + out = _DEST / asset + print(f" {asset} <- {url}") + urllib.request.urlretrieve(url, out) + if not asset.endswith(".exe"): + out.chmod(0o755) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--only", + choices=ASSETS, + help="Download a single binary instead of all (used for per-platform wheels).", + ) + args = parser.parse_args() + + version = _version() + targets = [args.only] if args.only else ASSETS + _DEST.mkdir(parents=True, exist_ok=True) + print(f"Vendoring gomc-rest {version} into {_DEST}") + for asset in targets: + _download(version, asset) + print("Done.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/gomc_rest/binaries/.gitignore b/src/gomc_rest/binaries/.gitignore new file mode 100644 index 0000000..4fd9294 --- /dev/null +++ b/src/gomc_rest/binaries/.gitignore @@ -0,0 +1,4 @@ +# Vendored at build time by scripts/vendor_binaries.py — never commit binaries. +* +!.gitignore +!README.md From 0d76990a66e5ca209a286c9480e1ea29e37fa40f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 05:26:25 +0000 Subject: [PATCH 6/8] Address review: bundle v1.4.0, fix token/lifecycle, version guard, smoke test P1: bump GOMC_REST_VERSION to v1.4.0 (v1.3.0 lacks -token and is below the client's MINIMUM_SUPPORTED_GOMC_REST_VERSION), and add a smoke test that launches with default settings. P2: - token="" now disables auth correctly: distinguish unspecified (None -> generate) from "" (disable), and always pass -token explicitly (empty when disabled) so an inherited GOMCR_TOKEN can't enable server-side auth while the client stays unauthenticated. - Wrap post-Popen startup validation in try/except and close() the subprocess on any failure, since the caller never receives a Server. - close() now also releases the PLCClient HTTP session and atexit.unregister()s itself, so repeated launch/close no longer accumulates connections/objects. - release.yml verifies the pushed tag matches project.version before building. Add ci.yml to vendor the Linux binary and run the smoke test on PRs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- .github/workflows/ci.yml | 25 +++++++++++++++++++++ .github/workflows/release.yml | 9 ++++++++ GOMC_REST_VERSION | 2 +- src/gomc_rest/_server.py | 42 +++++++++++++++++++++++++++-------- tests/test_smoke.py | 40 +++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1e1434 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package and test deps + run: python -m pip install -e . pytest + + - name: Vendor the Linux server binary + run: python scripts/vendor_binaries.py --only gomc-rest-linux-amd64 + + - name: Run tests + run: python -m pytest -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8434fa7..b3e216f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,15 @@ jobs: with: python-version: "3.11" + - name: Check tag matches project version + if: github.ref_type == 'tag' + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + proj=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_bytes().decode())['project']['version'])") + echo "tag=$tag project.version=$proj" + test "$tag" = "$proj" || { echo "::error::tag $GITHUB_REF_NAME does not match project.version $proj"; exit 1; } + - name: Install build tooling run: python -m pip install --upgrade build wheel diff --git a/GOMC_REST_VERSION b/GOMC_REST_VERSION index 18fa8e7..0d0c52f 100644 --- a/GOMC_REST_VERSION +++ b/GOMC_REST_VERSION @@ -1 +1 @@ -v1.3.0 +v1.4.0 diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py index 18db5bd..cbb0204 100644 --- a/src/gomc_rest/_server.py +++ b/src/gomc_rest/_server.py @@ -46,27 +46,41 @@ def __init__( # A per-launch random token is generated by default, so even another # process on this host that learns the port cannot call the API without - # it. Pass token="" to disable auth (closed-network use). - self.token = secrets.token_urlsafe(24) if token is None else (token or None) + # it. token is None -> generate; token == "" -> disable auth; otherwise + # use the given token. + if token is None: + self.token = secrets.token_urlsafe(24) + elif token == "": + self.token = None + else: + self.token = token # Default: bind to loopback only, so no other host can reach the # server. server_mode binds all interfaces so other apps on the # network can call the REST API (give them self.token). listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}" + # Always pass -token explicitly (empty when disabled) so an inherited + # GOMCR_TOKEN cannot enable auth on the server while the client stays + # unauthenticated. args = [ str(binary_path()), "-listen", listen, "-host", plc_host, "-port", str(plc_port), - *(["-token", self.token] if self.token else []), + "-token", self.token or "", *(extra_args or []), ] self._proc = subprocess.Popen(args) atexit.register(self.close) - self.client = PLCClient(self.base_url, token=self.token) - self._wait_until_healthy(startup_timeout) - self._check_version() + try: + self._wait_until_healthy(startup_timeout) + self._check_version() + except BaseException: + # Never leave the subprocess running if startup validation fails; + # the caller never receives a Server to close() itself. + self.close() + raise def _wait_until_healthy(self, timeout: float) -> None: deadline = time.monotonic() + timeout @@ -80,19 +94,29 @@ def _wait_until_healthy(self, timeout: float) -> None: return except Exception: time.sleep(0.1) - self.close() raise TimeoutError(f"gomc-rest did not become healthy within {timeout}s.") def _check_version(self) -> None: if not self.client.is_supported_version(): - self.close() raise RuntimeError( f"Bundled gomc-rest {self.client.version()} is not supported by " "the installed gomc-rest-client. Update the bundled binary." ) def close(self) -> None: - """Terminate the subprocess. Safe to call more than once.""" + """Release the client session and terminate the subprocess. + + Safe to call more than once. + """ + atexit.unregister(self.close) + + client = getattr(self, "client", None) + if client is not None: + try: + client.__exit__(None, None, None) + except Exception: + pass + proc = getattr(self, "_proc", None) if proc is None or proc.poll() is not None: return diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..2f99693 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,40 @@ +"""Smoke test: the bundled server starts with default settings and is healthy. + +Skipped automatically when the server binary has not been vendored (so the +suite still passes locally without a download); CI vendors it first. +""" + +from __future__ import annotations + +import pytest + +import gomc_rest +from gomc_rest._binaries import binary_path + + +def _binary_available() -> bool: + try: + binary_path() + return True + except RuntimeError: + return False + + +pytestmark = pytest.mark.skipif( + not _binary_available(), reason="server binary not vendored" +) + + +def test_launch_default_is_healthy(): + # Default settings generate a random token and pass it to both ends. + with gomc_rest.launch() as plc: + assert isinstance(plc.health(), dict) + + +def test_launch_auth_disabled(): + server = gomc_rest.launch(token="") + try: + assert server.token is None + assert isinstance(server.client.health(), dict) + finally: + server.close() From 3273e79dced42c924ba28fe00fad9de024e343f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 07:16:56 +0000 Subject: [PATCH 7/8] Address review round 2: perms, dep cap, token via env, publish guard, docs - _binaries.py: chmod only when the executable bit is actually missing, so a non-owner running a root-installed, already-executable binary no longer hits PermissionError; raise a clear error if a needed chmod is denied. (P1) - pyproject.toml: cap gomc-rest-client to >=0.10.0,<0.11 so a future client raising its minimum server version can't break installs of this package. (P2) - _server.py: pass the token via GOMCR_TOKEN in a copied env instead of the -token argv, so it isn't visible in the process list / /proc cmdline. (P2) - release.yml: gate the publish job on github.ref_type == 'tag' so workflow_dispatch builds but never publishes from an arbitrary branch. (P2) - README: bundled version v1.4.0, dependency cap, documented threat model (same-OS-user can read the env), and a release procedure that bumps both project.version and __version__ to match the tag. (P2) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- .github/workflows/release.yml | 3 +++ README.md | 32 +++++++++++++++++++++++++------- pyproject.toml | 5 ++++- src/gomc_rest/_binaries.py | 18 ++++++++++++++---- src/gomc_rest/_server.py | 13 ++++++++----- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3e216f..e7880b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,9 @@ jobs: publish: name: publish to PyPI needs: build-wheels + # Only publish for real version tags; workflow_dispatch builds wheels for + # verification but must never publish from an arbitrary branch. + if: github.ref_type == 'tag' runs-on: ubuntu-latest environment: pypi permissions: diff --git a/README.md b/README.md index 17fccec..920cda7 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,21 @@ finally: The server has no TLS — only enable `server_mode` on a trusted network. +**Threat model.** The token is passed to the server via the `GOMCR_TOKEN` +environment variable (not the command line), so it does not appear in the +process list. It protects against other hosts and other OS users. It does +**not** protect against another process running as the **same OS user**, which +can read the server's environment (e.g. `/proc//environ`); the OS user +boundary is the trust boundary here. + ## Versions -This package bundles a pinned `gomc-rest` binary. The bundled server must -satisfy `gomc-rest-client`'s `MINIMUM_SUPPORTED_GOMC_REST_VERSION` -(currently **v1.3.0**); `launch()` verifies this on startup. +This package bundles a pinned `gomc-rest` binary (currently **v1.4.0**, set in +`GOMC_REST_VERSION`) that must satisfy `gomc-rest-client`'s +`MINIMUM_SUPPORTED_GOMC_REST_VERSION`; `launch()` verifies this on startup. The +`gomc-rest-client` dependency is capped (`>=0.10.0,<0.11`) so a future client +that raises its minimum server version can't be installed without also bumping +the bundled binary. ## Releasing / bundled binaries @@ -87,7 +97,15 @@ committed to git; they are fetched from the matching gomc-rest GitHub release. into `src/gomc_rest/binaries/`. - On a `v*` tag, `.github/workflows/release.yml` builds one platform-specific wheel per OS (each bundling only its matching binary) and publishes to PyPI - via trusted publishing. - -To bump the bundled server, edit `GOMC_REST_VERSION` (keep it >= the client's -`MINIMUM_SUPPORTED_GOMC_REST_VERSION`) and cut a new tag. + via trusted publishing. The release job verifies the tag equals + `project.version`; `workflow_dispatch` builds wheels for verification only and + never publishes. + +To cut a release: + +1. To change the bundled server, edit `GOMC_REST_VERSION` (keep it within the + range accepted by the pinned `gomc-rest-client`). +2. Bump the package version in **both** `pyproject.toml` (`project.version`) + and `src/gomc_rest/__init__.py` (`__version__`) — they must match. +3. Tag that exact version, e.g. `git tag v0.2.0 && git push origin v0.2.0` + (the tag must equal `project.version` or the release job fails). diff --git a/pyproject.toml b/pyproject.toml index 04e4351..0444bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,10 @@ keywords = ["mitsubishi", "plc", "mc-protocol", "slmp", "gomc-rest"] # HTTP layer is delegated to gomc-rest-client (pattern A). We only add the # bundled-binary + subprocess lifecycle on top. dependencies = [ - "gomc-rest-client>=0.10.0", + # Cap the client to a range known to accept the bundled server version + # (GOMC_REST_VERSION). A future client that raises its minimum server + # version must not be pulled in without bumping the bundled binary too. + "gomc-rest-client>=0.10.0,<0.11", ] [project.urls] diff --git a/src/gomc_rest/_binaries.py b/src/gomc_rest/_binaries.py index 52aa13d..81450cc 100644 --- a/src/gomc_rest/_binaries.py +++ b/src/gomc_rest/_binaries.py @@ -52,9 +52,19 @@ def binary_path() -> Path: "(see gomc_rest/binaries/README.md)." ) - # sdists/VCS checkouts may drop the executable bit. - if system != "windows": - mode = path.stat().st_mode - path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + # Wheels preserve the executable bit set at build time; only repair it when + # it is actually missing (e.g. an sdist/VCS checkout). Chmod'ing + # unconditionally breaks the common root-installs / non-root-runs setup, + # where the running user does not own an already-executable binary. + if system != "windows" and not path.stat().st_mode & stat.S_IXUSR: + try: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except PermissionError as exc: + raise RuntimeError( + f"Bundled binary {path} is not executable and its permissions " + "cannot be changed by this user. Reinstall the wheel or add the " + "executable bit." + ) from exc return path diff --git a/src/gomc_rest/_server.py b/src/gomc_rest/_server.py index cbb0204..75a672a 100644 --- a/src/gomc_rest/_server.py +++ b/src/gomc_rest/_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import atexit +import os import secrets import socket import subprocess @@ -59,18 +60,20 @@ def __init__( # server. server_mode binds all interfaces so other apps on the # network can call the REST API (give them self.token). listen = f":{self._port}" if server_mode else f"127.0.0.1:{self._port}" - # Always pass -token explicitly (empty when disabled) so an inherited - # GOMCR_TOKEN cannot enable auth on the server while the client stays - # unauthenticated. args = [ str(binary_path()), "-listen", listen, "-host", plc_host, "-port", str(plc_port), - "-token", self.token or "", *(extra_args or []), ] - self._proc = subprocess.Popen(args) + # Pass the token via the environment, not argv, so it is not exposed in + # the process list / /proc//cmdline. Set GOMCR_TOKEN explicitly + # (empty when disabled) so an inherited value can't enable auth on the + # server while the client stays unauthenticated. + env = os.environ.copy() + env["GOMCR_TOKEN"] = self.token or "" + self._proc = subprocess.Popen(args, env=env) atexit.register(self.close) self.client = PLCClient(self.base_url, token=self.token) try: From 0db1c7fbbd75e8084a487e1b576bedf1aa83732c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 10:11:17 +0000 Subject: [PATCH 8/8] Address review round 3: correct glibc tag + verify binary checksums P1 (glibc tag): the v1.4.0 amd64 binary is dynamically linked and needs GLIBC_2.34, so tag its wheel manylinux_2_34_x86_64 instead of manylinux2014 (glibc 2.17), which would let pip install it on systems where it fails with 'GLIBC_2.34 not found'. The arm64 binary is statically linked (verified: no dynamic section), so the wider manylinux2014_aarch64 tag stays correct. Add a floor-glibc-amd64 CI job that runs the smoke test inside a glibc-2.34 container so the binary is proven to start at its declared floor. P2 (supply chain): pin the trusted SHA-256 of each release asset in checksums/.sha256 (committed, not fetched alongside the binary) and verify every download in vendor_binaries.py, failing closed on mismatch or a missing pin. Document adding the checksum file when bumping the bundled server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SQbVkPEprK2ebcjck5ouUy --- .github/workflows/ci.yml | 16 +++++++++++++ .github/workflows/release.yml | 7 +++++- README.md | 8 +++++-- checksums/v1.4.0.sha256 | 3 +++ scripts/vendor_binaries.py | 43 ++++++++++++++++++++++++++++++++--- 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 checksums/v1.4.0.sha256 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1e1434..8d30a7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,19 @@ jobs: - name: Run tests run: python -m pytest -v + + # Verify the dynamically-linked amd64 binary actually starts on its declared + # glibc floor (manylinux_2_34 == glibc 2.34), not just on the latest runner. + floor-glibc-amd64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Smoke test on glibc 2.34 + run: | + docker run --rm -v "$PWD":/w -w /w \ + quay.io/pypa/manylinux_2_34_x86_64 bash -c ' + PY=/opt/python/cp311-cp311/bin/python + "$PY" -m pip install -e . pytest && + "$PY" scripts/vendor_binaries.py --only gomc-rest-linux-amd64 && + "$PY" -m pytest -v + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7880b0..f4341e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,14 @@ jobs: - os: windows-latest asset: gomc-rest.exe plat: win_amd64 + # amd64 binary is dynamically linked and needs GLIBC_2.34, so it must + # be tagged manylinux_2_34 (not 2014/glibc 2.17) or pip would install + # it on older systems where it fails with "GLIBC_2.34 not found". - os: ubuntu-latest asset: gomc-rest-linux-amd64 - plat: manylinux2014_x86_64 + plat: manylinux_2_34_x86_64 + # arm64 binary is statically linked (no glibc dependency), so the + # widest valid tag is fine. - os: ubuntu-24.04-arm asset: gomc-rest-linux-arm64 plat: manylinux2014_aarch64 diff --git a/README.md b/README.md index 920cda7..1c00024 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ The bundled server version is pinned in `GOMC_REST_VERSION`. Binaries are not committed to git; they are fetched from the matching gomc-rest GitHub release. - Locally: `python scripts/vendor_binaries.py` downloads all three binaries - into `src/gomc_rest/binaries/`. + into `src/gomc_rest/binaries/`, verifying each against the trusted SHA-256 + values committed in `checksums/.sha256`. - On a `v*` tag, `.github/workflows/release.yml` builds one platform-specific wheel per OS (each bundling only its matching binary) and publishes to PyPI via trusted publishing. The release job verifies the tag equals @@ -104,7 +105,10 @@ committed to git; they are fetched from the matching gomc-rest GitHub release. To cut a release: 1. To change the bundled server, edit `GOMC_REST_VERSION` (keep it within the - range accepted by the pinned `gomc-rest-client`). + range accepted by the pinned `gomc-rest-client`) and add a matching + `checksums/.sha256` with the trusted SHA-256 of each asset. If the + new binaries change their glibc requirement, update the `plat` tags in + `release.yml` accordingly. 2. Bump the package version in **both** `pyproject.toml` (`project.version`) and `src/gomc_rest/__init__.py` (`__version__`) — they must match. 3. Tag that exact version, e.g. `git tag v0.2.0 && git push origin v0.2.0` diff --git a/checksums/v1.4.0.sha256 b/checksums/v1.4.0.sha256 new file mode 100644 index 0000000..0e1f04b --- /dev/null +++ b/checksums/v1.4.0.sha256 @@ -0,0 +1,3 @@ +4d3233c2e4ac83040358e2ef243d86706cbdf6141f92f8b6270f8f32b74a11e5 gomc-rest.exe +4e12c6f27ee75707d7b8a6c54e43e69883250e23da507afbbc95533111df30a1 gomc-rest-linux-amd64 +a94bf94e79115d372c06f99ca7e31b38f7025e2578a4d4cd2ca44b5bfeca7165 gomc-rest-linux-arm64 diff --git a/scripts/vendor_binaries.py b/scripts/vendor_binaries.py index 4439d36..9b6a9ed 100644 --- a/scripts/vendor_binaries.py +++ b/scripts/vendor_binaries.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys import urllib.request @@ -27,6 +28,7 @@ _ROOT = Path(__file__).resolve().parent.parent _DEST = _ROOT / "src" / "gomc_rest" / "binaries" +_CHECKSUMS = _ROOT / "checksums" def _version() -> str: @@ -36,11 +38,45 @@ def _version() -> str: return (_ROOT / "GOMC_REST_VERSION").read_text().strip() -def _download(version: str, asset: str) -> None: +def _expected_checksums(version: str) -> dict[str, str]: + """Load the trusted SHA-256 values pinned in this repo for `version`. + + These are committed, not fetched from the same release, so swapping a + release asset after the fact is detected. Missing file -> fail closed. + """ + path = _CHECKSUMS / f"{version}.sha256" + if not path.exists(): + raise RuntimeError( + f"No pinned checksums for {version} at {path}. Add the trusted " + "SHA-256 values before vendoring." + ) + sums: dict[str, str] = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line: + continue + digest, name = line.split() + sums[name] = digest + return sums + + +def _download(version: str, asset: str, expected: dict[str, str]) -> None: url = f"https://github.com/{REPO}/releases/download/{version}/{asset}" out = _DEST / asset print(f" {asset} <- {url}") urllib.request.urlretrieve(url, out) + + want = expected.get(asset) + if want is None: + out.unlink(missing_ok=True) + raise RuntimeError(f"No pinned checksum for {asset} in {version}.sha256.") + got = hashlib.sha256(out.read_bytes()).hexdigest() + if got != want: + out.unlink(missing_ok=True) + raise RuntimeError( + f"Checksum mismatch for {asset}: expected {want}, got {got}." + ) + if not asset.endswith(".exe"): out.chmod(0o755) @@ -55,12 +91,13 @@ def main() -> int: args = parser.parse_args() version = _version() + expected = _expected_checksums(version) targets = [args.only] if args.only else ASSETS _DEST.mkdir(parents=True, exist_ok=True) print(f"Vendoring gomc-rest {version} into {_DEST}") for asset in targets: - _download(version, asset) - print("Done.") + _download(version, asset, expected) + print("Done (checksums verified).") return 0