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:
@@ -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 '',
|
||||
|
||||
Reference in New Issue
Block a user