diff --git a/docs/hermes_full_handoff_acceptance.md b/docs/hermes_full_handoff_acceptance.md index 23efe79f..56a44b1d 100644 --- a/docs/hermes_full_handoff_acceptance.md +++ b/docs/hermes_full_handoff_acceptance.md @@ -136,7 +136,12 @@ unknown or reconciling state is not healthy. `release.exact-lineage-is-running` atomically binds: - fixed remote `main` SHA and local `origin/main`; -- open draft PR #19, base `main`, existing feature ref, and exact reviewed head; +- merged PR #19 — `state=closed` with `merged=true`, base `main`, the existing + feature ref, and the exact reviewed head — with that head additionally proven + an ancestor of the release `main` by `git merge-base --is-ancestor`. A PR + that is still open, closed without merging, or carrying a different head is a + `FAIL`: after integration, only the merged terminal state proves the reviewed + code is what runs; - the exact `:git--build-@sha256:` image identity, with the source SHA equal to the release `main` SHA; - current Deployment generation and revision; diff --git a/scripts/ops/hermes_handoff_checks_delivery.py b/scripts/ops/hermes_handoff_checks_delivery.py index 102f93e4..c2388647 100644 --- a/scripts/ops/hermes_handoff_checks_delivery.py +++ b/scripts/ops/hermes_handoff_checks_delivery.py @@ -225,7 +225,7 @@ def _release_lineage(targets: Targets) -> CheckSpec: ) return check( "release.exact-lineage-is-running", - "Remote main, reviewed PR, image, build, Flux, and running revision are identical", + "Remote main, merged reviewed PR, image, build, Flux, and running revision agree", "release", "release_lineage", [ @@ -251,6 +251,15 @@ def _release_lineage(targets: Targets) -> CheckSpec: "rev-parse", f"{targets.remote}/main", ), + step( + "merge-ancestry", + OPERATOR, + "git", + "merge-base", + "--is-ancestor", + targets.reviewed_head_sha, + f"{targets.remote}/main", + ), step( "deployment", OPERATOR, diff --git a/scripts/ops/hermes_handoff_evaluators.py b/scripts/ops/hermes_handoff_evaluators.py index ea0a21b8..6216e37f 100644 --- a/scripts/ops/hermes_handoff_evaluators.py +++ b/scripts/ops/hermes_handoff_evaluators.py @@ -420,11 +420,9 @@ def evaluate_release_lineage(spec: CheckSpec, outcomes: dict[str, Outcome]) -> E return pr fields = { "number": expected["pr_number"], - "state": "open", - "draft": True, - "merged": False, + "state": "closed", + "merged": True, "base.ref": "main", - "base.sha": main_sha, "head.ref": expected["head_ref"], "head.sha": head_sha, } @@ -437,6 +435,10 @@ def evaluate_release_lineage(spec: CheckSpec, outcomes: dict[str, Outcome]) -> E return Evaluation(FAIL, f"reviewed PR {path} does not match", {path: actual}) if outcomes["build-source"].stdout.strip() != build_sha: return Evaluation(FAIL, "build source SHA does not match the fixed release build SHA") + ancestry = outcomes["merge-ancestry"] + if not ancestry.ok: + status = FAIL if ancestry.returncode == 1 else NOT_RUN + return Evaluation(status, "reviewed head is not a proven ancestor of remote main") deployment = outcomes["deployment"].stdout.strip().split("\t") if len(deployment) != 6: return Evaluation(NOT_RUN, "Deployment projection is malformed") diff --git a/scripts/ops/hermes_handoff_run.py b/scripts/ops/hermes_handoff_run.py index 45459dc8..2d6eae0e 100644 --- a/scripts/ops/hermes_handoff_run.py +++ b/scripts/ops/hermes_handoff_run.py @@ -134,7 +134,7 @@ def validate_targets(targets: Targets) -> list[str]: or targets.reviewed_head_ref != "feature/hermes-full-handoff-acceptance" ): problems.append( - "reviewed PR identity must remain fixed to draft PR #19 and its existing branch" + "reviewed PR identity must remain fixed to PR #19 and its existing branch" ) for label, value in ( ("baseline commit", targets.baseline_commit), diff --git a/testing/tests/test_hermes_handoff_evaluators.py b/testing/tests/test_hermes_handoff_evaluators.py index 4de16c7e..4df1380f 100644 --- a/testing/tests/test_hermes_handoff_evaluators.py +++ b/testing/tests/test_hermes_handoff_evaluators.py @@ -341,6 +341,7 @@ def lineage_spec(): "remote-main", "reviewed-pr", "build-source", + "merge-ancestry", "deployment", "pods", "replicasets", @@ -364,16 +365,18 @@ def lineage_spec(): def lineage_outcomes(): pull = { "number": 19, - "state": "open", - "draft": True, - "merged": False, - "base": {"ref": "main", "sha": MAIN}, + "state": "closed", + "draft": False, + "merged": True, + # The recorded base SHA is the pre-merge base, not the current main. + "base": {"ref": "main", "sha": "3" * 40}, "head": {"ref": "feature/hermes-full-handoff-acceptance", "sha": HEAD}, } return { "remote-main": outcome(stdout=f"{MAIN}\trefs/heads/main\n"), "reviewed-pr": outcome(stdout=json.dumps(pull)), "build-source": outcome(stdout=BUILD), + "merge-ancestry": outcome(), "deployment": outcome(stdout=f"5\t5\t2\t2\t7\t{IMAGE}"), "replicasets": outcome( stdout=f"rs-old\t6\t0\t0\t{IMAGE}\told\nrs-live\t7\t2\t2\t{IMAGE}\thash7\n" @@ -392,6 +395,34 @@ def test_release_lineage_atomically_binds_every_release_identity() -> None: assert evaluate(lineage_spec(), lineage_outcomes()).status == model.PASS +@pytest.mark.parametrize( + "patch", + [ + {"state": "open", "draft": True, "merged": False}, + {"state": "closed", "merged": False}, + {"head": {"ref": "feature/hermes-full-handoff-acceptance", "sha": "9" * 40}}, + ], + ids=["still-open-draft", "closed-without-merging", "wrong-head-sha"], +) +def test_release_lineage_accepts_only_the_merged_terminal_pr_state(patch) -> None: + """After integration, only the merged PR proves the reviewed code runs.""" + outcomes = lineage_outcomes() + pull = {**json.loads(outcomes["reviewed-pr"].stdout), **patch} + outcomes["reviewed-pr"] = outcome(stdout=json.dumps(pull)) + + assert evaluate(lineage_spec(), outcomes).status == model.FAIL + + +def test_release_lineage_requires_the_merged_head_to_be_an_ancestor_of_main() -> None: + not_ancestor = lineage_outcomes() + not_ancestor["merge-ancestry"] = outcome(returncode=1) + assert evaluate(lineage_spec(), not_ancestor).status == model.FAIL + + undecidable = lineage_outcomes() + undecidable["merge-ancestry"] = outcome(returncode=128) + assert evaluate(lineage_spec(), undecidable).status == model.NOT_RUN + + @pytest.mark.parametrize( ("key", "bad"), [