feat(update): stable/beta update channel; stable follows release tags (#684)

Adds auto_update.channel: stable follows the newest vX.Y.Z release tag
(detached HEAD; pre-releases and other tags ignored), beta follows main as
before. Nothing ever moves a device backwards: a checkout newer than the
newest release keeps following main (or stays put when detached) until a
release contains its commit. Legacy configs migrate to stable when they
reach a release. Update Code, the weekly updater's preflight, and the
verifier's rollback (back to old_ref: branch or detached release) all
honour the channel. General tab Update Channel select, GET/POST
/api/v3/system/update-channel, release-aware Overview banner and Tools git
panel. New installs default to stable.

Rig fix (ledpi): /system/check-update reports update_available: false when
the channel's action is none (a detached HEAD newer than the newest
release), matching Update Code; the Tools panel no longer calls every
detached HEAD "a release".

Merged with main through #687 (heartbeat verifier, #683 login, #688
plugin_catalog, #685 Tailwind build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 15:35:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 64c7289593
commit 7804ea8f69
21 changed files with 1499 additions and 50 deletions
+15
View File
@@ -14,6 +14,7 @@ the same reason: the rollback cannot depend on packages the update changed.
The updater leaves data/auto_update_pending.json:
{"status": "pending", "old_head": ..., "new_head": ...,
"old_ref": "main" | "" (detached) | absent (older updaters),
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
This moves its status to "verifying" and then to one of "success",
@@ -276,6 +277,20 @@ class Verifier:
if not old:
return False, 'the commit to roll back to is unknown'
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
# An update may have moved HEAD between main and a detached release
# tag (the stable/beta channels). Go back to where HEAD was -- the
# branch, or detached -- before resetting, or resetting would drag
# the wrong ref: main onto a release commit, or leave a device that
# was following main stuck on a detached one. No old_ref (an older
# updater wrote this file) means HEAD never moved between refs.
old_ref = pending.get('old_ref')
if old_ref is not None:
move = (['git', 'checkout', '--quiet', '--force', old_ref] if old_ref
else ['git', 'checkout', '--quiet', '--force', '--detach', old])
result = self._run(move, timeout=GIT_RESET_TIMEOUT_SECONDS)
if result.returncode != 0:
return False, (f'"{" ".join(move)}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
# --hard: the updater refuses to run with local edits to tracked core
# files (web_interface/auto_update.local_changes), so outside the
# plugin folders the only thing this discards is the update. Edits