Merge remote-tracking branch 'agent/benma-agent/create-firmware-release'
What changed, and why it matters
This commit is a merge that adds and updates release-helper scripts for the BitBox02 hardware wallet. It introduces a new script to draft GitHub firmware releases, refactors existing scripts to share a common parser for signed firmware files, and adds type annotations and tests. There is no direct evidence in the diff of a security vulnerability or malicious change; it appears to be routine release-tooling maintenance.
Review the new release automation for supply-chain and operational risks (e.g., ensure gh CLI authentication is scoped, confirm asset paths and tag names are validated, verify the signed_firmware.py parser matches the device-side format exactly). No immediate security patch is indicated by the diff itself.
Security signals we found
New release automation script interacts with GitHub API and local firmware binaries
Refactored signed-firmware parsing into a shared module with explicit product magics and size limits
Added dry-run and interactive confirmation to release creation to reduce accidental mutations
No changes to bootloader, firmware, or cryptographic verification logic in device code
Evidence from the diff
The commit merges the ‘create-firmware-release’ branch. Key changes: (1) new scripts/create_release.py automates drafting GitHub releases, verifying remote tags, fetching changelogs, discovering assets, computing firmware sighashes, and uploading assets via the gh CLI; (2) new scripts/signed_firmware.py centralizes parsing and hashing of signed firmware containers, adding two new product magics for BitBox02 Nova variants; (3) scripts/create_release_assertions.py is refactored to use signed_firmware.py instead of inline parsing; (4) type annotations and TypedDicts are added across several scripts; (5) tests are added in test/scripts/test_create_release.py. No runtime firmware code is changed. The new release script performs only read/validate operations on local assets and uses gh for GitHub mutations, with a dry-run mode and interactive confirmation.
Changed components
scripts/create_release.pyscripts/signed_firmware.pyscripts/create_release_assertions.pyscripts/bootloader_update.pyscripts/generate_version_headers.pyscripts/copy_st_drivers.pyscripts/graphics/convert.pyscripts/lint-pythontest/scripts/test_create_release.pyInspect captured patch +1038 / −89
### scripts/bootloader_update.py
@@ -5,6 +5,7 @@
import hashlib
import struct
from pathlib import Path
+from typing import TypedDict
import ecdsa
@@ -68,6 +69,24 @@
assert all(len(pubkey) == 64 for pubkey in STAGE1_ROOT_PUBKEYS)
+class Stage1Header(TypedDict):
+ """Parsed fields from a stage1 image header."""
+
+ prefix: bytes
+ magic: int
+ flags: int
+ header_version: int
+ product_id: int
+ header_len: int
+ image_len: int
+ monotonic_version: int
+ stage1_marketing_version_len: int
+ stage1_marketing_version_field: bytes
+ stage1_marketing_version: str
+ reserved: bytes
+ signatures: list[bytes]
+
+
def _parse_product_id(value: str) -> int:
if value in PRODUCT_IDS:
return PRODUCT_IDS[value]
@@ -94,7 +113,7 @@ def _decode_stage1_marketing_version(
return version.decode("ascii")
-def _unpack_header(header: bytes) -> dict:
+def _unpack_header(header: bytes) -> Stage1Header:
if len(header) != STAGE1_HEADER_LEN:
raise RuntimeError("invalid header length")
values = struct.unpack(STAGE1_HEADER_PREFIX_FORMAT, header[:STAGE1_HEADER_SIGNED_LEN])
@@ -119,7 +138,7 @@ def _unpack_header(header: bytes) -> dict:
}
-def _pack_prefix(header: dict) -> bytes:
+def _pack_prefix(header: Stage1Header) -> bytes:
return struct.pack(
STAGE1_HEADER_PREFIX_FORMAT,
STAGE1_HEADER_MAGIC,
@@ -154,11 +173,11 @@ def _stage1_signed_digest(image: bytes) -> bytes:
return hasher.digest()
-def _signatures_are_zero(header: dict) -> bool:
+def _signatures_are_zero(header: Stage1Header) -> bool:
return not any(byte for signature in header["signatures"] for byte in signature)
-def _verify_header_signatures(header: dict, image: bytes) -> None:
+def _verify_header_signatures(header: Stage1Header, image: bytes) -> None:
digest = _stage1_signed_digest(image)
valid = 0
for verifying_key, signature in zip(STAGE1_ROOT_VERIFYING_KEYS, header["signatures"]):
@@ -176,7 +195,7 @@ def _verify_header_signatures(header: dict, image: bytes) -> None:
raise RuntimeError("stage1 header signatures do not verify")
-def _validate_fixed_fields(header: dict, expected_product_id: int | None = None) -> None:
+def _validate_fixed_fields(header: Stage1Header, expected_product_id: int | None = None) -> None:
if header["magic"] != STAGE1_HEADER_MAGIC:
raise RuntimeError("invalid header magic")
if header["header_version"] != STAGE1_HEADER_FORMAT_VERSION:
@@ -195,7 +214,7 @@ def _validate_fixed_fields(header: dict, expected_product_id: int | None = None)
raise RuntimeError("reserved header bytes are not zero")
-def _validate_raw_stage1(image: bytes) -> dict:
+def _validate_raw_stage1(image: bytes) -> Stage1Header:
header = _unpack_header(image[:STAGE1_HEADER_LEN])
_validate_fixed_fields(header)
if header["image_len"] != 0:
@@ -211,7 +230,7 @@ def _validate_complete_stage1(
image: bytes,
expected_product_id: int | None,
require_signatures: bool,
-) -> dict:
+) -> Stage1Header:
header = _unpack_header(image[:STAGE1_HEADER_LEN])
_validate_fixed_fields(header, expected_product_id)
if header["image_len"] != len(image):
@@ -232,7 +251,7 @@ def _write_if_changed(path: Path, data: bytes) -> None:
path.write_bytes(data)
-def prepare_stage1_unsigned(args) -> None:
+def prepare_stage1_unsigned(args: argparse.Namespace) -> None:
image = Path(args.raw_bin).read_bytes()
header = _validate_raw_stage1(image)
header["image_len"] = len(image)
@@ -267,7 +286,7 @@ def _update_payload(signed_stage1: bytes, product_id: int, development: bool) ->
return signed_stage1 + b"\xff" * (BOOTLOADER_UPGRADE_PAYLOAD_LEN - len(signed_stage1))
-def create_stage1_fw_embedding(args) -> None:
+def create_stage1_fw_embedding(args: argparse.Namespace) -> None:
payload = _update_payload(Path(args.signed_bin).read_bytes(), args.product_id, args.development)
_write_if_changed(Path(args.out_bin), payload)
@@ -290,7 +309,7 @@ def _validate_stage0(stage0: bytes, product_id: int, development: bool) -> bytes
return stage0
-def create_stage0_fw_embedding(args) -> None:
+def create_stage0_fw_embedding(args: argparse.Namespace) -> None:
stage0 = _validate_stage0(
Path(args.stage0_bin).read_bytes(),
args.product_id,
### scripts/copy_st_drivers.py
@@ -42,7 +42,9 @@
import shutil
import sys
import tempfile
+from collections.abc import Sequence
from pathlib import Path
+from typing import Any
BOARD_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -61,11 +63,11 @@
BOARD_FILE_EXCLUDES = ("*_it.c", "*_it.h")
-def eprint(*args, **kwargs):
+def eprint(*args: object, **kwargs: Any) -> None:
print(*args, file=sys.stderr, **kwargs)
-def parse_args():
+def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Import an STM32U5 STM32Cube project, replacing external/ST/<board>, "
@@ -89,11 +91,11 @@ def parse_args():
return parser.parse_args()
-def repo_root():
+def repo_root() -> Path:
return Path(__file__).resolve().parent.parent
-def validate_board(board):
+def validate_board(board: str) -> None:
if not BOARD_RE.fullmatch(board):
raise ValueError(
"board must match {} (letters, digits, '.', '_' and '-', no slashes)".format(
@@ -104,7 +106,7 @@ def validate_board(board):
raise ValueError("board name '{}' is reserved".format(board))
-def collect_copy_directories(source):
+def collect_copy_directories(source: Path) -> tuple[list[Path], list[Path]]:
missing = [path for path in REQUIRED_DIRECTORIES if not (source / path).is_dir()]
if missing:
raise FileNotFoundError(
@@ -127,13 +129,13 @@ def collect_copy_directories(source):
def print_plan(
- source,
- board_destination,
- common_destination,
- driver_destination,
- board_dirs,
- driver_dirs,
-):
+ source: Path,
+ board_destination: Path,
+ common_destination: Path,
+ driver_destination: Path,
+ board_dirs: Sequence[Path],
+ driver_dirs: Sequence[Path],
+) -> None:
print("Source: {}".format(source))
print("Board: {}".format(board_destination))
print("Common: {}".format(common_destination))
@@ -148,14 +150,14 @@ def print_plan(
print(" - {}".format(directory))
-def rewrite_file(path, replacements):
+def rewrite_file(path: Path, replacements: Sequence[tuple[str, str]]) -> None:
content = path.read_text(encoding="utf-8")
for old, new in replacements:
content = content.replace(old, new)
path.write_text(content, encoding="utf-8")
-def rewrite_file_regex_once(path, replacements):
+def rewrite_file_regex_once(path: Path, replacements: Sequence[tuple[str, str]]) -> None:
content = path.read_text(encoding="utf-8")
for pattern, replacement in replacements:
content, count = re.subn(pattern, replacement, content)
@@ -170,7 +172,7 @@ def rewrite_file_regex_once(path, replacements):
path.write_text(content, encoding="utf-8")
-def rewrite_board_entrypoint(board_dir):
+def rewrite_board_entrypoint(board_dir: Path) -> None:
board_inc_dir = board_dir / "Inc"
board_src_dir = board_dir / "Src"
@@ -224,7 +226,7 @@ def rewrite_board_entrypoint(board_dir):
)
-def normalize_line_endings(root):
+def normalize_line_endings(root: Path) -> None:
for path in root.rglob("*"):
if not path.is_file() or path.is_symlink():
continue
@@ -238,19 +240,19 @@ def normalize_line_endings(root):
path.write_bytes(normalized)
-def remove_excluded_board_files(board_dir):
+def remove_excluded_board_files(board_dir: Path) -> None:
for pattern in BOARD_FILE_EXCLUDES:
for path in board_dir.rglob(pattern):
path.unlink()
-def remove_common_board_files(board_dir):
+def remove_common_board_files(board_dir: Path) -> None:
hal_conf = board_dir / "Inc" / HAL_CONF.name
if hal_conf.exists():
hal_conf.unlink()
-def copy_common_directory(source, temp_root):
+def copy_common_directory(source: Path, temp_root: Path) -> Path:
src = source / HAL_CONF
if not src.is_file():
raise FileNotFoundError("source project is missing {}".format(HAL_CONF))
@@ -262,7 +264,7 @@ def copy_common_directory(source, temp_root):
return common_dir
-def copy_board_directory(source, temp_root, board_dirs):
+def copy_board_directory(source: Path, temp_root: Path, board_dirs: Sequence[Path]) -> Path:
board_dir = temp_root / "board"
for directory in board_dirs:
src = source / directory
@@ -275,7 +277,7 @@ def copy_board_directory(source, temp_root, board_dirs):
return board_dir
-def copy_driver_directory(source, temp_root, driver_dirs):
+def copy_driver_directory(source: Path, temp_root: Path, driver_dirs: Sequence[Path]) -> Path:
drivers_dir = temp_root / "Drivers"
for directory in driver_dirs:
src = source / directory
@@ -285,13 +287,19 @@ def copy_driver_directory(source, temp_root, driver_dirs):
return drivers_dir
-def replace_directory(source, destination):
+def replace_directory(source: Path, destination: Path) -> None:
if destination.exists():
shutil.rmtree(destination)
source.rename(destination)
-def copy_directories(source, st_root, board, board_dirs, driver_dirs):
+def copy_directories(
+ source: Path,
+ st_root: Path,
+ board: str,
+ board_dirs: Sequence[Path],
+ driver_dirs: Sequence[Path],
+) -> None:
temp_root = Path(tempfile.mkdtemp(prefix=".copy-st-drivers-", dir=st_root))
try:
temp_common_dir = copy_common_directory(source, temp_root)
@@ -309,7 +317,7 @@ def copy_directories(source, st_root, board, board_dirs, driver_dirs):
shutil.rmtree(temp_root, ignore_errors=True)
-def main():
+def main() -> int:
args = parse_args()
try:
validate_board(args.board)
### scripts/create_release.py
@@ -0,0 +1,433 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+
+"""Create a draft GitHub release for BitBox02 firmware."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from string import Template
+from typing import Sequence
+from urllib.parse import quote
+
+import signed_firmware as firmware_format
+
+
+REPOSITORY = "BitBoxSwiss/bitbox02-firmware"
+REPOSITORY_URL = f"https://github.com/{REPOSITORY}"
+
+VERSION_PATTERN = re.compile(r"v?(\d+\.\d+\.\d+)\Z")
+SIMULATOR_ASSET_PATTERN = "bitbox02-multi-{version}-simulator1.0.0-linux-amd64"
+
+FIRMWARE_RELEASE_TEMPLATE = Template(
+ """**Release notes:**
+
+$changelog_entries
+
+**Note:**
+
+When upgrading from a firmware with version v9.17.0 or below, you first must install and run [firmware v9.17.1](${repository_url}/releases/tag/firmware%2Fv9.17.1).
+
+When upgrading from a firmware with version v9.26.1 or below, you first must install and run [firmware v9.26.2](${repository_url}/releases/tag/firmware%2Fv9.26.2).
+
+The [BitBoxApp](https://bitbox.swiss/app/) automatically performs these steps.
+
+**Simulator:**
+
+This release contains a simulator executable. Its primary use case is integration testing.
+
+**Verify this release:**
+
+Please see the [instructions here](${repository_url}/tree/master/releases) on how to reproduce this binary.
+
+Alternatively, [verify the signatures](${repository_url}/tree/master/releases/firmware-${version}) from the community to verify this build. See the instructions [here](${tagged_releases_url}#verify-assertions-by-the-community) on how to do so.
+
+We [welcome your signature](${repository_url}/tree/master/releases#contribute-your-signature) confirming this build.
+
+**Verify the hash shown by the BitBox02:**
+
+The hash of the firmware as verified/shown by the BitBox02 at startup is:
+
+$hash_lines
+
+See this [documentation](${tagged_releases_url}#verify-the-hash-as-shown-by-the-bitbox02-at-startup) for more details about this hash."""
+)
+
+SIMULATOR_RELEASE_TEMPLATE = Template(
+ """**Release notes:**
+
+$changelog_entries
+
+**Simulator:**
+
+This release contains a simulator executable. Its primary use case is integration testing."""
+)
+
+
+class ReleaseError(RuntimeError):
+ """An error that should be reported without a traceback."""
+
+
+@dataclass(frozen=True)
+class ReleaseProduct:
+ """Properties of one firmware release product."""
+
+ label: str
+ asset_pattern: str
+ firmware_product: firmware_format.Product
+
+ def asset_name(self, version: str) -> str:
+ """Return the release asset name for a normalized version."""
+
+ return self.asset_pattern.format(version=version)
+
+
+PRODUCTS = (
+ ReleaseProduct(
+ label="BitBox02 Bitcoin-only",
+ asset_pattern="firmware-bitbox02-btconly.{version}.signed.bin",
+ firmware_product=firmware_format.BITBOX02_BTCONLY,
+ ),
+ ReleaseProduct(
+ label="BitBox02 Multi",
+ asset_pattern="firmware-bitbox02-multi.{version}.signed.bin",
+ firmware_product=firmware_format.BITBOX02_MULTI,
+ ),
+ ReleaseProduct(
+ label="BitBox02 Nova Bitcoin-only",
+ asset_pattern="firmware-bitbox02nova-btconly.{version}.signed.bin",
+ firmware_product=firmware_format.BITBOX02_NOVA_BTCONLY,
+ ),
+ ReleaseProduct(
+ label="BitBox02 Nova Multi",
+ asset_pattern="firmware-bitbox02nova-multi.{version}.signed.bin",
+ firmware_product=firmware_format.BITBOX02_NOVA_MULTI,
+ ),
+)
+
+
+@dataclass(frozen=True)
+class FirmwareAsset:
+ """A firmware asset and its bootloader-visible hash."""
+
+ product: ReleaseProduct
+ path: Path
+ sighash: str
+
+
+def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
+ """Parse command-line arguments."""
+
+ parser = argparse.ArgumentParser(description="Create a draft BitBox02 firmware release")
+ parser.add_argument("version", help="Release version, with or without a leading 'v'")
+ parser.add_argument("input_dir", type=Path, help="Directory containing the release assets")
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="validate and preview the release without prompting or creating it",
+ )
+ return parser.parse_args(argv)
+
+
+def normalize_version(value: str) -> str:
+ """Normalize X.Y.Z or vX.Y.Z to vX.Y.Z."""
+
+ match = VERSION_PATTERN.fullmatch(value)
+ if match is None:
+ raise ReleaseError(f"Invalid version '{value}'; expected X.Y.Z or vX.Y.Z")
+ return f"v{match.group(1)}"
+
+
+def run_gh(args: Sequence[str], input_text: str | None = None) -> subprocess.CompletedProcess[str]:
+ """Run an authenticated GitHub CLI command."""
+
+ try:
+ return subprocess.run(
+ ["gh", *args],
+ check=False,
+ capture_output=True,
+ input=input_text,
+ text=True,
+ )
+ except FileNotFoundError as exc:
+ raise ReleaseError("gh is required but was not found in PATH") from exc
+
+
+def command_error(action: str, result: subprocess.CompletedProcess[str]) -> ReleaseError:
+ """Create a useful error from a failed gh invocation."""
+
+ details = result.stderr.strip() or result.stdout.strip() or "unknown error"
+ return ReleaseError(f"Failed to {action}: {details}")
+
+
+def is_not_found(result: subprocess.CompletedProcess[str]) -> bool:
+ """Return whether a gh API failure was an HTTP 404."""
+
+ return "HTTP 404" in result.stderr
+
+
+def verify_remote_tag(tag_name: str) -> None:
+ """Require tag_name to already exist in the upstream GitHub repository."""
+
+ encoded_tag = quote(tag_name, safe="")
+ result = run_gh(["api", f"repos/{REPOSITORY}/git/ref/tags/{encoded_tag}"])
+ if result.returncode == 0:
+ return
+ if is_not_found(result):
+ raise ReleaseError(f"Remote tag '{tag_name}' does not exist in {REPOSITORY}")
+ raise command_error(f"verify remote tag '{tag_name}'", result)
+
+
+def ensure_release_absent(tag_name: str) -> None:
+ """Require that no GitHub release already exists for tag_name."""
+
+ encoded_tag = quote(tag_name, safe="")
+ result = run_gh(["api", f"repos/{REPOSITORY}/releases/tags/{encoded_tag}"])
+ if result.returncode == 0:
+ raise ReleaseError(f"A GitHub release already exists for tag '{tag_name}'")
+ if not is_not_found(result):
+ raise command_error(f"check for an existing release for '{tag_name}'", result)
+
+
+def fetch_tagged_changelog(tag_name: str) -> str:
+ """Fetch CHANGELOG.md exactly as it appears at the remote tag."""
+
+ encoded_tag = quote(tag_name, safe="")
+ result = run_gh(
+ [
+ "api",
+ "-H",
+ "Accept: application/vnd.github.raw+json",
+ f"repos/{REPOSITORY}/contents/CHANGELOG.md?ref={encoded_tag}",
+ ]
+ )
+ if result.returncode != 0:
+ raise command_error(f"fetch CHANGELOG.md at '{tag_name}'", result)
+ return result.stdout
+
+
+def extract_changelog_entries(changelog: str, version: str) -> str:
+ """Extract the Markdown below the exact firmware version heading."""
+
+ heading = f"### {version}"
+ lines = changelog.splitlines()
+ heading_indices = [index for index, line in enumerate(lines) if line.rstrip() == heading]
+ if len(heading_indices) != 1:
+ raise ReleaseError(
+ f"Expected exactly one '{heading}' section in the tagged CHANGELOG.md, "
+ f"found {len(heading_indices)}"
+ )
+
+ entries = []
+ for line in lines[heading_indices[0] + 1 :]:
+ if re.match(r"^#{1,3}\s", line):
+ break
+ entries.append(line)
+
+ rendered = "\n".join(entries).strip()
+ if not rendered:
+ raise ReleaseError(f"The '{heading}' section in the tagged CHANGELOG.md is empty")
+ return rendered
+
+
+def discover_assets(
+ input_dir: Path, version: str
+) -> tuple[Path, list[tuple[ReleaseProduct, Path]]]:
+ """Find the required simulator and any recognized firmware assets."""
+
+ try:
+ resolved_input_dir = input_dir.expanduser().resolve(strict=True)
+ except FileNotFoundError as exc:
+ raise ReleaseError(f"Input directory does not exist: {input_dir}") from exc
+ if not resolved_input_dir.is_dir():
+ raise ReleaseError(f"Input path is not a directory: {resolved_input_dir}")
+
+ simulator = resolved_input_dir / SIMULATOR_ASSET_PATTERN.format(version=version)
+ if not simulator.exists():
+ raise ReleaseError(f"Required simulator asset is missing: {simulator}")
+ if not simulator.is_file():
+ raise ReleaseError(f"Simulator asset is not a regular file: {simulator}")
+
+ firmware_assets = []
+ for product in PRODUCTS:
+ path = resolved_input_dir / product.asset_name(version)
+ if not path.exists():
+ continue
+ if not path.is_file():
+ raise ReleaseError(f"Firmware asset is not a regular file: {path}")
+ firmware_assets.append((product, path))
+
+ return simulator, firmware_assets
+
+
+def calculate_firmware_sighash(path: Path, product: ReleaseProduct) -> str:
+ """Calculate the firmware hash verified and shown by the bootloader."""
+
+ try:
+ signed_firmware = path.read_bytes()
+ except OSError as exc:
+ raise ReleaseError(f"Failed to read firmware asset '{path}': {exc}") from exc
+
+ try:
+ parsed = firmware_format.parse(signed_firmware)
+ except ValueError as exc:
+ raise ReleaseError(f"Invalid firmware asset '{path}': {exc}") from exc
+
+ if parsed.product != product.firmware_product:
+ magic = signed_firmware[: firmware_format.MAGIC_LEN]
+ raise ReleaseError(
+ f"Firmware asset '{path}' has magic {magic.hex()}, expected "
+ f"{product.firmware_product.magic.hex()} for {product.label}"
+ )
+ return firmware_format.sighash(parsed).hex()
+
+
+def render_release_notes(
+ version: str, changelog_entries: str, firmware_assets: Sequence[FirmwareAsset]
+) -> str:
+ """Render release notes in the established firmware release format."""
+
+ template_values = {"changelog_entries": changelog_entries.strip()}
+ if not firmware_assets:
+ return SIMULATOR_RELEASE_TEMPLATE.substitute(template_values).strip() + "\n"
+
+ hash_lines = "\n".join(
+ f"- {asset.product.label}: `{asset.sighash}`" for asset in firmware_assets
+ )
+ template_values.update(
+ {
+ "repository_url": REPOSITORY_URL,
+ "version": version,
+ "tagged_releases_url": f"{REPOSITORY_URL}/tree/firmware/{version}/releases",
+ "hash_lines": hash_lines,
+ }
+ )
+ return FIRMWARE_RELEASE_TEMPLATE.substitute(template_values).strip() + "\n"
+
+
+def print_preview(
+ version: str,
+ tag_name: str,
+ release_notes: str,
+ simulator: Path,
+ firmware_assets: Sequence[FirmwareAsset],
+) -> None:
+ """Display the complete pending GitHub mutation."""
+
+ print("Action: create a draft GitHub release")
+ print(f"Repository: {REPOSITORY}")
+ print(f"Tag: {tag_name} (must already exist)")
+ print(f"Title: {version}")
+ print("Assets:")
+ print(f" - {simulator}")
+ for asset in firmware_assets:
+ print(f" - {asset.path}")
+
+ if firmware_assets:
+ print("Firmware hashes shown by the device:")
+ for asset in firmware_assets:
+ print(f" - {asset.product.label}: {asset.sighash}")
+ print("Firmware signatures are not verified by this script.")
+
+ print("\nRelease notes:\n")
+ print(release_notes, end="")
+
+
+def confirm_release() -> bool:
+ """Ask for default-negative confirmation."""
+
+ try:
+ response = input("\nCreate this draft release? [y/N] ")
+ except EOFError:
+ return False
+ return response.strip().lower() in {"y", "yes"}
+
+
+def create_draft_release(
+ version: str,
+ tag_name: str,
+ release_notes: str,
+ asset_paths: Sequence[Path],
+) -> str:
+ """Create the draft release and upload all selected assets."""
+
+ result = run_gh(
+ [
+ "release",
+ "create",
+ tag_name,
+ *(str(path) for path in asset_paths),
+ "--repo",
+ REPOSITORY,
+ "--draft",
+ "--verify-tag",
+ "--title",
+ version,
+ "--notes-file",
+ "-",
+ ],
+ input_text=release_notes,
+ )
+ if result.returncode != 0:
+ error = command_error("create the draft release", result)
+ raise ReleaseError(
+ f"{error}\nGitHub may have retained a partially created draft; inspect the "
+ "repository before retrying."
+ )
+ return result.stdout.strip()
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Run the release creation workflow."""
+
+ args = parse_args(argv)
+ try:
+ version = normalize_version(args.version)
+ tag_name = f"firmware/{version}"
+ simulator, discovered_firmware = discover_assets(args.input_dir, version)
+
+ verify_remote_tag(tag_name)
+ ensure_release_absent(tag_name)
+ changelog = fetch_tagged_changelog(tag_name)
+ changelog_entries = extract_changelog_entries(changelog, version)
+
+ firmware_assets = [
+ FirmwareAsset(
+ product=product,
+ path=path,
+ sighash=calculate_firmware_sighash(path, product),
+ )
+ for product, path in discovered_firmware
+ ]
+ release_notes = render_release_notes(version, changelog_entries, firmware_assets)
+
+ print_preview(version, tag_name, release_notes, simulator, firmware_assets)
+ if args.dry_run:
+ print("\nDry run complete; no release was created.")
+ return 0
+ if not confirm_release():
+ print("Cancelled; no release was created.")
+ return 0
+
+ asset_paths = [simulator, *(asset.path for asset in firmware_assets)]
+ release_url = create_draft_release(version, tag_name, release_notes, asset_paths)
+ if release_url:
+ print(f"Created draft release: {release_url}")
+ else:
+ print("Created draft release.")
+ return 0
+ except ReleaseError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ except KeyboardInterrupt:
+ print("\nCancelled; no release was created.", file=sys.stderr)
+ return 130
+
+
+if __name__ == "__main__":
+ sys.exit(main())
### scripts/create_release_assertions.py
@@ -6,40 +6,41 @@
from __future__ import annotations
import argparse
-import hashlib
import subprocess
import sys
from pathlib import Path
+from typing import TypedDict, cast
from urllib import error, request
+import signed_firmware as firmware_format
+
REPOSITORY_ROOT = Path(__file__).resolve().parent.parent
UPSTREAM_REPOSITORY_URL = "https://github.com/BitBoxSwiss/bitbox02-firmware"
UPSTREAM_GIT_URL = f"{UPSTREAM_REPOSITORY_URL}.git"
-MAGIC_LEN = 4
-MAGIC_MULTI = bytes.fromhex("653f362b")
-MAGIC_BTCONLY = bytes.fromhex("11233b0b")
-VERSION_FIELD_LEN = 4
-NUM_ROOT_KEYS = 3
-NUM_SIGNING_KEYS = 3
-SIGNING_PUBKEYS_DATA_LEN = VERSION_FIELD_LEN + NUM_SIGNING_KEYS * 64 + NUM_ROOT_KEYS * 64
-FIRMWARE_DATA_LEN = VERSION_FIELD_LEN + NUM_SIGNING_KEYS * 64
-SIGDATA_LEN = SIGNING_PUBKEYS_DATA_LEN + FIRMWARE_DATA_LEN
+class Product(TypedDict):
+ """Release asset metadata for one firmware product."""
+
+ label: str
+ asset_name: str
+ filename: str
+ expected_magic: bytes
+
-PRODUCTS = (
+PRODUCTS: tuple[Product, ...] = (
{
"label": "BitBox02 Multi",
"asset_name": "firmware-bitbox02-multi.{version}.signed.bin",
"filename": "assertion-bitbox02-multi.txt",
- "expected_magic": MAGIC_MULTI,
+ "expected_magic": firmware_format.BITBOX02_MULTI.magic,
},
{
"label": "BitBox02 Bitcoin-only",
"asset_name": "firmware-bitbox02-btconly.{version}.signed.bin",
"filename": "assertion-bitbox02-btconly.txt",
- "expected_magic": MAGIC_BTCONLY,
+ "expected_magic": firmware_format.BITBOX02_BTCONLY.magic,
},
)
@@ -112,7 +113,7 @@ def download_signed_firmware(version: str, asset_name: str) -> bytes:
request_headers = {"User-Agent": "bitbox02-release-assertion-generator"}
try:
with request.urlopen(request.Request(url, headers=request_headers)) as response:
- return response.read()
+ return cast(bytes, response.read())
except error.HTTPError as exc:
raise RuntimeError(
f"Failed to download '{asset_name}' from {url}: HTTP {exc.code}"
@@ -124,17 +125,16 @@ def download_signed_firmware(version: str, asset_name: str) -> bytes:
def extract_unsigned_firmware_hash(
signed_firmware: bytes, expected_magic: bytes, asset_name: str
) -> str:
- if len(signed_firmware) < MAGIC_LEN + SIGDATA_LEN:
- raise RuntimeError(f"Downloaded asset '{asset_name}' is too small to be a signed firmware")
-
- actual_magic = signed_firmware[:MAGIC_LEN]
- if actual_magic != expected_magic:
+ try:
+ parsed = firmware_format.parse(signed_firmware)
+ except ValueError as exc:
+ raise RuntimeError(f"Downloaded asset '{asset_name}' is invalid: {exc}") from exc
+ if parsed.product.magic != expected_magic:
+ actual_magic = signed_firmware[: firmware_format.MAGIC_LEN]
raise RuntimeError(
f"Downloaded asset '{asset_name}' has unexpected magic {actual_magic.hex()}"
)
-
- firmware = signed_firmware[MAGIC_LEN + SIGDATA_LEN :]
- return hashlib.sha256(firmware).hexdigest()
+ return firmware_format.unsigned_sha256(parsed).hex()
def render_assertion(version: str, product_label: str, commit_hash: str, sha256_hash: str) -> str:
### scripts/generate_version_headers.py
@@ -15,7 +15,9 @@
import subprocess
import sys
import textwrap
+from collections.abc import Mapping, Sequence
from string import Template
+from typing import Any, TypedDict, cast, overload
SEMVER_RE = re.compile(
@@ -44,30 +46,54 @@
ZERO_GIT_COMMIT_HASH_SHORT = "0000000000"
-def eprintln(*args, **kwargs):
+class Manifest(TypedDict):
+ """Validated release version manifest."""
+
+ firmware: str
+ bootloader: str
+ stage0: int
+
+
+class VersionInfo(TypedDict):
+ """Version fields used to render the generated headers."""
+
+ base: str
+ full: str
+ full_len: int
+ full_w16: str
+ major: str
+ minor: str
+ patch: str
+ has_metadata: bool
+
+
+def eprintln(*args: object, **kwargs: Any) -> None:
print(*args, **kwargs, file=sys.stderr)
-def system(*args, **kwargs):
- res = subprocess.run(
- *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", **kwargs
+def system(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]:
+ res = cast(
+ subprocess.CompletedProcess[str],
+ subprocess.run( # type: ignore[call-overload]
+ *args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", **kwargs
+ ),
)
if res.returncode != 0:
eprintln("Failed to run `{}`".format(args[0]))
eprintln("stderr: {}".format(res.stderr))
return res
-def parse_tags(rows, only_signed, prefix):
+def parse_tags(rows: Sequence[str], only_signed: bool, prefix: str | None) -> list[str]:
"""Parses `git tag` rows and returns matching refnames."""
- rows = [row[4:] for row in rows if row.startswith("tag")]
- rows = [row[2:] for row in rows if row[0] == "Y" or not only_signed]
+ tags = [row[4:] for row in rows if row.startswith("tag")]
+ tags = [row[2:] for row in tags if row[0] == "Y" or not only_signed]
if prefix is None:
- rows = [row for row in rows if "/" not in row]
- return rows
+ tags = [row for row in tags if "/" not in row]
+ return tags
-def git_list_cmd(prefix, extra_args=None):
+def git_list_cmd(prefix: str | None, extra_args: Sequence[str] | None = None) -> list[str]:
if extra_args is None:
extra_args = []
cmd = ["git", "tag", "--list", "--sort=taggerdate"]
@@ -84,7 +110,13 @@ def git_list_cmd(prefix, extra_args=None):
return cmd
-def compute_tag_version(repo_root, prefix, check_gpg=False, verify=False, check_semver=False):
+def compute_tag_version(
+ repo_root: str | os.PathLike[str],
+ prefix: str | None,
+ check_gpg: bool = False,
+ verify: bool = False,
+ check_semver: bool = False,
+) -> str:
git = shutil.which("git")
if git is None:
raise RuntimeError("Command `git` not found.")
@@ -161,7 +193,19 @@ def compute_tag_version(repo_root, prefix, check_gpg=False, verify=False, check_
return version
-def git_output(repo_root, args, default=None):
+@overload
+def git_output(repo_root: str | os.PathLike[str], args: Sequence[str], default: str) -> str: ...
+
+
+@overload
+def git_output(
+ repo_root: str | os.PathLike[str], args: Sequence[str], default: None = None
+) -> str | None: ...
+
+
+def git_output(
+ repo_root: str | os.PathLike[str], args: Sequence[str], default: str | None = None
+) -> str | None:
git = shutil.which("git")
if git is None:
return default
@@ -182,24 +226,26 @@ def git_output(repo_root, args, default=None):
return value if value else default
-def strict_tag_version(repo_root, prefix):
+def strict_tag_version(repo_root: str | os.PathLike[str], prefix: str | None) -> str:
return compute_tag_version(repo_root, prefix, check_gpg=True, verify=False, check_semver=True)
-def load_manifest(manifest_path):
+def load_manifest(manifest_path: str | os.PathLike[str]) -> Manifest:
with open(manifest_path, "r", encoding="utf-8") as infile:
- manifest = json.load(infile)
+ manifest = cast(dict[str, object], json.load(infile))
for key in ("firmware", "bootloader"):
value = manifest.get(key)
if not isinstance(value, str) or not RELEASE_VERSION_RE.match(value):
raise ValueError("Manifest entry '{}' must be a semver string like v1.2.3".format(key))
stage0 = manifest.get("stage0")
if not isinstance(stage0, int) or isinstance(stage0, bool) or stage0 < 0 or stage0 > 0xFFFF:
raise ValueError("Manifest entry 'stage0' must be an integer in the uint16_t range")
- return manifest
+ return cast(Manifest, manifest)
-def build_version_info(base_version, tag_version, git_commit_hash_short):
+def build_version_info(
+ base_version: str, tag_version: str, git_commit_hash_short: str
+) -> VersionInfo:
parts = base_version.split(".")
if len(parts) != 3 or not parts[0].startswith("v"):
raise ValueError("invalid version format: {}".format(base_version))
@@ -219,31 +265,33 @@ def build_version_info(base_version, tag_version, git_commit_hash_short):
}
-def render_template(template_path, substitutions):
+def render_template(
+ template_path: str | os.PathLike[str], substitutions: Mapping[str, object]
+) -> str:
with open(template_path, "r", encoding="utf-8") as infile:
template = Template(infile.read())
return template.substitute(substitutions)
-def write_file(path, contents):
+def write_file(path: str | os.PathLike[str], contents: str) -> None:
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as outfile:
outfile.write(contents)
-def cmake_quote(value):
+def cmake_quote(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def write_cmake_vars(
- path,
- firmware_info,
- bootloader_info,
- git_firmware_version_string,
- git_bootloader_version_string,
-):
+ path: str | os.PathLike[str],
+ firmware_info: VersionInfo,
+ bootloader_info: VersionInfo,
+ git_firmware_version_string: str,
+ git_bootloader_version_string: str,
+) -> None:
contents = textwrap.dedent(
"""\
set(GIT_FIRMWARE_VERSION_STRING "{git_firmware_version_string}")
@@ -262,7 +310,12 @@ def write_cmake_vars(
write_file(path, contents)
-def generate_headers(repo_root, output_dir, cmake_vars_out=None, manifest_path=None):
+def generate_headers(
+ repo_root: str,
+ output_dir: str,
+ cmake_vars_out: str | None = None,
+ manifest_path: str | None = None,
+) -> None:
if manifest_path is None:
manifest_path = os.path.join(repo_root, "versions.json")
manifest = load_manifest(manifest_path)
@@ -332,7 +385,7 @@ def generate_headers(repo_root, output_dir, cmake_vars_out=None, manifest_path=N
)
-def main_generate(argv=None):
+def main_generate(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate version headers from versions.json")
parser.add_argument("--repo-root", required=True)
parser.add_argument("--output-dir", required=True)
@@ -355,7 +408,7 @@ def main_generate(argv=None):
return 0
-def main_get_version(argv=None):
+def main_get_version(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=textwrap.dedent(
"""
@@ -423,7 +476,7 @@ def main_get_version(argv=None):
return 0
-def main(argv=None):
+def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Version metadata helpers")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("generate", help="Generate version headers and optional CMake vars")
### scripts/graphics/convert.py
@@ -3,9 +3,10 @@
"""Helper script to convert images to c source code"""
import argparse
+from collections.abc import Iterator
-def convert(content, width, height):
+def convert(content: str, width: int, height: int) -> Iterator[list[str]]:
"""Convert function"""
i = 0
char = 0
@@ -31,7 +32,7 @@ def convert(content, width, height):
yield res
-def main():
+def main() -> None:
"""Main function"""
parser = argparse.ArgumentParser()
parser.add_argument("pbmfile")
### scripts/lint-python
@@ -16,14 +16,15 @@ command -v ${MYPY} >/dev/null 2>&1 || { echo >&2 "${MYPY} is missing"; exit 1; }
# Store files as array in ARGS
ARGS=($(find py releases -name '*.py' | grep -v -e generated -e old))
+SCRIPT_ARGS=($(find scripts -name '*.py'))
# implicit-reexport: `from foo import bar` re-exports (normal Python3 behavior), otherwise mypy
# expects `from foo import bar as bar`.
# namespace-packages: follow imports in subfolders without `__init__.py` (also normal Python3
# behavior).
# We must typecheck the whole `py` directory even if only a few files are modified so that mypy
# sees all types
-${MYPY} --implicit-reexport --namespace-packages --ignore-missing-imports --strict py/send_message.py py/bitbox02/bitbox02 releases/describe_signed_firmware.py
+${MYPY} --implicit-reexport --namespace-packages --ignore-missing-imports --strict py/send_message.py py/bitbox02/bitbox02 releases/describe_signed_firmware.py "${SCRIPT_ARGS[@]}"
# Must run from root directory where .pylintrc is
# We ignore refactor and convention messages because they can differ with `black`
### scripts/signed_firmware.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+
+"""Parse and hash signed BitBox02 firmware containers."""
+
+from __future__ import annotations
+
+import hashlib
+import struct
+from dataclasses import dataclass
+
+
+# A signed firmware file consists of MAGIC_LEN bytes of a product marker, followed by SIGDATA_LEN
+# bytes of signature data, and ending with the firmware bytes from the reproducible build.
+MAGIC_LEN = 4
+MAX_FIRMWARE_SIZE = 884736
+NUM_ROOT_KEYS = 3
+NUM_SIGNING_KEYS = 3
+VERSION_LEN = 4
+SIGNING_PUBKEYS_DATA_LEN = VERSION_LEN + NUM_SIGNING_KEYS * 64 + NUM_ROOT_KEYS * 64
+FIRMWARE_DATA_LEN = VERSION_LEN + NUM_SIGNING_KEYS * 64
+SIGDATA_LEN = SIGNING_PUBKEYS_DATA_LEN + FIRMWARE_DATA_LEN
+
+
+@dataclass(frozen=True)
+class Product:
+ """Product values included in the signed-firmware format."""
+
+ product_id: int
+ magic: bytes
+
+
+BITBOX02_MULTI = Product(product_id=1, magic=bytes.fromhex("653f362b"))
+BITBOX02_BTCONLY = Product(product_id=2, magic=bytes.fromhex("11233b0b"))
+BITBOX02_NOVA_MULTI = Product(product_id=3, magic=bytes.fromhex("5b648ceb"))
+BITBOX02_NOVA_BTCONLY = Product(product_id=4, magic=bytes.fromhex("48714774"))
+
+PRODUCTS = (
+ BITBOX02_MULTI,
+ BITBOX02_BTCONLY,
+ BITBOX02_NOVA_MULTI,
+ BITBOX02_NOVA_BTCONLY,
+)
+PRODUCT_BY_MAGIC = {product.magic: product for product in PRODUCTS}
+
+
+@dataclass(frozen=True)
+class SignedFirmware:
+ """Parsed signed-firmware fields needed by release tooling."""
+
+ product: Product
+ sigdata: bytes
+ firmware: bytes
+
+ @property
+ def version(self) -> bytes:
+ """Return the encoded monotonic firmware version."""
+
+ return self.sigdata[SIGNING_PUBKEYS_DATA_LEN : SIGNING_PUBKEYS_DATA_LEN + VERSION_LEN]
+
+
+def parse(data: bytes) -> SignedFirmware:
+ """Parse and validate a signed-firmware container."""
+
+ prefix_len = MAGIC_LEN + SIGDATA_LEN
+ if len(data) <= prefix_len:
+ raise ValueError("firmware is too small to contain firmware data")
+
+ magic = data[:MAGIC_LEN]
+ product = PRODUCT_BY_MAGIC.get(magic)
+ if product is None:
+ raise ValueError(f"unrecognized firmware magic {magic.hex()}")
+
+ sigdata = data[MAGIC_LEN:prefix_len]
+ firmware = data[prefix_len:]
+ if len(firmware) > MAX_FIRMWARE_SIZE:
+ raise ValueError(
+ f"firmware payload is {len(firmware)} bytes, exceeding the maximum "
+ f"of {MAX_FIRMWARE_SIZE}"
+ )
+ return SignedFirmware(product=product, sigdata=sigdata, firmware=firmware)
+
+
+def unsigned_sha256(signed_firmware: SignedFirmware) -> bytes:
+ """Return the SHA-256 digest of the reproducible, unsigned firmware bytes."""
+
+ return hashlib.sha256(signed_firmware.firmware).digest()
+
+
+def sighash(signed_firmware: SignedFirmware) -> bytes:
+ """Return the current firmware hash verified and shown by the bootloader."""
+
+ firmware_padded = signed_firmware.firmware + b"\xff" * (
+ MAX_FIRMWARE_SIZE - len(signed_firmware.firmware)
+ )
+ preimage = (
+ struct.pack("<H", signed_firmware.product.product_id)
+ + signed_firmware.version
+ + firmware_padded
+ )
+ return hashlib.sha256(preimage).digest()
### test/scripts/test_create_release.py
@@ -0,0 +1,333 @@
+# SPDX-License-Identifier: Apache-2.0
+
+"""Tests for scripts/create_release.py."""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import importlib.util
+import io
+import struct
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+SCRIPTS_DIR = REPOSITORY_ROOT / "scripts"
+sys.path.insert(0, str(SCRIPTS_DIR))
+MODULE_PATH = SCRIPTS_DIR / "create_release.py"
+SPEC = importlib.util.spec_from_file_location("create_release_under_test", MODULE_PATH)
+if SPEC is None or SPEC.loader is None:
+ raise RuntimeError(f"Could not load {MODULE_PATH}")
+CREATE_RELEASE = importlib.util.module_from_spec(SPEC)
+sys.modules[SPEC.name] = CREATE_RELEASE
+SPEC.loader.exec_module(CREATE_RELEASE)
+FIRMWARE_FORMAT = CREATE_RELEASE.firmware_format
+
+ASSERTION_MODULE_PATH = SCRIPTS_DIR / "create_release_assertions.py"
+ASSERTION_SPEC = importlib.util.spec_from_file_location(
+ "create_release_assertions_under_test", ASSERTION_MODULE_PATH
+)
+if ASSERTION_SPEC is None or ASSERTION_SPEC.loader is None:
+ raise RuntimeError(f"Could not load {ASSERTION_MODULE_PATH}")
+CREATE_ASSERTIONS = importlib.util.module_from_spec(ASSERTION_SPEC)
+sys.modules[ASSERTION_SPEC.name] = CREATE_ASSERTIONS
+ASSERTION_SPEC.loader.exec_module(CREATE_ASSERTIONS)
+
+
+def completed_process(
+ returncode: int = 0, stdout: str = "", stderr: str = ""
+) -> subprocess.CompletedProcess[str]:
+ """Create a gh-like subprocess result."""
+
+ return subprocess.CompletedProcess(
+ args=["gh"], returncode=returncode, stdout=stdout, stderr=stderr
+ )
+
+
+def write_signed_firmware(
+ path: Path,
+ product: CREATE_RELEASE.ReleaseProduct,
+ monotonic_version: int,
+ firmware: bytes = b"firmware",
+) -> bytes:
+ """Write a minimally populated signed-firmware container."""
+
+ sigdata = bytearray(FIRMWARE_FORMAT.SIGDATA_LEN)
+ struct.pack_into("<I", sigdata, FIRMWARE_FORMAT.SIGNING_PUBKEYS_DATA_LEN, monotonic_version)
+ path.write_bytes(product.firmware_product.magic + sigdata + firmware)
+ return bytes(sigdata)
+
+
+class VersionAndChangelogTests(unittest.TestCase):
+ """Test release input normalization."""
+
+ def test_normalize_version(self) -> None:
+ self.assertEqual(CREATE_RELEASE.normalize_version("9.26.6"), "v9.26.6")
+ self.assertEqual(CREATE_RELEASE.normalize_version("v9.26.6"), "v9.26.6")
+
+ def test_normalize_version_rejects_invalid_values(self) -> None:
+ for value in ("", "V9.26.6", "v9.26", "v9.26.6-rc1"):
+ with self.subTest(value=value), self.assertRaises(CREATE_RELEASE.ReleaseError):
+ CREATE_RELEASE.normalize_version(value)
+
+ def test_extract_changelog_entries(self) -> None:
+ changelog = """# Changelog
+
+## Firmware
+
+### [Unreleased]
+- Later change
+
+### v9.26.6
+- First release change
+- Second release change
+
+### v9.26.5
+- Previous change
+"""
+ self.assertEqual(
+ CREATE_RELEASE.extract_changelog_entries(changelog, "v9.26.6"),
+ "- First release change\n- Second release change",
+ )
+
+ def test_extract_changelog_entries_requires_one_nonempty_section(self) -> None:
+ for changelog in ("### v9.26.5\n- Old", "### v9.26.6\n\n### v9.26.5"):
+ with self.subTest(changelog=changelog), self.assertRaises(CREATE_RELEASE.ReleaseError):
+ CREATE_RELEASE.extract_changelog_entries(changelog, "v9.26.6")
+
+
+class AssetTests(unittest.TestCase):
+ """Test asset discovery and signed-firmware parsing."""
+
+ def test_shared_product_values(self) -> None:
+ self.assertEqual(
+ [(product.product_id, product.magic.hex()) for product in FIRMWARE_FORMAT.PRODUCTS],
+ [
+ (1, "653f362b"),
+ (2, "11233b0b"),
+ (3, "5b648ceb"),
+ (4, "48714774"),
+ ],
+ )
+
+ def test_discover_assets_allows_any_firmware_subset(self) -> None:
+ version = "v9.26.6"
+ with tempfile.TemporaryDirectory() as temp_dir:
+ directory = Path(temp_dir)
+ simulator = directory / CREATE_RELEASE.SIMULATOR_ASSET_PATTERN.format(version=version)
+ simulator.touch()
+ selected_products = (CREATE_RELEASE.PRODUCTS[0], CREATE_RELEASE.PRODUCTS[3])
+ for product in selected_products:
+ (directory / product.asset_name(version)).touch()
+ (directory / "unrelated-file").touch()
+
+ actual_simulator, firmware_assets = CREATE_RELEASE.discover_assets(directory, version)
+
+ self.assertEqual(actual_simulator, simulator)
+ self.assertEqual(
+ firmware_assets,
+ [
+ (product, directory / product.asset_name(version))
+ for product in selected_products
+ ],
+ )
+
+ def test_discover_assets_allows_simulator_only(self) -> None:
+ version = "v9.26.6"
+ with tempfile.TemporaryDirectory() as temp_dir:
+ directory = Path(temp_dir)
+ simulator = directory / CREATE_RELEASE.SIMULATOR_ASSET_PATTERN.format(version=version)
+ simulator.touch()
+
+ actual_simulator, firmware_assets = CREATE_RELEASE.discover_assets(directory, version)
+
+ self.assertEqual(actual_simulator, simulator)
+ self.assertEqual(firmware_assets, [])
+
+ def test_discover_assets_requires_simulator(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir, self.assertRaises(
+ CREATE_RELEASE.ReleaseError
+ ):
+ CREATE_RELEASE.discover_assets(Path(temp_dir), "v9.26.6")
+
+ def test_calculate_sighash_for_every_product(self) -> None:
+ firmware = b"test firmware payload"
+ monotonic_version = 53
+ with tempfile.TemporaryDirectory() as temp_dir:
+ for product in CREATE_RELEASE.PRODUCTS:
+ with self.subTest(product=product.label):
+ path = Path(temp_dir) / product.asset_name("v9.26.6")
+ sigdata = write_signed_firmware(path, product, monotonic_version, firmware)
+ version_bytes = sigdata[
+ FIRMWARE_FORMAT.SIGNING_PUBKEYS_DATA_LEN : FIRMWARE_FORMAT.SIGNING_PUBKEYS_DATA_LEN
+ + FIRMWARE_FORMAT.VERSION_LEN
+ ]
+ padded = firmware + b"\xff" * (
+ FIRMWARE_FORMAT.MAX_FIRMWARE_SIZE - len(firmware)
+ )
+ expected = hashlib.sha256(
+ struct.pack("<H", product.firmware_product.product_id)
+ + version_bytes
+ + padded
+ ).hexdigest()
+
+ self.assertEqual(
+ CREATE_RELEASE.calculate_firmware_sighash(path, product), expected
+ )
+
+ def test_calculate_sighash_rejects_malformed_firmware(self) -> None:
+ product = CREATE_RELEASE.PRODUCTS[0]
+ with tempfile.TemporaryDirectory() as temp_dir:
+ directory = Path(temp_dir)
+ path = directory / product.asset_name("v9.26.6")
+
+ path.write_bytes(b"too short")
+ with self.assertRaisesRegex(CREATE_RELEASE.ReleaseError, "too small"):
+ CREATE_RELEASE.calculate_firmware_sighash(path, product)
+
+ write_signed_firmware(path, CREATE_RELEASE.PRODUCTS[1], 50)
+ with self.assertRaisesRegex(CREATE_RELEASE.ReleaseError, "has magic"):
+ CREATE_RELEASE.calculate_firmware_sighash(path, product)
+
+ write_signed_firmware(path, product, 50, b"x" * (FIRMWARE_FORMAT.MAX_FIRMWARE_SIZE + 1))
+ with self.assertRaisesRegex(CREATE_RELEASE.ReleaseError, "exceeding the maximum"):
+ CREATE_RELEASE.calculate_firmware_sighash(path, product)
+
+ def test_assertion_hash_uses_shared_parser(self) -> None:
+ product = CREATE_RELEASE.PRODUCTS[1]
+ firmware = b"unsigned firmware"
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / product.asset_name("v9.26.6")
+ write_signed_firmware(path, product, 53, firmware)
+ signed_firmware = path.read_bytes()
+
+ self.assertEqual(
+ CREATE_ASSERTIONS.extract_unsigned_firmware_hash(
+ signed_firmware, product.firmware_product.magic, path.name
+ ),
+ hashlib.sha256(firmware).hexdigest(),
+ )
+ with self.assertRaisesRegex(RuntimeError, "unexpected magic"):
+ CREATE_ASSERTIONS.extract_unsigned_firmware_hash(
+ signed_firmware, FIRMWARE_FORMAT.BITBOX02_BTCONLY.magic, path.name
+ )
+
+
+class ReleaseNotesTests(unittest.TestCase):
+ """Test the release-note template."""
+
+ def test_render_release_notes_with_arbitrary_firmware_subset(self) -> None:
+ selected_products = (CREATE_RELEASE.PRODUCTS[0], CREATE_RELEASE.PRODUCTS[3])
+ firmware_assets = [
+ CREATE_RELEASE.FirmwareAsset(product, Path(product.asset_name("v9.26.6")), "ab" * 32)
+ for product in selected_products
+ ]
+
+ notes = CREATE_RELEASE.render_release_notes(
+ "v9.26.6", "- First change\n- Second change", firmware_assets
+ )
+
+ self.assertTrue(notes.startswith("**Release notes:**\n\n- First change\n- Second change"))
+ self.assertIn("**Note:**", notes)
+ self.assertIn("**Simulator:**", notes)
+ self.assertIn("**Verify this release:**", notes)
+ self.assertIn("BitBox02 Bitcoin-only: `" + "ab" * 32 + "`", notes)
+ self.assertIn("BitBox02 Nova Multi: `" + "ab" * 32 + "`", notes)
+ self.assertNotIn("- BitBox02 Multi:", notes)
+ self.assertNotIn("- BitBox02 Nova Bitcoin-only:", notes)
+ self.assertIn("/tree/master/releases/firmware-v9.26.6", notes)
+ self.assertIn("/tree/firmware/v9.26.6/releases#verify-assertions-by-the-community", notes)
+ self.assertIn(
+ "/tree/firmware/v9.26.6/releases"
+ "#verify-the-hash-as-shown-by-the-bitbox02-at-startup",
+ notes,
+ )
+
+ def test_render_simulator_only_release_notes(self) -> None:
+ notes = CREATE_RELEASE.render_release_notes("v9.26.6", "- Simulator fix", [])
+
+ self.assertEqual(
+ notes,
+ "**Release notes:**\n\n- Simulator fix\n\n"
+ "**Simulator:**\n\n"
+ "This release contains a simulator executable. Its primary use case is integration "
+ "testing.\n",
+ )
+
+
+class GithubWorkflowTests(unittest.TestCase):
+ """Test GitHub preflight and mutation boundaries."""
+
+ @mock.patch.object(CREATE_RELEASE, "run_gh")
+ def test_verify_remote_tag_reports_missing_tag(self, run_gh: mock.Mock) -> None:
+ run_gh.return_value = completed_process(returncode=1, stderr="gh: Not Found (HTTP 404)")
+
+ with self.assertRaisesRegex(CREATE_RELEASE.ReleaseError, "does not exist"):
+ CREATE_RELEASE.verify_remote_tag("firmware/v9.26.6")
+
+ self.assertIn("firmware%2Fv9.26.6", run_gh.call_args.args[0][1])
+
+ @mock.patch.object(CREATE_RELEASE, "run_gh")
+ def test_create_draft_release_uses_existing_tag_and_draft(self, run_gh: mock.Mock) -> None:
+ run_gh.return_value = completed_process(stdout="https://example.test/release\n")
+ assets = [Path("simulator"), Path("firmware.bin")]
+
+ url = CREATE_RELEASE.create_draft_release(
+ "v9.26.6", "firmware/v9.26.6", "release notes\n", assets
+ )
+
+ self.assertEqual(url, "https://example.test/release")
+ args = run_gh.call_args.args[0]
+ self.assertEqual(args[:3], ["release", "create", "firmware/v9.26.6"])
+ self.assertIn("--draft", args)
+ self.assertIn("--verify-tag", args)
+ self.assertEqual(run_gh.call_args.kwargs["input_text"], "release notes\n")
+
+ @mock.patch.object(CREATE_RELEASE, "create_draft_release")
+ @mock.patch.object(CREATE_RELEASE, "confirm_release")
+ @mock.patch.object(CREATE_RELEASE, "fetch_tagged_changelog")
+ @mock.patch.object(CREATE_RELEASE, "ensure_release_absent")
+ @mock.patch.object(CREATE_RELEASE, "verify_remote_tag")
+ def test_dry_run_validates_and_previews_without_prompt_or_mutation(
+ self,
+ verify_remote_tag: mock.Mock,
+ ensure_release_absent: mock.Mock,
+ fetch_tagged_changelog: mock.Mock,
+ confirm_release: mock.Mock,
+ create_draft_release: mock.Mock,
+ ) -> None:
+ fetch_tagged_changelog.return_value = "### v9.26.6\n- Release change\n"
+ with tempfile.TemporaryDirectory() as temp_dir:
+ directory = Path(temp_dir)
+ simulator = directory / CREATE_RELEASE.SIMULATOR_ASSET_PATTERN.format(version="v9.26.6")
+ simulator.touch()
+ product = CREATE_RELEASE.PRODUCTS[2]
+ firmware = directory / product.asset_name("v9.26.6")
+ write_signed_firmware(firmware, product, 50)
+ output = io.StringIO()
+
+ with contextlib.redirect_stdout(output):
+ result = CREATE_RELEASE.main(["9.26.6", str(directory), "--dry-run"])
+
+ self.assertEqual(result, 0)
+ verify_remote_tag.assert_called_once_with("firmware/v9.26.6")
+ ensure_release_absent.assert_called_once_with("firmware/v9.26.6")
+ fetch_tagged_changelog.assert_called_once_with("firmware/v9.26.6")
+ confirm_release.assert_not_called()
+ create_draft_release.assert_not_called()
+ self.assertIn("BitBox02 Nova Bitcoin-only", output.getvalue())
+ self.assertIn("Dry run complete; no release was created.", output.getvalue())
+
+ @mock.patch("builtins.input", side_effect=EOFError)
+ def test_confirmation_defaults_to_no_on_eof(self, _input: mock.Mock) -> None:
+ self.assertFalse(CREATE_RELEASE.confirm_release())
+
+
+if __name__ == "__main__":
+ unittest.main()Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.