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
+24
View File
@@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Update channels
- Devices no longer pick up every merge to `main`. A new setting,
`auto_update.channel`, picks what Update Code and the weekly automatic
update install: `stable` follows the newest release tag (`vX.Y.Z` by
semantic version; pre-releases and other tags are ignored) and checks it
out with a detached HEAD, and `beta` follows `main` as every device did
before. New installs default to `stable` (config template and installer).
- Nobody is moved backwards. A device running code newer than the newest
release, which is any device that pulled `main` since that release, keeps
following `main` until a release contains its commit, then moves to it and
follows releases. A config written before channels existed behaves the
same way and is saved as `stable` when that move happens. Switching from
beta to stable says so instead of installing an older version.
- Switch channels on the General tab (Update Channel, under Automatic
Updates) or with `GET`/`POST /api/v3/system/update-channel`. The Overview
update banner compares release tags on stable ("LEDMatrix v3.8.0 is
available") rather than commits on `main`. A detached checkout newer
than the newest release gets no banner: Update Code leaves it where it
is until a release includes it.
- A move between `main` and a release tag carries local edits across as the
pull's `--autostash` does, and the automatic update's health check rolls
it back to where HEAD was: the branch, or the detached release.
### Frozen-panel detection
A render loop stuck inside a plugin's `display()` left `ledmatrix.service`
+2 -1
View File
@@ -1,7 +1,8 @@
{
"web_display_autostart": true,
"auto_update": {
"enabled": false
"enabled": false,
"channel": "stable"
},
"schedule": {
"enabled": false,
+16 -3
View File
@@ -336,8 +336,19 @@ everything else through `_reinstall_with_rollback()`.
- **Update Code** on the Overview tab and the automatic updater both call
`perform_core_update()` in
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
`git pull --rebase`, reinstall changed requirement files, report whether a
restart is needed.
fetch branches and tags, move the checkout for the update channel, reinstall
changed requirement files, report whether a restart is needed.
- **Update channels** (`auto_update.channel`):
[`web_interface/update_channel.py`](../web_interface/update_channel.py)
decides the move. `stable` checks out the newest `vX.Y.Z` tag (detached
HEAD) when it contains the current commit; `beta` is
`git pull --rebase --autostash` on the current branch, and leaves a
detached release for `main` first. A stable device newer than the newest
release keeps pulling `main` until a release contains its commit, so no
update ever moves backwards; a config without the key is written as
`stable` once the device reaches a release. Checkouts carry uncommitted
edits across with `git stash create`/`apply`, and keep them in the stash
list if they no longer apply.
- **Automatic updates** (`auto_update.enabled`, off by default):
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
runs in the web process, checks every 30 minutes, and updates at most
@@ -350,7 +361,9 @@ everything else through `_reinstall_with_rollback()`.
both services, waits for the web API to answer and the display service to
stay up -- and, when the display wrote a heartbeat before the update, to
keep one fresh from the restarted process (see Liveness) -- and on failure
resets to the previous commit and restarts again.
returns to where HEAD was (the branch, or detached on the previous
release; `old_ref` in the pending file), resets to the previous commit
and restarts again.
Plugin updates run only after a verified core update. State is in
`data/auto_update_state.json` and `data/auto_update_pending.json`.
- **Startup validator.** `StartupValidator`
+1
View File
@@ -17,6 +17,7 @@ tooling against it.
|---|---|---|---|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
| `auto_update.channel` | `"stable"` or `"beta"`, `"stable"` (template) | What Update Code and the weekly update install. `stable`: the newest `vX.Y.Z` release tag (pre-releases ignored), checked out with a detached HEAD. `beta`: `main`. Never moves a device backwards: one newer than the newest release keeps following `main` until a release contains its commit. Missing (configs from before channels) behaves like `stable` and is saved as `stable` once the device is on a release. General tab, Update Channel | `web_interface/update_channel.py` (`resolve()`) |
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps |
+16
View File
@@ -240,6 +240,22 @@ The fastest way to verify a plugin works without waiting for the rotation:
- Install community plugins straight from a GitHub URL via
**Install from GitHub** on the same tab.
### Keep LEDMatrix Up to Date
- **Update Code** on the **Overview** tab installs the newest version, and a
banner at the top of the page says when one is available.
- **General → Automatic Updates** does it once a week, overnight, with a
health check that undoes an update that breaks the device.
- **General → Update Channel** picks which version that is. **Stable** (the
default) installs releases, which have been tested and have release
notes. **Beta** installs the newest code as soon as it is written, before
it is released: fixes arrive sooner, and so do new problems.
- Switching to Stable never installs an older version than the one you
have. If your device is already newer than the latest release (which is
normal if it was set up or updated from the newest code), it keeps
getting the newest code until the next release includes it, then follows
releases from there. The General tab says when this is the case.
### Enable Advanced Features
**Vegas Scroll Mode:**
+45 -4
View File
@@ -1485,20 +1485,59 @@ Get LEDMatrix repository version.
**GET** `/api/v3/system/check-update`
Whether `origin/main` has commits the checkout lacks. Cached briefly.
Fields at the top level (no envelope):
Whether newer code is available on this device's update channel. On
`stable` that is a newer release tag than the checkout (`target_version`
names it); on `beta`, and on `stable` while it waits on a branch for a
release that contains the current commit, it is commits on `origin/main`
the checkout lacks. A detached checkout newer than the newest release is
never offered an update: Update Code leaves it where it is until a release
includes it, and `channel_message` says so in the General tab's words.
Cached briefly. Fields at the top level (no envelope):
```json
{
"update_available": true,
"remote_sha": "abc123...",
"commits_behind": 3
"commits_behind": 3,
"target_version": "v3.8.0",
"channel": "stable",
"configured_channel": "stable",
"waiting": false,
"newest_release": "v3.8.0",
"current_release": null,
"channel_message": "Stable: release v3.8.0 is available."
}
```
When git cannot run the check, the response also carries
`"check_failed": true` and an `error` explaining why.
### Update Channel
**GET** `/api/v3/system/update-channel`
The update channel and what the next Update Code or weekly update would do
(in `data`): `configured` (`"stable"`, `"beta"` or `null` for a config from
before channels), `channel` (the one in effect), `waiting` (stable, but the
device is newer than the newest release, so it follows `main` for now),
`action` (`none`, `checkout_tag`, `pull` or `switch_to_beta`),
`newest_release`, `current_release`, `branch` (`""` when on a release tag),
`message`. Reads local refs; `?fetch=1` fetches from origin first.
**POST** `/api/v3/system/update-channel`
```json
{
"channel": "beta"
}
```
Saves `auto_update.channel`. The next update applies it; switching to
`stable` never installs an older version than the one running, and the
`message` says when the device keeps following `main` until a newer release.
400 for anything but `stable` or `beta`. The General tab form also accepts
`auto_update_channel` on `POST /api/v3/config/main`.
### Automatic Update Status
**GET** `/api/v3/system/auto-update`
@@ -1524,7 +1563,9 @@ Hide the current automatic-update alert until a new one replaces it.
Branch, dirty state, recent commits and remote for the Tools tab. Fields at
the top level: `branch`, `dirty`, `status`, `recent_commits`, `remote_url`
(credentials scrubbed), `upstream`, `can_pull`.
(credentials scrubbed), `upstream`, `can_pull`, and for the update channel
`detached`, `version` (`git describe`), `current_release` (the release tag
HEAD is exactly on, else `null`) and `channel_message` (detached only).
### Git Branches
+41
View File
@@ -295,6 +295,42 @@ sudo systemctl cat ledmatrix-web | grep User
---
#### Issue: Updates and the update channel
**Symptoms:**
- The General tab says "Stable: this device runs code newer than the newest
release ... keeps following main"
- Tools shows a version such as `v3.8.0` instead of a branch name, or `git
status` over SSH says `HEAD detached at v3.8.0`
- Update Code says "already up to date" while GitHub's `main` has newer commits
**Explanation:** these are the Stable update channel working as intended
(`auto_update.channel`, General → Update Channel). Stable installs the
newest release tag, which git checks out without a branch ("detached
HEAD"); that is normal and every update path handles it. Stable never
installs an older version than the one running, so a device that is ahead of
the newest release keeps following `main` until a release includes its
commit, then switches to releases on its own.
**Solutions:**
1. **Want the newest code instead?** Set Update Channel to **Beta** and click
Update Code. The device leaves the release for `main` and pulls it.
Or from SSH:
```bash
curl -X POST http://localhost:5000/api/v3/system/update-channel \
-H 'Content-Type: application/json' -d '{"channel": "beta"}'
```
2. **See what the next update will do:**
```bash
curl 'http://localhost:5000/api/v3/system/update-channel?fetch=1'
```
3. **Local changes after a channel switch:** edits that no longer fit the new
version are kept in the git stash rather than lost; `git stash list`
shows them as "LEDMatrix autostash before update".
---
### WiFi & AP Mode Issues
#### AP Mode Not Activating
@@ -1010,6 +1046,11 @@ git reset --hard HEAD~1
# Or rollback to specific commit
git reset --hard <commit-hash>
# On the Stable update channel HEAD is a release tag, not a branch:
# go back to an earlier release instead (the next update moves forward again)
git tag --list 'v*' --sort=-v:refname | head
git checkout --detach v3.7.0
# Restart all services
sudo systemctl restart ledmatrix
sudo systemctl restart ledmatrix-web
+8 -1
View File
@@ -78,7 +78,9 @@ The Overview tab provides at-a-glance information and quick actions:
- **Start Display** / **Stop Display** — control the display service
- **Restart Display Service** — apply configuration changes
- **Restart Web Service** — restart the web UI itself
- **Update Code** — `git pull` the latest version (stashes local changes)
- **Update Code** — update to the newest version on the update channel (the
newest release on Stable, the newest code on `main` on Beta; stashes local
changes). The channel is set on the General tab.
- **Reboot System** / **Shutdown System** — confirm-gated power controls
**Display Preview:**
@@ -90,6 +92,11 @@ The Overview tab provides at-a-glance information and quick actions:
Configure basic system settings:
- **Automatic Updates** — weekly updates with a health check and rollback
- **Update Channel** — **Stable** (default) installs releases; **Beta**
installs the newest code on `main` before it is released. Switching to
Stable never installs an older version: a device ahead of the newest
release keeps following `main` until a release includes it
- **Timezone** — used by all time/date displays
- **Location** — city/state/country for weather and other location-aware
plugins
+4
View File
@@ -835,6 +835,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
cat > "$PROJECT_ROOT_DIR/config/config.json" <<'EOF'
{
"web_display_autostart": true,
"auto_update": {
"enabled": false,
"channel": "stable"
},
"timezone": "America/Chicago",
"display": {
"hardware": {
+15
View File
@@ -14,6 +14,7 @@ the same reason: the rollback cannot depend on packages the update changed.
The updater leaves data/auto_update_pending.json:
{"status": "pending", "old_head": ..., "new_head": ...,
"old_ref": "main" | "" (detached) | absent (older updaters),
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
This moves its status to "verifying" and then to one of "success",
@@ -276,6 +277,20 @@ class Verifier:
if not old:
return False, 'the commit to roll back to is unknown'
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
# An update may have moved HEAD between main and a detached release
# tag (the stable/beta channels). Go back to where HEAD was -- the
# branch, or detached -- before resetting, or resetting would drag
# the wrong ref: main onto a release commit, or leave a device that
# was following main stuck on a detached one. No old_ref (an older
# updater wrote this file) means HEAD never moved between refs.
old_ref = pending.get('old_ref')
if old_ref is not None:
move = (['git', 'checkout', '--quiet', '--force', old_ref] if old_ref
else ['git', 'checkout', '--quiet', '--force', '--detach', old])
result = self._run(move, timeout=GIT_RESET_TIMEOUT_SECONDS)
if result.returncode != 0:
return False, (f'"{" ".join(move)}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
# --hard: the updater refuses to run with local edits to tracked core
# files (web_interface/auto_update.local_changes), so outside the
# plugin folders the only thing this discards is the update. Edits
+17
View File
@@ -903,6 +903,23 @@
"OPTIONS"
]
],
[
"/api/v3/system/update-channel",
"api_v3.get_update_channel",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/system/update-channel",
"api_v3.set_update_channel",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/system/version",
"api_v3.get_system_version",
+693
View File
@@ -0,0 +1,693 @@
"""Stable and beta update channels (web_interface/update_channel.py).
stable follows the newest vX.Y.Z release tag; beta follows main, as every
device did before channels existed. What these pin down:
* which tags count as releases (not pre-releases, not anything else);
* which channel is in effect, including configs written before channels
existed, which follow main until the newest release contains their commit
and then move to stable and say so in config.json;
* that nothing ever moves a device to an older commit than the one it runs;
* that the real update paths (Update Code, the weekly updater and its
rollback) handle a detached release checkout and switching back and forth.
Git runs for real against a throwaway origin and device clone.
"""
import importlib.util
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from web_interface import update_channel as uc # noqa: E402
from web_interface import auto_update as au # noqa: E402
needs_git = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
_spec = importlib.util.spec_from_file_location(
'auto_update_verify_for_channels', ROOT / 'scripts' / 'utils' / 'auto_update_verify.py')
av = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(av)
def git(cwd, *args):
result = subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', *args],
cwd=str(cwd), capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
class Repo:
"""An origin, a checkout that publishes to it, and the device's clone."""
def __init__(self, tmp):
self.seed = tmp / 'seed'
self.seed.mkdir()
git(self.seed, 'init', '-q', '-b', 'main')
(self.seed / 'app.py').write_text('v = 1\n')
(self.seed / 'notes.txt').write_text('notes\n')
git(self.seed, 'add', '.')
git(self.seed, 'commit', '-qm', 'one')
self.origin = tmp / 'origin.git'
git(tmp, 'clone', '-q', '--bare', str(self.seed), str(self.origin))
git(self.seed, 'remote', 'add', 'origin', str(self.origin))
self.device = tmp / 'device'
git(tmp, 'clone', '-q', str(self.origin), str(self.device))
def head(self):
return git(self.device, 'rev-parse', 'HEAD')
def branch(self):
return uc.current_branch(self.device)
def publish(self, text):
(self.seed / 'app.py').write_text(text)
git(self.seed, 'commit', '-qam', text.strip())
git(self.seed, 'push', '-q', 'origin', 'main')
return git(self.seed, 'rev-parse', 'HEAD')
def tag(self, name, annotated=False):
if annotated:
git(self.seed, 'tag', '-a', name, '-m', name)
else:
git(self.seed, 'tag', name)
git(self.seed, 'push', '-q', 'origin', name)
return git(self.seed, 'rev-parse', f'{name}^{{commit}}')
def fetch(self):
assert uc.fetch(self.device).returncode == 0
def resolve(self, channel=None):
self.fetch()
config = {'auto_update': {'channel': channel}} if channel else {}
return uc.resolve(self.device, config)
STABLE = {'auto_update': {'channel': 'stable'}}
BETA = {'auto_update': {'channel': 'beta'}}
# -- tag parsing ---------------------------------------------------------------
class TestReleaseTags:
@pytest.mark.parametrize('name,expected', [
('v3.7.0', (3, 7, 0)),
('v0.0.1', (0, 0, 1)),
('v10.20.30', (10, 20, 30)),
(' v3.7.0\n', (3, 7, 0)),
])
def test_releases_parse(self, name, expected):
assert uc.parse_release_tag(name) == expected
@pytest.mark.parametrize('name', [
'v3.8.0-rc1', 'v3.8.0-beta.2', 'v3.8.0+build5', # pre-releases, build metadata
'3.7.0', 'v3.7', 'v3', 'v3.7.0.1', 'V3.7.0', # not vX.Y.Z
'v03.7.0', 'v3.07.0', # leading zeros are not semver
'release-3.7', 'latest', 'stable', '', None,
])
def test_everything_else_is_ignored(self, name):
assert uc.parse_release_tag(name) is None
def test_newest_is_by_semver_not_by_string(self):
# String order would pick v3.9.0; v3.10.0 is newer.
assert uc.newest_release_tag(['v3.9.0', 'v3.10.0', 'v3.2.1']) == 'v3.10.0'
def test_a_newer_pre_release_or_junk_tag_is_not_newest(self):
assert uc.newest_release_tag(['v3.7.0', 'v3.8.0-rc1', 'v99', 'nightly']) == 'v3.7.0'
def test_no_releases(self):
assert uc.newest_release_tag(['v3.8.0-rc1', 'foo']) is None
assert uc.newest_release_tag([]) is None
class TestConfiguredChannel:
def test_reads_auto_update_channel(self):
assert uc.configured_channel(STABLE) == 'stable'
assert uc.configured_channel(BETA) == 'beta'
assert uc.configured_channel({'auto_update': {'channel': ' Beta '}}) == 'beta'
@pytest.mark.parametrize('config', [
{}, {'auto_update': {}}, {'auto_update': True}, {'auto_update': {'channel': 'nightly'}},
{'auto_update': {'channel': 3}}, None,
])
def test_missing_or_nonsense_is_none(self, config):
assert uc.configured_channel(config) is None
def test_template_default_is_stable(self):
import json
template = json.loads((ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
assert template['auto_update']['channel'] == 'stable'
def test_set_channel_rejects_nonsense(self):
with pytest.raises(ValueError):
uc.set_channel(MagicMock(), 'nightly')
# -- channel selection, migration and no-downgrade -------------------------------
@needs_git
class TestResolve:
def test_beta_on_a_branch_pulls(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
status = repo.resolve('beta')
assert (status.channel, status.action) == ('beta', uc.ACTION_PULL)
def test_no_releases_updates_as_before(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
for channel in (None, 'stable'):
status = repo.resolve(channel)
assert (status.channel, status.action, status.waiting) == ('beta', uc.ACTION_PULL, True)
assert not status.migrate
def test_legacy_config_moves_to_a_release_that_contains_its_commit(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
sha = repo.tag('v1.0.0')
status = repo.resolve(None)
assert status.channel == 'stable'
assert status.action == uc.ACTION_CHECKOUT_TAG
assert status.target_sha == sha and status.newest_release == 'v1.0.0'
assert status.migrate, "a config without a channel must be written as stable when it moves"
def test_legacy_config_ahead_of_the_newest_release_stays_on_main(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'pull', '-q') # device now newer than v1.0.0
status = repo.resolve(None)
assert (status.channel, status.action) == ('beta', uc.ACTION_PULL)
assert status.waiting and not status.migrate
def test_legacy_config_on_the_release_commit_is_migrated_without_moving(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
status = repo.resolve(None)
assert (status.channel, status.action) == ('stable', uc.ACTION_NONE)
assert status.migrate and status.current_release == 'v1.0.0'
def test_stable_never_picks_an_older_release(self, tmp_path):
"""Switching beta -> stable on a device ahead of the newest release."""
repo = Repo(tmp_path)
old_release = repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'pull', '-q')
status = repo.resolve('stable')
assert status.action != uc.ACTION_CHECKOUT_TAG
assert status.target_sha != old_release
assert status.waiting and 'newer than the newest release' in status.message
def test_stable_waiting_while_detached_stays_put(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'fetch', '-q')
git(repo.device, 'checkout', '-q', '--detach', 'origin/main')
status = repo.resolve('stable')
assert (status.action, status.waiting) == (uc.ACTION_NONE, True)
def test_a_newer_release_is_taken_once_it_contains_the_commit(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'pull', '-q')
repo.publish('v = 3\n')
new = repo.tag('v1.1.0', annotated=True) # annotated tags resolve to their commit
status = repo.resolve('stable')
assert (status.action, status.newest_release, status.target_sha) == (
uc.ACTION_CHECKOUT_TAG, 'v1.1.0', new)
def test_a_newer_pre_release_is_not_followed(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
repo.tag('v1.1.0-rc1')
status = repo.resolve('stable')
assert (status.action, status.newest_release) == (uc.ACTION_NONE, 'v1.0.0')
def test_beta_on_a_detached_release_switches_back_to_main(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
git(repo.device, 'fetch', '-q', '--tags')
git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0')
status = repo.resolve('beta')
assert (status.channel, status.action) == ('beta', uc.ACTION_SWITCH_TO_BETA)
@needs_git
class TestCheckout:
def test_refuses_to_move_backwards(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'pull', '-q')
repo.fetch()
head = repo.head()
result, _ = uc.checkout_release(repo.device, 'v1.0.0')
assert result.returncode != 0 and 'backwards' in result.stderr
assert repo.head() == head
def test_refuses_a_non_release_name(self, tmp_path):
with pytest.raises(ValueError):
uc.checkout_release(tmp_path, '--orphan')
def test_local_edits_are_carried_across(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
repo.tag('v1.0.0')
repo.fetch()
(repo.device / 'notes.txt').write_text('my notes\n')
result, note = uc.checkout_release(repo.device, 'v1.0.0')
assert result.returncode == 0, result.stderr
assert (repo.device / 'app.py').read_text() == 'v = 2\n'
assert (repo.device / 'notes.txt').read_text() == 'my notes\n'
assert note == '' and git(repo.device, 'stash', 'list') == ''
def test_edits_that_no_longer_apply_are_kept_in_the_stash(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
repo.tag('v1.0.0')
repo.fetch()
(repo.device / 'app.py').write_text('v = "mine"\n') # conflicts with v = 2
result, note = uc.checkout_release(repo.device, 'v1.0.0')
assert result.returncode == 0
assert 'stash' in note
assert (repo.device / 'app.py').read_text() == 'v = 2\n'
assert git(repo.device, 'status', '--porcelain', '--untracked-files=no') == ''
assert 'LEDMatrix autostash' in git(repo.device, 'stash', 'list')
assert git(repo.device, 'stash', 'show', '-p', 'stash@{0}').count('mine') == 1
# -- Update Code (perform_core_update) against a real clone --------------------------
class FakeConfigManager:
def __init__(self, config):
self.config = config
self.saves = 0
def load_config(self):
import copy
return copy.deepcopy(self.config)
def save_config(self, config):
self.config = config
self.saves += 1
@pytest.fixture
def update_code(monkeypatch):
"""The real perform_core_update, pointed at a test clone and a config."""
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
def point_at(device, config):
cm = FakeConfigManager(config)
monkeypatch.setattr(system, 'PROJECT_ROOT', device)
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False)
monkeypatch.setattr(system, '_pip_install_requirements',
lambda *a, **k: pytest.fail('no requirements changed'))
return system.perform_core_update, cm
return point_at
@needs_git
class TestUpdateCode:
def test_stable_checks_out_the_newest_release(self, tmp_path, update_code):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
release = repo.tag('v1.0.0')
repo.publish('v = 3 (unreleased)\n')
update, cm = update_code(repo.device, dict(STABLE))
result = update()
assert result['status'] == 'success', result['message']
assert repo.head() == release and repo.branch() == ''
assert 'v1.0.0' in result['message'] and result['restart_required']
assert result['channel'] == 'stable'
assert update()['restart_required'] is False, "second run is already up to date"
def test_legacy_config_is_migrated_and_saved(self, tmp_path, update_code):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
release = repo.tag('v1.0.0')
update, cm = update_code(repo.device, {'auto_update': {'enabled': False}})
assert update()['status'] == 'success'
assert repo.head() == release
assert cm.config['auto_update'] == {'enabled': False, 'channel': 'stable'}
def test_legacy_config_ahead_of_the_release_keeps_pulling_main(self, tmp_path, update_code):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
repo.publish('v = 2\n')
git(repo.device, 'pull', '-q')
newest = repo.publish('v = 3\n')
update, cm = update_code(repo.device, {'auto_update': {'enabled': True}})
result = update()
assert result['status'] == 'success', result['message']
assert repo.head() == newest and repo.branch() == 'main'
assert 'channel' not in cm.config['auto_update'], "not migrated while ahead of the release"
# The next release contains the device's commit: now it moves, forward.
release = repo.tag('v1.1.0')
assert update()['status'] == 'success'
assert repo.head() == release
assert cm.config['auto_update']['channel'] == 'stable'
def test_stable_beta_stable_round_trip_never_goes_backwards(self, tmp_path, update_code):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
r1 = repo.tag('v1.0.0')
tip = repo.publish('v = 3\n')
update, cm = update_code(repo.device, dict(STABLE))
assert update()['status'] == 'success'
assert (repo.head(), repo.branch()) == (r1, '')
cm.config = dict(BETA)
result = update()
assert result['status'] == 'success', result['message']
assert (repo.head(), repo.branch()) == (tip, 'main')
assert git(repo.device, 'rev-parse', '--abbrev-ref', '@{u}') == 'origin/main'
cm.config = dict(STABLE)
result = update()
assert result['status'] == 'success'
assert (repo.head(), repo.branch()) == (tip, 'main'), \
"switching to stable ahead of the newest release must not check out v1.0.0"
assert 'newer than the newest release' in result['message']
# A release on exactly this commit: already there, nothing moves.
assert repo.tag('v1.1.0') == tip
assert update()['status'] == 'success'
assert (repo.head(), repo.branch()) == (tip, 'main')
# The next one is newer: now it moves to the release.
repo.publish('v = 4\n')
r3 = repo.tag('v1.2.0')
assert update()['status'] == 'success'
assert (repo.head(), repo.branch()) == (r3, '')
def test_beta_from_a_release_keeps_plugin_edits(self, tmp_path, update_code):
repo = Repo(tmp_path)
(repo.seed / 'plugin-repos').mkdir()
(repo.seed / 'plugin-repos' / 'p.py').write_text('x = 1\n')
git(repo.seed, 'add', '.')
git(repo.seed, 'commit', '-qm', 'plugin')
git(repo.seed, 'push', '-q', 'origin', 'main')
repo.tag('v1.0.0')
tip = repo.publish('v = 2\n')
git(repo.device, 'fetch', '-q', '--tags')
git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0')
(repo.device / 'plugin-repos' / 'p.py').write_text('x = 2 # store update\n')
update, _ = update_code(repo.device, dict(BETA))
result = update(stash_local_changes=False)
assert result['status'] == 'success', result['message']
assert (repo.head(), repo.branch()) == (tip, 'main')
assert 'store update' in (repo.device / 'plugin-repos' / 'p.py').read_text()
def test_a_failed_fetch_reports_and_changes_nothing(self, tmp_path, update_code):
repo = Repo(tmp_path)
head = repo.head()
git(repo.device, 'remote', 'set-url', 'origin', str(tmp_path / 'gone.git'))
update, _ = update_code(repo.device, dict(STABLE))
result = update()
assert result['status'] == 'error' and result['message'].startswith('Update failed')
assert repo.head() == head
# -- the weekly updater and its rollback --------------------------------------------
def _updater(repo, config, pickup=True):
"""An AutoUpdater on the clone whose core update is the real Update Code path."""
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
cm = FakeConfigManager(config)
state = {}
def run(args, **kwargs):
if args[0] == 'sudo':
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
return subprocess.run(args, **kwargs)
def sleep(seconds):
updater = state['updater']
if pickup and updater.request_file.exists():
updater.request_file.unlink()
pending = au._read_json(updater.pending_file)
pending['status'] = 'verifying'
au._write_json(updater.pending_file, pending)
updater = au.AutoUpdater(
config_manager=cm, core_update=system.perform_core_update,
project_root=repo.device, clock=lambda: 0, restart=lambda unit: None, run=run,
service_active=lambda unit: True, helper_ready=lambda: True,
disk_free=lambda path: 10 ** 12, sleep=sleep)
state['updater'] = updater
return updater, cm
@needs_git
class TestWeeklyUpdateAndRollback:
@pytest.fixture(autouse=True)
def wire(self, monkeypatch):
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
self.monkeypatch = monkeypatch
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
monkeypatch.setattr(system, '_pip_install_requirements',
lambda *a, **k: pytest.fail('no requirements changed'))
def _point(self, repo, cm):
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
self.monkeypatch.setattr(system, 'PROJECT_ROOT', repo.device)
self.monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False)
def test_preflight_targets_the_release_and_records_the_branch(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
release = repo.tag('v1.0.0')
updater, cm = _updater(repo, dict(STABLE))
outcome, _, info = updater.preflight({})
assert outcome == 'ready'
assert info['upstream_head'] == release and info['old_ref'] == 'main'
assert info['release'] == 'v1.0.0'
def test_preflight_on_the_newest_release_is_up_to_date_and_migrates(self, tmp_path):
repo = Repo(tmp_path)
repo.tag('v1.0.0')
updater, cm = _updater(repo, {'auto_update': {'enabled': True}})
outcome, message, _ = updater.preflight({})
assert outcome == 'up_to_date' and 'v1.0.0' in message
assert cm.config['auto_update']['channel'] == 'stable'
def test_a_release_that_was_rolled_back_is_not_retried(self, tmp_path):
repo = Repo(tmp_path)
repo.publish('v = 2\n')
release = repo.tag('v1.0.0')
updater, _ = _updater(repo, dict(STABLE))
outcome, message, _ = updater.preflight({'rolled_back_head': release})
assert outcome == 'up_to_date' and 'v1.0.0' in message
def test_move_to_a_release_then_roll_back_returns_to_main(self, tmp_path):
repo = Repo(tmp_path)
before = repo.head()
repo.publish('v = 2\n')
release = repo.tag('v1.0.0')
updater, cm = _updater(repo, dict(STABLE))
self._point(repo, cm)
result = updater.run()
assert result['core_outcome'] == 'verifying', result['core_message']
assert 'v1.0.0' in result['core_message']
pending = au._read_json(updater.pending_file)
assert (pending['old_ref'], pending['new_head'], pending['release']) == ('main', release, 'v1.0.0')
assert (repo.head(), repo.branch()) == (release, '')
ok, detail = av.Verifier(repo.device).rollback(pending)
assert ok, detail
assert (repo.head(), repo.branch()) == (before, 'main'), \
"rollback must put HEAD back on main, not leave it detached"
assert git(repo.device, 'rev-parse', 'main') == before
def test_back_to_main_then_roll_back_returns_to_the_release(self, tmp_path):
repo = Repo(tmp_path)
release = repo.tag('v1.0.0')
tip = repo.publish('v = 2\n')
git(repo.device, 'fetch', '-q', '--tags')
git(repo.device, 'checkout', '-q', '--detach', 'v1.0.0')
updater, cm = _updater(repo, dict(BETA))
self._point(repo, cm)
result = updater.run()
assert result['core_outcome'] == 'verifying', result['core_message']
pending = au._read_json(updater.pending_file)
assert pending['old_ref'] == '' and (repo.head(), repo.branch()) == (tip, 'main')
ok, detail = av.Verifier(repo.device).rollback(pending)
assert ok, detail
assert (repo.head(), repo.branch()) == (release, '')
assert git(repo.device, 'rev-parse', 'main') == tip, \
"rolling back from main to a release must not drag main backwards"
def test_rollback_without_old_ref_behaves_as_before(self, tmp_path):
repo = Repo(tmp_path)
old = repo.head()
new = repo.publish('v = 2\n')
git(repo.device, 'pull', '-q')
ok, _ = av.Verifier(repo.device).rollback({'old_head': old, 'new_head': new})
assert ok and (repo.head(), repo.branch()) == (old, 'main')
# -- API and the General tab ---------------------------------------------------------
@pytest.fixture
def client(monkeypatch, tmp_path):
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import api_v3, system
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
cm = FakeConfigManager({'auto_update': {'enabled': False}})
monkeypatch.setattr(pkg.api_v3, 'config_manager', cm, raising=False)
monkeypatch.setattr(pkg.api_v3, 'plugin_catalog', None, raising=False)
repo = Repo(tmp_path)
monkeypatch.setattr(system, 'PROJECT_ROOT', repo.device)
c = app.test_client()
c.cm, c.repo = cm, repo
return c
@needs_git
class TestChannelApi:
def test_rejects_an_unknown_channel(self, client):
for body in ({'channel': 'nightly'}, {}, ['stable']):
r = client.post('/api/v3/system/update-channel', json=body)
assert r.status_code == 400
assert 'channel' not in client.cm.config['auto_update']
def test_switch_to_beta_and_back(self, client):
r = client.post('/api/v3/system/update-channel', json={'channel': 'beta'})
assert r.status_code == 200 and client.cm.config['auto_update']['channel'] == 'beta'
assert r.get_json()['data']['channel'] == 'beta'
client.repo.tag('v1.0.0')
client.repo.publish('v = 2\n')
git(client.repo.device, 'pull', '-q')
client.repo.fetch()
r = client.post('/api/v3/system/update-channel', json={'channel': 'stable'}).get_json()
assert client.cm.config['auto_update']['channel'] == 'stable'
assert r['data']['waiting'] is True
assert 'newer than the newest release' in r['message'], \
"switching to stable ahead of a release must say it keeps following main"
def test_get_reports_the_plan(self, client):
client.repo.publish('v = 2\n')
client.repo.tag('v1.0.0')
data = client.get('/api/v3/system/update-channel?fetch=1').get_json()['data']
assert data['newest_release'] == 'v1.0.0' and data['action'] == uc.ACTION_CHECKOUT_TAG
assert data['configured'] is None and data['channel'] == 'stable'
def test_check_update_compares_release_tags_on_stable(self, client, monkeypatch):
from web_interface.blueprints import api_v3 as pkg
monkeypatch.setitem(pkg._update_check_cache, 'result', None)
client.cm.config = dict(STABLE)
client.repo.tag('v1.0.0')
client.repo.publish('v = 2 (unreleased)\n')
client.repo.fetch()
git(client.repo.device, 'checkout', '-q', '--detach', 'v1.0.0')
data = client.get('/api/v3/system/check-update').get_json()
assert data['update_available'] is False, "main being ahead is not an update on stable"
assert data['channel'] == 'stable' and data['current_release'] == 'v1.0.0'
client.repo.publish('v = 3\n')
client.repo.tag('v1.1.0')
monkeypatch.setitem(pkg._update_check_cache, 'result', None)
data = client.get('/api/v3/system/check-update').get_json()
assert data['update_available'] is True and data['target_version'] == 'v1.1.0'
assert data['commits_behind'] == 2
def _check_update(self, monkeypatch):
from web_interface.blueprints import api_v3 as pkg
monkeypatch.setitem(pkg._update_check_cache, 'result', None)
return self.client.get('/api/v3/system/check-update').get_json()
def _update_code(self, monkeypatch):
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
monkeypatch.setattr(system, '_pip_install_requirements',
lambda *a, **k: pytest.fail('no requirements changed'))
return system.perform_core_update()
@pytest.mark.parametrize('config', [STABLE, {'auto_update': {'enabled': True}}],
ids=['stable', 'legacy'])
def test_check_update_detached_ahead_of_the_release_is_not_an_update(
self, client, monkeypatch, config):
# The ledpi rig: a detached HEAD that pulled main after the newest
# release, with main moved on since. Update Code leaves it where it
# is, so check-update must not offer "12 commits" and an Update Now
# that then answers "already up to date".
self.client = client
client.cm.config = {k: dict(v) for k, v in config.items()}
repo = client.repo
repo.tag('v1.0.0')
ahead = repo.publish('v = 2 (unreleased)\n')
repo.publish('v = 3 (unreleased)\n')
repo.fetch()
git(repo.device, 'checkout', '-q', '--detach', ahead)
data = self._check_update(monkeypatch)
assert data['update_available'] is False, \
"the channel's action is none: nothing for Update Code to install"
assert data['commits_behind'] == 0 and data['waiting'] is True
general_tab = uc.resolve(repo.device, client.cm.load_config()).message
assert data['channel_message'] == general_tab
assert 'moves to the first release that includes it' in data['channel_message']
result = self._update_code(monkeypatch)
assert result['status'] == 'success' and 'already up to date' in result['message']
assert (repo.head(), repo.branch()) == (ahead, ''), "Update Code agrees: nothing moved"
info = client.get('/api/v3/system/git-info').get_json()
assert info['detached'] is True and info['current_release'] is None
assert info['channel_message'] == general_tab
@pytest.mark.parametrize('config', [BETA, STABLE], ids=['beta', 'stable-waiting'])
def test_check_update_on_a_branch_behind_main_is_an_update(self, client, monkeypatch, config):
# beta, and stable waiting on a branch for a release: both pull main.
self.client = client
client.cm.config = {k: dict(v) for k, v in config.items()}
repo = client.repo
repo.tag('v1.0.0')
repo.publish('v = 2 (unreleased)\n')
git(repo.device, 'pull', '-q')
tip = repo.publish('v = 3 (unreleased)\n')
data = self._check_update(monkeypatch)
assert data['update_available'] is True and data['commits_behind'] == 1
assert data['remote_sha'] == tip and 'target_version' not in data
result = self._update_code(monkeypatch)
assert result['status'] == 'success', result['message']
assert (repo.head(), repo.branch()) == (tip, 'main'), "Update Code agrees: it pulled main"
def test_git_info_names_the_release_when_on_one(self, client):
client.cm.config = dict(STABLE)
client.repo.tag('v1.0.0')
client.repo.fetch()
git(client.repo.device, 'checkout', '-q', '--detach', 'v1.0.0')
info = client.get('/api/v3/system/git-info').get_json()
assert info['detached'] is True and info['current_release'] == 'v1.0.0'
def test_general_form_saves_the_channel(self, client, monkeypatch):
r = client.post('/api/v3/config/main', json={'auto_update_channel': 'beta'})
assert r.status_code == 200, r.get_json()
assert client.cm.config['auto_update']['channel'] == 'beta'
r = client.post('/api/v3/config/main', json={'auto_update_channel': 'weekly'})
assert r.status_code == 400
assert client.cm.config['auto_update']['channel'] == 'beta'
+16
View File
@@ -190,6 +190,22 @@ class TestTurnedOn:
assert r.status_code == 401
assert 'tokens' not in secrets_on_disk(config_manager)['web_auth']
def test_the_update_channel_needs_login(self, config_manager, api_v3_module):
# It decides what code the next update installs: no route of its own
# opts out of the login, so the default rule covers it.
app = build(config_manager, api_v3_module)
signed_in = enable(app)
stranger = lan_client(app)
assert stranger.get('/api/v3/system/update-channel').status_code == 401
r = stranger.post('/api/v3/system/update-channel', json={'channel': 'beta'})
assert r.status_code == 401
# (The config template's migration writes the default, stable.)
assert config_manager.load_config()['auto_update'].get('channel') != 'beta'
r = signed_in.post('/api/v3/system/update-channel', json={'channel': 'beta'})
assert r.status_code == 200, r.get_json()
assert config_manager.load_config()['auto_update']['channel'] == 'beta'
def test_unknown_paths_do_not_leak_a_404_first(self, config_manager, api_v3_module):
app = build(config_manager, api_v3_module)
enable(app)
+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