From 48c6813198941fa65f269f5051e9e19c4507078d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Anderss=C3=A9n?= Date: Fri, 13 Mar 2026 23:40:42 +0200 Subject: [PATCH 1/2] Add audited cross-platform release artifacts --- .github/workflows/release.yml | 115 +++++++++++ .gitignore | 8 +- .vscode/tasks.json | 70 +++++++ CHANGELOG.md | 10 + Makefile | 87 +++++++- README.md | 11 + docs/release_artifacts.md | 134 +++++++++++++ include/version.h | 6 +- tools/build_win64_release.sh | 39 ++++ tools/collect_linux_runtime_deps.py | 98 +++++++++ tools/collect_mingw_runtime_dlls.py | 99 +++++++++ tools/package_release.py | 220 ++++++++++++++++++++ tools/release_audit.py | 300 ++++++++++++++++++++++++++++ visualization/scenarios.json | 4 +- 14 files changed, 1186 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .vscode/tasks.json create mode 100644 docs/release_artifacts.md create mode 100644 tools/build_win64_release.sh create mode 100644 tools/collect_linux_runtime_deps.py create mode 100644 tools/collect_mingw_runtime_dlls.py create mode 100644 tools/package_release.py create mode 100644 tools/release_audit.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b095a2e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-linux-x64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Linux release dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc make python3 libraylib-dev + + - name: Build Linux release bundle + run: | + make package-release-linux \ + CC=gcc \ + PYTHON=python3 \ + RELEASE_PLATFORM=linux-x64 \ + RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-linux-x64.tar.gz + + - name: Audit Linux release bundle + run: | + rm -rf ./dist/test-linux + mkdir -p ./dist/test-linux + tar -xzf ./dist/engine-control-test-rig-simulator-linux-x64.tar.gz -C ./dist/test-linux + cd ./dist/test-linux/engine-control-test-rig-simulator-linux-x64 + python3 tools/release_audit.py --bundle-dir . --skip-visualizer + + - name: Upload Linux bundle artifact + uses: actions/upload-artifact@v4 + with: + name: release-linux-x64 + path: dist/engine-control-test-rig-simulator-linux-x64.tar.gz + + build-win64: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install MSYS2 toolchain + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + mingw-w64-x86_64-gcc + mingw-w64-x86_64-make + mingw-w64-x86_64-python + mingw-w64-x86_64-raylib + + - name: Build Win64 release bundle + shell: msys2 {0} + run: | + make all visualizer CC=gcc PYTHON=python + make generate-visualization-json CC=gcc PYTHON=python + + rm -rf dist/win64-runtime + mkdir -p dist/win64-runtime + python tools/collect_mingw_runtime_dlls.py \ + --output-dir ./dist/win64-runtime \ + --binary ./build/testrig.exe \ + --binary ./build/visualizer.exe \ + --search-dir /mingw64/bin \ + --objdump objdump + + python tools/package_release.py \ + --platform win64 \ + --testrig ./build/testrig.exe \ + --visualizer ./build/visualizer.exe \ + --archive ./dist/engine-control-test-rig-simulator-win64.zip \ + --extra-tree ./dist/win64-runtime:. + + - name: Audit Win64 release bundle + shell: msys2 {0} + run: | + rm -rf ./dist/test-win64 + mkdir -p ./dist/test-win64 + python -c 'import zipfile; zipfile.ZipFile("./dist/engine-control-test-rig-simulator-win64.zip").extractall("./dist/test-win64")' + cd ./dist/test-win64/engine-control-test-rig-simulator-win64 + python tools/release_audit.py --bundle-dir . --skip-visualizer + + - name: Upload Win64 bundle artifact + uses: actions/upload-artifact@v4 + with: + name: release-win64 + path: dist/engine-control-test-rig-simulator-win64.zip + + publish: + needs: + - build-linux-x64 + - build-win64 + runs-on: ubuntu-latest + steps: + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + dist/engine-control-test-rig-simulator-linux-x64.tar.gz + dist/engine-control-test-rig-simulator-win64.zip + generate_release_notes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6a916d7..5f10975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ build/ +build-win64/ coverage/ -.vscode/ +.cache/ +dist/ +tools/__pycache__/ +*.pyc +.vscode/* +!.vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d2c56f3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,70 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "release: test linux artifact", + "type": "shell", + "command": "make", + "args": [ + "test-release-linux", + "RELEASE_PLATFORM=linux-x64", + "RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-linux-x64.tar.gz", + "CC=gcc", + "PYTHON=python3" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "release: test win64 artifact with wine", + "type": "shell", + "command": "make", + "args": [ + "test-release-win64-local", + "RELEASE_PLATFORM=win64", + "RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-win64.zip", + "PYTHON=python3" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "release: package linux artifact", + "type": "shell", + "command": "make", + "args": [ + "package-release-linux", + "RELEASE_PLATFORM=linux-x64", + "RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-linux-x64.tar.gz", + "CC=gcc", + "PYTHON=python3" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "release: package win64 artifact", + "type": "shell", + "command": "make", + "args": [ + "package-release-win64-local", + "RELEASE_PLATFORM=win64", + "RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-win64.zip", + "PYTHON=python3" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "release: generate visualization bundle", + "type": "shell", + "command": "make", + "args": [ + "generate-visualization-json", + "CC=gcc", + "PYTHON=python3" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b921e..cfa6fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to the Engine Control Test Rig Simulator are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) conventions. +## [1.4.0] - 2026-03-13 + +### Added +- GitHub Release workflow that builds and publishes Linux and Win64 runnable artifacts for both the simulator and the Raylib visualizer. +- Portable release bundle audit script and packaged verification assets so unpacked artifacts can validate simulator behavior outside the source tree. +- Local Linux and Wine-based Win64 artifact test targets, runtime dependency collection helpers, and VS Code tasks for repeatable release dry runs. + +### Changed +- Release documentation moved out of the main README into a dedicated release artifacts document to keep the top-level project overview focused on architecture and verification. + ## [1.3.2] - 2026-03-13 ### Changed diff --git a/Makefile b/Makefile index f02a59f..2187399 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,32 @@ CC = clang GCOV = llvm-cov gcov +PYTHON ?= python3 SRC_DIR = src BUILD_DIR = ./build COVERAGE_DIR = ./coverage COVERAGE_HTML_DIR = ./coverage/html - -TARGET = $(BUILD_DIR)/testrig -VISUALIZER_TARGET = $(BUILD_DIR)/visualizer -UNIT_TEST_TARGET = $(BUILD_DIR)/unit_tests -VALGRIND_TARGET = $(BUILD_DIR)/testrig_valgrind -VALGRIND_UNIT_TEST_TARGET = $(BUILD_DIR)/unit_tests_valgrind +DIST_DIR = ./dist +LINUX_RELEASE_RUNTIME_DIR = $(DIST_DIR)/linux-runtime +WIN64_RELEASE_RUNTIME_DIR = $(DIST_DIR)/win64-runtime +WIN64_BUILD_DIR = ./build-win64 + +EXEEXT = +ifeq ($(OS),Windows_NT) +EXEEXT = .exe +RAYLIB_LIBS ?= -lraylib -lopengl32 -lgdi32 -lwinmm +else +RAYLIB_LIBS ?= -lraylib -lm -lpthread -ldl -lrt -lX11 +endif + +TARGET = $(BUILD_DIR)/testrig$(EXEEXT) +VISUALIZER_TARGET = $(BUILD_DIR)/visualizer$(EXEEXT) +UNIT_TEST_TARGET = $(BUILD_DIR)/unit_tests$(EXEEXT) +VALGRIND_TARGET = $(BUILD_DIR)/testrig_valgrind$(EXEEXT) +VALGRIND_UNIT_TEST_TARGET = $(BUILD_DIR)/unit_tests_valgrind$(EXEEXT) + +RELEASE_PLATFORM ?= unknown +RELEASE_ARCHIVE ?= $(DIST_DIR)/engine-control-test-rig-simulator-$(RELEASE_PLATFORM).tar.gz VISUALIZER_SRC = visualization/src/main.c \ visualization/src/visualizer_app.c \ @@ -48,10 +64,10 @@ CFLAGS = $(COMMON_CFLAGS) -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 DEBUG_CFLAGS = $(COMMON_CFLAGS) -O0 -g -fsanitize=address,undefined -fno-omit-frame-pointer COVERAGE_CFLAGS = $(COMMON_CFLAGS) -O0 --coverage VALGRIND_CFLAGS = $(COMMON_CFLAGS) -O0 -g -fno-omit-frame-pointer +VISUALIZER_CFLAGS = $(filter-out -pedantic,$(CFLAGS)) -Wno-pedantic CPPFLAGS = -I./include -I./src -I./visualization/include -DSIM_BUILD_COMMIT_OVERRIDE=\"$(BUILD_COMMIT)\" LDFLAGS = -lm -RAYLIB_LIBS = -lraylib -lm -lpthread -ldl VALGRIND ?= valgrind VALGRIND_ARGS = --leak-check=full --show-leak-kinds=all --track-origins=yes --errors-for-leak-kinds=all --error-exitcode=1 LCOV_RC_OPTS ?= --rc derive_function_end_line=0 @@ -65,7 +81,7 @@ $(TARGET): $(BUILD_DIR) $(SRCS) $(CC) $(CPPFLAGS) $(CFLAGS) -o $(TARGET) $(SRCS) $(LDFLAGS) $(VISUALIZER_TARGET): $(BUILD_DIR) $(VISUALIZER_SRC) - $(CC) $(CPPFLAGS) $(CFLAGS) -o $(VISUALIZER_TARGET) $(VISUALIZER_SRC) $(RAYLIB_LIBS) + $(CC) $(CPPFLAGS) $(VISUALIZER_CFLAGS) -o $(VISUALIZER_TARGET) $(VISUALIZER_SRC) $(RAYLIB_LIBS) $(UNIT_TEST_TARGET): $(BUILD_DIR) $(UNIT_TEST_SRCS) $(UNIT_TEST_DEPS) $(CC) $(CPPFLAGS) $(CFLAGS) -o $(UNIT_TEST_TARGET) $(UNIT_TEST_SRCS) $(UNIT_TEST_DEPS) $(LDFLAGS) @@ -87,6 +103,7 @@ clean: rm -f $(VISUALIZER_TARGET) rm -f $(BUILD_DIR)/*.gcno $(BUILD_DIR)/*.gcda $(BUILD_DIR)/*.info rm -rf $(COVERAGE_DIR) + rm -rf $(DIST_DIR) run-script: $(TARGET) @if [ -z "$(SCRIPT)" ]; then \ @@ -126,7 +143,59 @@ run-visualizer: $(VISUALIZER_TARGET) $(VISUALIZER_TARGET) "$(JSON)" generate-visualization-json: $(TARGET) - python3 tools/generate_visualization_scenario_json.py + $(PYTHON) tools/generate_visualization_scenario_json.py --testrig "$(TARGET)" + +package-release: $(TARGET) $(VISUALIZER_TARGET) generate-visualization-json + $(PYTHON) tools/package_release.py \ + --platform "$(RELEASE_PLATFORM)" \ + --testrig "$(TARGET)" \ + --visualizer "$(VISUALIZER_TARGET)" \ + --archive "$(RELEASE_ARCHIVE)" + +package-release-linux: $(TARGET) $(VISUALIZER_TARGET) generate-visualization-json + rm -rf "$(LINUX_RELEASE_RUNTIME_DIR)" + mkdir -p "$(LINUX_RELEASE_RUNTIME_DIR)/lib" + $(PYTHON) tools/collect_linux_runtime_deps.py \ + --output-dir "$(LINUX_RELEASE_RUNTIME_DIR)/lib" \ + --binary "$(TARGET)" \ + --binary "$(VISUALIZER_TARGET)" + $(PYTHON) tools/package_release.py \ + --platform "$(RELEASE_PLATFORM)" \ + --testrig "$(TARGET)" \ + --visualizer "$(VISUALIZER_TARGET)" \ + --archive "$(RELEASE_ARCHIVE)" \ + --linux-launchers \ + --extra-tree "$(LINUX_RELEASE_RUNTIME_DIR)/lib:lib" + +package-release-win64-local: + sh tools/build_win64_release.sh + rm -rf "$(WIN64_RELEASE_RUNTIME_DIR)" + mkdir -p "$(WIN64_RELEASE_RUNTIME_DIR)" + $(PYTHON) tools/collect_mingw_runtime_dlls.py \ + --output-dir "$(WIN64_RELEASE_RUNTIME_DIR)" \ + --binary "$(WIN64_BUILD_DIR)/testrig.exe" \ + --binary "$(WIN64_BUILD_DIR)/visualizer.exe" \ + --search-dir /usr/x86_64-w64-mingw32/bin + $(PYTHON) tools/package_release.py \ + --platform "$(RELEASE_PLATFORM)" \ + --testrig "$(WIN64_BUILD_DIR)/testrig.exe" \ + --visualizer "$(WIN64_BUILD_DIR)/visualizer.exe" \ + --archive "$(RELEASE_ARCHIVE)" \ + --extra-tree "$(WIN64_RELEASE_RUNTIME_DIR):." + +test-release-linux: package-release-linux + rm -rf "$(DIST_DIR)/test-linux" + mkdir -p "$(DIST_DIR)/test-linux" + tar -xzf "$(RELEASE_ARCHIVE)" -C "$(DIST_DIR)/test-linux" + cd "$(DIST_DIR)/test-linux/engine-control-test-rig-simulator-$(RELEASE_PLATFORM)" && \ + $(PYTHON) tools/release_audit.py --bundle-dir . --visualizer-timeout 3 + +test-release-win64-local: package-release-win64-local + rm -rf "$(DIST_DIR)/test-win64" + mkdir -p "$(DIST_DIR)/test-win64" + $(PYTHON) -c 'import zipfile; zipfile.ZipFile("$(RELEASE_ARCHIVE)").extractall("$(DIST_DIR)/test-win64")' + cd "$(DIST_DIR)/test-win64/engine-control-test-rig-simulator-$(RELEASE_PLATFORM)" && \ + $(PYTHON) tools/release_audit.py --bundle-dir . --command-prefix wine --visualizer-timeout 5 --skip-visualization-regeneration analyze-cppcheck: cppcheck --enable=all --std=c11 --error-exitcode=1 \ diff --git a/README.md b/README.md index cfc5568..2ec006a 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,16 @@ Theme selection: - Start with `--theme default`, `--theme dos`, `--theme onyx`, `--theme gruvbox`, or `--theme light` - Press `T` while the visualizer is running to cycle between themes +### Release bundles + +Release packaging, shipped audit flow, local artifact testing, and VS Code tasks are documented in [docs/release_artifacts.md](docs/release_artifacts.md). + +At a high level: + +- tags matching `v*` publish Linux and Win64 runnable artifacts +- each bundle includes a shipped simulator audit entry point via `tools/release_audit.py` +- local Linux and Wine-based Win64 artifact tests execute that packaged audit before release, with the Wine path using a lighter audit mode to avoid repeated slow Wine startups + ## Module API Overview @@ -397,6 +407,7 @@ Usage: | [docs/static_analysis_baseline_policy.md](docs/static_analysis_baseline_policy.md) | Zero-warning baseline policy | | [docs/determinism_guarantee.md](docs/determinism_guarantee.md) | Determinism implementation and CI enforcement via SHA-256 replay check | | [docs/schema_evolution.md](docs/schema_evolution.md) | Semantic versioning policy for JSON output schema | +| [docs/release_artifacts.md](docs/release_artifacts.md) | Release packaging, shipped audit flow, local artifact testing, and VS Code tasks | | [docs/message_map.md](docs/message_map.md) | BusFrame ID registry documentation with payload layouts | | [docs/error_severity_model.md](docs/error_severity_model.md) | Structured severity/recoverability classification reference | | [docs/MISRA_C:2012_Supported_Rules.md](docs/MISRA_C:2012_Supported_Rules.md) | List of supported MISRA C:2012 rules | diff --git a/docs/release_artifacts.md b/docs/release_artifacts.md new file mode 100644 index 0000000..28b9edc --- /dev/null +++ b/docs/release_artifacts.md @@ -0,0 +1,134 @@ +# Release Artifacts + +This project publishes runnable Linux and Win64 release bundles for both the simulator and the Raylib visualizer. + +## Published assets + +Tagging the repository with `v*` drives `.github/workflows/release.yml`, which builds and uploads: + +- `engine-control-test-rig-simulator-linux-x64.tar.gz` +- `engine-control-test-rig-simulator-win64.zip` + +Each archive contains: + +- `testrig` or `testrig.exe` +- `visualizer` or `visualizer.exe` +- `calibration.json` +- `scenarios/` +- `schema/engine_test_rig.schema.json` +- `tests/integration/invalid_script.txt` +- `tools/release_audit.py` +- `tools/validate_json_contract.py` +- `visualization/scenarios.json` +- `visualization/PxPlus_IBM_EGA_8x14.ttf` + +Platform-specific runtime packaging: + +- Linux bundles discovered shared libraries under `lib/` and provides `run-testrig.sh` plus `run-visualizer.sh` wrapper scripts that set `LD_LIBRARY_PATH`. +- Win64 bundles the required non-system MinGW runtime DLLs next to the `.exe` files. + +## Shipped audit workflow + +Every bundle includes a portable black-box audit entry point: + +```bash +python3 tools/release_audit.py +``` + +The audit script verifies the packaged simulator and runtime data by: + +- running the built-in validation suite in console and JSON modes +- validating JSON output against the shipped schema +- checking the calibration-file path +- checking the negative parser path with the shipped invalid-script fixture +- regenerating the visualization bundle from the packaged simulator outputs and comparing it to the shipped `visualization/scenarios.json` +- smoke-launching the visualizer unless `--skip-visualizer` is used + +Windows invocation: + +```powershell +py -3 tools\release_audit.py +``` + +or: + +```powershell +python tools\release_audit.py +``` + +The GitHub Release workflow runs the packaged simulator audit in both the Linux and Win64 jobs before uploading artifacts. Those CI audits use `--skip-visualizer` because hosted runners are headless. + +## Local artifact testing + +Local artifact testing exercises the packaged bundles, not just the build tree: + +- Linux: builds the tarball, bundles shared libraries, unpacks the archive, and runs the shipped audit. +- Win64 on Linux: cross-builds Raylib and the project, creates the zip, unpacks it, and runs the shipped audit against the packaged `.exe` files via Wine. + +The local Wine-based audit skips the visualization-bundle regeneration comparison because repeated Wine process startup makes that specific check disproportionately slow. The rest of the simulator audit still runs, and native release audits can still execute the full regeneration path. + +Dry-run commands: + +```bash +make test-release-linux RELEASE_PLATFORM=linux-x64 RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-linux-x64.tar.gz CC=gcc PYTHON=python3 +make test-release-win64-local RELEASE_PLATFORM=win64 RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-win64.zip PYTHON=python3 +``` + +Prerequisites for local testing on Linux: + +- Native Linux path: `gcc`, `make`, `python3`, `raylib`, `ldd`, and a working GUI session for the visualizer smoke launch. +- Win64-on-Linux path: `x86_64-w64-mingw32-gcc`, `x86_64-w64-mingw32-g++`, `cmake`, `git`, and `wine`. + +## Pre-tag checklist + +Use this sequence before pushing a release tag: + +1. Run the local Linux artifact test. + +```bash +make test-release-linux RELEASE_PLATFORM=linux-x64 RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-linux-x64.tar.gz CC=gcc PYTHON=python3 +``` + +2. Run the local Win64 artifact test under Wine. + +```bash +make test-release-win64-local RELEASE_PLATFORM=win64 RELEASE_ARCHIVE=./dist/engine-control-test-rig-simulator-win64.zip PYTHON=python3 +``` + +3. Check the working tree and review the release-related diff. + +```bash +git status +git diff --stat +``` + +4. Commit the release changes. + +```bash +git add . +git commit -m "Add audited cross-platform release artifacts" +``` + +5. Create and push the release tag. + +```bash +git tag v0.1.0 +git push origin HEAD +git push origin v0.1.0 +``` + +6. Verify the GitHub Release workflow succeeds and that both uploaded artifacts are present on the GitHub release page. + +7. As a final spot-check, download each published archive, unpack it, and run the shipped audit from the bundle itself. + +## VS Code tasks + +Workspace tasks in `.vscode/tasks.json` support the local release workflow: + +- `release: test linux artifact` +- `release: test win64 artifact with wine` +- `release: package linux artifact` +- `release: package win64 artifact` +- `release: generate visualization bundle` + +Run them from Terminal -> Run Task in VS Code. \ No newline at end of file diff --git a/include/version.h b/include/version.h index 9461387..11e421e 100644 --- a/include/version.h +++ b/include/version.h @@ -6,11 +6,11 @@ #endif #define SIM_VERSION_MAJOR 1 -#define SIM_VERSION_MINOR 3 -#define SIM_VERSION_PATCH 2 +#define SIM_VERSION_MINOR 4 +#define SIM_VERSION_PATCH 0 #define SIM_SCHEMA_VERSION "1.0.0" -#define SIM_SOFTWARE_VERSION "1.3.2" +#define SIM_SOFTWARE_VERSION "1.4.0" #define SIM_BUILD_COMMIT SIM_BUILD_COMMIT_OVERRIDE #endif diff --git a/tools/build_win64_release.sh b/tools/build_win64_release.sh new file mode 100644 index 0000000..ab3675f --- /dev/null +++ b/tools/build_win64_release.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +RAYLIB_SRC_DIR=${RAYLIB_SRC_DIR:-$REPO_ROOT/.cache/raylib-win64-src} +RAYLIB_BUILD_DIR=${RAYLIB_BUILD_DIR:-$REPO_ROOT/.cache/raylib-win64-build} +BUILD_DIR=${BUILD_DIR:-$REPO_ROOT/build-win64} +PYTHON_BIN=${PYTHON_BIN:-python3} + +mkdir -p "$REPO_ROOT/.cache" + +if [ ! -d "$RAYLIB_SRC_DIR/.git" ]; then + git clone --depth 1 https://github.com/raysan5/raylib.git "$RAYLIB_SRC_DIR" +fi + +cmake -S "$RAYLIB_SRC_DIR" -B "$RAYLIB_BUILD_DIR" \ + -G "Unix Makefiles" \ + -DCMAKE_SYSTEM_NAME=Windows \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=x86_64-w64-mingw32-gcc \ + -DCMAKE_CXX_COMPILER=x86_64-w64-mingw32-g++ \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DPLATFORM=Desktop + +cmake --build "$RAYLIB_BUILD_DIR" -j2 + +cd "$REPO_ROOT" +make generate-visualization-json CC=gcc PYTHON="$PYTHON_BIN" +C_INCLUDE_PATH="$RAYLIB_SRC_DIR/src" \ +make -B \ + BUILD_DIR="$BUILD_DIR" \ + EXEEXT=.exe \ + CC=x86_64-w64-mingw32-gcc \ + PYTHON="$PYTHON_BIN" \ + BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ + RAYLIB_LIBS="-L$RAYLIB_BUILD_DIR/raylib -lraylib -lopengl32 -lgdi32 -lwinmm" \ + all visualizer \ No newline at end of file diff --git a/tools/collect_linux_runtime_deps.py b/tools/collect_linux_runtime_deps.py new file mode 100644 index 0000000..6624ba6 --- /dev/null +++ b/tools/collect_linux_runtime_deps.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +SKIP_PREFIXES = ( + "linux-vdso", + "linux-gate", +) + +SKIP_NAMES = ( + "ld-linux", + "ld-musl", +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Collect Linux shared library dependencies for one or more executables." + ) + parser.add_argument("--output-dir", required=True, help="Directory where copied shared libraries will be placed.") + parser.add_argument("--binary", action="append", required=True, help="Executable to inspect with ldd.") + return parser.parse_args() + + +def should_skip(name: str, resolved_path: str) -> bool: + if any(name.startswith(prefix) for prefix in SKIP_PREFIXES): + return True + if any(part in name for part in SKIP_NAMES): + return True + if any(part in resolved_path for part in SKIP_NAMES): + return True + return False + + +def collect_dependencies(binary_path: Path) -> set[Path]: + result = subprocess.run( + ["ldd", str(binary_path)], + check=True, + capture_output=True, + text=True, + ) + + dependencies: set[Path] = set() + for raw_line in result.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + + if "=>" in line: + left, right = [part.strip() for part in line.split("=>", 1)] + if right == "not found": + raise FileNotFoundError(f"missing dependency for {binary_path}: {left}") + resolved_path = right.split("(", 1)[0].strip() + if not resolved_path.startswith("/"): + continue + if should_skip(left, resolved_path): + continue + dependencies.add(Path(resolved_path)) + continue + + token = line.split("(", 1)[0].strip() + if token.startswith("/") and not should_skip(token, token): + dependencies.add(Path(token)) + + return dependencies + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + dependencies: set[Path] = set() + try: + for binary_arg in args.binary: + binary_path = Path(binary_arg).resolve() + if not binary_path.is_file(): + raise FileNotFoundError(f"binary not found: {binary_path}") + dependencies.update(collect_dependencies(binary_path)) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + for dependency in sorted(dependencies): + shutil.copy2(dependency, output_dir / dependency.name) + + for dependency in sorted(dependencies): + print(dependency) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tools/collect_mingw_runtime_dlls.py b/tools/collect_mingw_runtime_dlls.py new file mode 100644 index 0000000..8129ffd --- /dev/null +++ b/tools/collect_mingw_runtime_dlls.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Collect MinGW runtime DLLs required by one or more Windows executables." + ) + parser.add_argument("--output-dir", required=True, help="Directory where copied DLLs will be placed.") + parser.add_argument("--binary", action="append", required=True, help="Windows executable to inspect.") + parser.add_argument("--search-dir", action="append", required=True, help="Directory to search for dependent DLLs.") + parser.add_argument( + "--objdump", + default="x86_64-w64-mingw32-objdump", + help="objdump executable to use for dependency inspection.", + ) + return parser.parse_args() + + +def is_windows_system_dll(dll_name: str) -> bool: + upper_name = dll_name.upper() + if upper_name.startswith("API-MS-WIN-") or upper_name.startswith("EXT-MS-"): + return True + return upper_name in { + "ADVAPI32.DLL", + "COMDLG32.DLL", + "GDI32.DLL", + "KERNEL32.DLL", + "OLE32.DLL", + "SHELL32.DLL", + "USER32.DLL", + "WINMM.DLL", + "WS2_32.DLL", + } + + +def parse_dependencies(binary_path: Path, objdump_executable: str) -> set[str]: + result = subprocess.run( + [objdump_executable, "-p", str(binary_path)], + check=True, + capture_output=True, + text=True, + ) + dependencies: set[str] = set() + for line in result.stdout.splitlines(): + line = line.strip() + if not line.startswith("DLL Name:"): + continue + dll_name = line.split(":", 1)[1].strip() + if is_windows_system_dll(dll_name): + continue + dependencies.add(dll_name) + return dependencies + + +def locate_dll(dll_name: str, search_dirs: list[Path]) -> Path: + lower_name = dll_name.lower() + for search_dir in search_dirs: + candidate = search_dir / dll_name + if candidate.is_file(): + return candidate + for child in search_dir.iterdir(): + if child.is_file() and child.name.lower() == lower_name: + return child + raise FileNotFoundError(f"unable to locate runtime DLL: {dll_name}") + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + search_dirs = [Path(path).resolve() for path in args.search_dir] + + try: + dependencies: set[str] = set() + for binary_arg in args.binary: + binary_path = Path(binary_arg).resolve() + if not binary_path.is_file(): + raise FileNotFoundError(f"binary not found: {binary_path}") + dependencies.update(parse_dependencies(binary_path, args.objdump)) + + for dll_name in sorted(dependencies): + dll_path = locate_dll(dll_name, search_dirs) + shutil.copy2(dll_path, output_dir / dll_path.name) + print(dll_path) + except (FileNotFoundError, OSError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tools/package_release.py b/tools/package_release.py new file mode 100644 index 0000000..b1a094f --- /dev/null +++ b/tools/package_release.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +import argparse +import os +import shutil +import stat +import sys +import tarfile +import tempfile +import zipfile +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a release archive containing the simulator, visualizer, and runtime data." + ) + parser.add_argument("--platform", required=True, help="Platform label to embed in the bundle name.") + parser.add_argument("--testrig", required=True, help="Path to the simulator executable.") + parser.add_argument("--visualizer", required=True, help="Path to the visualizer executable.") + parser.add_argument("--archive", required=True, help="Output archive path (.zip or .tar.gz).") + parser.add_argument( + "--extra-entry", + action="append", + default=[], + help="Extra runtime file to place in the bundle. Format: source[:relative/destination].", + ) + parser.add_argument( + "--extra-tree", + action="append", + default=[], + help="Extra directory tree to place in the bundle. Format: source_dir[:relative/destination].", + ) + parser.add_argument( + "--linux-launchers", + action="store_true", + help="Add Linux launcher scripts that set LD_LIBRARY_PATH to the bundled lib directory.", + ) + return parser.parse_args() + + +def ensure_file(path: Path, label: str) -> Path: + if not path.is_file(): + raise FileNotFoundError(f"{label} not found: {path}") + return path + + +def copy_file(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def copy_tree(source: Path, destination: Path) -> None: + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(source, destination) + + +def parse_mapping(raw_value: str) -> tuple[Path, Path | None]: + source_part, separator, destination_part = raw_value.partition(":") + source_path = Path(source_part).expanduser().resolve() + if not separator: + return source_path, None + return source_path, Path(destination_part) + + +def write_linux_launchers(bundle_dir: Path, testrig_name: str, visualizer_name: str) -> None: + launchers = { + "run-testrig.sh": f"#!/usr/bin/env sh\nset -eu\nSCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\nexport LD_LIBRARY_PATH=\"$SCRIPT_DIR/lib${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\nexec \"$SCRIPT_DIR/{testrig_name}\" \"$@\"\n", + "run-visualizer.sh": f"#!/usr/bin/env sh\nset -eu\nSCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\nexport LD_LIBRARY_PATH=\"$SCRIPT_DIR/lib${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\nexec \"$SCRIPT_DIR/{visualizer_name}\" \"$@\"\n", + } + + for launcher_name, content in launchers.items(): + launcher_path = bundle_dir / launcher_name + launcher_path.write_text(content, encoding="utf-8") + launcher_path.chmod(launcher_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def write_run_notes(destination: Path, + bundle_name: str, + testrig_name: str, + visualizer_name: str, + has_linux_launchers: bool) -> None: + testrig_cmd = f"./{testrig_name} --run-all" + testrig_script_cmd = f"./{testrig_name} --script scenarios/normal_operation.txt --json" + visualizer_cmd = f"./{visualizer_name} visualization/scenarios.json" + + if has_linux_launchers: + testrig_cmd = "./run-testrig.sh --run-all" + testrig_script_cmd = "./run-testrig.sh --script scenarios/normal_operation.txt --json" + visualizer_cmd = "./run-visualizer.sh visualization/scenarios.json" + + lines = [ + f"{bundle_name}", + "", + "Contents:", + f"- {testrig_name}: simulator CLI", + f"- {visualizer_name}: Raylib scenario visualizer", + "- calibration.json: optional runtime calibration input", + "- scenarios/: scripted scenario files for the simulator", + "- schema/engine_test_rig.schema.json: JSON contract used by the shipped audit suite", + "- tests/integration/invalid_script.txt: negative test fixture for parser error validation", + "- tools/release_audit.py: portable black-box release audit for the packaged simulator", + "- visualization/scenarios.json: pre-generated visualizer scenario bundle", + "- visualization/PxPlus_IBM_EGA_8x14.ttf: visualizer font asset", + "", + "Quick start:", + f"- Run the simulator: {testrig_cmd}", + f"- Run a script: {testrig_script_cmd}", + f"- Start the visualizer: {visualizer_cmd}", + "- Run the shipped audit suite with Python 3: python3 tools/release_audit.py", + "", + "Notes:", + "- The visualizer loads visualization/PxPlus_IBM_EGA_8x14.ttf via a relative path, so keep the shipped directory layout intact.", + "- On Windows, use py -3 tools\\release_audit.py or python tools\\release_audit.py to run the audit suite.", + ] + if has_linux_launchers: + lines.append("- Linux launchers set LD_LIBRARY_PATH so the bundled shared libraries are used first.") + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def create_archive(bundle_dir: Path, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + + if archive_path.suffix == ".zip": + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(bundle_dir.rglob("*")): + archive.write(path, arcname=path.relative_to(bundle_dir.parent)) + return + + if archive_path.name.endswith(".tar.gz"): + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(bundle_dir, arcname=bundle_dir.name) + return + + raise ValueError(f"unsupported archive format: {archive_path}") + + +def main() -> int: + args = parse_args() + repo_root = Path.cwd() + testrig_path = ensure_file(Path(args.testrig).resolve(), "testrig executable") + visualizer_path = ensure_file(Path(args.visualizer).resolve(), "visualizer executable") + archive_path = Path(args.archive).resolve() + bundle_name = f"engine-control-test-rig-simulator-{args.platform}" + + static_files = [ + ensure_file(repo_root / "README.md", "README"), + ensure_file(repo_root / "calibration.json", "calibration.json"), + ensure_file(repo_root / "tools/release_audit.py", "release audit script"), + ensure_file(repo_root / "tools/validate_json_contract.py", "JSON validator script"), + ensure_file(repo_root / "visualization/scenarios.json", "visualization scenarios"), + ensure_file(repo_root / "visualization/PxPlus_IBM_EGA_8x14.ttf", "visualizer font"), + ensure_file(repo_root / "tests/integration/invalid_script.txt", "invalid-script test fixture"), + ] + scenarios_dir = repo_root / "scenarios" + if not scenarios_dir.is_dir(): + raise FileNotFoundError(f"scenarios directory not found: {scenarios_dir}") + schema_dir = repo_root / "schema" + if not schema_dir.is_dir(): + raise FileNotFoundError(f"schema directory not found: {schema_dir}") + + try: + with tempfile.TemporaryDirectory(prefix="release-bundle-") as tmp_dir: + bundle_dir = Path(tmp_dir) / bundle_name + bundle_dir.mkdir(parents=True, exist_ok=True) + + copy_file(testrig_path, bundle_dir / testrig_path.name) + copy_file(visualizer_path, bundle_dir / visualizer_path.name) + + for file_path in static_files: + relative_path = file_path.relative_to(repo_root) + copy_file(file_path, bundle_dir / relative_path) + + copy_tree(scenarios_dir, bundle_dir / "scenarios") + copy_tree(schema_dir, bundle_dir / "schema") + + for extra_entry in args.extra_entry: + extra_path, destination_rel = parse_mapping(extra_entry) + ensure_file(extra_path, "extra runtime file") + if destination_rel is None: + copy_file(extra_path, bundle_dir / extra_path.name) + else: + copy_file(extra_path, bundle_dir / destination_rel) + + for extra_tree in args.extra_tree: + tree_path, destination_rel = parse_mapping(extra_tree) + if not tree_path.is_dir(): + raise FileNotFoundError(f"extra runtime directory not found: {tree_path}") + if destination_rel is None: + copy_tree(tree_path, bundle_dir / tree_path.name) + elif str(destination_rel) == ".": + for child in sorted(tree_path.iterdir()): + target = bundle_dir / child.name + if child.is_dir(): + copy_tree(child, target) + else: + copy_file(child, target) + else: + copy_tree(tree_path, bundle_dir / destination_rel) + + if args.linux_launchers: + write_linux_launchers(bundle_dir, testrig_path.name, visualizer_path.name) + + write_run_notes(bundle_dir / "RUNNING.txt", + bundle_name, + testrig_path.name, + visualizer_path.name, + args.linux_launchers) + create_archive(bundle_dir, archive_path) + except (FileNotFoundError, OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(f"created release archive: {archive_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tools/release_audit.py b/tools/release_audit.py new file mode 100644 index 0000000..e52244a --- /dev/null +++ b/tools/release_audit.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 + +import argparse +import json +import shlex +import subprocess +import sys +import tempfile +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a black-box audit of the shipped release bundle using the packaged simulator inputs." + ) + parser.add_argument( + "--bundle-dir", + help="Path to the unpacked release bundle root. Defaults to the parent of this script when run from tools/.", + ) + parser.add_argument( + "--command-prefix", + default="", + help="Optional command prefix used to launch the packaged binaries, for example 'wine'.", + ) + parser.add_argument( + "--visualizer-timeout", + type=float, + default=3.0, + help="Seconds to allow the visualizer to run before treating the timeout as a successful smoke test.", + ) + parser.add_argument( + "--skip-visualizer", + action="store_true", + help="Skip the visualizer smoke test and only audit the simulator outputs.", + ) + parser.add_argument( + "--skip-visualization-regeneration", + action="store_true", + help="Skip rebuilding visualization/scenarios.json from the packaged simulator inputs.", + ) + return parser.parse_args() + + +def determine_bundle_dir(raw_bundle_dir: str | None) -> Path: + if raw_bundle_dir: + return Path(raw_bundle_dir).resolve() + + script_path = Path(__file__).resolve() + if script_path.parent.name == "tools": + return script_path.parent.parent + return Path.cwd().resolve() + + +def parse_prefix(raw_prefix: str) -> list[str]: + return shlex.split(raw_prefix) + + +def find_simulator_command(bundle_dir: Path, prefix: list[str]) -> list[str]: + launcher_path = bundle_dir / "run-testrig.sh" + if launcher_path.is_file() and not prefix: + return [str(launcher_path)] + + for candidate in ("testrig.exe", "testrig"): + binary_path = bundle_dir / candidate + if binary_path.is_file(): + return [*prefix, str(binary_path)] + + raise FileNotFoundError("could not find packaged simulator executable") + + +def find_visualizer_command(bundle_dir: Path, prefix: list[str]) -> list[str]: + launcher_path = bundle_dir / "run-visualizer.sh" + if launcher_path.is_file() and not prefix: + return [str(launcher_path)] + + for candidate in ("visualizer.exe", "visualizer"): + binary_path = bundle_dir / candidate + if binary_path.is_file(): + return [*prefix, str(binary_path)] + + raise FileNotFoundError("could not find packaged visualizer executable") + + +def run_command(command: list[str], cwd: Path, timeout: float | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def require_success(completed: subprocess.CompletedProcess[str], description: str) -> None: + if completed.returncode == 0: + return + raise RuntimeError( + f"{description} failed with exit code {completed.returncode}\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +def load_json_or_fail(raw_json: str, description: str) -> dict: + try: + payload = json.loads(raw_json) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{description} did not produce valid JSON: {exc}\noutput:\n{raw_json}") from exc + if not isinstance(payload, dict): + raise RuntimeError(f"{description} JSON payload must be an object") + return payload + + +def validate_with_schema(bundle_dir: Path, output_path: Path) -> None: + validator = bundle_dir / "tools" / "validate_json_contract.py" + schema = bundle_dir / "schema" / "engine_test_rig.schema.json" + if not validator.is_file(): + raise FileNotFoundError(f"validator script not found: {validator}") + if not schema.is_file(): + raise FileNotFoundError(f"schema not found: {schema}") + + completed = run_command([sys.executable, str(validator), str(output_path), str(schema)], cwd=bundle_dir) + require_success(completed, f"schema validation for {output_path.name}") + + +def validate_shipped_visualization_bundle(bundle_dir: Path, shipped_visualization_bundle: Path) -> dict: + validate_with_schema(bundle_dir, shipped_visualization_bundle) + payload = load_json_or_fail( + shipped_visualization_bundle.read_text(encoding="utf-8"), + "packaged visualization bundle", + ) + summary = payload.get("summary") + scenarios = payload.get("scenarios") + if not isinstance(summary, dict): + raise RuntimeError("packaged visualization bundle is missing a valid summary object") + if not isinstance(scenarios, list): + raise RuntimeError("packaged visualization bundle is missing a valid scenarios array") + if summary.get("passed") != summary.get("total"): + raise RuntimeError("packaged visualization bundle does not report all scenarios passing") + if summary.get("total") != len(scenarios): + raise RuntimeError("packaged visualization bundle summary does not match the scenario count") + return payload + + +def run_json_probe(bundle_dir: Path, + simulator_command: list[str], + args: list[str], + output_path: Path, + description: str) -> dict: + completed = run_command([*simulator_command, *args], cwd=bundle_dir) + require_success(completed, description) + output_path.write_text(completed.stdout, encoding="utf-8") + validate_with_schema(bundle_dir, output_path) + return load_json_or_fail(completed.stdout, description) + + +def run_invalid_script_probe(bundle_dir: Path, simulator_command: list[str], output_path: Path) -> None: + invalid_script = bundle_dir / "tests" / "integration" / "invalid_script.txt" + if not invalid_script.is_file(): + raise FileNotFoundError(f"invalid-script fixture not found: {invalid_script}") + + completed = run_command([*simulator_command, "--script", str(invalid_script), "--json"], cwd=bundle_dir) + if completed.returncode == 0: + raise RuntimeError("invalid scripted scenario unexpectedly passed") + + output_path.write_text(completed.stdout, encoding="utf-8") + validate_with_schema(bundle_dir, output_path) + payload = load_json_or_fail(completed.stdout, "invalid-script probe") + error = payload.get("error") + if not isinstance(error, dict): + raise RuntimeError("invalid-script probe did not return an error object") + if error.get("code") != "STATUS_PARSE_ERROR": + raise RuntimeError(f"invalid-script probe returned unexpected error code: {error.get('code')}") + if error.get("module") != "script_parser": + raise RuntimeError(f"invalid-script probe returned unexpected module: {error.get('module')}") + if error.get("severity") != "ERROR": + raise RuntimeError(f"invalid-script probe returned unexpected severity: {error.get('severity')}") + + +def regenerate_visualization_payload(bundle_dir: Path, simulator_command: list[str]) -> dict: + scenarios_dir = bundle_dir / "scenarios" + script_paths = sorted(scenarios_dir.glob("*.txt")) + if not script_paths: + raise FileNotFoundError(f"no scenario scripts found in: {scenarios_dir}") + + combined: dict | None = None + combined_scenarios = [] + + for script_path in script_paths: + completed = run_command([*simulator_command, "--script", str(script_path), "--json"], cwd=bundle_dir) + require_success(completed, f"scenario replay for {script_path.name}") + payload = load_json_or_fail(completed.stdout, f"scenario replay for {script_path.name}") + scenarios = payload.get("scenarios") + if not isinstance(scenarios, list) or len(scenarios) != 1: + raise RuntimeError(f"unexpected scenario payload for {script_path.name}") + + scenario_payload = dict(scenarios[0]) + scenario_payload["scenario"] = script_path.stem + + if combined is None: + combined = { + "schema_version": payload.get("schema_version", "1.0.0"), + "software_version": payload.get("software_version", "unknown"), + "build_commit": payload.get("build_commit", "unknown"), + } + + combined_scenarios.append(scenario_payload) + + assert combined is not None + combined["scenarios"] = combined_scenarios + combined["summary"] = { + "passed": sum(1 for scenario in combined_scenarios if scenario.get("pass") is True), + "total": len(combined_scenarios), + } + return combined + + +def smoke_test_visualizer(bundle_dir: Path, visualizer_command: list[str], timeout_seconds: float) -> None: + scenario_bundle = bundle_dir / "visualization" / "scenarios.json" + if not scenario_bundle.is_file(): + raise FileNotFoundError(f"visualization bundle not found: {scenario_bundle}") + + try: + completed = run_command([*visualizer_command, str(scenario_bundle)], cwd=bundle_dir, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + return + + if completed.returncode != 0: + raise RuntimeError( + f"visualizer smoke test failed with exit code {completed.returncode}\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +def main() -> int: + args = parse_args() + bundle_dir = determine_bundle_dir(args.bundle_dir) + prefix = parse_prefix(args.command_prefix) + + simulator_command = find_simulator_command(bundle_dir, prefix) + visualizer_command = find_visualizer_command(bundle_dir, prefix) + + shipped_visualization_bundle = bundle_dir / "visualization" / "scenarios.json" + if not shipped_visualization_bundle.is_file(): + raise FileNotFoundError(f"packaged visualization bundle not found: {shipped_visualization_bundle}") + shipped_payload = validate_shipped_visualization_bundle(bundle_dir, shipped_visualization_bundle) + + with tempfile.TemporaryDirectory(prefix="release-audit-") as temp_dir: + temp_path = Path(temp_dir) + + console_probe = run_command([*simulator_command, "--run-all"], cwd=bundle_dir) + require_success(console_probe, "simulator console test suite") + + run_all_payload = run_json_probe( + bundle_dir, + simulator_command, + ["--run-all", "--json"], + temp_path / "run-all.json", + "simulator JSON test suite", + ) + summary = run_all_payload.get("summary") + if not isinstance(summary, dict) or summary.get("passed") != summary.get("total"): + raise RuntimeError("simulator JSON test suite did not report all scenarios passing") + + run_json_probe( + bundle_dir, + simulator_command, + ["--run-all", "--config", "calibration.json", "--json"], + temp_path / "run-all-config.json", + "runtime calibration JSON suite", + ) + run_json_probe( + bundle_dir, + simulator_command, + ["--script", "scenarios/normal_operation.txt", "--json"], + temp_path / "normal-operation.json", + "normal operation JSON scenario", + ) + run_invalid_script_probe(bundle_dir, simulator_command, temp_path / "invalid-script.json") + + if not args.skip_visualization_regeneration: + regenerated_payload = regenerate_visualization_payload(bundle_dir, simulator_command) + regenerated_path = temp_path / "regenerated-visualization.json" + regenerated_path.write_text(json.dumps(regenerated_payload, indent=2) + "\n", encoding="utf-8") + validate_with_schema(bundle_dir, regenerated_path) + + if regenerated_payload != shipped_payload: + raise RuntimeError("packaged visualization/scenarios.json does not match the simulator outputs in the bundle") + + if not args.skip_visualizer: + smoke_test_visualizer(bundle_dir, visualizer_command, args.visualizer_timeout) + + print("release audit passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/visualization/scenarios.json b/visualization/scenarios.json index d35534d..1bd9338 100644 --- a/visualization/scenarios.json +++ b/visualization/scenarios.json @@ -1,7 +1,7 @@ { "schema_version": "1.0.0", - "software_version": "1.3.2", - "build_commit": "7c76285", + "software_version": "1.4.0", + "build_commit": "2c38a09", "scenarios": [ { "scenario": "cold_start_warmup_and_ramp_high_temp_shutdown", From 7eed074fb6566b90f37827e8f59a0fe357f76b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Anderss=C3=A9n?= Date: Fri, 13 Mar 2026 23:48:04 +0200 Subject: [PATCH 2/2] Test: CI release fix test, testing without ":" in MISRA document filename --- .github/workflows/release.yml | 2 +- README.md | 2 +- ...:2012_Supported_Rules.md => MISRA_C_2012_Supported_Rules.md} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename docs/{MISRA_C:2012_Supported_Rules.md => MISRA_C_2012_Supported_Rules.md} (99%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b095a2e..da5a9b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Install Linux release dependencies run: | sudo apt-get update - sudo apt-get install -y gcc make python3 libraylib-dev + sudo apt-get install -y gcc make python3 raylib-dev - name: Build Linux release bundle run: | diff --git a/README.md b/README.md index 2ec006a..7fc2342 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ Usage: | [docs/release_artifacts.md](docs/release_artifacts.md) | Release packaging, shipped audit flow, local artifact testing, and VS Code tasks | | [docs/message_map.md](docs/message_map.md) | BusFrame ID registry documentation with payload layouts | | [docs/error_severity_model.md](docs/error_severity_model.md) | Structured severity/recoverability classification reference | -| [docs/MISRA_C:2012_Supported_Rules.md](docs/MISRA_C:2012_Supported_Rules.md) | List of supported MISRA C:2012 rules | +| [docs/MISRA_C_2012_Supported_Rules.md](docs/MISRA_C_2012_Supported_Rules.md) | List of supported MISRA C:2012 rules | | `docs/adr/` | Architecture Decision Records (ADR-001 through ADR-004) | | [CHANGELOG.md](CHANGELOG.md) | Version history following Keep a Changelog conventions | diff --git a/docs/MISRA_C:2012_Supported_Rules.md b/docs/MISRA_C_2012_Supported_Rules.md similarity index 99% rename from docs/MISRA_C:2012_Supported_Rules.md rename to docs/MISRA_C_2012_Supported_Rules.md index d29b6d7..3620eca 100644 --- a/docs/MISRA_C:2012_Supported_Rules.md +++ b/docs/MISRA_C_2012_Supported_Rules.md @@ -212,4 +212,4 @@ | Rule 23.5 | A generic selection should not depend on implicit pointer type conversion. | Advisory | | Rule 23.6 | The controlling expression of a generic selection shall have an essential type that matches its standard type. | Required | | Rule 23.7 | A generic selection that is expanded from a macro should evaluate its argument only once. | Advisory | -| Rule 23.8 | A default association shall appear as either the first or the last association of a generic selection. | Required | +| Rule 23.8 | A default association shall appear as either the first or the last association of a generic selection. | Required | \ No newline at end of file