Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d6cbf9b
feat: reverse-DNS QSettings scope with one-shot legacy migration (#33)
Apr 30, 2026
2d371ba
ci: install libgl1 in smoke job so PyQt6.QtGui imports
Apr 30, 2026
c2cfa73
fix: hermetic QSettings test isolation + sync after migrate
Apr 30, 2026
471a8f5
fix: refactor migrate_settings_once to take injected QSettings + fix …
Apr 30, 2026
3b23620
fix: select-based poll in run_admin_streaming so timeout fires on a
Apr 30, 2026
f3c4a34
ci: pin upload-artifact to v3 (Forgejo doesn't support v4)
Apr 30, 2026
1b4e2a7
ci: bump python-appimage to >=1.4,<2 (drop distutils dependency)
Apr 30, 2026
6f27f90
ci: install squashfs-tools for appimage build
Apr 30, 2026
76dd918
ci: install libgl1 in appimage + release jobs too
Apr 30, 2026
500a12c
ci: add full PyQt6 runtime libs to appimage + release jobs
Apr 30, 2026
1f63122
refactor: collapse 4 HTTP workers into HttpWorker + retry/backoff (#2…
Apr 30, 2026
694008d
ci: real-hardware flash regression workflow + runner setup doc (#22)
Apr 30, 2026
2616634
docs: HDZero trademark exposure note + rename contingency (#43)
Apr 30, 2026
f125961
ci: add mypy gate (strict on safety-critical, typed-defs elsewhere)
Apr 30, 2026
afb7433
fix: relax mypy on main + internet_panel to import-checks only
Apr 30, 2026
ebb000a
fix: ignore_errors on main+internet_panel; type-annotate flash_ops st…
Apr 30, 2026
c73c885
chore: tighten mypy on main + internet_panel to disallow_untyped_defs…
Apr 30, 2026
a20a65b
fix: Optional types for flash_log handle + parameterize device dicts
Apr 30, 2026
675e9f4
Merge pull request 'chore: tighten mypy on main + internet_panel (#68…
Apr 30, 2026
109661e
docs: post-marathon drift refresh (CLAUDE.md, README, CHANGELOG, SECU…
Apr 30, 2026
e7289f0
Merge pull request 'docs: post-marathon drift refresh (CLAUDE.md, REA…
Apr 30, 2026
585a993
release: cut v0.3.0
Apr 30, 2026
a0bfbd6
Merge pull request 'release: cut v0.3.0' (#71) from release/0.3.0 int…
Apr 30, 2026
7ccd865
fix(main): skip crash dialog on KeyboardInterrupt (#74)
May 2, 2026
662921f
ci: re-trigger after Forgejo restart
May 3, 2026
99343c5
ci: kick after runner restart
May 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions .forgejo/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@ jobs:
run: |
set -eux
sudo apt-get update
# libegl1 / libxkbcommon0 / libdbus-1-3 cover the shared libs PyQt6
# wheels dlopen at import time even under QT_QPA_PLATFORM=offscreen.
# libgl1 / libegl1 / libxkbcommon0 / libdbus-1-3 cover the shared libs
# PyQt6 wheels dlopen at import time, even under QT_QPA_PLATFORM=offscreen.
# libgl1 is needed because PyQt6.QtGui transitively links libGL.so.1 —
# discovered when CI first ran on a Forgejo runner (prior queue-never-
# executed runs masked this).
sudo apt-get install -y --no-install-recommends \
libegl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libxcb-cursor0
libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libxcb-cursor0
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: ruff
run: ruff check .

- name: mypy
# Strict on safety-critical modules (flash_ops, udev_check,
# app_logging, app_settings); signature-typing on main +
# internet_panel. See pyproject [tool.mypy] for rationale.
run: mypy

- name: gitleaks
# Scan full history on push to main; staged diff is already covered
# by the pre-commit hook. Use --no-git for forks/runners that don't
Expand All @@ -42,7 +51,7 @@ jobs:
/tmp/gitleaks detect --source . --redact --verbose

- name: py_compile
run: python -m py_compile flash_ops.py main.py internet_panel.py udev_check.py app_logging.py
run: python -m py_compile flash_ops.py main.py internet_panel.py udev_check.py app_logging.py app_settings.py

- name: pytest
# --cov measures coverage; no --cov-fail-under yet — first establish
Expand Down Expand Up @@ -75,7 +84,7 @@ jobs:
PY

- name: Upload coverage report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage-xml
path: coverage.xml
Expand All @@ -98,13 +107,18 @@ jobs:
sudo apt-get update
# appimagetool needs FUSE at runtime; CI containers usually lack
# /dev/fuse, so we set APPIMAGE_EXTRACT_AND_RUN=1 below to bypass.
# libgl1/libegl1/libxkbcommon0/libdbus-1-3/libfontconfig1/libxcb-cursor0
# are needed at AppImage runtime by PyQt6 (the `--version` smoke
# step launches the AppImage, which imports PyQt6.QtGui before
# argparse runs). Same set as the smoke job.
sudo apt-get install -y --no-install-recommends \
file desktop-file-utils zsync wget
file desktop-file-utils zsync wget squashfs-tools \
libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libxcb-cursor0
python -m pip install --upgrade pip
pip install pipx
# Pin python-appimage so a future upstream change can't silently
# break the build. Bump deliberately when validated.
pipx install 'python-appimage==0.34'
pipx install 'python-appimage>=1.4,<2'

- name: Build AppImage
env:
Expand Down Expand Up @@ -141,7 +155,7 @@ jobs:
./dist/HDZeroProgrammer-x86_64.AppImage --check-rule

- name: Upload AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: HDZeroProgrammer-x86_64.AppImage
path: dist/HDZeroProgrammer-x86_64.AppImage
Expand Down
160 changes: 160 additions & 0 deletions .forgejo/workflows/hw-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Real-hardware flash regression test.
#
# Runs only on push to main (NOT on PRs from contributors — too slow,
# too risky for branches that haven't been reviewed) AND only on a
# runner labeled `hdzero-hw`. Without such a runner registered the
# job queues forever and is skipped on Forgejo housekeeping; the
# default `lab01-hdzero` runner advertises no `hdzero-hw` label so
# this never blocks day-to-day CI.
#
# Setup expectations for an `hdzero-hw` runner (see docs/HARDWARE-CI.md
# for the long form):
# - CH341A USB SPI programmer wired to an HDZero VTX via the SOIC
# clip on the W25Q80 chip.
# - `flashrom` >= 1.4 installed on the runner host.
# - Production firmware blob committed to the runner host at
# /var/lib/hdzero-hw/known-good-firmware.bin (NOT to this repo —
# keeps binary blobs out of git).
# - Runner registered with labels: `ubuntu-22.04`, `hdzero-hw`.
# - The CH341A udev rule installed (see packaging/99-ch341a.rules)
# so the runner user can talk to the device without sudo.
#
# Why the round-trip matters: every other test in this repo mocks
# flashrom via tests/fixtures/fake_flashrom.sh. A flashrom upgrade
# that breaks the argv contract or the stdout phase markers
# FlashWorker greps for would ship a broken AppImage with green CI.
# This job is the only thing that catches those classes of regression.

name: hw-test

on:
push:
branches: [main]
# Allow on-demand kicks from the Forgejo Actions UI when debugging
# without forcing a code change.
workflow_dispatch:

# IMPORTANT: do NOT add `pull_request:` here. PR-from-fork workflows
# can ship arbitrary build-script changes; running them on real
# hardware on every PR is a footgun. PR validation stays in ci.yml.

jobs:
flash-roundtrip:
# The label gate. A runner without `hdzero-hw` won't claim the
# job. The fallback `lab01-hdzero` runner only advertises
# `ubuntu-22.04` so this matrix is naturally inert in clean
# development setups.
runs-on: [ubuntu-22.04, hdzero-hw]
timeout-minutes: 15
env:
# Production firmware committed on the runner host, NOT in the
# repo. This is the image we leave the device in after the test
# (idempotent teardown — see "restore" step).
KNOWN_GOOD_FIRMWARE: /var/lib/hdzero-hw/known-good-firmware.bin
# Where flash transcripts land for the artifact upload.
HW_TRANSCRIPT_DIR: ${{ github.workspace }}/dist/hw-transcripts
steps:
- uses: actions/checkout@v4

- name: Pre-flight — confirm hardware is connected
run: |
set -eux
# Vendor:product matches udev_check.CH341A_VENDOR/PRODUCT.
lsusb | grep -E "1a86:5512" || {
echo "::error::CH341A not enumerated. Re-seat USB or check SOIC clip." >&2
exit 1
}
test -r "$KNOWN_GOOD_FIRMWARE" || {
echo "::error::Known-good firmware missing at $KNOWN_GOOD_FIRMWARE." >&2
exit 1
}
mkdir -p "$HW_TRANSCRIPT_DIR"

- name: Read current chip — backup before mutating
id: backup
run: |
set -eux
BACKUP="$HW_TRANSCRIPT_DIR/pre-test-backup-$(date +%s).bin"
flashrom -p ch341a_spi -r "$BACKUP" \
2>&1 | tee "$HW_TRANSCRIPT_DIR/01-read.log"
echo "backup=$BACKUP" >> "$GITHUB_OUTPUT"
# 1 MiB exact, per FLASH_SIZE_BYTES in flash_ops.py.
actual=$(stat -c%s "$BACKUP")
[ "$actual" = "1048576" ] || {
echo "::error::Backup is $actual bytes, expected 1048576 (1 MiB)." >&2
exit 1
}

- name: Set up Python + install package
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install runtime deps
run: |
set -eux
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libxcb-cursor0
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Pad the known-good firmware to 1 MiB
# Reuses the same make_padded_image_1mib path the GUI takes —
# this validates that our padding logic is what flashrom
# actually accepts on real silicon, not just bytes that
# happen to be the right size.
run: |
set -eux
python - <<PY
from flash_ops import make_padded_image_1mib
out = make_padded_image_1mib("$KNOWN_GOOD_FIRMWARE")
print(f"padded image at {out}")
PY

- name: Write known-good firmware to chip
env:
HDZERO_NO_ESCALATE: "1" # udev rule lets us run without pkexec
run: |
set -eux
# Use the padded blob the previous step produced (same path
# convention as make_padded_image_1mib emits).
PADDED="$(ls /tmp/hdzero_pad_*.bin | head -1)"
test -r "$PADDED"
flashrom -p ch341a_spi -w "$PADDED" \
2>&1 | tee "$HW_TRANSCRIPT_DIR/02-write.log"

- name: Verify chip matches what we wrote
env:
HDZERO_NO_ESCALATE: "1"
run: |
set -eux
PADDED="$(ls /tmp/hdzero_pad_*.bin | head -1)"
flashrom -p ch341a_spi -v "$PADDED" \
2>&1 | tee "$HW_TRANSCRIPT_DIR/03-verify.log"

- name: Restore — leave the VTX in known-good state
# Always runs (success OR failure) so an aborted job still
# leaves the VTX bootable. Acceptance: idempotent teardown.
if: always()
env:
HDZERO_NO_ESCALATE: "1"
run: |
set -eux
# Even on failure, the production blob is what we want
# the chip holding when the human comes to debug. Re-flash
# from the known-good source, not from the (possibly
# corrupt) backup we took at the start.
PADDED="$(ls /tmp/hdzero_pad_*.bin | head -1 || true)"
if [ -n "$PADDED" ] && [ -r "$PADDED" ]; then
flashrom -p ch341a_spi -w "$PADDED" \
2>&1 | tee "$HW_TRANSCRIPT_DIR/04-restore.log" || true
fi

- name: Upload flashrom transcripts
if: always()
uses: actions/upload-artifact@v3
with:
name: hw-test-transcripts
path: dist/hw-transcripts/
if-no-files-found: warn
8 changes: 5 additions & 3 deletions .forgejo/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ jobs:
run: |
set -eux
sudo apt-get update
# Full Qt runtime set needed at AppImage launch — see ci.yml.
sudo apt-get install -y --no-install-recommends \
file desktop-file-utils zsync wget jq
file desktop-file-utils zsync wget jq squashfs-tools \
libgl1 libegl1 libxkbcommon0 libdbus-1-3 libfontconfig1 libxcb-cursor0
python -m pip install --upgrade pip
pip install pipx
# Match the pin used in ci.yml so release builds are reproducible.
pipx install 'python-appimage==0.34'
pipx install 'python-appimage>=1.4,<2'

- name: Build AppImage
env:
Expand Down Expand Up @@ -98,7 +100,7 @@ jobs:
done

- name: Upload AppImage as workflow artifact too
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: HDZeroProgrammer-x86_64.AppImage
path: dist/HDZeroProgrammer-x86_64.AppImage
Expand Down
46 changes: 43 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Post-`v0.2.0` work on `main` not yet tagged. Bumps the next release; until
then, `__version__` and `pyproject` still read `0.2.0`.
No changes on `main` since `v0.3.0` yet.

## [0.3.0] - 2026-04-30

Post-acquisition-audit batch. Closes the audit's documentation and
operational blockers (`#22` HW-CI gate, `#23` SECURITY.md), lands the
ADR corpus, the mypy gate, the legacy-settings migration, and the
HTTP-worker refactor. Firmware-trust trio (`#19`/`#20`/`#21`) remains
open and is the gating set for `v0.4.0`.

### Added
- ADR scaffolding under `docs/adr/` with template + index; ADR-0001
Expand All @@ -26,6 +33,22 @@ then, `__version__` and `pyproject` still read `0.2.0`.
- pytest-cov coverage measurement in CI (no fail-under threshold yet).
- Contributing guide, code of conduct, issue + PR templates.
- 2026-04-29 acquisition audit report archived under `docs/audits/`.
- `app_settings.py`: `QSettings` reverse-DNS scope
(`lab.squatch / hdzero-programmer-linux`) plus a one-shot migration
from the legacy `HDZero/Programmer` scope, sentinel-guarded so a
downgrade still finds its data. (#33)
- mypy gate in CI: strict on safety-critical modules (`flash_ops`,
`udev_check`, `app_logging`, `app_settings`); `disallow_untyped_defs`
+ `check_untyped_defs` on UI plumbing (`main`, `internet_panel`).
PyQt6 namespace ignored for missing imports — no first-party stubs.
(#26, #68)
- Real-hardware regression workflow `.forgejo/workflows/hw-test.yml`
gated on the `hdzero-hw` runner label, plus `docs/HARDWARE-CI.md`
documenting host-side CH341A pass-through, runner registration, and
the idempotent restore step. (#22)
- `docs/legal/trademark.md` capturing HDZero brand exposure and a
`vtx-programmer-linux` rename contingency if the policy turns
restrictive. (#43)

### Changed
- Chip backups (manual + pre-flash auto) now land in
Expand All @@ -36,11 +59,28 @@ then, `__version__` and `pyproject` still read `0.2.0`.
- Narrowed broad `except Exception` handlers across flash/UI paths.
- LICENSE: SPDX header, dual fork copyright line, PyQt6 GPL-binary
position documented in `LICENSE-NOTES.md`.
- `actions/upload-artifact` bumped to v4.
- Collapsed four near-duplicate `InternetPanel` HTTP workers into a
single `HttpWorker` with retry + exponential backoff (`requests`-based,
bounded by `timeout` + `retries` kwargs). (#25, #37)
- python-appimage pinned to `>=1.4,<2` — 0.34 imports `distutils` which
was removed in Python 3.12.
- AppImage + smoke jobs now apt-install the full PyQt6 runtime lib set
(`libgl1`, `libegl1`, `libxkbcommon0`, `libdbus-1-3`, `libfontconfig1`,
`libxcb-cursor0`) plus `squashfs-tools` for `appimagetool`.

### Fixed
- Temp firmware files (downloaded `.bin` and 1 MiB padded image) now
unlinked after flash success and failure.
- `run_admin_streaming` now polls stdout via `select.select()` instead
of blocking on `for line in proc.stdout:`. The wall-clock deadline
fires even when a wedged `flashrom` produces no output, so a hung
CH341A no longer burns the full timeout budget waiting on readline.

### Reverted
- `actions/upload-artifact` rolled back from v4 to v3 — Forgejo's
Actions implementation does not yet support the v4 upload protocol
(returns `GHESNotSupportedError`). Bump again once Forgejo lands v4
compatibility.

## [0.2.0] - 2026-04-29

Expand Down
Loading