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
+72 -20
View File
@@ -10,6 +10,9 @@ Nothing here is allowed to leave a device broken without saying so:
the checkout has local edits or commits, a rebase or merge is in progress,
the branch has no upstream, disk is low, the newest commit was already
rolled back once, or the health check is not set up.
* **On its channel.** ``auto_update.channel`` picks the newest release tag
(stable) or main (beta); web_interface/update_channel.py decides, and never
moves a device to an older commit than the one it runs.
* **Verified, and rolled back.** The pull itself is the Overview "Update Code"
path (``perform_core_update``). Restarting and checking the result is handed
to ledmatrix-update-verify.service (scripts/utils/auto_update_verify.py),
@@ -39,6 +42,8 @@ import time
from datetime import datetime
from pathlib import Path
from web_interface import update_channel
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -159,6 +164,12 @@ def _short(sha):
return (sha or 'unknown')[:7]
def _label(pending):
"""The new version for messages: its release tag when it is one, else the short commit."""
release, new = pending.get('release'), pending.get('new_head')
return f'{release} ({_short(new)})' if release else _short(new)
def is_due(now, next_due, local_hour):
"""Due once ``next_due`` has passed, in quiet hours or after the grace period."""
if next_due is None or now < next_due:
@@ -483,10 +494,6 @@ class AutoUpdater:
return 'blocked', ('A git merge is in progress in the LEDMatrix folder. '
'Finish or abort it; automatic updates will not touch it.'), {}
if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0:
return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). '
'Use Update Code once, or Tools -> Switch branch.'), {}
# The same predicate perform_core_update refuses on when called from
# here, so what passes this check is never stashed by the pull.
changed = local_changes(self.project_root, run=self.run_command)
@@ -498,24 +505,64 @@ class AutoUpdater:
return 'blocked', (f'Only {free // (1024 * 1024)} MB of disk space is free; an update '
f'needs at least {MIN_FREE_BYTES // (1024 * 1024)} MB.'), {}
fetch = self._git('fetch', '--quiet', timeout=120)
fetch = update_channel.fetch(self.project_root, run=self.run_command)
if fetch.returncode != 0:
detail = next((ln.strip() for ln in (fetch.stderr or '').splitlines() if ln.strip()), '')
return 'error', f'Could not check for LEDMatrix updates: {detail or "git fetch failed"}.', {}
ahead = self._count('@{u}..HEAD')
if ahead:
return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. '
'Automatic updates will not rebase them; update manually with Update Code.'), {}
if not self._count('HEAD..@{u}'):
return 'up_to_date', 'LEDMatrix is already up to date.', {}
upstream = self._git('rev-parse', '@{u}').stdout.strip()
if upstream and upstream == state.get('rolled_back_head'):
return 'up_to_date', (f'The newest LEDMatrix version ({_short(upstream)}) failed its health '
'check and was rolled back before; waiting for a newer one.'), {}
# Where the update goes: the newest release (stable) or main (beta).
channel = update_channel.resolve(self.project_root, self._config(), run=self.run_command)
if channel.migrate and channel.action == update_channel.ACTION_NONE:
# Already on the newest release: nothing to update, but a config
# from before channels existed now says stable.
self._persist_stable_channel()
head = self._git('rev-parse', 'HEAD').stdout.strip()
return 'ready', '', {'old_head': head, 'upstream_head': upstream}
old_ref = update_channel.current_branch(self.project_root, run=self.run_command)
if channel.action == update_channel.ACTION_NONE:
return 'up_to_date', channel.message, {}
if channel.action == update_channel.ACTION_CHECKOUT_TAG:
target, label = channel.target_sha, channel.newest_release
elif channel.action == update_channel.ACTION_SWITCH_TO_BETA:
beta = f'{update_channel.REMOTE}/{update_channel.BETA_BRANCH}'
if self._count(f'{beta}..HEAD'):
return 'blocked', (f'This checkout has local commits that are not on {beta}. Automatic '
'updates will not leave them behind; update manually with Update Code.'), {}
target, label = self._git('rev-parse', beta).stdout.strip(), update_channel.BETA_BRANCH
if target == head:
return 'up_to_date', 'LEDMatrix is already up to date.', {}
else:
if self._git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').returncode != 0:
return 'blocked', ('The current branch has no upstream to update from (or HEAD is detached). '
'Use Update Code once, or Tools -> Switch branch.'), {}
ahead = self._count('@{u}..HEAD')
if ahead:
return 'blocked', (f'This checkout has {ahead} local commit(s) that are not upstream. '
'Automatic updates will not rebase them; update manually with Update Code.'), {}
if not self._count('HEAD..@{u}'):
return 'up_to_date', 'LEDMatrix is already up to date.', {}
target = self._git('rev-parse', '@{u}').stdout.strip()
label = _short(target)
if target and target == state.get('rolled_back_head'):
return 'up_to_date', (f'The newest LEDMatrix version ({label}) failed its health '
'check and was rolled back before; waiting for a newer one.'), {}
return 'ready', '', {'old_head': head, 'upstream_head': target, 'old_ref': old_ref,
'release': channel.newest_release
if channel.action == update_channel.ACTION_CHECKOUT_TAG else None}
def _config(self):
try:
return self.config_manager.load_config() or {}
except Exception:
logger.debug("Auto-update could not load config for the channel", exc_info=True)
return {}
def _persist_stable_channel(self):
try:
update_channel.set_channel(self.config_manager, 'stable')
logger.info("Update channel set to stable: this device is on a release now")
except Exception:
logger.warning("Could not save the stable update channel", exc_info=True)
def update_core(self, state):
try:
@@ -549,6 +596,11 @@ class AutoUpdater:
'status': 'pending',
'old_head': old_head,
'new_head': new_head,
# Where HEAD was: a branch name, or '' when detached on a release.
# The rollback returns there, not just to the commit, so a move
# between main and a release tag is undone completely.
'old_ref': info.get('old_ref'),
'release': info.get('release') if new_head == info.get('upstream_head') else None,
'display_was_active': display_was_active,
'dependency_failures': list(core.get('dependency_failures') or []),
'created_at': self.clock(),
@@ -570,7 +622,7 @@ class AutoUpdater:
return {'outcome': 'up_to_date', 'message': core.get('message') or 'LEDMatrix is already up to date.'}
handoff = {'outcome': 'verifying',
'message': (f'Updated LEDMatrix from {_short(old_head)} to {_short(new_head)}; '
'message': (f'Updated LEDMatrix from {_short(old_head)} to {_label(pending)}; '
'restarting and checking the services.')}
# Recorded before the handoff: the health check restarts this process.
self._store_run(state, handoff, [], [])
@@ -626,11 +678,11 @@ class AutoUpdater:
f'See "journalctl -u {VERIFY_UNIT}".')
elif status == 'success':
outcome = 'updated'
message = (f'Updated LEDMatrix from {_short(old)} to {_short(new)}; '
message = (f'Updated LEDMatrix from {_short(old)} to {_label(pending)}; '
'the services restarted and stayed healthy.')
elif status == 'rolled_back':
outcome = 'rolled_back'
message = (f'The LEDMatrix update to {_short(new)} was rolled back to {_short(old)} because '
message = (f'The LEDMatrix update to {_label(pending)} was rolled back to {_short(old)} because '
f'{reason or "it failed its health check"}.' + (f' Note: {detail}.' if detail else ''))
state['rolled_back_head'] = new
else:
+15 -1
View File
@@ -31,7 +31,7 @@ FORM_SECTION_FIELD = '__form_section'
#: Fields of the General tab. Any one of them in a /config/main post means the
#: General form was submitted, so its unchecked checkboxes read as False.
GENERAL_FIELDS = ('timezone', 'city', 'state', 'country', 'web_display_autostart',
'plugins_directory', 'auto_update_enabled')
'plugins_directory', 'auto_update_enabled', 'auto_update_channel')
#: Top-level fields save_main_config stores somewhere of its own (location,
#: plugin_system, ...), never as a config key of the same name.
@@ -522,6 +522,20 @@ def save_main_config():
if not isinstance(current_config.get('auto_update'), dict):
current_config['auto_update'] = {}
_set_checkbox(current_config['auto_update'], 'enabled', 'auto_update_enabled')
if 'auto_update_channel' in data:
# stable/beta (web_interface/update_channel.py). Only stored
# here; the next update applies it, never moving backwards.
from web_interface import update_channel
channel = update_channel.normalize_channel(data['auto_update_channel'])
if channel is None:
return jsonify({'status': 'error',
'message': "auto_update_channel must be 'stable' or 'beta'"}), 400
if not isinstance(current_config.get('auto_update'), dict):
current_config['auto_update'] = {}
if current_config['auto_update'].get('channel') != channel:
current_config['auto_update']['channel'] = channel
# The Overview banner compares against the channel's target.
_pkg._update_check_cache['result'] = None
if 'timezone' in data:
current_config['timezone'] = data['timezone']
+212 -17
View File
@@ -80,18 +80,88 @@ def dismiss_auto_update_alert():
return jsonify({'status': 'success'})
def _channel_payload(channel, fetch_error=''):
from web_interface import update_channel
data = dict(channel)
data['channels'] = list(update_channel.CHANNELS)
data['fetch_error'] = fetch_error or None
return data
@api_v3.route('/system/update-channel', methods=['GET'])
def get_update_channel():
"""The update channel: configured, in effect, and what the next update does.
Reads local refs only, unless ``?fetch=1`` asks it to check origin first.
No local except: failures reach the blueprint-wide handler, which logs the
traceback and returns the redacted detail.
"""
fetch = str(request.args.get('fetch', '')).lower() in ('1', 'true', 'yes')
channel, fetch_error = channel_status(fetch=fetch)
return jsonify({'status': 'success', 'data': _channel_payload(channel, fetch_error)})
@api_v3.route('/system/update-channel', methods=['POST'])
def set_update_channel():
"""Switch between the stable and beta update channels: ``{"channel": "stable"}``.
Only the setting changes here; the next Update Code or weekly update
applies it. Switching to stable never moves a device backwards: one
running code newer than the newest release keeps following main until a
release includes it, and the response says so.
"""
from web_interface import update_channel
payload = request.get_json(silent=True)
channel = update_channel.normalize_channel(payload.get('channel')) if isinstance(payload, dict) else None
if channel is None:
return jsonify({'status': 'error',
'message': "channel must be 'stable' or 'beta'"}), 400
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 503
update_channel.set_channel(cm, channel)
_update_check_cache['result'] = None
status, _ = channel_status(fetch=False)
if channel == 'beta':
message = (f'Switched to the beta channel. Updates now follow {update_channel.BETA_BRANCH}, '
'the newest code, before it is released.')
elif status.action == update_channel.ACTION_CHECKOUT_TAG:
message = (f'Switched to the stable channel. The next update moves this device to release '
f'{status.newest_release}.')
else:
# On the newest release already, or waiting for one that includes
# this commit; status.message says which.
message = f'Switched to the stable channel. {status.message}'
if channel == 'beta' or status.action == update_channel.ACTION_CHECKOUT_TAG:
message += ' Use Update Code on the Overview tab to apply it now.'
return jsonify({'status': 'success', 'message': message, 'data': _channel_payload(status)})
@api_v3.route('/system/check-update', methods=['GET'])
def check_for_update():
"""Check whether a newer LEDMatrix commit is available on origin/main."""
"""Check whether newer LEDMatrix code is available on this device's update channel.
stable compares HEAD with the newest release tag; beta (and stable while
it waits on a branch for a release newer than this commit) with
origin/main. When the channel says an update would do nothing -- on the
newest release, or detached and newer than it -- it is never reported
as available, whatever origin/main holds. The
response carries ``channel``, ``waiting``, ``newest_release`` and, when
the update is a release, ``target_version``.
"""
now = _pkg.time.time()
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
return jsonify(_update_check_cache['result'])
from web_interface import update_channel
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
try:
cwd = str(PROJECT_ROOT)
fetch_result = subprocess.run(
['git', 'fetch', 'origin', 'main', '--quiet'],
# main and the release tags only: this runs on page loads, with
# a short timeout, and other branches are not needed to answer.
['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE,
update_channel.BETA_BRANCH],
capture_output=True, timeout=10, cwd=cwd,
)
if fetch_result.returncode != 0:
@@ -102,6 +172,33 @@ def check_for_update():
_update_check_cache['result'] = failed
_update_check_cache['ts'] = now
return jsonify(failed)
channel, _ = channel_status(cwd, fetch=False)
channel_fields = {'channel': channel.channel, 'configured_channel': channel.configured,
'waiting': channel.waiting, 'newest_release': channel.newest_release,
'current_release': channel.current_release,
'channel_message': channel.message}
if channel.channel == 'stable' or channel.action == update_channel.ACTION_NONE:
# On a release (or about to move to one): compare tags, not
# branch commits -- main is always ahead of the newest release.
# ACTION_NONE covers a detached HEAD newer than the newest
# release too: Update Code leaves it where it is until a release
# includes it, so origin/main being ahead is not an update it
# would install. channel_message says so, in the General tab's
# words.
result = {'update_available': False, 'remote_sha': channel.newest_release_sha or 'unknown',
'commits_behind': 0, **channel_fields}
if channel.action == update_channel.ACTION_CHECKOUT_TAG:
count_str = subprocess.run(
['git', 'rev-list', '--count', f'HEAD..{channel.newest_release_sha}'],
capture_output=True, text=True, timeout=5, cwd=cwd,
).stdout.strip()
result.update(update_available=True, target_version=channel.newest_release,
commits_behind=int(count_str) if count_str.isdigit() else 0)
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
return jsonify(result)
local = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=5, cwd=cwd,
@@ -112,7 +209,7 @@ def check_for_update():
).stdout.strip()
if not local or not remote:
return jsonify(_safe)
return jsonify({**_safe, **channel_fields})
if local == remote:
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
@@ -123,6 +220,7 @@ def check_for_update():
).stdout.strip()
count = int(count_str) if count_str.isdigit() else 0
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
result.update(channel_fields)
_update_check_cache['result'] = result
_update_check_cache['ts'] = now
@@ -190,16 +288,76 @@ def perform_core_update(stash_local_changes=True):
_core_update_lock.release()
def _load_config_quietly():
# getattr: the blueprint only has a config_manager once the app wired one.
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return {}
try:
return cm.load_config() or {}
except Exception:
logger.warning("Could not load config to read the update channel", exc_info=True)
return {}
def _persist_stable_channel():
"""A config that predates channels moves to stable once it is on a release."""
cm = getattr(api_v3, 'config_manager', None)
if not cm:
return
from web_interface import update_channel
try:
update_channel.set_channel(cm, 'stable')
logger.info("Update channel set to stable: this device is on a release now")
except Exception:
logger.warning("Could not save the stable update channel", exc_info=True)
def channel_status(project_dir=None, fetch=True):
"""The update channel's plan for this checkout (update_channel.resolve).
Returns ``(status, fetch_error)``; ``fetch_error`` is git's first line
when fetching failed, else ''.
"""
from web_interface import update_channel
project_dir = str(project_dir or PROJECT_ROOT)
fetch_error = ''
if fetch:
fetched = update_channel.fetch(project_dir)
if fetched.returncode != 0:
stderr = fetched.stderr.decode(errors='replace') if isinstance(fetched.stderr, bytes) else (fetched.stderr or '')
fetch_error = next((ln.strip() for ln in stderr.splitlines() if ln.strip()), 'git fetch failed')
return update_channel.resolve(project_dir, _load_config_quietly()), fetch_error
def _perform_core_update_locked(stash_local_changes=True):
project_dir = str(PROJECT_ROOT)
from web_interface import update_channel
# Which code to move to: the newest release (stable) or main (beta).
# See web_interface/update_channel.py; it never picks an older commit.
channel, fetch_error = channel_status(project_dir)
if fetch_error:
logger.warning("git fetch failed before update: %s", fetch_error)
return {'status': 'error', 'message': f"Update failed: {fetch_error}",
'restart_required': False, 'dependency_failures': []}
action = channel.action
if action == update_channel.ACTION_NONE:
if channel.migrate:
_persist_stable_channel()
return {'status': 'success', 'restart_required': False, 'dependency_failures': [],
'channel': channel.channel,
'message': f"LEDMatrix is already up to date. {channel.message}"}
# Decide how to pull BEFORE stashing. If this checkout cannot be
# updated at all, stashing first would put the user's local changes
# away for an update that was never going to run.
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return {'status': 'error', 'message': pull_error, 'restart_required': False}
pull_args, upstream_note = None, ''
if action == update_channel.ACTION_PULL:
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
if pull_error:
logger.warning("git pull not attempted: %s", pull_error)
return {'status': 'error', 'message': pull_error, 'restart_required': False}
# Local changes, counted exactly as the automatic update's preflight
# counts them (auto_update.local_changes): mode-only changes and the
@@ -261,15 +419,30 @@ def _perform_core_update_locked(stash_local_changes=True):
# to restart onto code whose dependencies did not install.
dependency_failures = []
# Perform the git pull. Branches without an upstream were given
# an explicit "origin <branch>" above so the update still works.
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
# Move the checkout. A pull on beta; branches without an upstream were
# given an explicit "origin <branch>" above so the update still works.
# Stable checks out the release tag, carrying edits across the way the
# pull's --autostash does.
channel_note = ''
if action == update_channel.ACTION_CHECKOUT_TAG:
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
elif action == update_channel.ACTION_SWITCH_TO_BETA:
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
if result.returncode == 0:
result = subprocess.run(['git', 'pull', '--rebase', '--autostash'],
capture_output=True, text=True, timeout=60, cwd=project_dir)
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
else:
result = subprocess.run(
pull_args,
capture_output=True,
text=True,
timeout=60,
cwd=project_dir
)
if channel.waiting and channel.newest_release:
channel_note = channel.message
# Give the branch tracking information so the next pull is a plain
# `git pull` — otherwise every update repeats the fallback.
@@ -288,10 +461,16 @@ def _perform_core_update_locked(stash_local_changes=True):
pull_message = "Code updated successfully."
if has_changes:
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout:
# A checkout (a channel move) prints nothing; it always moved.
if (action != update_channel.ACTION_PULL
or (result.stdout and "Already up to date" not in result.stdout)):
pull_message = f"Code updated successfully.{stash_info}"
if upstream_note:
pull_message = f"{pull_message} {upstream_note}"
if channel_note:
pull_message = f"{pull_message} {channel_note}"
if channel.migrate and action == update_channel.ACTION_CHECKOUT_TAG:
_persist_stable_channel()
# Keep Python dependencies in sync automatically: if the pull
# changed a requirements file, install it now — users updating
@@ -376,6 +555,7 @@ def _perform_core_update_locked(stash_local_changes=True):
'message': pull_message,
'restart_required': bool(result.returncode == 0 and code_changed),
'dependency_failures': dependency_failures,
'channel': channel.channel,
}
@@ -619,8 +799,23 @@ def get_git_info():
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
branch_name = branch.stdout.strip()
upstream = _git_upstream(d)
current_release, channel_message = None, ''
if not branch_name:
# Detached is not always "on a release": a device that pulled
# main and was then detached is newer than the newest one.
# Local refs only; the panel must not wait on the network.
try:
channel, _ = channel_status(d, fetch=False)
current_release, channel_message = channel.current_release, channel.message
except Exception:
logger.debug("git-info: could not read the update channel", exc_info=True)
return jsonify({
'branch': branch_name,
# No branch: the stable update channel checks out release tags.
'detached': not branch_name,
'version': get_git_version(),
'current_release': current_release,
'channel_message': channel_message,
'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
+8
View File
@@ -476,9 +476,17 @@ def _load_general_partial():
except Exception:
logger.debug("Could not read auto-update status", exc_info=True)
auto_update_status = None
try:
# Local refs only: a page load must not wait on the network.
from web_interface import update_channel
update_channel_status = update_channel.resolve(update_channel.PROJECT_ROOT, main_config)
except Exception:
logger.debug("Could not read the update channel", exc_info=True)
update_channel_status = None
return render_template('v3/partials/general.html',
main_config=main_config,
auto_update_status=auto_update_status,
update_channel_status=update_channel_status,
web_login=_web_login_state())
+6 -1
View File
@@ -1052,7 +1052,12 @@
if (data.update_available && getDismissedSha() !== data.remote_sha) {
var n = data.commits_behind || 0;
var msg = 'A new LEDMatrix update is available';
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
if (data.target_version) {
// Stable channel: the update is a release.
msg = 'LEDMatrix ' + data.target_version + ' is available';
} else if (n > 0) {
msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
}
document.getElementById('update-banner-text').textContent = msg;
banner.style.display = '';
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
@@ -69,6 +69,22 @@
{% endif %}
</div>
<!-- Update channel: which code Update Code and the weekly update move to -->
{% set configured_channel = (main_config.auto_update or {}).channel if (main_config.auto_update or {}).channel in ('stable', 'beta') else 'stable' %}
<div class="form-group" id="setting-general-auto_update_channel" data-setting-key="auto_update.channel">
<label for="auto_update_channel" class="block text-sm font-medium text-gray-700">Update Channel{{ ui.help_tip('Which LEDMatrix version updates install, both Update Code on the Overview tab and the weekly automatic update.\nStable: the newest release. Recommended.\nBeta: the newest code on main, before it is released. Gets fixes sooner, and problems too.\nSwitching to Stable never installs an older version than the one running: a device that is ahead of the newest release keeps following main until a release includes its version, then moves to releases.\nDefault: Stable.', 'Update Channel') }}</label>
<select id="auto_update_channel" name="auto_update_channel" class="form-control mt-1">
<option value="stable" {% if configured_channel == 'stable' %}selected{% endif %}>Stable (releases)</option>
<option value="beta" {% if configured_channel == 'beta' %}selected{% endif %}>Beta (newest code on main)</option>
</select>
{% if update_channel_status %}
<p class="mt-1 text-xs {{ 'text-amber-700' if update_channel_status.waiting and configured_channel == 'stable' else 'text-gray-500' }}">
{{ update_channel_status.message }}
</p>
{% endif %}
<p class="mt-1 text-xs text-gray-500">Takes effect at the next update: use Update Code on the Overview tab to apply it now.</p>
</div>
<!-- Timezone -->
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
@@ -519,7 +519,7 @@
let html = `<div class="space-y-2">
<div class="flex items-center gap-2">
<i class="fas fa-code-branch text-gray-400"></i>
<span class="font-mono text-gray-800">${escHtml(d.branch || 'unknown')}</span>
<span class="font-mono text-gray-800">${escHtml(d.detached ? (d.version || 'detached') : (d.branch || 'unknown'))}</span>
${dirtyBadge}
</div>`;
@@ -534,7 +534,14 @@
</div>`;
}
if (d.upstream) {
if (d.detached && d.current_release) {
// The stable update channel sits on release tags, with no branch.
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-tag mr-1"></i>On release ${escHtml(d.current_release)}, not a branch (stable update channel). Pull Latest follows the update channel set on the General tab.</p>`;
} else if (d.detached) {
// Detached but not on a release: say what the channel does
// with it, in the General tab's words.
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-tag mr-1"></i>Not on a branch or a release. ${escHtml(d.channel_message || '')} Pull Latest follows the update channel set on the General tab.</p>`;
} else if (d.upstream) {
html += `<p class="text-xs text-gray-500 mt-1"><i class="fas fa-link mr-1"></i>tracking <span class="font-mono">${escHtml(d.upstream)}</span></p>`;
} else if (d.can_pull) {
html += `<p class="text-xs text-blue-700 mt-1"><i class="fas fa-info-circle mr-1"></i>No upstream set; Pull Latest will use <span class="font-mono">origin/${escHtml(d.branch || '')}</span> and set it.</p>`;
+263
View File
@@ -0,0 +1,263 @@
"""Update channels: which LEDMatrix code "Update Code" and the weekly updater move to.
* **stable** follows releases: the newest ``vX.Y.Z`` tag, by semantic version.
Pre-release tags (``v3.8.0-rc1``) and anything else that is not exactly
``vX.Y.Z`` are ignored. The checkout sits on the tag with a detached HEAD.
* **beta** is how every device updated before channels existed: it follows
``main`` (``git pull --rebase --autostash`` on the current branch).
The setting is ``auto_update.channel`` in config.json. New installs get
``stable`` from config/config.template.json (and the installer).
**Nobody is moved backwards.** stable only ever checks out a release tag that
contains the current commit, so a device running code newer than the newest
release -- anything that pulled main since that release, or a fresh install
of main -- keeps following main ("waiting") until a release that contains its
commit exists, and moves to it at the next update. A config written before
channels existed (no key) behaves the same way, and the key is written as
``stable`` when that move happens.
Standard library only, and every git call goes through ``run`` (default:
``subprocess.run`` looked up at call time, so tests that patch it are seen).
"""
import re
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CHANNELS = ('stable', 'beta')
DEFAULT_CHANNEL = 'stable'
#: The branch beta follows, and the one a device on a release tag moves to.
BETA_BRANCH = 'main'
REMOTE = 'origin'
#: Exactly vMAJOR.MINOR.PATCH, no leading zeros, nothing after it.
_RELEASE_TAG_RE = re.compile(r'v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)')
#: Actions an update can take (``ChannelStatus.action``).
ACTION_NONE = 'none' # nothing to do: on the newest release, or waiting
ACTION_CHECKOUT_TAG = 'checkout_tag' # stable: move forward to the newest release tag
ACTION_PULL = 'pull' # beta (or waiting for a release): pull the current branch
ACTION_SWITCH_TO_BETA = 'switch_to_beta' # beta on a detached release tag: go back to main
AUTOSTASH_MESSAGE = 'LEDMatrix autostash before update'
def parse_release_tag(name):
"""``(major, minor, patch)`` for a release tag such as ``v3.7.0``, else None."""
match = _RELEASE_TAG_RE.fullmatch((name or '').strip())
return tuple(int(part) for part in match.groups()) if match else None
def newest_release_tag(names):
"""The highest release tag among ``names`` by semver, or None."""
releases = [(parse_release_tag(n), n.strip()) for n in names or ()]
releases = [pair for pair in releases if pair[0] is not None]
return max(releases)[1] if releases else None
def normalize_channel(value):
"""'stable' or 'beta' from user input, else None."""
if not isinstance(value, str):
return None
value = value.strip().lower()
return value if value in CHANNELS else None
def configured_channel(config):
"""The channel config.json names, or None when it names none (or nonsense)."""
section = (config or {}).get('auto_update')
if not isinstance(section, dict):
return None
return normalize_channel(section.get('channel'))
def set_channel(config_manager, channel):
"""Write ``auto_update.channel`` to config.json. Returns the saved config."""
channel = normalize_channel(channel)
if channel is None:
raise ValueError(f"channel must be one of {', '.join(CHANNELS)}")
config = config_manager.load_config()
if not isinstance(config.get('auto_update'), dict):
config['auto_update'] = {}
config['auto_update']['channel'] = channel
config_manager.save_config(config)
return config
class ChannelStatus(dict):
"""What the channel means for this checkout right now (a JSON-able dict).
Keys: ``configured`` ('stable', 'beta' or None), ``channel`` (the one in
effect: 'beta' while stable is waiting), ``waiting``, ``migrate`` (write
'stable' to a config that names no channel), ``action``, ``head``,
``branch`` ('' when detached), ``newest_release`` and its commit
``newest_release_sha``, ``current_release`` (the release tag HEAD is
exactly on, if any), ``target_sha`` for ACTION_CHECKOUT_TAG, and
``message`` for people.
"""
def __getattr__(self, name):
try:
return self[name]
except KeyError as e:
raise AttributeError(name) from e
def _git(project_dir, run, *args, timeout=30):
run = run or subprocess.run
return run(['git', *args], cwd=str(project_dir), capture_output=True, text=True, timeout=timeout)
def _out(result):
out = result.stdout
if isinstance(out, bytes):
out = out.decode(errors='replace')
return (out or '').strip() if result.returncode == 0 else ''
def fetch(project_dir, run=None, timeout=120):
"""Fetch origin's branches and tags. ``--force`` so a moved tag is updated, not an error."""
return _git(project_dir, run, 'fetch', '--quiet', '--tags', '--force', REMOTE, timeout=timeout)
def release_tags(project_dir, run=None):
return [line for line in _out(_git(project_dir, run, 'tag', '--list', 'v*')).splitlines() if line.strip()]
def current_branch(project_dir, run=None):
"""The checked-out branch, or '' when HEAD is detached."""
return _out(_git(project_dir, run, 'symbolic-ref', '--quiet', '--short', 'HEAD'))
def is_ancestor(project_dir, older, newer, run=None):
"""True when ``older`` is ``newer`` or one of its ancestors."""
return _git(project_dir, run, 'merge-base', '--is-ancestor', older, newer).returncode == 0
def resolve(project_dir, config, run=None):
"""Decide what an update on this checkout should do. Reads local refs only:
fetch first (``fetch``) for an answer about what origin has."""
configured = configured_channel(config)
status = ChannelStatus(
configured=configured, channel='beta', waiting=False, migrate=False,
action=ACTION_PULL, head='', branch=None, newest_release=None,
newest_release_sha='', current_release=None, target_sha='', message='')
if configured == 'beta':
status['branch'] = current_branch(project_dir, run)
if status.branch:
status['message'] = f'Beta: following {BETA_BRANCH} (the newest code, before it is released).'
else:
status['action'] = ACTION_SWITCH_TO_BETA
status['message'] = (f'Beta: this device is on a release; the next update moves it to '
f'{BETA_BRANCH}, the newest code.')
return status
newest = newest_release_tag(release_tags(project_dir, run))
if newest is None:
# No release to follow (never fetched, or a fork without tags):
# update exactly as before channels existed.
status['waiting'] = True
status['message'] = (f'Stable: no release has been published yet, so updates follow '
f'{BETA_BRANCH} until one is.')
return status
head = _out(_git(project_dir, run, 'rev-parse', 'HEAD'))
tag_sha = _out(_git(project_dir, run, 'rev-parse', f'{newest}^{{commit}}'))
status.update(head=head, newest_release=newest, newest_release_sha=tag_sha,
branch=current_branch(project_dir, run))
if head and head == tag_sha:
status['current_release'] = newest
if head and tag_sha and is_ancestor(project_dir, head, newest, run):
status['channel'] = 'stable'
status['migrate'] = configured is None
if head == tag_sha:
status['action'] = ACTION_NONE
status['message'] = f'Stable: on the newest release, {newest}.'
else:
status['action'] = ACTION_CHECKOUT_TAG
status['target_sha'] = tag_sha
status['message'] = f'Stable: release {newest} is available.'
return status
# The newest release does not contain this commit: moving to it would go
# backwards. Keep following the branch until a release that does exists.
status['waiting'] = True
if status.branch:
status['message'] = (f'Stable: this device runs code newer than the newest release ({newest}), '
f'so it keeps following {BETA_BRANCH} and moves to the first release '
'that includes its current version.')
else:
# Detached and newer than the release: there is no branch to follow,
# so stay put until a release catches up.
status['action'] = ACTION_NONE
status['message'] = (f'Stable: this device runs code newer than the newest release ({newest}); '
'it stays on it and moves to the first release that includes it.')
return status
def checkout(project_dir, args, run=None, timeout=120):
"""``git checkout <args>`` that carries uncommitted edits across, like ``pull --autostash``.
The edits are saved as a stash commit (``git stash create``, which
leaves the stash list alone), the tree is cleaned, the checkout runs,
and the edits are reapplied. If they no longer apply they are kept in
the stash list rather than left half-merged, which is what git's own
autostash does. Returns ``(result, note)``: ``result`` is the checkout's
CompletedProcess, ``note`` a sentence for the user or ''.
"""
stash_sha = _out(_git(project_dir, run, 'stash', 'create', AUTOSTASH_MESSAGE))
if stash_sha:
cleaned = _git(project_dir, run, 'reset', '--hard', '--quiet', timeout=timeout)
if cleaned.returncode != 0:
return cleaned, ''
result = _git(project_dir, run, 'checkout', '--quiet', *args, timeout=timeout)
note = ''
if stash_sha:
applied = _git(project_dir, run, 'stash', 'apply', '--quiet', stash_sha, timeout=timeout)
if applied.returncode != 0:
_git(project_dir, run, 'reset', '--hard', '--quiet', timeout=timeout)
_git(project_dir, run, 'stash', 'store', '-m', f'{AUTOSTASH_MESSAGE} (did not reapply)', stash_sha)
note = ('Local changes could not be reapplied to the new version and were kept '
'in the git stash (git stash list).')
return result, note
def checkout_release(project_dir, tag, run=None):
"""Move to release ``tag`` (detached HEAD). Refuses a tag that would go backwards."""
if parse_release_tag(tag) is None:
raise ValueError(f'not a release tag: {tag!r}')
head = _out(_git(project_dir, run, 'rev-parse', 'HEAD'))
if not head or not is_ancestor(project_dir, head, tag, run):
failed = subprocess.CompletedProcess(
['git', 'checkout', tag], 1, stdout='',
stderr=f'release {tag} does not contain the current commit; refusing to move backwards')
return failed, ''
return checkout(project_dir, ['--detach', f'{tag}^{{commit}}'], run)
def checkout_beta_branch(project_dir, run=None):
"""Leave a detached release for ``main``, tracking origin/main. The caller then pulls.
Straight to origin/main when the local branch has nothing of its own
(the usual case: it is wherever the device last left main, often older
than the release it is on). Going through that older commit would make
the carried edits apply to the wrong version, and a plugin file that did
not exist yet would drop them into the stash. A local branch with
commits of its own is checked out as it is and rebased by the pull.
"""
local_ref = f'refs/heads/{BETA_BRANCH}'
remote_ref = f'{REMOTE}/{BETA_BRANCH}'
local = _git(project_dir, run, 'show-ref', '--verify', '--quiet', local_ref).returncode == 0
if local and not is_ancestor(project_dir, local_ref, remote_ref, run):
result, note = checkout(project_dir, [BETA_BRANCH], run)
else:
# -B: create it, or fast-forward it (it is an ancestor, so nothing is lost).
result, note = checkout(project_dir, ['-B', BETA_BRANCH, remote_ref], run)
if result.returncode == 0:
# A branch left without tracking would make the pull that follows
# take the no-upstream fallback; set it while we are here.
_git(project_dir, run, 'branch', f'--set-upstream-to={REMOTE}/{BETA_BRANCH}', BETA_BRANCH)
return result, note