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
+16
View File
@@ -190,6 +190,22 @@ class TestTurnedOn:
assert r.status_code == 401
assert 'tokens' not in secrets_on_disk(config_manager)['web_auth']
def test_the_update_channel_needs_login(self, config_manager, api_v3_module):
# It decides what code the next update installs: no route of its own
# opts out of the login, so the default rule covers it.
app = build(config_manager, api_v3_module)
signed_in = enable(app)
stranger = lan_client(app)
assert stranger.get('/api/v3/system/update-channel').status_code == 401
r = stranger.post('/api/v3/system/update-channel', json={'channel': 'beta'})
assert r.status_code == 401
# (The config template's migration writes the default, stable.)
assert config_manager.load_config()['auto_update'].get('channel') != 'beta'
r = signed_in.post('/api/v3/system/update-channel', json={'channel': 'beta'})
assert r.status_code == 200, r.get_json()
assert config_manager.load_config()['auto_update']['channel'] == 'beta'
def test_unknown_paths_do_not_leak_a_404_first(self, config_manager, api_v3_module):
app = build(config_manager, api_v3_module)
enable(app)