Skip to content

Release process & strategy

Audience: maintainers. This page documents how a release is cut today, what CI enforces and the known risks of the setup. It is the single place to start when preparing a release; the design rationale behind individual decisions stays in Development, and planned improvements are tracked in the Roadmap.

What a release ships

Artifact Source Consumer
Integration (HACS/zip) custom_components/nspanel_haui/ at the tag Home Assistant users
nspanel_haui.bin factory flash image compiled by CI from esphome/nspanel_haui_universal.yaml manual/serial flashing; same binary for EU and US panels
nspanel_haui_ota.bin OTA app image compiled by the same CI build installed by the HAUI Firmware update entity
firmware-manifest.json generated by CI for v* tag pushes ESPHome web-tools update schema; the firmware polls it for OTA metadata
nspanel_haui.tft nextion/nspanel_haui.HMI, compiled in Nextion Editor landscape panels
nspanel_haui_portrait.tft nextion/nspanel_haui_portrait.HMI, generated from the landscape HMI by scripts/gen_portrait display_model: us panels

The five attached asset names — nspanel_haui.bin, nspanel_haui_ota.bin, firmware-manifest.json, nspanel_haui.tft and nspanel_haui_portrait.tft — are a frozen contract. haui/device_config.py maps display_model to the TFT filenames; haui/controller/update.py resolves the matching TFT from the GitHub release whose tag matches the firmware's yaml_version (the v prefix is ignored by _try_parse_version). The firmware polls firmware-manifest.json for the OTA image and its checksum. Renaming an asset breaks automatic updates for already-flashed panels, so a rename needs a migration story first. Adding a variant (for example the high-speed TFT planned for v0.5) is safe as long as the existing names stay unchanged; the update controller must map each device to the right variant.

Versioning and tag policy

Semantic Versioning. custom_components/nspanel_haui/manifest.json is the source of truth; haui/version.py reads it at runtime. Four places must agree, and the combined bump script updates all of them:

  1. custom_components/nspanel_haui/manifest.json — source of truth.
  2. pyproject.toml — packaging mirror.
  3. esphome/nspanel_haui/version.yaml — firmware compatibility version.
  4. The Nextion HMI files (nextion/nspanel_haui.HMI and nextion/nspanel_haui_portrait.HMI) — global system.tftVersion, checked by the release gate against manifest.json.

Also update the CHANGELOG.md heading and release date by hand. scripts/bump_version.py --check is the dry run CI uses to fail on drift, and tests/test_bump_version.py covers the script.

Shipping unchanged TFTs: a release may keep the previous TFT binaries (a hotfix whose display file did not change). Bump with --text-only so the HMI system.tftVersion — and with it the compiled TFTs — stays at the previous version, and set the repository variable TFT_VERSION_PIN to that version (e.g. 0.4.3). CI (tests.yml) and the release gate then tolerate the HMI/integration version mismatch and report it as a warning (verify_nextion.py --allow-tft-version-mismatch) instead of failing. Delete the variable again when a release bumps the TFTs. Panels keep the pinned TFT: the display reports the old version and the update controller does not auto-reinstall it; an automatic firmware update to such a release runs one TFT install that cannot confirm and leaves the HAUI Display entity asking for a manual retry until a release ships a matching newer TFT.Tags: use vX.Y.Z. A v* tag push starts release.yml. Manual dispatch re-runs the release workflow for the selected ref and generates firmware-manifest.json only when that ref is a v* tag. Older history is tagged without the prefix (0.3.2, 0.3.1); those tags never ran the automated workflow. Do not "fix" old tags — the device-side lookup tolerates both forms.

Never push a tag to test firmware. Every v* tag becomes a regular release, GitHub marks it latest, and every panel's HAUI Firmware entity reads releases/latest/download/firmware-manifest.json — so all panels with firmware_auto_update on install it. Use a local test release instead.

Test firmware updates with a local release

scripts/dev_release.py turns a local build into the two files a release carries for over-the-air updates, written to a folder Home Assistant serves (/config/www/… is served as /local/…): nspanel_haui_ota.bin and firmware-manifest.json (the schema the release workflow writes, with the image URL and md5).

  1. Build the test panel's firmware with two substitutions, either in its device YAML or on the command line:

    esphome -s yaml_version 0.4.3-dev1 \
      -s firmware_manifest_url http://homeassistant.local:8123/local/nspanel/firmware-manifest.json \
      compile esphome/nspanel_haui_universal.yaml
    

    yaml_version must equal the manifest version (ESPHome offers an update whenever they differ, so a mismatch reinstalls forever), and each test release needs a newer one (-dev1, -dev2, …) because Home Assistant only turns the update entity on for a newer version. The manifest URL keeps the installed test build reading the local manifest; one that reads the GitHub manifest sees the release as an update, and the hub reinstalls the release over it.

  2. Write the test release:

    python scripts/dev_release.py \
      --image esphome/.esphome/build/nspanel-haui/build/firmware.ota.bin \
      --version 0.4.3-dev1 \
      --base-url http://homeassistant.local:8123/local/nspanel \
      --out /path/to/homeassistant/config/www/nspanel
    

    The script refuses a factory image, an image larger than the app partition, and an image whose embedded yaml_version or manifest URL does not match.

  3. Flash the first test build once from the ESPHome dashboard (or with nspanel_haui.update_firmware and the image url). From then on, each newer test release shows up on the panel's HAUI Firmware entity, and the hub installs it automatically or with Device Manager → Install firmware.

The hub treats yaml_version as the TFT version the firmware needs, so a test version newer than the installed TFT makes it look for a GitHub release of that version and report "No GitHub release". On the test panel, turn off auto_install, or install a TFT once with nspanel_haui.update_tft and url (it then stays until Home Assistant restarts). To leave the test, flash a build without the two substitutions.

Branches and cadence

master is release-only. Normal work never lands there directly: it arrives through a merge from dev. A release hotfix may branch from master when a patch cannot wait for the next dev cycle. Every tag is cut from a commit whose CI was green on its source branch.

  • master — release branch. Receives merges from dev; release hotfixes branch from master only when a patch cannot wait. Every tag vX.Y.Z is cut from a green commit here. GitHub Pages deploys from this branch and HACS users install from its releases. Protected by the "Protect master (release-only)" ruleset: no direct pushes, no force-pushes, no deletion, and a merge needs a PR with the always-running CI checks green.
  • dev — integration branch, the target for all day-to-day work, and the repository's default branch, so new PRs base on it automatically. CI runs here on every push and PR.
  • Feature branches — branch off dev, open the PR against dev.
  • Hotfix branches — branch off master only when a released version needs a patch that cannot wait for the next dev cycle; PR into master, tag, then merge master back into dev.

The cycle in one line: feature → PR into dev → release prep on dev (version bump, CHANGELOG, release notes, TFTs) → PR dev → master → tag on master → release.yml publishes → merge master back into dev → open the next CHANGELOG section.

Enforcement: the master ruleset blocks direct pushes, force-pushes and branch deletion, and requires a pull request whose always-running checks are green before merge. It has no bypass actors, so the release merge from dev goes through a PR like everything else. Tags are unaffected — release.yml still fires on a v* tag pushed to master. To add an escape hatch, add a bypass actor to the ruleset rather than weakening the rules.

dev itself is not protected: it takes direct pushes and PRs alike. Add a ruleset there if the branch starts collecting unreviewed changes.

No fixed calendar. Ship when user-visible changes accumulate or a fix cannot wait. The 0.4.x line is a hard gate: it finishes the binary and TFT install and proves the base and core functions before any v0.5 work starts. Patch releases in that line close regressions and gate failures; minor releases (0.5.0) ship features and breaking changes. Any breaking change must ship with a docs/upgrading.md entry and a CHANGELOG "Breaking changes" section — no alias is kept as a fallback, so the upgrade guide is the only migration path.

CI gates and local checks

Workflow Triggers What it enforces
tests.yml pushes/PRs to master, dev mkdocs build --strict; icon cheatsheet, translation and version-source freshness; Nextion asset gate; pytest tests/; ruff check custom_components/ scripts/; mypy; node --test 'tests/frontend/*.test.mjs'; esphome config esphome/nspanel_haui_universal.yaml
docs.yml docs/mkdocs changes on pushes/PRs to master, dev; manual dispatch mkdocs build --strict; deploys Pages from master only
validate.yml pushes/PRs to master, dev; nightly; manual dispatch hassfest and HACS validation
release.yml v* tags; manual dispatch tag vs manifest.json version; Nextion release gate; ESPHome config + compile; firmware manifest (tag refs only); prepared release notes; asset upload

Gap to keep in mind: a tag push does not re-run tests.yml — only release.yml runs. The tag build trusts that the tagged commit was already green on a branch. Cut tags only from a passing master commit, and re-check git status for uncommitted work first.

Run the same gates locally

Every command below is what CI runs. Run them before opening a PR or cutting a tag; release prep is not finished until they pass on the final commit.

# Documentation and generated assets
mkdocs build --strict
python3 scripts/gen_fonts/gen_cheatsheet.py && git diff --exit-code -- docs/cheatsheet.html
python3 scripts/translations/extract_translations.py && git diff --exit-code -- custom_components/nspanel_haui/haui/locale scripts/translations/translate.json

# Version and Nextion gates
VERSION=$(python3 -c "import json; print(json.load(open('custom_components/nspanel_haui/manifest.json'))['version'])")
python3 scripts/bump_version.py "$VERSION" --check
python3 scripts/verify_nextion/verify_nextion.py --require-portrait
python3 scripts/gen_portrait/gen_portrait.py --check

# Backend, lint and type checks
pytest tests/
ruff check custom_components/ scripts/
mypy

# Frontend tests
node --test 'tests/frontend/*.test.mjs'

# Firmware config (install requirements-esphome.txt first)
esphome config esphome/nspanel_haui_universal.yaml

python3 may be python in your environment; on this repo's dev venv use .venv/bin/python (or activate .venv first). The ESPHome check needs the pinned toolchain from requirements-esphome.txt.

Release runbook

1. Pre-flight (a few days before)

  • Confirm dev and master are green (tests, frontend, docs, hassfest/HACS).
  • Confirm the device passlist in the Roadmap's v0.4.x section passed on real EU and US hardware. The 0.4.x line is a hard gate: v0.5 work does not start until every row is green.
  • Freeze features; only fixes land from here.
  • Decide the version number and check for pending breaking changes.

2. Version bump

Run the bump script — it writes the manifest, pyproject.toml, ESPHome version.yaml and both HMI system.tftVersion values in one pass:

python3 scripts/bump_version.py 0.5.0            # bump every source
python3 scripts/bump_version.py 0.5.0 --check    # dry run, writes nothing

The script edits the HMI in place; that is only legal for a same-length version (0.4.2 → 0.4.3). A shorter or longer version resizes the attribute record and the script refuses it — use the Nextion Editor for that bump. The script does not touch CHANGELOG.md, so add or update its section and date by hand. Re-save the HMI and recompile both TFTs (see next step) — the gate fails on a stale tftVersion or a stale TFT.

scripts/bump_tft_version.py bumps only the HMI tftVersion text; the combined script above calls it. Both are importable and covered by tests/test_bump_version.py.

3. Nextion assets (human, GUI-only)

  1. Open nextion/nspanel_haui.HMI in Nextion Editor; set system.tftVersion.
  2. Make or verify the release's display changes; compile File → TFT file output → nextion/nspanel_haui.tft.
  3. Re-run python3 scripts/gen_portrait/gen_portrait.py so the portrait HMI picks up landscape edits, then compile nextion/nspanel_haui_portrait.tft. The recompile is mandatory after any PORTRAIT_ATTRS/HMI change — the committed HMI alone does not reach devices. gen_portrait.py --check verifies attribute values as well as widget coverage, so an attribute-only change fails the gate until the HMI is regenerated.
  4. The Nextion gates are part of Run the same gates locally. Also run python3 scripts/gen_preview/gen_preview.py --check-missing to confirm every panel type has a preview renderer.

4. Docs and firmware

  • Update user-facing docs for behaviour changes (install, services, display models).
  • Breaking changes: write the docs/upgrading.md entry and the CHANGELOG "Breaking changes" section during release prep on dev.
  • Verify the universal firmware still validates: esphome config esphome/nspanel_haui_universal.yaml.
  • The universal binary serves any number of panels: the MAC suffix gives each device a unique name and access point, so no per-device build is needed. The baked API/OTA/web credentials remain shared — keep that caveat documented.

5. Release notes and tagging

Prepare the GitHub release body in .github/release-notes/<tag>.md (for example .github/release-notes/v0.4.2.md) during release prep on dev, before the merge to master. Because master takes no direct commits, the merge carries the file to the tagged commit. release.yml publishes it as the release body and falls back to the changelog pointer when no file exists. Keep the changelog as the canonical notes and summarise them here: highlights, anything to do before updating, links and the issues addressed with their contributors. Credit external contributors by their GitHub handle and keep individual maintainer names out of the release body.

After the merge lands on master, tag and publish:

git tag -a vX.Y.Z -m "NSPanel HAUI X.Y.Z"
git push origin vX.Y.Z

The tag push starts release.yml, which checks that the tag matches manifest.json, runs the Nextion gate, validates and compiles the universal firmware, and attaches the two firmware images, both TFTs and firmware-manifest.json to the GitHub release. Watch the run; do not announce before it is green.

6. Post-release

  • Verify the release page carries the two firmware images (nspanel_haui.bin, nspanel_haui_ota.bin), both TFTs and firmware-manifest.json with the expected names, and that the body shows the prepared release notes.
  • Flash one panel from the published .bin and confirm TFT auto-update, as a smoke test of the exact published artifacts.
  • Announce; link the changelog and the update warning.
  • Merge master back into dev, then open the next CHANGELOG.md section.

7. Rollback

If the published assets are broken: mark the GitHub release as a draft (or delete it) and publish a patch release. The update controller only installs the TFT for the firmware's yaml_version, so a corrected tag is enough; never re-point an existing tag at different bytes.

Guardrails and known gaps

These are the places the process can still go wrong; check them during release prep.

Version and release notes

  • Four version places — manifest, pyproject.toml, ESPHome version.yaml and both HMI files. Drift is the most likely release failure; scripts/bump_version.py updates them in one pass and --check catches drift in CI.
  • Tag pushes skip tests.yml — only release.yml runs on a tag. Tag only a commit that was green on its source branch.
  • Release notes are a separate file from the changelog — .github/release-notes/<tag>.md becomes the release body when present; release.yml falls back to the changelog pointer. Keep the changelog canonical and the prepared notes in sync.

Nextion (GUI-only)

  • The Nextion Editor is GUI-only — TFT compilation cannot be scripted. The version bump is scripted only for same-length versions, and the release gate can only verify the result.
  • The portrait TFT derives from the landscape HMI — regenerate it after every landscape edit. Forgetting the step fails the gate or ships a stale portrait.
  • Asset names are frozen — renaming one silently breaks auto-update for flashed panels. Add variants without renaming existing assets.

Distribution

  • ~120 MB of Nextion binaries live in git — HACS downloads include them; repo and zip size grow with every TFT rebuild. Git LFS or release-only storage is planned.
  • The universal binary bakes shared credentials — MAC suffixes give each device a unique name and access point, but the API/OTA/web credentials are shared. Credential isolation is not solved.
  • ESPHome is pinned to a minimum version — base.yaml defines the floor and requirements-esphome.txt pins the build toolchain. Bump both together.

Planned improvements to this setup are tracked in the Roadmap.