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