Skip to content

Commit a305e5d

Browse files
Merge issue-143-remediation: Fix scheduled aggregate runner drift validation (#143)
2 parents 3c54be5 + fb9af9a commit a305e5d

4 files changed

Lines changed: 271 additions & 14 deletions

File tree

.github/workflows/scheduled-full-regression.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ jobs:
517517
--report .tmp/verification-report.json \
518518
--state .tmp/repository-state.json \
519519
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
520+
--allow-hosted-runner-drift \
520521
--output .tmp/scheduled-state.json
521522
- name: Preserve the complete aggregate scheduled evidence
522523
uses: actions/upload-artifact@v4

ci/tests/test_workflows.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,13 @@ def test_scheduled_evidence_capture_and_record_symmetrically_allow_runner_drift(
410410
assert f"--component {name}" in verification_invocations(jobs[name], "environment")[0]
411411

412412

413+
def test_scheduled_aggregate_state_explicitly_allows_hosted_runner_drift() -> None:
414+
jobs = workflow("scheduled-full-regression.yml")["jobs"]
415+
invocations = verification_invocations(jobs["full-regression"], "scheduled-state")
416+
assert len(invocations) == 1
417+
assert invocations[0].split().count("--allow-hosted-runner-drift") == 1
418+
419+
413420
def test_scheduled_playwright_executes_and_records_the_planner_core_command() -> None:
414421
playwright = workflow("scheduled-full-regression.yml")["jobs"]["playwright"]
415422
planner_command = json.loads((ROOT / "ci" / "ownership.json").read_text(encoding="utf-8"))[

ci/verification.py

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,19 +1027,98 @@ def repository_state(
10271027
) -> dict[str, Any]:
10281028
graph = dict(graph) if graph is not None else load_graph()
10291029
_manifest, source_tree = git_manifest(repository, revision)
1030-
environment = environment_fingerprint()
1030+
environment = validate_environment_fingerprint(environment_fingerprint())
1031+
component_environment = {
1032+
component: validate_environment_fingerprint(component_environment_fingerprint(component))
1033+
for component in PLAN_COMPONENTS
1034+
}
10311035
payload = {
1036+
"component_environment": component_environment,
10321037
"component_environment_sha256": {
1033-
component: component_environment_fingerprint(component)["sha256"]
1034-
for component in PLAN_COMPONENTS
1038+
component: fingerprint["sha256"]
1039+
for component, fingerprint in component_environment.items()
10351040
},
1041+
"environment": environment,
10361042
"environment_sha256": environment["sha256"],
10371043
"graph_sha256": graph_digest(graph),
10381044
"policy_version": graph["policy_version"],
10391045
"source_manifest_sha256": source_tree["manifest_sha256"],
10401046
"tree_oid": source_tree["tree_oid"],
10411047
}
1042-
return payload | {"verification_state_sha256": sha256_json(payload)}
1048+
return _validate_repository_state(payload | {"verification_state_sha256": sha256_json(payload)})
1049+
1050+
1051+
def _validate_repository_state(value: object) -> dict[str, Any]:
1052+
"""Validate the aggregate state retained for scheduled coverage decisions."""
1053+
expected = {
1054+
"component_environment",
1055+
"component_environment_sha256",
1056+
"environment",
1057+
"environment_sha256",
1058+
"graph_sha256",
1059+
"policy_version",
1060+
"source_manifest_sha256",
1061+
"tree_oid",
1062+
"verification_state_sha256",
1063+
}
1064+
if not isinstance(value, dict) or set(value) != expected:
1065+
raise VerificationError("repository state has an invalid shape")
1066+
try:
1067+
environment = validate_environment_fingerprint(value["environment"])
1068+
component_environment = value["component_environment"]
1069+
if not isinstance(component_environment, dict) or set(component_environment) != set(
1070+
PLAN_COMPONENTS
1071+
):
1072+
raise VerificationError("repository state component environments are incomplete")
1073+
validated_components = {
1074+
component: validate_environment_fingerprint(component_environment[component])
1075+
for component in PLAN_COMPONENTS
1076+
}
1077+
except (TypeError, ValueError) as exc:
1078+
raise VerificationError("repository state environment fingerprint is invalid") from exc
1079+
1080+
if value["environment_sha256"] != environment["sha256"]:
1081+
raise VerificationError("repository state global environment digest does not match")
1082+
component_digests = value["component_environment_sha256"]
1083+
if (
1084+
not isinstance(component_digests, dict)
1085+
or set(component_digests) != set(PLAN_COMPONENTS)
1086+
or component_digests
1087+
!= {
1088+
component: fingerprint["sha256"]
1089+
for component, fingerprint in validated_components.items()
1090+
}
1091+
):
1092+
raise VerificationError("repository state component environment digests do not match")
1093+
for field in ("graph_sha256", "source_manifest_sha256", "verification_state_sha256"):
1094+
if not isinstance(value[field], str) or not SHA256_RE.fullmatch(value[field]):
1095+
raise VerificationError("repository state digest is invalid")
1096+
if not isinstance(value["tree_oid"], str) or not SHA_RE.fullmatch(value["tree_oid"]):
1097+
raise VerificationError("repository state tree identity is invalid")
1098+
if not isinstance(value["policy_version"], int) or isinstance(value["policy_version"], bool):
1099+
raise VerificationError("repository state policy version is invalid")
1100+
1101+
identity = dict(value)
1102+
digest = identity.pop("verification_state_sha256")
1103+
if digest != sha256_json(identity):
1104+
raise VerificationError("repository state digest does not match its contents")
1105+
return value
1106+
1107+
1108+
def _scheduled_environment_matches(
1109+
actual: Mapping[str, Any],
1110+
planned: Mapping[str, Any],
1111+
*,
1112+
allow_hosted_runner_drift: bool,
1113+
) -> bool:
1114+
"""Apply exact state identity by default; opt into hosted-runner drift explicitly."""
1115+
if not allow_hosted_runner_drift:
1116+
return actual == planned
1117+
return environment_matches_plan(
1118+
actual,
1119+
planned,
1120+
allow_hosted_runner_drift=True,
1121+
)
10431122

10441123

10451124
def build_scheduled_state_envelope(
@@ -1050,22 +1129,35 @@ def build_scheduled_state_envelope(
10501129
run_id: int,
10511130
run_attempt: int,
10521131
workflow: str = ".github/workflows/scheduled-full-regression.yml",
1132+
allow_hosted_runner_drift: bool = False,
10531133
) -> dict[str, Any]:
10541134
plan = validate_plan(dict(plan))
10551135
report = validate_report(dict(report))
1136+
state = _validate_repository_state(dict(state))
10561137
if report["verdict"] != "success" or report["plan_sha256"] != sha256_json(plan):
10571138
raise VerificationError("scheduled state requires a successful plan-bound report")
1139+
planned_component_environment = {
1140+
component: item["environment"] for component, item in plan["components"].items()
1141+
}
10581142
if (
1059-
state.get("verification_state_sha256") is None
1060-
or state.get("source_manifest_sha256") != plan["source_tree"]["manifest_sha256"]
1061-
or state.get("graph_sha256") != plan["graph_sha256"]
1062-
or state.get("policy_version") != plan["policy_version"]
1063-
or state.get("environment_sha256") != plan["environment"]["sha256"]
1064-
or state.get("component_environment_sha256")
1065-
!= {
1066-
component: item["environment"]["sha256"]
1067-
for component, item in plan["components"].items()
1068-
}
1143+
state["source_manifest_sha256"] != plan["source_tree"]["manifest_sha256"]
1144+
or state["tree_oid"] != plan["source_tree"]["tree_oid"]
1145+
or state["graph_sha256"] != plan["graph_sha256"]
1146+
or state["policy_version"] != plan["policy_version"]
1147+
or not _scheduled_environment_matches(
1148+
state["environment"],
1149+
plan["environment"],
1150+
allow_hosted_runner_drift=allow_hosted_runner_drift,
1151+
)
1152+
or set(state["component_environment"]) != set(planned_component_environment)
1153+
or any(
1154+
not _scheduled_environment_matches(
1155+
state["component_environment"][component],
1156+
planned_environment,
1157+
allow_hosted_runner_drift=allow_hosted_runner_drift,
1158+
)
1159+
for component, planned_environment in planned_component_environment.items()
1160+
)
10691161
):
10701162
raise VerificationError("scheduled repository state does not match the plan")
10711163
evidence_ids = sorted(
@@ -1690,6 +1782,7 @@ def main() -> None:
16901782
scheduled_state_parser.add_argument("--state", required=True)
16911783
scheduled_state_parser.add_argument("--run-id", type=int, required=True)
16921784
scheduled_state_parser.add_argument("--run-attempt", type=int, required=True)
1785+
scheduled_state_parser.add_argument("--allow-hosted-runner-drift", action="store_true")
16931786
scheduled_state_parser.add_argument("--output", required=True)
16941787

16951788
args = parser.parse_args()
@@ -1841,6 +1934,7 @@ def main() -> None:
18411934
state=state,
18421935
run_id=args.run_id,
18431936
run_attempt=args.run_attempt,
1937+
allow_hosted_runner_drift=args.allow_hosted_runner_drift,
18441938
),
18451939
args.output,
18461940
)

tests_ci/test_verification.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88

99
from ci.content_invariants import build_invariant_artifact
1010
from ci.evidence import build_envelope
11+
from ci.ownership import sha256_json
1112
from ci.selection import ChangeRecord, classify_records
1213
from ci.verification import (
1314
VerificationError,
1415
build_plan,
16+
build_scheduled_state_envelope,
1517
create_report,
1618
materialize_reused_evidence,
1719
read_worktree_change_records,
@@ -40,6 +42,45 @@ def make_plan(tmp_path: Path, changed: dict[str, str]):
4042
)
4143

4244

45+
def scheduled_state_fixture(
46+
tmp_path: Path,
47+
monkeypatch: pytest.MonkeyPatch,
48+
*,
49+
planned_image: tuple[str, str] = ("ubuntu24", "20260801.1"),
50+
actual_image: tuple[str, str] | None = None,
51+
):
52+
monkeypatch.setenv("ImageOS", planned_image[0])
53+
monkeypatch.setenv("ImageVersion", planned_image[1])
54+
repository, plan = make_plan(tmp_path, {"api/service.py": "changed\n"})
55+
evidence = tmp_path / "evidence"
56+
evidence.mkdir()
57+
origin = {"issue": 143, "kind": "local", "producer_role": "engineer", "worktree": "143"}
58+
for component, item in plan["components"].items():
59+
if item["disposition"] != "rerun":
60+
continue
61+
records, output = component_output(evidence, plan, component)
62+
envelope = build_envelope(
63+
plan=plan,
64+
component=component,
65+
result="success",
66+
origin=origin,
67+
command=item["command"],
68+
execution_environment=item["environment"],
69+
artifacts=records,
70+
machine_output=output,
71+
completed_at=NOW,
72+
)
73+
(evidence / f"{component}-evidence.json").write_text(
74+
__import__("json").dumps(envelope, sort_keys=True), encoding="utf-8"
75+
)
76+
report = create_report(plan=plan, result_directory=evidence, phase="engineer")
77+
if actual_image is not None:
78+
monkeypatch.setenv("ImageOS", actual_image[0])
79+
monkeypatch.setenv("ImageVersion", actual_image[1])
80+
state = repository_state(repository, plan["head"])
81+
return plan, report, state
82+
83+
4384
def test_single_app_plan_reruns_affected_closure_and_preserves_baseline_without_evidence(
4485
tmp_path: Path,
4586
) -> None:
@@ -390,6 +431,120 @@ def test_repository_state_ignores_commit_metadata_but_changes_with_tree(tmp_path
390431
assert base_state["verification_state_sha256"] != head_state["verification_state_sha256"]
391432

392433

434+
def test_scheduled_state_accepts_exact_aggregate_environment_without_opt_in(
435+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
436+
) -> None:
437+
plan, report, state = scheduled_state_fixture(tmp_path, monkeypatch)
438+
439+
assert set(state["component_environment"]) == set(plan["components"])
440+
assert state["environment_sha256"] == state["environment"]["sha256"]
441+
assert state["component_environment_sha256"] == {
442+
component: fingerprint["sha256"]
443+
for component, fingerprint in state["component_environment"].items()
444+
}
445+
446+
envelope = build_scheduled_state_envelope(
447+
plan=plan,
448+
report=report,
449+
state=state,
450+
run_id=143,
451+
run_attempt=1,
452+
)
453+
454+
assert envelope["verification_state_sha256"] == state["verification_state_sha256"]
455+
456+
457+
def test_scheduled_state_requires_explicit_opt_in_for_same_family_drift(
458+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
459+
) -> None:
460+
plan, report, state = scheduled_state_fixture(
461+
tmp_path,
462+
monkeypatch,
463+
actual_image=("ubuntu24", "20260808.1"),
464+
)
465+
466+
with pytest.raises(VerificationError, match="scheduled repository state"):
467+
build_scheduled_state_envelope(
468+
plan=plan,
469+
report=report,
470+
state=state,
471+
run_id=143,
472+
run_attempt=1,
473+
)
474+
475+
envelope = build_scheduled_state_envelope(
476+
plan=plan,
477+
report=report,
478+
state=state,
479+
run_id=143,
480+
run_attempt=1,
481+
allow_hosted_runner_drift=True,
482+
)
483+
assert envelope["verification_state_sha256"] == state["verification_state_sha256"]
484+
485+
486+
def test_scheduled_state_rejects_different_runner_family_even_with_opt_in(
487+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
488+
) -> None:
489+
plan, report, state = scheduled_state_fixture(
490+
tmp_path,
491+
monkeypatch,
492+
actual_image=("windows2025", "20260808.1"),
493+
)
494+
495+
with pytest.raises(VerificationError, match="scheduled repository state"):
496+
build_scheduled_state_envelope(
497+
plan=plan,
498+
report=report,
499+
state=state,
500+
run_id=143,
501+
run_attempt=1,
502+
allow_hosted_runner_drift=True,
503+
)
504+
505+
506+
@pytest.mark.parametrize("field", ["environment", "component_environment"])
507+
def test_scheduled_state_rejects_tampered_raw_environment_fingerprints(
508+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str
509+
) -> None:
510+
plan, report, state = scheduled_state_fixture(tmp_path, monkeypatch)
511+
tampered = deepcopy(state)
512+
if field == "environment":
513+
tampered["environment"]["browser"] = "firefox"
514+
else:
515+
tampered["component_environment"]["django"]["browser"] = "firefox"
516+
517+
with pytest.raises(VerificationError, match="repository state"):
518+
build_scheduled_state_envelope(
519+
plan=plan,
520+
report=report,
521+
state=tampered,
522+
run_id=143,
523+
run_attempt=1,
524+
allow_hosted_runner_drift=True,
525+
)
526+
527+
528+
def test_scheduled_state_rejects_recomputed_tree_identity_tamper(
529+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
530+
) -> None:
531+
plan, report, state = scheduled_state_fixture(tmp_path, monkeypatch)
532+
tampered = deepcopy(state)
533+
tampered["tree_oid"] = "f" * len(state["tree_oid"])
534+
identity = dict(tampered)
535+
identity.pop("verification_state_sha256")
536+
tampered["verification_state_sha256"] = sha256_json(identity)
537+
538+
with pytest.raises(VerificationError, match="scheduled repository state"):
539+
build_scheduled_state_envelope(
540+
plan=plan,
541+
report=report,
542+
state=tampered,
543+
run_id=143,
544+
run_attempt=1,
545+
)
546+
547+
393548
def test_worktree_plan_includes_dirty_tracked_and_untracked_candidate_files(
394549
tmp_path: Path,
395550
) -> None:

0 commit comments

Comments
 (0)