diff --git a/control_plane/product_reconcile.py b/control_plane/product_reconcile.py index 2ebbc15af..e34fe86ef 100644 --- a/control_plane/product_reconcile.py +++ b/control_plane/product_reconcile.py @@ -3946,11 +3946,10 @@ def verified_testing_rebuild_artifact( if profile.driver_id != "odoo" or lane.instance != "testing": raise ValueError("Requested build selection requires an Odoo testing lane.") identity = product_repository_identity(record_store, profile) + transport = _TestingRebuildBuildReads(resolve_build_provenance_transport(record_store, profile)) try: return verify_requested_build_artifact( - transport=_TestingRebuildBuildReads( - resolve_build_provenance_transport(record_store, profile) - ), + transport=transport, repository=profile.repository, repository_id=identity.repository_id, commit=source_git_ref, @@ -3961,13 +3960,18 @@ def verified_testing_rebuild_artifact( except BuildProvenanceError as error: if error.code != "build_purpose_mismatch": raise - verified = latest_verified_testing_build(record_store, profile, lane) - if (verified.manifest.artifact_id, verified.manifest.source_commit) != ( - artifact_id, - source_git_ref, - ): + verified = verify_build_artifact( + transport=transport, + repository=profile.repository, + repository_id=identity.repository_id, + commit=source_git_ref, + purpose="release", + context=lane.context, + image_repository=profile.image.repository, + ) + if verified.manifest.artifact_id != artifact_id: raise BuildProvenanceError( - "The selected build is not the current testing release.", code="build_purpose_mismatch" + "The selected build run or attempt changed.", code="manifest_commit_mismatch" ) return verified diff --git a/docs/disposable-lanes.md b/docs/disposable-lanes.md index a4297debd..d9993b0fc 100644 --- a/docs/disposable-lanes.md +++ b/docs/disposable-lanes.md @@ -2786,7 +2786,10 @@ major matching and production release eligibility are unchanged. Automatic release reconciliation still holds behind a serving PR candidate until source history proves forward movement. An explicit rebuild can select the newest verified release independently; selection retains the recorded release baseline -for complete run/history checks and refuses an older requested release. +for complete run/history checks. A migrated pair can instead name the main build +it was produced with, even after main moves on; that build must still be a +verified release of the default branch's first-parent history, and the pair must +match its image. Reconciliation then moves testing forward on the restored data. Once the normal worker has recorded its private candidate application, the original authenticated testing caller uploads one part at a time to diff --git a/tests/test_product_reconcile.py b/tests/test_product_reconcile.py index 02c826fef..7a918b24e 100644 --- a/tests/test_product_reconcile.py +++ b/tests/test_product_reconcile.py @@ -928,20 +928,36 @@ def test_request_during_run_returns_it_to_pending_with_the_plan(self) -> None: class ProductReconcileTestingTests(ProductReconcileTestCase): - def test_requested_migrated_release_cannot_select_an_ancestor_build(self) -> None: + def test_migrated_release_selects_its_pair_main_build_after_main_moves_on(self) -> None: from control_plane.product_reconcile import verified_testing_rebuild_artifact self._running_release(20, DEPLOYABLE) self.github.add_run(10, OLDER) profile = self.store.read_product_profile_record("site") lane = next(item for item in profile.lanes if item.instance == "testing") - current = verified_testing_rebuild_artifact( - self.store, profile, lane, artifact_id="artifact-cm-run-20-1", source_git_ref=DEPLOYABLE + older = verified_testing_rebuild_artifact( + self.store, profile, lane, artifact_id="artifact-cm-run-10-1", source_git_ref=OLDER ) - self.assertEqual(current.manifest.source_commit, DEPLOYABLE) + assert older.manifest.source_build is not None + self.assertEqual( + (older.manifest.source_commit, older.manifest.source_build.purpose), + (OLDER, "release"), + ) + + def test_migrated_release_refuses_a_push_build_that_never_was_main_tip(self) -> None: + from control_plane.product_reconcile import verified_testing_rebuild_artifact + + self._running_release(20, DEPLOYABLE) + self.github.add_run(50, PR_HEAD) + profile = self.store.read_product_profile_record("site") + lane = next(item for item in profile.lanes if item.instance == "testing") with self.assertRaises(BuildProvenanceError): verified_testing_rebuild_artifact( - self.store, profile, lane, artifact_id="artifact-cm-run-10-1", source_git_ref=OLDER + self.store, + profile, + lane, + artifact_id="artifact-cm-run-50-1", + source_git_ref=PR_HEAD, ) def serving_candidate( @@ -1166,10 +1182,7 @@ def divergent_main(path: str) -> object: self.assertEqual(verified.manifest.source_commit, NEWEST) def test_explicit_release_selection_keeps_release_baseline_while_candidate_serves(self) -> None: - from control_plane.product_reconcile import ( - latest_verified_testing_release, - verified_testing_rebuild_artifact, - ) + from control_plane.product_reconcile import latest_verified_testing_release statuses: tuple[Literal["pass", "reconciliation_required", "fail"], ...] = ( "pass", @@ -1198,10 +1211,6 @@ def test_explicit_release_selection_keeps_release_baseline_while_candidate_serve ) self.assertEqual((held["current_pull_request_number"], held["held"]), (5, True)) self.github.add_run(10, OLDER) - with self.assertRaises(BuildProvenanceError): - verified_testing_rebuild_artifact( - self.store, profile, lane, artifact_id="artifact-cm-run-10-1", source_git_ref=OLDER - ) self.github.add_run(40, NEWEST) self.assertEqual( latest_verified_testing_release(self.store, profile, lane).manifest.source_commit,