mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user