The config that deploys itself
One service in this apartment datacenter is not deployed by CI. The smart-home hub fetches the reviewed branch itself, merges it, runs Home Assistant’s own configuration check against the merged tree, and resets back to its last-good commit if the check fails — or if the check cannot run at all. In exchange, the running machine may commit its interface edits straight to that protected branch, unreviewed. This note is about that trade and the controls that make it survivable: a branch nobody can rewrite, a gate that fails closed, and a commit path that refuses a secret-shaped file before the commit exists. The last one exists because ignoring files was not enough. It is also honest about what the pipeline cannot prove: no automation is tested, no firmware is compiled, and much of the running instance lives in storage git never sees. As of 2026-09-08.
Everything else in this fleet changes the same way: a merge request is reviewed, a pipeline runs, and a person presses the button that applies the new state. The smart home runs the other way round.
A pull, not a push
The hub’s container carries a forked deploy integration that watches the reviewed branch, fetches it every five minutes, and merges it into its own configuration directory. There is no deploy job and no login seam: nothing in CI reaches the running instance.
Dashboards, scenes and scripts are edited in the interface, by hand, and a one-way push from CI would overwrite that work on every deploy. So the sync is two-way, and everything that follows exists to make that safe.
Merge, check, roll back
The integration records the commit it is standing on, merges the incoming branch, and only then runs Home Assistant’s own configuration check against the merged tree. If the check reports errors it resets hard to the recorded commit. Nothing reloads; the box goes on serving the tree that worked.
The other branch matters more. If the validator itself raises — a helper whose signature moved, an import that failed — it does not wave the tree through. It blocks, and the comment in the code is one line: a gate that cannot run must block.
A pull that passes goes the other way: an event fires and a git-tracked automation applies it — a reload, or a full restart when the core configuration or an integration’s code changed. A merge to the reviewed branch can restart the house, on a tree the check has already validated.
The machine writes back
Interface edits are auto-committed on the box and pushed straight to the protected branch. No merge request, no review — and this is the only repository in the fleet where that is allowed. CI still runs on those commits, but only after they are on the branch, which is why the fail-closed guards below, not the pipeline, are the gates on that path.
It is written down as an accepted deviation: losing every dashboard edit on the next pull was judged worse. The controls named in the same breath carry the weight; three matter here.
The branch stays protected. No force-push, from anyone. A secret that reaches the history can be rotated but never rewritten out of it — which is why the guard against secrets has to work before a commit exists.
The pre-deploy gate is fail-closed, as above: the machine allowed to write to the branch is also the one that refuses to keep a tree that would not load.
The commit path refuses secret-shaped paths. The box stages everything, then matches the staged paths against a pattern list, and on any match resets the index and refuses. The commit never happens.
That guard exists because ignoring files was not enough — an unattended stage-everything commit can sweep up a path the ignore rules miss, and a backup copy of an ignored file is not covered by the original’s pattern. The refusal is deliberately total: the automatic pull takes its own safety commit first, so a blocked commit blocks the pull too, and merges stop reaching the house until the offending path is gone.
What the pipeline proves, and what it cannot
Twelve jobs are defined across two stages, lint and validate. There is no deploy stage.
Configuration validation runs Home Assistant’s own check inside the official image, pinned by tag and digest to the version production runs, and it blocks the merge request. The check alone is a false green: Home Assistant logs a schema-invalid item, disables just that item and carries on, so only a syntax error or a core-block failure exits non-zero. The captured log is therefore searched for the failure strings as well. A separate job scans the full git history for secrets.
The pipeline proves the configuration parses and loads under a pinned image, and that the local patches on the vendored code are still in place. Not one automation, template or presence rule has a test.
Much of the running instance is not in git, and cannot be. Scenes and scripts created in the interface, dashboards, every integration’s config entry and every credential live in the hub’s own storage, deliberately untracked. For the files git tracks, the repository is the running configuration — except while a gate is holding the box on an older tree, which is the point of the gates.
Nothing here compiles firmware either: nine node configs are validated in CI, but the builds happen in a container on the host, and a compile that never runs is invisible to git and CI alike. Nothing proves the firmware on a given node was built from the config git holds for it. What those nodes carry, and which of them run firmware written here rather than merely configured here, is the subject of the radios note.
One more thing changes the tree without a person in it. The third-party integrations installed on the hub update themselves in a weekly window on Sunday morning, and the automatic commit then publishes the new versions to the branch — an unreviewed change to the running house, by design, on the same accepted deviation as the interface edits. The window is narrow on purpose. It skips firmware of every kind, because an over-the-air update would overwrite the lamp firmware written here, wipe the credentials off the sensor nodes and push a development build onto the radio coordinator. It skips an update that reboots the storage box. And it skips the handful of components carrying local patches, because installing over one of those clobbers the patch and the commit publishes the clobber.
One press showed how little the repository controls. On 2026-08-24 the vendor’s own update entity was pressed; it flashed the stock factory image, which carries no credentials for anything to reach the hardware with, and all four Apollo multi-sensor nodes left the network at once. That entity and the vendor’s firmware button are now hidden on all eight vendor nodes — hiding the entity alone leaves the identical flash one press away, because the button calls it. The nodes have since gone further than hiding: the firmware’s own web updater is disabled outright and the fallback access point each node raises when it cannot find the network now carries a password, so the same flash is no longer reachable over the air at all.
What is missing, and what is planned
- The off-box backup has been decrypted and booted in isolation; a from-scratch rebuild onto an empty box has not. The bootstrap restores the untracked state — from the off-box backup, decrypted with the emergency key held for it — onto an empty configuration directory, and the sync takes over from there. The backup note has both halves: the restore that has actually been performed, and the fleet-wide proof still owed.
- Planned: one pass over the physical controls — inventory every button, dial and pad, then decide the mapping per room alongside retiring the lighting behaviours still running inside a vendor bridge, so two systems never drive the same lamps.
Both items name the same absence: nothing here has been proved by watching it work. The gates prove that a tree loads — not that the house running on it could be rebuilt, and not that its physical controls agree with it.
Deploy integration, pipeline and node configs read in the smart-home configuration repository, and the pinned validation images in the shared CI templates, on 2026-09-08; dated incidents carry the date recorded with them, and planned items are marked as planned.