Add validated OTA candidate publishing and fix 1.1.2 dependency failure

This commit is contained in:
2026-09-27 19:00:20 +08:00
parent 98eadc5c8b
commit f80c4d9938
26 changed files with 571 additions and 25 deletions
@@ -241,6 +241,8 @@ def release_project(tmp_path, monkeypatch):
source.mkdir()
(source / 'VERSION').write_text('1.0.6\n', encoding='utf-8')
(source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8')
(source / 'requirements.txt').write_text('example==1.0.0\n', encoding='utf-8')
(source / 'requirements-dev.txt').write_text('-r requirements.txt\n', encoding='utf-8')
deps = tmp_path / '发布更新相关/其他依赖'
binary = deps / 'frp/v/frpc'
binary.parent.mkdir(parents=True)
@@ -249,7 +251,9 @@ def release_project(tmp_path, monkeypatch):
(binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8')
wheels = deps / 'aarch64-py311'
wheels.mkdir()
(wheels/'SHA256SUMS').write_text('fixture', encoding='utf-8')
wheel = wheels / 'example-1.0.0-py3-none-any.whl'
wheel.write_bytes(b'fixture wheel')
(wheels/'SHA256SUMS').write_text(f'{hashlib.sha256(wheel.read_bytes()).hexdigest()} {wheel.name}\n', encoding='ascii')
policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': {
'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]}
(source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8')
@@ -258,15 +262,102 @@ def release_project(tmp_path, monkeypatch):
return exporter, source
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
def validated_ota_report(tmp_path, candidate, source_version, target_version):
artifact = candidate / f'matrix-screen-controller-{target_version}.ota'
report = tmp_path / f'validated-{target_version}.json'
report.write_text(json.dumps({
'schema_version': 1, 'artifact_type': 'ota',
'package_sha256': hashlib.sha256(artifact.read_bytes()).hexdigest(),
'software_version': target_version, 'source_version': source_version,
'restored_version': source_version, 'status': 'success',
'validated_at': '2026-09-27T18:00:00+08:00',
'offline_install_passed': True, 'ota_health_passed': True,
'runtime_lifecycle_passed': True, 'user_data_preserved': True,
'frp_state_preserved': True, 'bluetooth_state_preserved': True,
'transaction_cleanup_passed': True, 'baseline_restored': True,
}), encoding='utf-8')
return report
def publish_fixture_ota(tmp_path, monkeypatch, exporter, current, target, notes):
import sys
candidate = tmp_path / f'candidate-{target}'
args = ['export', 'ota', '--notes', notes, '--candidate-output', str(candidate)]
if target.endswith('.0'):
args.extend(['--version', target])
monkeypatch.setattr(sys, 'argv', args)
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, current, target)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', notes,
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
assert exporter.main() == 0
return candidate
@pytest.mark.parametrize('failure', ['missing', 'version', 'digest'])
def test_ota_candidate_rejects_incomplete_offline_wheels(tmp_path, monkeypatch, failure):
import sys
exporter, source = release_project(tmp_path, monkeypatch)
wheels = tmp_path / '发布更新相关/其他依赖/aarch64-py311'
if failure == 'missing':
(source / 'requirements-dev.txt').write_text('-r requirements.txt\nparamiko==4.0.0\n', encoding='utf-8')
expected = 'offline wheel missing'
elif failure == 'version':
(source / 'requirements.txt').write_text('example==2.0.0\n', encoding='utf-8')
expected = 'offline wheel version missing'
else:
(wheels / 'example-1.0.0-py3-none-any.whl').write_bytes(b'changed')
expected = 'digest differs'
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
with pytest.raises(ValueError, match=expected):
exporter.main()
assert not candidate.exists()
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
@pytest.mark.parametrize('failure', ['report', 'source', 'artifact'])
def test_ota_candidate_promotion_rejects_changed_evidence(tmp_path, monkeypatch, failure):
import sys
from scripts.promote_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
if failure == 'report':
document = json.loads(report.read_text(encoding='utf-8'))
document['package_sha256'] = '0' * 64
report.write_text(json.dumps(document), encoding='utf-8')
expected = 'matching successful OTA'
elif failure == 'source':
(source / 'changed.py').write_text('CHANGED = True\n', encoding='utf-8')
expected = 'software file list differs'
else:
artifact = candidate / 'matrix-screen-controller-1.1.0.ota'
artifact.write_bytes(artifact.read_bytes() + b'tampered')
expected = 'manifest differs'
with pytest.raises(ValueError, match=expected):
promote(source, candidate, report, 'test')
assert (source / 'VERSION').read_text(encoding='utf-8').strip() == '1.0.6'
assert not (tmp_path / '发布更新相关/OTA数据包/1.1.0').exists()
assert json.loads((tmp_path / '发布记录.json').read_text(encoding='utf-8'))['releases'] == []
assert not (tmp_path / '.release-export.lock').exists()
def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch):
import tarfile
exporter, source = release_project(tmp_path, monkeypatch)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
assert exporter.main() == 0
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'test')
assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch'])
assert exporter.main() == 0
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.1.0', '1.1.1', 'patch')
artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota'))
with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t:
assert not any('system-dependencies' in item.name for item in t)
@@ -282,7 +373,14 @@ def test_failed_export_does_not_consume_version(tmp_path, monkeypatch):
exporter, source = release_project(tmp_path, monkeypatch)
original = (tmp_path/'发布记录.json').read_bytes()
original_version = (source/'VERSION').read_bytes()
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test'])
candidate = tmp_path / 'candidate-1.1.0'
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0',
'--candidate-output', str(candidate), '--notes', 'test'])
assert exporter.main() == 0
report = validated_ota_report(tmp_path, candidate, '1.0.6', '1.1.0')
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'test',
'--validated-candidate', str(candidate),
'--validation-report', str(report)])
def fail(*args):
raise OSError('injected history write failure')
monkeypatch.setattr(exporter, '_atomic_json', fail)
@@ -319,11 +417,9 @@ def test_provisioned_account_does_not_need_sudo_group(host, monkeypatch):
@pytest.mark.parametrize('fail_commit', [False, True])
def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit):
import sys
from scripts.repair_ota_release import promote
exporter, source = release_project(tmp_path, monkeypatch)
monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original'])
exporter.main()
publish_fixture_ota(tmp_path, monkeypatch, exporter, '1.0.6', '1.1.0', 'original')
artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota'
old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes()
(source/'fix.py').write_text('FIXED = True\n',encoding='utf-8')