Scope complete build identity to lifecycle gates

This commit is contained in:
firestar5683
2026-10-23 00:26:57 -05:00
parent 4bee14a4d7
commit 1f8205231f
3 changed files with 80 additions and 6 deletions
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "349a001593163710a6f498e0569187d3454d140f07cbcd54475c97eaa354841d", "schema_version": 1}
{"protected_inventory_sha256": "1c1d910d2f315ee514bcfe3255fabf251cb24c0abbc0af32d5c58ce412d4a4b9", "schema_version": 1}
+12 -5
View File
@@ -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:
@@ -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)