diff --git a/prebuilt.json b/prebuilt.json index b0a7a3180f..c4eb79d5d6 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "349a001593163710a6f498e0569187d3454d140f07cbcd54475c97eaa354841d", "schema_version": 1} +{"protected_inventory_sha256": "1c1d910d2f315ee514bcfe3255fabf251cb24c0abbc0af32d5c58ce412d4a4b9", "schema_version": 1} diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index f8bca1ca27..5be7665014 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -561,7 +561,7 @@ def source_provenance(): return subprocess.check_output(["git", "--no-optional-locks", "-c", "gc.auto=0", "-c", "maintenance.auto=false", *args], cwd=ROOT).decode().strip() manifest = ROOT / "upstream-sync.json" return {"revision": git("rev-parse", "HEAD"), "status": git("status", "--porcelain"), - "inputs": source_input_snapshot(), "build_identity": lifecycle_shards.build_identity(ROOT), + "inputs": source_input_snapshot(), "manifest_sha256": sha256(manifest), "dependencies": json.loads(manifest.read_text())["dependencies"], "runner_sha256": sha256(ROOT / "tools/test_runner.py"), "suite_runner_sha256": sha256(__file__), "pytest_runner_sha256": sha256(ROOT / "tools/ci/vehicle_pytest.py"), @@ -649,16 +649,19 @@ def run(suite, output, collect_only=False, shard_index=None, shard_count=4): try: plan["targets"] = targets_for(suite) plan["source"] = source_provenance() + if suite == "aol-lifecycle": + plan["source"]["build_identity"] = lifecycle_shards.build_identity(ROOT) plan.update(vehicle_inventory()) plan["native"] = native_provenance() if suite != "interfaces" and not collect_only: plan["native"]["safety"] = build_safety_library(suite == "safety-release", output) if suite == "aol-lifecycle" and not collect_only: plan["native"]["aol_protocol"] = build_aol_protocol(output) - build_input_digest = hashlib.sha256(json.dumps(plan["source"]["build_identity"]["inputs"], sort_keys=True).encode()).hexdigest() - for name in ('safety', 'aol_protocol'): - if name in plan["native"]: - plan["native"][name]["build_inputs_sha256"] = build_input_digest + if suite == "aol-lifecycle": + build_input_digest = hashlib.sha256(json.dumps(plan["source"]["build_identity"]["inputs"], sort_keys=True).encode()).hexdigest() + for name in ('safety', 'aol_protocol'): + if name in plan["native"]: + plan["native"][name]["build_inputs_sha256"] = build_input_digest phase = "collection" unittest_targets, pytest_targets = vehicle_pytest.split_targets(ROOT, plan["targets"]) plan["runners"] = {"unittest": unittest_targets, "pytest": pytest_targets} @@ -698,6 +701,10 @@ def run(suite, output, collect_only=False, shard_index=None, shard_count=4): errors.extend(pytest_errors) phase = "source verification" plan["source_after"] = source_provenance() + if suite == "aol-lifecycle": + plan["source_after"]["build_identity"] = lifecycle_shards.build_identity(ROOT) + if plan["source"]["build_identity"] != plan["source_after"]["build_identity"]: + gate_errors.append("Lifecycle source/build identity changed during the suite") if plan["source"].get("inputs") != plan["source_after"].get("inputs"): gate_errors.append("Vehicle/control source inputs changed during the suite; rerun against stable source") except Exception: diff --git a/tools/ci/tests/test_lifecycle_provenance_scope.py b/tools/ci/tests/test_lifecycle_provenance_scope.py new file mode 100644 index 0000000000..5ae3a8fd74 --- /dev/null +++ b/tools/ci/tests/test_lifecycle_provenance_scope.py @@ -0,0 +1,67 @@ +"""Keep lifecycle build identity mandatory without imposing its outputs on other gates.""" +from contextlib import ExitStack, redirect_stdout +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from tools.ci import run_vehicle_tests as vehicle +from tools import test_runner + + +class TestLifecycleProvenanceScope(unittest.TestCase): + def test_general_source_provenance_does_not_require_lifecycle_outputs(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'upstream-sync.json').write_text('{"dependencies": {}}') + for name in ('tools/test_runner.py', 'tools/ci/vehicle_pytest.py', 'tools/ci/lifecycle_shards.py'): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('# fixture\n') + with patch.object(vehicle, 'ROOT', root), patch.object(vehicle, 'source_input_snapshot', return_value={'original': True}), \ + patch.object(vehicle.subprocess, 'check_output', return_value=b'fixture'), \ + patch.object(vehicle.lifecycle_shards, 'build_identity', side_effect=AssertionError('unrelated artifact inventory')) as identity: + source = vehicle.source_provenance() + self.assertEqual(source['inputs'], {'original': True}) + self.assertNotIn('build_identity', source) + identity.assert_not_called() + + def test_missing_lifecycle_build_identity_still_denies_collection(self): + with tempfile.TemporaryDirectory() as directory, \ + patch.object(vehicle, 'targets_for', return_value=['fixture.py']), \ + patch.object(vehicle, 'source_provenance', return_value={}), \ + patch.object(vehicle.lifecycle_shards, 'build_identity', side_effect=ValueError('missing declared artifact')), \ + patch.object(vehicle.test_runner, 'collect') as collect, redirect_stdout(io.StringIO()): + self.assertEqual(vehicle.run('aol-lifecycle', directory, collect_only=True), 1) + result = json.loads((Path(directory) / 'results.json').read_text()) + self.assertEqual(result['error_phase'], 'initialization') + self.assertIn('missing declared artifact', result['collection_errors'][0]) + collect.assert_not_called() + + def test_lifecycle_records_native_input_digest_and_rejects_full_identity_change(self): + identity = {'inputs': {'source': 'original'}, 'declared_outputs': {'exact.so': {'sha256': 'a' * 64, 'mode': 420}}} + changed = {'inputs': {'source': 'original'}, 'declared_outputs': {'exact.so': {'sha256': 'b' * 64, 'mode': 420}}} + for after, expected in ((identity, 0), (changed, 1)): + with self.subTest(changed=after != identity), tempfile.TemporaryDirectory() as directory, ExitStack() as stack: + test = unittest.FunctionTestCase(lambda: None) + stack.enter_context(patch.object(test, 'id', return_value='fixture.Case.test_pass')) + for name, value in (('targets_for', ['fixture.py']), ('source_provenance', {'inputs': 'stable'}), + ('vehicle_inventory', {}), ('native_provenance', {}), ('build_safety_library', {}), + ('build_aol_protocol', {})): + stack.enter_context(patch.object(vehicle, name, side_effect=(lambda *args, value=value: value.copy() if isinstance(value, dict) else value))) + stack.enter_context(patch.object(vehicle.lifecycle_shards, 'build_identity', side_effect=[identity, after])) + stack.enter_context(patch.object(vehicle.vehicle_pytest, 'split_targets', return_value=(['fixture.py'], []))) + stack.enter_context(patch.object(vehicle.vehicle_pytest, 'collect', return_value=([], []))) + stack.enter_context(patch.object(vehicle.vehicle_pytest, 'run', return_value=([], []))) + stack.enter_context(patch.object(test_runner, 'collect', return_value=([test], []))) + stack.enter_context(patch.object(test_runner, 'run_batch', return_value=[test_runner.make_record(test.id())])) + stack.enter_context(redirect_stdout(io.StringIO())) + self.assertEqual(vehicle.run('aol-lifecycle', directory), expected) + coverage = json.loads((Path(directory) / 'coverage.json').read_text()) + self.assertEqual(coverage['source']['build_identity'], identity) + self.assertEqual(coverage['source_after']['build_identity'], after) + self.assertEqual(coverage['native']['safety']['build_inputs_sha256'], coverage['native']['aol_protocol']['build_inputs_sha256']) + self.assertEqual(len(coverage['native']['safety']['build_inputs_sha256']), 64) + self.assertEqual(bool(coverage['gate_errors']), expected == 1)