Preserve standard OS updates and rollback in version picker

This commit is contained in:
AngusBell97
2026-09-11 16:11:29 +01:00
committed by firestar5683
parent f88ef758ca
commit 4d585dbcbc
6 changed files with 162 additions and 3 deletions
@@ -309,8 +309,11 @@ export const SystemTools = {
try {
const selected = this.versionCommits.find(item => item.sha === commit)
const version = commit === "latest" ? "Latest" : `${versionTitle(selected, branch === "StarPilot")}\nCommit: ${commit}`
const policy = commit === "latest" ? "Automatic updates will remain off after installation. You can enable them in settings." : "Automatic updates will be paused for this earlier version."
if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\nThis replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device.\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return
const policy = commit === "latest" ? "This uses the normal branch updater, including any required OS update during startup. Your automatic-update setting is unchanged." : "Automatic updates will be paused for this earlier version."
const recovery = commit === "latest"
? "This replaces the current software. Settings and statistics are kept. Local code changes may be overwritten; standard Rollback saves the previous committed version."
: "This replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device."
if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\n${recovery}\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return
// Refresh driving/updater state after the user has reviewed the target.
await this.loadFastStatus({throwOnError: true})
if (this.branchSwitchBlocked || this.targetBranch !== branch || generation !== this.versionGeneration) {
@@ -139,7 +139,7 @@ test('return to Latest uses pinned branch and confirms without changing automati
instance.fastStatus = {versionPin: {branch: 'feature/test', commit: SHA, installedAt: '2026-09-10'}}
await instance.returnToLatest()
assert.deepEqual(calls, [{branch: 'feature/test', commit: 'latest'}])
assert.ok(confirmations[0].message.includes('remain off'))
assert.ok(confirmations[0].message.includes('automatic-update setting is unchanged'))
})
test('API preserves branch encoding, pagination head, abort signal and confirmed install body', async () => {
const calls = []
@@ -265,3 +265,18 @@ test('changing branch during a multi-page release search cancels remaining pages
instance.selectTargetBranch('Dom');finish();await pending
assert.equal(requests,2);assert.equal(instance.versionCommits.length,0);assert.equal(instance.versionLoading,false)
})
test('Latest explains normal OS handling and local-edit behavior; historical confirmation keeps recovery details', async () => {
const {instance, confirmations} = fixture()
instance.selectTargetBranch('Dom')
await instance.installSelectedVersion()
assert.match(confirmations[0].message, /normal branch updater/)
assert.match(confirmations[0].message, /required OS update/)
assert.match(confirmations[0].message, /Local code changes may be overwritten/)
assert.doesNotMatch(confirmations[0].message, /code changes are backed up/)
await instance.onVersionModeSelect({target:{value:'earlier'}})
instance.selectedCommit=SHA
await instance.installSelectedVersion()
assert.match(confirmations[1].message, /updates will be paused/)
assert.match(confirmations[1].message, /code changes are backed up/)
})
@@ -0,0 +1,121 @@
"""Real local Git update/rollback regressions. No device, OS flashing or reboot."""
import ast
from datetime import datetime, timezone
import json
from pathlib import Path
import subprocess
import threading
import time
from types import SimpleNamespace
from unittest.mock import Mock
from flask import Flask, jsonify, request
import pytest
from test_version_routes import server
from test_version_install_rehearsal import rehearsal, git, load_module, write
MODULE_DIR = Path(__file__).resolve().parents[1]
def unpack(state):
return (getattr(state, name) for name in ('root','repo','origin','old','latest','data','installer','history'))
def test_latest_uses_existing_branch_worker_without_historical_restrictions(server):
_, ns = server
ns['_branch_switch_worker'] = Mock()
ns['version_history'].resolve_version.side_effect = AssertionError('Latest must not require history API')
ns['_version_install_worker']('StarPilot', 'latest')
ns['_branch_switch_worker'].assert_called_once_with('StarPilot')
ns['version_install'].install.assert_not_called()
ns['_set_fast_update_error_state'].assert_not_called()
def legacy_namespace(repo, data, installer):
def run(repo, args, **_):
return subprocess.run(['git','-C',str(repo),*args], capture_output=True, text=True)
def stdout(repo, args, **_):
return git(repo, *args)
def config(repo, key):
return run(repo, ['config','--get',key]).stdout.strip()
def fetch(repo, args, **_):
result=run(repo,args)
return result.returncode, result.stderr
params=SimpleNamespace(get_bool=lambda key:(data/'params/d'/key).read_bytes()==b'1',
put_bool=lambda key,value:write(data,'params/d/'+key,'1' if value else '0'))
app=Flask(__name__)
ns=dict(app=app,request=request,jsonify=jsonify,params=params,time=time,datetime=datetime,timezone=timezone,
threading=SimpleNamespace(Thread=Mock()), _fast_update_lock=threading.Lock(), _fast_update_state={'running':False},
_FAST_UPDATE_TOTAL_STEPS=5, _FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S=20, _FAST_ROLLBACK_FETCH_TIMEOUT_S=20,
_ROLLBACK_REF='refs/starpilot/rollback', _ROLLBACK_BRANCH_CONFIG_KEY='starpilot.rollbackbranch',
_ROLLBACK_RECORDED_AT_CONFIG_KEY='starpilot.rollbackrecordedat',
_get_openpilot_root=lambda:repo, _run_git=run, _git_stdout=stdout,
_git_config_get=config, _git_config_set=lambda repo,key,value:git(repo,'config',key,value),
_git_config_unset=lambda repo,key:run(repo,['config','--unset',key]),
_git_update_ref=lambda repo,ref,sha:git(repo,'update-ref',ref,sha),
_git_delete_ref=lambda repo,ref:git(repo,'update-ref','-d',ref),
_git_has_commit=lambda repo,sha:run(repo,['cat-file','-e',sha+'^{commit}']).returncode==0,
_build_shallow_fetch_args=lambda branch:['fetch','--depth=1','origin',branch],
_build_shallow_fetch_commit_args=lambda sha:['fetch','--depth=1','origin',sha],
_run_git_with_progress=fetch, _clear_generated_build_state=Mock(), _run_submodule_update_if_needed=Mock(),
_set_fast_update_state=Mock(), _set_fast_update_progress=Mock(), _set_fast_update_error_state=Mock(),
_finish_update_and_reboot=Mock(), version_install=SimpleNamespace(clear_pin=lambda:installer.clear_pin(data)))
names={'_is_valid_git_branch_name','_save_rollback_target','_load_rollback_target','_clear_rollback_target',
'_branch_switch_worker','_rollback_worker','run_update_rollback'}
for node in ast.walk(ast.parse((MODULE_DIR/'the_galaxy.py').read_text())):
if isinstance(node,ast.FunctionDef) and node.name in names:
exec(compile(ast.Module(body=[node],type_ignores=[]),'the_galaxy.py','exec'),ns)
return ns, app.test_client()
def test_latest_changed_os_still_uses_standard_update_and_rolls_back(rehearsal):
root, repo, origin, old, latest, data, installer, history = unpack(rehearsal)
seed=root/'seed'
git(seed,'checkout','-b','StarPilot')
write(seed,'launch_env.sh','export AGNOS_VERSION="99.0"\n')
write(seed,'system/hardware/tici/agnos.json','[{"name":"system","hash":"new-os"}]\n')
git(seed,'add','.');git(seed,'commit','-m','Different OS requirement')
target=git(seed,'rev-parse','HEAD')
git(seed,'push',str(origin),'StarPilot')
write(data,'starpilot/version_selection.json',json.dumps({'branch':'Dom','commit':latest}))
ns, client=legacy_namespace(repo,data,installer)
ns['_branch_switch_worker']('StarPilot')
ns['_set_fast_update_error_state'].assert_not_called()
assert git(repo,'rev-parse','HEAD')==target
assert '99.0' in (repo/'launch_env.sh').read_text()
assert not (data/'starpilot/version_selection.json').exists()
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'1'
assert ns['_load_rollback_target'](repo)['rollbackCommit']==latest
ns['_finish_update_and_reboot'].assert_called_once()
assert client.post('/api/update/rollback').status_code==202
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
ns['_rollback_worker']()
ns['_set_fast_update_error_state'].assert_not_called()
assert git(repo,'rev-parse','HEAD')==latest
assert git(repo,'branch','--show-current')=='Dom'
assert ns['_load_rollback_target'](repo)['rollbackAvailable'] is False
assert (data/'params/d/ExampleSetting').read_text()=='preserve me'
def test_historical_install_records_previous_commit_for_existing_rollback(rehearsal):
root, repo, origin, old, latest, data, installer, history = unpack(rehearsal)
ns, client=legacy_namespace(repo,data,installer)
git(repo,'fetch','--depth=1','origin',old)
installer.install(repo,{'branch':'Dom','commit':old,'pinned':True},data_root=data,
check_parked=lambda:installer.require_parked(data),progress=lambda *args:None)
ns['_save_rollback_target'](repo,'Dom',latest)
assert ns['_load_rollback_target'](repo)['rollbackAvailable'] is True
assert client.post('/api/update/rollback').status_code==202
ns['_rollback_worker']()
ns['_set_fast_update_error_state'].assert_not_called()
assert git(repo,'rev-parse','HEAD')==latest
assert not (data/'starpilot/version_selection.json').exists()
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
def test_rollback_remains_blocked_while_driving_or_updating(rehearsal):
root, repo, origin, old, latest, data, installer, history = unpack(rehearsal)
git(repo,'fetch','--depth=1','origin',old)
ns,client=legacy_namespace(repo,data,installer)
ns['_save_rollback_target'](repo,'Dom',old)
write(data,'params/d/IsOnroad','1')
assert client.post('/api/update/rollback').status_code==409
write(data,'params/d/IsOnroad','0')
ns['_fast_update_state']['running']=True
assert client.post('/api/update/rollback').status_code==409
ns['threading'].Thread.assert_not_called()
assert git(repo,'rev-parse','HEAD')==latest
@@ -123,3 +123,10 @@ def test_history_has_no_artificial_page_depth_cutoff(server):
reply=client.get('/api/update/versions?branch=Dom&page=1201&head='+'b'*40)
assert reply.status_code==200
ns['version_history'].list_versions.assert_called_once_with('/repo','Dom',page=1201,head='b'*40)
def test_historical_worker_records_pre_install_branch_and_commit_for_rollback(server):
_, ns = server
ns['_git_stdout'].side_effect=['a'*40, 'feature/test', 'b'*40]
ns['_version_install_worker']('Dom','a'*40)
ns['_save_rollback_target'].assert_called_once_with('/repo','feature/test','b'*40)
ns['_set_fast_update_error_state'].assert_not_called()
@@ -3021,6 +3021,12 @@ def _version_install_worker(branch, selection):
installed = None
try:
version_install.require_parked()
if selection == "latest":
# Preserve the existing branch updater, including boot-time AGNOS handling.
# Historical compatibility and GitHub-history restrictions apply only to
# exact revisions, never to an ordinary update to the latest branch head.
_branch_switch_worker(branch)
return
_set_fast_update_progress(1, "Resolving selected version", 10.0, branch)
target = version_history.resolve_version(repo_path, branch, selection)
version_install.require_parked()
@@ -3167,6 +3173,7 @@ def _branch_switch_worker(target_branch):
_set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.")
_run_submodule_update_if_needed(repo_path, step=4)
version_install.clear_pin()
_finish_update_and_reboot(
f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection."
)
@@ -3236,6 +3243,7 @@ def _rollback_worker():
_set_fast_update_progress(3, "Applying rollback target", 100.0, f"Now on {target_branch} @ {short_commit}.")
_run_submodule_update_if_needed(repo_path, step=4)
version_install.clear_pin()
try:
_clear_rollback_target(repo_path)
except Exception as exception:
@@ -83,6 +83,11 @@ def validate_target(target):
raise InstallError('An exact validated commit and branch are required')
def clear_pin(data_root=Path('/data')):
"""Normal branch updates and rollback supersede a historical selection."""
(Path(data_root) / 'starpilot/version_selection.json').unlink(missing_ok=True)
def read_pin(repo, data_root=Path('/data')):
try:
value = json.loads((Path(data_root) / 'starpilot/version_selection.json').read_text())