#!/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()