From ae2097248ee9adb97e417811b90751e8c2062037 Mon Sep 17 00:00:00 2001 From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:58:51 -0500 Subject: [PATCH 1/2] fix: let a migrated restore use the main build its pair was made for A migrated pair is bound to the exact image it was produced with. The selector only accepted the lane's current testing release, so any later main merge stranded a verified pair. Verify the requested main build as a release of the default branch's first-parent history instead; the pair's image binding and every use, data and separation check stay, and reconciliation moves testing forward on the restored data. --- control_plane/product_reconcile.py | 25 ++++++++++++++--------- docs/disposable-lanes.md | 5 ++++- tests/test_product_reconcile.py | 32 ++++++++++++++++++------------ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/control_plane/product_reconcile.py b/control_plane/product_reconcile.py index 2ebbc15af..28333cd57 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,21 @@ 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, - ): + # A migrated pair is bound to the image it was produced with, so any verified main + # build qualifies, not only the newest. Reconciliation then moves testing forward on + # the restored data like any other release. + 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..269442e9f 100644 --- a/tests/test_product_reconcile.py +++ b/tests/test_product_reconcile.py @@ -928,20 +928,33 @@ 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_requested_migrated_release_selects_the_main_build_its_pair_was_made_for( + self, + ) -> None: from control_plane.product_reconcile import verified_testing_rebuild_artifact + # Main moved on after the pair was produced; the pair's own build still qualifies. 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"), + ) + # A push build of a commit that never was main's tip is still refused. + self.github.add_run(50, PR_HEAD) 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 +1179,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 +1208,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, From 0b2e456d3591e0b6658fa86130fdc841178ad39a Mon Sep 17 00:00:00 2001 From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:05:41 -0500 Subject: [PATCH 2/2] fix: carry the migrated build reasons in test names (cbusillo/launchplane#5837) --- control_plane/product_reconcile.py | 3 --- tests/test_product_reconcile.py | 13 ++++++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/control_plane/product_reconcile.py b/control_plane/product_reconcile.py index 28333cd57..e34fe86ef 100644 --- a/control_plane/product_reconcile.py +++ b/control_plane/product_reconcile.py @@ -3960,9 +3960,6 @@ def verified_testing_rebuild_artifact( except BuildProvenanceError as error: if error.code != "build_purpose_mismatch": raise - # A migrated pair is bound to the image it was produced with, so any verified main - # build qualifies, not only the newest. Reconciliation then moves testing forward on - # the restored data like any other release. verified = verify_build_artifact( transport=transport, repository=profile.repository, diff --git a/tests/test_product_reconcile.py b/tests/test_product_reconcile.py index 269442e9f..7a918b24e 100644 --- a/tests/test_product_reconcile.py +++ b/tests/test_product_reconcile.py @@ -928,12 +928,9 @@ def test_request_during_run_returns_it_to_pending_with_the_plan(self) -> None: class ProductReconcileTestingTests(ProductReconcileTestCase): - def test_requested_migrated_release_selects_the_main_build_its_pair_was_made_for( - 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 - # Main moved on after the pair was produced; the pair's own build still qualifies. self._running_release(20, DEPLOYABLE) self.github.add_run(10, OLDER) profile = self.store.read_product_profile_record("site") @@ -946,8 +943,14 @@ def test_requested_migrated_release_selects_the_main_build_its_pair_was_made_for (older.manifest.source_commit, older.manifest.source_build.purpose), (OLDER, "release"), ) - # A push build of a commit that never was main's tip is still refused. + + 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,