diff --git a/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md b/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md index d4b723cd58..97a1e6dc3a 100644 --- a/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md +++ b/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md @@ -255,12 +255,12 @@ frontend pull requests conflict. Until 2026-10-03 each frontend commit rewrote `frontend-modern/browser-verification.json`, so the first pull request to merge left every other one conflicting, and each conflict cost a rebuild on the new `main` and a fresh browser pass. Files that are only ever added cannot collide. -The shared file is retired: the guard refuses any commit that writes it. Range -mode still accepts it as evidence from a commit that modified it, because that -commit was verified while its own parent still carried the file. A pull request -that still rewrites it is repaired by merging `main` and taking `main`'s copy of -the file, not by a rebuild. The file is deleted in a change of its own, because -deleting it conflicts with every pull request that still rewrites it. +The shared file is retired and deleted. The guard refuses a staged tree or a +range tip that carries it, so resolving a merge by keeping the file cannot +bring it back. Range mode still accepts it as evidence from a commit that +modified it, because that commit was verified while its own parent still +carried the file. A pull request that still rewrites it is repaired by merging +`main` and removing the file with `git rm`, not by a rebuild. `scripts/release_control/browser_verification_guard.py` enforces the receipt against the staged index locally and against the final integration range in diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json deleted file mode 100644 index db181fda93..0000000000 --- a/frontend-modern/browser-verification.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "version": 1, - "base_sha": "0e90236d3bb901dc84d4d07e70d4d2a09d5e6705", - "verified_at": "2026-10-02T23:55:48Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/shared/Table.tsx" - ], - "content_sha256": { - "frontend-modern/src/components/shared/Table.tsx": "8e5029d5ee9dddc86a4733fa1e7249793cdbedb135f3634e75112f8fb3b327c9" - }, - "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/proxmox/replication", - "/proxmox/ceph", - "/proxmox/mail", - "/docker/overview", - "/docker/images", - "/docker/storage", - "/docker/networks", - "/docker/swarm", - "/kubernetes/overview", - "/kubernetes/nodes", - "/kubernetes/workloads", - "/kubernetes/services", - "/kubernetes/storage", - "/kubernetes/configuration", - "/kubernetes/events", - "/truenas/overview", - "/truenas/storage", - "/truenas/services", - "/truenas/apps", - "/truenas/vms", - "/truenas/shares", - "/truenas/protection", - "/vmware/overview", - "/vmware/storage", - "/vmware/networks", - "/vmware/health", - "/vmware/activity", - "/standalone/machines", - "/standalone/availability", - "/alerts/overview", - "/alerts/history", - "/alerts/thresholds", - "/actions", - "/settings/infrastructure", - "/settings/monitoring", - "/settings/pulse-intelligence", - "/settings/organization", - "/settings/security-users", - "/settings/security-webhooks" - ], - "viewports": [ - { - "width": 1440, - "height": 900 - }, - { - "width": 1280, - "height": 900 - }, - { - "width": 900, - "height": 900 - }, - { - "width": 700, - "height": 900 - }, - { - "width": 560, - "height": 900 - }, - { - "width": 390, - "height": 900 - } - ], - "states": [ - "Mock stack on origin/main plus this change; Playwright sweep of every table on the routes above at all six viewports, before and after, recording computed th/td padding per cell and leaf elements with scrollWidth > clientWidth under hidden overflow (455 table states compared)", - "Default TableCell/TableHead with no caller padding keep 8px/12px horizontal and 2px/6px vertical padding (Settings infrastructure source and agent tables unchanged at 12px/12px/6px/6px)", - "Platform tables (Proxmox nodes, Docker hosts and containers, Kubernetes clusters/nodes/deployments/pods, TrueNAS systems, vSphere, Machines): cells 12px -> 8px horizontal at 700px and up and 8px -> 6px at 560/390px; header cells keep the stylesheet 6px horizontal and drop to 2px vertical, header rows 29px -> 21px; row height 32px unchanged; clipped-cell count improved in 89 table states, unchanged in 362; the 4 states counted worse are live mock-value drift (relative-time and usage labels) with identical or wider content boxes", - "Physical Disks table (Proxmox > Storage > Physical Disks): cells 8px -> 4px horizontal at 390px and 12px -> 6px at 700px, matching the composed px-1 sm:px-1.5 lg:px-2; clipped cells at 390px 93 -> 18; detail row 12px -> 16px horizontal at 700px and 1440px", - "Storage pools table unchanged at 6px/8px cell padding across 390, 560 and 700px", - "Inline detail rows (Proxmox node, Kubernetes controller, TrueNAS system expansions): detail cell padding 12px/12px/2px/2px -> 0, detail panel inset now only its own px-2 sm:px-4; incident timeline panel in Alert History gets its p-3", - "Alerts overview stats table: dot column right padding 12px -> 0 and value column 12px -> 6px at 700px and up as its classes name; Alert History header cells 12px -> 8px horizontal; AI cost table (Settings > Pulse Intelligence) Est. USD column 12px -> 4px as its px-1 names", - "Workloads table header cells now 6px/8px to match their raw td data cells; Proxmox replication compact-mode !px-1 override still applies at 390px", - "Reactive case: switching a cell class between px-1 and a non-padding class re-resolves the base padding (unit test)" - ], - "interactions": [ - "Load each route at 1440, 1280, 900, 700, 560 and 390px and run the padding and clip sweep over all tables", - "Click Physical Disks on Proxmox > Storage at 390, 560, 700 and 1440px and expand the first disk row", - "Expand the first node row on Proxmox > Overview, the first deployment row on Kubernetes > Workloads, the first host row on Docker > Overview and the first system row on TrueNAS > Overview at 390, 560, 700 and 1440px", - "Expand the first alert row incident timeline on Alerts > History at 1440px", - "Compare before and after JSON sweeps per route, tab, viewport and table; re-measure the four states counted worse three times each", - "Revert and restore the change on the running dev server to A/B the Kubernetes clusters memory bar label at 560px" - ] -} diff --git a/scripts/release_control/browser_verification_guard.py b/scripts/release_control/browser_verification_guard.py index 1de3ed3f0a..71dd877d3c 100644 --- a/scripts/release_control/browser_verification_guard.py +++ b/scripts/release_control/browser_verification_guard.py @@ -30,9 +30,7 @@ RECEIPT_DIR = "frontend-modern/browser-verification" # The single shared receipt every frontend commit rewrote before per-commit # receipts. It is retired: nothing may write it again. Range mode still reads # it from commits that modified it, because those commits were verified -# honestly against a tree that carried it and are still merging. The file -# itself is deleted in a change of its own, since deleting it conflicts with -# every open pull request that still rewrites it. +# honestly against a tree that carried it and are still merging. LEGACY_RECEIPT_PATH = "frontend-modern/browser-verification.json" RECEIPT_FINGERPRINT_LENGTH = 16 DEFAULT_LANDED_REF = "origin/main" @@ -452,7 +450,8 @@ PRUNE_SEPARATELY = ( RETIRED_RECEIPT = ( - f"{LEGACY_RECEIPT_PATH} is retired and must not be written. Each commit now " + f"{LEGACY_RECEIPT_PATH} is retired and must not exist. Remove it with git rm; a " + "receipt it held stays valid in the commit that recorded it. Each commit now " f"records its receipt in its own file under {RECEIPT_DIR}/, so concurrent " "frontend changes no longer conflict." ) @@ -472,11 +471,15 @@ def range_receipt_coverage( that fails contributes no coverage and is reported as a diagnostic. Violations are returned separately because they block the range even when - coverage is complete: a commit that both records and deletes receipts. + coverage is complete: a tip that still carries the retired shared receipt + (recreated, or kept while resolving a merge with the main that deleted + it), or a commit that both records and deletes receipts. """ covered: dict[str, set[str]] = {} diagnostics: list[str] = [] violations: list[str] = [] + if LEGACY_RECEIPT_PATH in blob_ids(head, [LEGACY_RECEIPT_PATH], repo_root=repo_root): + violations.append(RETIRED_RECEIPT) for commit in receipt_commits_in_range(base, head, repo_root=repo_root): try: changes = receipt_changes(commit=commit, repo_root=repo_root) @@ -677,20 +680,25 @@ def check_commit( inherited = merged_in_paths( sorted({*changes, *frontend_paths}), commit=commit, repo_root=repo_root ) + # The index is held to the tree it will become: a merge that keeps the + # shared receipt changes nothing against HEAD yet would restore it on + # main. A named commit is held only to what it writes itself. + retired_receipt_written = ( + changes.get(LEGACY_RECEIPT_PATH, "D") != "D" + if commit + else LEGACY_RECEIPT_PATH + in blob_ids(None, [LEGACY_RECEIPT_PATH], repo_root=repo_root) + ) except subprocess.CalledProcessError as exc: return blocked(f"unable to inspect browser verification state: {exc}") - changes = {path: status for path, status in changes.items() if path not in inherited} - if changes.get(LEGACY_RECEIPT_PATH, "D") != "D": + if retired_receipt_written: return blocked( RETIRED_RECEIPT, - advice=[ - "While concluding a merge, take the merged parent's version: " - f"git checkout MERGE_HEAD -- {LEGACY_RECEIPT_PATH}" - if merging - else f"Unstage it and run: {GUARD_COMMAND} --write-template" - ], + advice=[f"For a new receipt, run: {GUARD_COMMAND} --write-template"], ) + + changes = {path: status for path, status in changes.items() if path not in inherited} frontend_paths = [path for path in frontend_paths if path not in inherited] recorded = sorted( path for path, status in changes.items() if status != "D" and path != LEGACY_RECEIPT_PATH diff --git a/scripts/release_control/browser_verification_guard_test.py b/scripts/release_control/browser_verification_guard_test.py index 7244377fef..1ea81ef175 100644 --- a/scripts/release_control/browser_verification_guard_test.py +++ b/scripts/release_control/browser_verification_guard_test.py @@ -61,6 +61,9 @@ class BrowserVerificationGuardTest(unittest.TestCase): self.assertLess(formatter, guard) + def test_shared_receipt_is_retired_from_the_tree(self) -> None: + self.assertFalse((REPO_ROOT / LEGACY_RECEIPT_PATH).exists()) + def test_frontend_runtime_paths_exclude_tests_and_receipt(self) -> None: self.assertEqual( frontend_runtime_paths( @@ -722,23 +725,21 @@ class IntegrationRangeTest(GuardRepoTestCase): class RetiredSharedReceiptTest(GuardRepoTestCase): - """Work verified against the shared receipt keeps its evidence; nothing new may write it.""" + """Work verified against the shared receipt keeps its evidence; nothing new may use it.""" def setUp(self) -> None: super().setUp() self.write_receipt([CHANGED_PATH], at=LEGACY_RECEIPT_PATH) self.base = self.commit("main while the shared receipt was current") - def shared_receipt_change(self, path: str, text: str, message: str) -> str: - self.write(path, text) - self.write_receipt([path], at=LEGACY_RECEIPT_PATH) - return self.commit(message) - - def test_in_flight_commit_is_repaired_by_merging_main(self) -> None: + def test_in_flight_commit_survives_a_merge_of_the_retirement(self) -> None: self.git("checkout", "--quiet", "-b", "in-flight", self.base) - self.shared_receipt_change(CHANGED_PATH, "export const a = 1;\n", "in flight") + self.write(CHANGED_PATH, "export const a = 1;\n") + self.write_receipt([CHANGED_PATH], at=LEGACY_RECEIPT_PATH) + self.commit("verified before the retirement") self.git("checkout", "--quiet", "main") - landed = self.shared_receipt_change(OTHER_PATH, "export const b = 1;\n", "landed first") + self.git("rm", "--quiet", LEGACY_RECEIPT_PATH) + retired = self.commit("retire the shared receipt") self.git("checkout", "--quiet", "in-flight") merge = subprocess.run( @@ -746,20 +747,33 @@ class RetiredSharedReceiptTest(GuardRepoTestCase): "merge", "--quiet", "--no-ff", "main"], cwd=self.repo_root, capture_output=True, text=True, env=self.env, ) - self.assertNotEqual(merge.returncode, 0, "both sides rewrote the shared receipt") + self.assertNotEqual(merge.returncode, 0, "modify/delete on the shared receipt") - # A hand-combined resolution is a new write of the retired file. - self.write(LEGACY_RECEIPT_PATH, "{}\n") + # Keeping the file changes nothing against HEAD, but would restore it + # on main once this branch merges. self.git("add", LEGACY_RECEIPT_PATH) status, _, stderr = self.run_guard() self.assertEqual(status, 1) self.assertIn(f"{LEGACY_RECEIPT_PATH} is retired", stderr) - self.assertIn("git checkout MERGE_HEAD", stderr) - self.git("checkout", "MERGE_HEAD", "--", LEGACY_RECEIPT_PATH) + self.git("rm", "--quiet", "--force", LEGACY_RECEIPT_PATH) self.assertEqual(self.run_guard()[0], 0) self.commit("merge main") - self.assertEqual(self.run_range(base=landed), (0, "")) + self.assertEqual(self.run_range(base=retired), (0, "")) + + def test_recreating_the_shared_receipt_is_rejected_even_with_a_valid_one(self) -> None: + self.git("rm", "--quiet", LEGACY_RECEIPT_PATH) + retired = self.commit("retire the shared receipt") + self.write(CHANGED_PATH, "export const a = 1;\n") + self.write_receipt([CHANGED_PATH]) + self.write_receipt([CHANGED_PATH], at=LEGACY_RECEIPT_PATH) + self.commit("verified, but also recreates the shared receipt") + + status, stderr = self.run_range(base=retired) + + self.assertEqual(status, 1) + self.assertIn(f"{LEGACY_RECEIPT_PATH} is retired", stderr) + self.assertNotIn("is not verified", stderr) class PruneTest(GuardRepoTestCase):