Source code for release

#!/usr/bin/env python3
"""
Release script for AddVantage PPG firmware.

This script:
1. Builds all firmware targets (standalone, DFU, bootloader, combined)
2. Runs HIL tests (flash + UART verification)
3. If tests pass, creates a git tag and pushes to trigger GitHub release

Usage:
    python release.py --version 3.2.8
    python release.py --version 3.2.8 --skip-test  # Dangerous: skip HIL test
    python release.py --version 3.2.8 --test-dfu   # Also test DFU upload
"""

import argparse
import subprocess
import sys
import os
import re
from pathlib import Path


[docs] def run_command(cmd: list, cwd: str = None, check: bool = True) -> subprocess.CompletedProcess: """Run a command and return the result.""" print(f" $ {' '.join(cmd)}") result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) if check and result.returncode != 0: print(f"Error: {result.stderr}") raise RuntimeError(f"Command failed: {' '.join(cmd)}") return result
[docs] def validate_version(version: str) -> bool: """Validate version string format (X.Y.Z).""" return bool(re.match(r'^\d+\.\d+\.\d+$', version))
[docs] def get_project_root() -> Path: """Get the project root directory.""" # This script is in tools/, project root is one level up return Path(__file__).parent.parent.resolve()
[docs] def build_firmware(project_root: Path, version: str) -> dict: """Build all firmware targets with specified version.""" print(f"\n[BUILD] Building firmware v{version}...") build_dir = project_root / "build" # Clean build directory if build_dir.exists(): import shutil shutil.rmtree(build_dir) build_dir.mkdir() # Parse version parts = version.split('.') major, minor, patch = parts[0], parts[1], parts[2] # Configure with version cmake_args = [ "cmake", "-G", "Ninja", f"-DCMAKE_TOOLCHAIN_FILE={project_root}/arm-gcc-toolchain.cmake", f"-DVERSION_MAJOR={major}", f"-DVERSION_MINOR={minor}", f"-DVERSION_PATCH={patch}", str(project_root) ] run_command(cmake_args, cwd=str(build_dir)) # Build all targets print("\n[BUILD] Building standalone application...") run_command(["ninja", "addvantage3.elf"], cwd=str(build_dir)) print("\n[BUILD] Building DFU application...") run_command(["ninja", "addvantage3_dfu.elf"], cwd=str(build_dir)) print("\n[BUILD] Building bootloader...") run_command(["ninja", "bootloader.elf"], cwd=str(build_dir)) # Patch DFU header with CRC print("\n[BUILD] Patching DFU application header...") patch_cmd = [sys.executable, str(project_root / "tools" / "patch_header.py"), str(build_dir / "addvantage3_dfu.bin"), "-v"] run_command(patch_cmd) # Build combined image print("\n[BUILD] Creating combined image...") run_command(["ninja", "combined"], cwd=str(build_dir)) # Verify all files exist files = { 'standalone_hex': build_dir / "addvantage3.hex", 'standalone_elf': build_dir / "addvantage3.elf", 'dfu_hex': build_dir / "addvantage3_dfu.hex", 'dfu_bin': build_dir / "addvantage3_dfu.bin", 'dfu_elf': build_dir / "addvantage3_dfu.elf", 'bootloader_hex': build_dir / "bootloader.hex", 'bootloader_bin': build_dir / "bootloader.bin", 'combined_hex': build_dir / "combined.hex", 'combined_bin': build_dir / "combined.bin", } for name, path in files.items(): if not path.exists(): raise RuntimeError(f"Build failed: {name} not found at {path}") print(f"\n[BUILD] Success! All targets built.") return files
[docs] def run_hil_test(project_root: Path, hex_file: Path, version: str, port: str = None) -> bool: """Run HIL test harness for standalone firmware.""" print(f"\n[TEST] Running HIL test (standalone)...") tools_dir = project_root / "tools" test_cmd = [ sys.executable, str(tools_dir / "test_harness.py"), "--hex", str(hex_file), "--expected-version", version, ] if port: test_cmd.extend(["--port", port]) result = subprocess.run(test_cmd, cwd=str(tools_dir)) return result.returncode == 0
[docs] def run_dfu_test(project_root: Path, files: dict, version: str, port: str = None) -> bool: """Run DFU upload test. This test: 1. Flashes the combined image (bootloader + app) 2. Verifies application boots 3. Triggers DFU mode 4. Uploads DFU binary via XMODEM 5. Verifies new application boots Requires device connected via both J-Link and USB-Serial. """ print(f"\n[TEST] Running DFU test...") tools_dir = project_root / "tools" test_cmd = [ sys.executable, str(tools_dir / "test_harness.py"), "--hex", str(files['combined_hex']), "--expected-version", version, "--test-dfu", "--dfu-binary", str(files['dfu_bin']), ] if port: test_cmd.extend(["--port", port]) result = subprocess.run(test_cmd, cwd=str(tools_dir)) return result.returncode == 0
[docs] def create_tag_and_push(version: str, project_root: Path) -> None: """Create git tag and push to origin.""" tag = f"v{version}" print(f"\n[GIT] Creating tag {tag}...") # Check if tag already exists result = run_command(["git", "tag", "-l", tag], cwd=str(project_root), check=False) if tag in result.stdout: raise RuntimeError(f"Tag {tag} already exists. Use a different version.") # Create annotated tag run_command( ["git", "tag", "-a", tag, "-m", f"Release {version}"], cwd=str(project_root) ) print(f"\n[GIT] Pushing tag {tag} to origin...") run_command(["git", "push", "origin", tag], cwd=str(project_root)) print(f"\n[DONE] Tag {tag} pushed. GitHub Actions will create the release.")
[docs] def main(): parser = argparse.ArgumentParser( description='Create a firmware release (HIL-gated)', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" This script builds all firmware targets, runs HIL tests, and creates a GitHub release. Build Outputs: - addvantage3.hex Standalone application (J-Link flash) - addvantage3_dfu.bin DFU application (field updates) - bootloader.hex DFU bootloader - combined.hex Bootloader + application (new deployments) Examples: python release.py --version 3.2.8 python release.py --version 3.2.8 --port COM3 python release.py --version 3.2.8 --test-dfu # Also test DFU upload python release.py --version 3.2.8 --dry-run # Test without pushing The release workflow: 1. Build all firmware targets with version baked in 2. Patch DFU binary header with size and CRC 3. Flash standalone firmware to hardware via J-Link 4. Verify version over UART 5. (Optional) Test DFU upload cycle 6. If tests pass, create git tag and push 7. GitHub Actions creates the release with all artifacts """ ) parser.add_argument('--version', required=True, help='Version string (e.g., 3.2.8)') parser.add_argument('--port', help='Serial port for HIL test (auto-detect if not specified)') parser.add_argument('--skip-test', action='store_true', help='Skip HIL test (DANGEROUS)') parser.add_argument('--test-dfu', action='store_true', help='Also run DFU upload test') parser.add_argument('--dry-run', action='store_true', help='Build and test, but do not push tag') args = parser.parse_args() # Validate version if not validate_version(args.version): print(f"Error: Invalid version format '{args.version}'. Expected X.Y.Z") sys.exit(1) project_root = get_project_root() print(f"Project root: {project_root}") try: # Step 1: Build all targets files = build_firmware(project_root, args.version) # Step 2: HIL test (standalone) if args.skip_test: print("\n[WARN] Skipping HIL test (--skip-test)") else: if not run_hil_test(project_root, files['standalone_hex'], args.version, args.port): print("\n[FAIL] HIL test (standalone) failed. Release aborted.") sys.exit(1) print("\n[PASS] HIL test (standalone) passed!") # Step 3: DFU test (optional) if args.test_dfu and not args.skip_test: if not run_dfu_test(project_root, files, args.version, args.port): print("\n[FAIL] DFU test failed. Release aborted.") sys.exit(1) print("\n[PASS] DFU test passed!") # Step 4: Create tag and push if args.dry_run: print(f"\n[DRY-RUN] Would create tag v{args.version} and push to origin") print("\nBuild artifacts:") for name, path in files.items(): print(f" {name}: {path}") else: create_tag_and_push(args.version, project_root) print(f"\n[SUCCESS] Release v{args.version} initiated!") print("Check GitHub Actions for release status.") except Exception as e: print(f"\n[ERROR] {e}") sys.exit(1)
if __name__ == "__main__": main()