From 56bbe5b0830fdefff94a0beedeca18d515ddba53 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 5 Aug 2026 16:32:20 -0400 Subject: [PATCH 1/3] [Homebrew/Shell] Bind release checks to sealed authority Validate immutable selection and artifact bindings without restoring a raw tap Git input.\n\nMake rollover fixtures derive the checked-in product phase and Ruby dependency shape so the same checks remain valid after a sealed selection is committed. --- ...check-homebrew-main-shell-release-locks.py | 201 ++++++++++++-- ...check-homebrew-main-shell-release-locks.py | 251 +++++++++++++----- docs/homebrew-publishing.md | 19 +- ...st-finalize-homebrew-main-shell-release.py | 142 +++++++--- scripts/test-homebrew-main-shell-closure.sh | 24 +- .../test-homebrew-main-shell-product-state.sh | 43 ++- 6 files changed, 531 insertions(+), 149 deletions(-) diff --git a/.github/scripts/check-homebrew-main-shell-release-locks.py b/.github/scripts/check-homebrew-main-shell-release-locks.py index f79c0c9454..806f9889a8 100755 --- a/.github/scripts/check-homebrew-main-shell-release-locks.py +++ b/.github/scripts/check-homebrew-main-shell-release-locks.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import pathlib import re @@ -15,7 +16,27 @@ SHA = re.compile(r"^[0-9a-f]{40}$") SHA256 = re.compile(r"^[0-9a-f]{64}$") +FORMULA = re.compile(r"^[a-z0-9][a-z0-9._-]{0,254}$") +SELECTION_TAG = re.compile( + r"^homebrew-prefix-selection-sha256-([0-9a-f]{64})$" +) MAX_CONTRACT_BYTES = 4 * 1024 * 1024 +SELECTION_INPUTS = { + "brewfile": "homebrew/main-shell.Brewfile", + "guest_layout": "homebrew/kandelo-guest-layout.json", + "migration_lock": "homebrew/main-shell-migration-lock.json", + "runtime_support": "homebrew/main-shell-homebrew-runtime-support.json", +} +ARTIFACT_INPUTS = { + "bootstrap_tree_spec_sha256": "homebrew/main-shell-brew-package-tree.json", + "brewfile_sha256": "homebrew/main-shell.Brewfile", + "demo_config_sha256": "homebrew/main-shell-demo.json", + "materialization_policy_sha256": "homebrew/main-shell-materialization-policy.json", + "migration_lock_sha256": "homebrew/main-shell-migration-lock.json", + "runtime_support_sha256": "homebrew/main-shell-homebrew-runtime-support.json", + "selection_lock_sha256": "homebrew/main-shell-selection-lock.json", + "shell_config_sha256": "homebrew/main-shell-default.json", +} class ContractError(RuntimeError): @@ -53,6 +74,150 @@ def exact_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: raise ContractError(f"{label} is not valid JSON") from error +def sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def exact_object(value: Any, keys: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + raise ContractError(f"{label} has an unsupported shape") + return value + + +def positive_integer(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise ContractError(f"{label} must be a positive integer") + return value + + +def require_selection_inputs(source_root: pathlib.Path, value: Any) -> None: + records = exact_object(value, set(SELECTION_INPUTS), "selection lock inputs") + for key, relative in SELECTION_INPUTS.items(): + record = exact_object( + records[key], {"path", "sha256"}, f"selection lock input {key}" + ) + if ( + record.get("path") != relative + or not isinstance(record.get("sha256"), str) + or not SHA256.fullmatch(record["sha256"]) + or record["sha256"] != sha256(regular_bytes(source_root, relative)) + ): + raise ContractError(f"selection lock input {key} is not exact") + + +def require_selection( + source_root: pathlib.Path, + value: Any, + tap_catalog: str, +) -> None: + selection = exact_object( + value, + {"arch", "inputs", "kind", "release", "schema", "state"}, + "selection lock", + ) + if ( + selection.get("schema") != 1 + or selection.get("kind") + != "kandelo-homebrew-main-shell-closed-selection-lock" + or selection.get("arch") != "wasm32" + or selection.get("state") != "sealed" + ): + raise ContractError("selection lock is not sealed for the main shell") + require_selection_inputs(source_root, selection.get("inputs")) + + release = exact_object( + selection.get("release"), + { + "assets", + "formula_count", + "prepared_tree_git_oid", + "repository", + "roots", + "selection_manifest_sha256", + "tag", + "target_commitish", + }, + "selection release", + ) + assets = exact_object( + release.get("assets"), + {"closed-selection.json", "closed-selection.zip"}, + "selection release assets", + ) + for name, value in assets.items(): + record = exact_object( + value, {"bytes", "sha256"}, f"selection release asset {name}" + ) + positive_integer(record.get("bytes"), f"selection release asset {name} bytes") + if not isinstance(record.get("sha256"), str) or not SHA256.fullmatch( + record["sha256"] + ): + raise ContractError(f"selection release asset {name} digest is invalid") + + roots = release.get("roots") + if ( + not isinstance(roots, list) + or not roots + or any( + not isinstance(root, str) or not FORMULA.fullmatch(root) + for root in roots + ) + or roots != sorted(set(roots)) + ): + raise ContractError("selection release roots are invalid") + formula_count = positive_integer( + release.get("formula_count"), "selection release Formula count" + ) + tag = release.get("tag") + tag_match = SELECTION_TAG.fullmatch(tag) if isinstance(tag, str) else None + if ( + formula_count < len(roots) + or release.get("repository") != "kandelo-dev/homebrew-tap-core" + or release.get("target_commitish") != tap_catalog + or not isinstance(release.get("prepared_tree_git_oid"), str) + or not SHA.fullmatch(release["prepared_tree_git_oid"]) + or not isinstance(release.get("selection_manifest_sha256"), str) + or not SHA256.fullmatch(release["selection_manifest_sha256"]) + or tag_match is None + or tag_match.group(1) != assets["closed-selection.json"]["sha256"] + ): + raise ContractError("selection release identity is not exact") + + +def require_artifact_inputs(source_root: pathlib.Path, value: Any) -> None: + records = exact_object(value, set(ARTIFACT_INPUTS), "artifact lock inputs") + for key, relative in ARTIFACT_INPUTS.items(): + expected = records[key] + if ( + not isinstance(expected, str) + or not SHA256.fullmatch(expected) + or expected != sha256(regular_bytes(source_root, relative)) + ): + raise ContractError(f"artifact lock input {key} is not exact") + + +def require_artifact(source_root: pathlib.Path, value: Any) -> None: + artifact = exact_object( + value, + {"image", "inputs", "kind", "schema", "source_date_epoch", "state"}, + "lazy artifact lock", + ) + require_artifact_inputs(source_root, artifact.get("inputs")) + image = exact_object( + artifact.get("image"), {"bytes", "sha256"}, "lazy artifact image" + ) + if ( + artifact.get("schema") != 3 + or artifact.get("kind") != "kandelo-homebrew-lazy-shell-artifact-lock" + or artifact.get("source_date_epoch") != 0 + or artifact.get("state") != "sealed" + or not isinstance(image.get("sha256"), str) + or not SHA256.fullmatch(image["sha256"]) + ): + raise ContractError("lazy artifact lock is not sealed to exact image bytes") + positive_integer(image.get("bytes"), "lazy artifact image byte count") + + def require_catalog(value: Any, expected: str, label: str) -> None: if ( not isinstance(value, dict) @@ -63,9 +228,7 @@ def require_catalog(value: Any, expected: str, label: str) -> None: def positive_revision(value: Any, label: str) -> int: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise ContractError(f"{label} must be a positive integer") - return value + return positive_integer(value, label) def check(source_root: pathlib.Path, tap_catalog: str, canary: str) -> None: @@ -85,17 +248,6 @@ def check(source_root: pathlib.Path, tap_catalog: str, canary: str) -> None: except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error: raise ContractError("shell build.toml is not valid UTF-8 TOML") from error - # WHY: the top-level UNPUBLISHED marker names this source recipe, whereas - # the nested Git input names the detached tap catalog used to build it. - # Treating both `commit` fields as an unstructured grep can validate the - # wrong owner and silently publish against a different catalog. - expected_git_inputs = [ - { - "name": "homebrew_tap_core", - "repository": "https://github.com/Kandelo-dev/homebrew-tap-core.git", - "commit": tap_catalog, - } - ] # WHY: kandelo-ref already binds this file to one reviewed source commit, # while the sealed artifact lock binds the bytes fetched for its package # identity. Validate the revision's package-schema shape here rather than @@ -105,7 +257,10 @@ def check(source_root: pathlib.Path, tap_catalog: str, canary: str) -> None: build.get("repo_url") != "https://github.com/Automattic/kandelo.git" or build.get("commit") != "UNPUBLISHED" or build.get("publication_state") != "ready" - or build.get("git_inputs") != expected_git_inputs + # WHY: the immutable closed selection is the package's one Formula + # authority. A raw tap Git input beside it could compose different + # source-only bytes than the public selection used by runtime proofs. + or build.get("git_inputs") is not None ): raise ContractError("shell build.toml release identity is not exact") @@ -119,12 +274,17 @@ def check(source_root: pathlib.Path, tap_catalog: str, canary: str) -> None: ), "runtime support", ) + selection = json_without_duplicate_keys( + regular_bytes(source_root, "homebrew/main-shell-selection-lock.json"), + "selection lock", + ) artifact = json_without_duplicate_keys( regular_bytes(source_root, "homebrew/main-shell-lazy-artifact-lock.json"), "lazy artifact lock", ) require_catalog(migration, tap_catalog, "migration lock") require_catalog(support, tap_catalog, "runtime support") + require_selection(source_root, selection, tap_catalog) installs = support.get("lifecycle_installs") if isinstance(support, dict) else None if ( @@ -147,16 +307,7 @@ def check(source_root: pathlib.Path, tap_catalog: str, canary: str) -> None: ): raise ContractError("runtime support does not bind the exact canary lifecycle") - image = artifact.get("image") if isinstance(artifact, dict) else None - if ( - artifact.get("state") != "sealed" - or not isinstance(image, dict) - or not SHA256.fullmatch(image.get("sha256", "")) - or not isinstance(image.get("bytes"), int) - or isinstance(image.get("bytes"), bool) - or image["bytes"] < 1 - ): - raise ContractError("lazy artifact lock is not sealed to exact image bytes") + require_artifact(source_root, artifact) def parse_args() -> argparse.Namespace: diff --git a/.github/scripts/test-check-homebrew-main-shell-release-locks.py b/.github/scripts/test-check-homebrew-main-shell-release-locks.py index 8c3717c2c9..08e9d13bd3 100755 --- a/.github/scripts/test-check-homebrew-main-shell-release-locks.py +++ b/.github/scripts/test-check-homebrew-main-shell-release-locks.py @@ -3,6 +3,7 @@ from __future__ import annotations +import hashlib import importlib.util import json import pathlib @@ -23,77 +24,165 @@ C = "c" * 40 -def write(root: pathlib.Path, relative: str, value: str) -> None: +def write(root: pathlib.Path, relative: str, value: str) -> pathlib.Path: path = root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(value) + return path + + +def write_json(root: pathlib.Path, relative: str, value: object) -> pathlib.Path: + return write(root, relative, json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def digest(path: pathlib.Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def rebind_locked_inputs(root: pathlib.Path) -> None: + selection_path = root / "homebrew/main-shell-selection-lock.json" + selection = json.loads(selection_path.read_text()) + for key, relative in MODULE.SELECTION_INPUTS.items(): + selection["inputs"][key]["sha256"] = digest(root / relative) + write_json(root, "homebrew/main-shell-selection-lock.json", selection) + + artifact_path = root / "homebrew/main-shell-lazy-artifact-lock.json" + artifact = json.loads(artifact_path.read_text()) + for key, relative in MODULE.ARTIFACT_INPUTS.items(): + artifact["inputs"][key] = digest(root / relative) + write_json(root, "homebrew/main-shell-lazy-artifact-lock.json", artifact) def fixture(root: pathlib.Path) -> None: write( root, "packages/registry/shell/build.toml", - f"""\ + """\ script_path = "packages/registry/shell/build-shell.sh" inputs = [] repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" -revision = 22 +revision = 23 publication_state = "ready" -[[git_inputs]] -name = "homebrew_tap_core" -repository = "https://github.com/Kandelo-dev/homebrew-tap-core.git" -commit = "{TF}" - [binary] index_url = "https://example.invalid/index.toml" """, ) - write( + write(root, "homebrew/main-shell.Brewfile", 'brew "example"\n') + write_json( + root, + "homebrew/kandelo-guest-layout.json", + {"kind": "test-layout", "schema": 1}, + ) + write_json( + root, + "homebrew/main-shell-brew-package-tree.json", + {"kind": "test-bootstrap-tree", "schema": 1}, + ) + write_json( + root, + "homebrew/main-shell-demo.json", + {"kind": "test-demo", "schema": 1}, + ) + write_json( + root, + "homebrew/main-shell-materialization-policy.json", + {"kind": "test-materialization", "schema": 1}, + ) + write_json( + root, + "homebrew/main-shell-default.json", + {"kind": "test-shell", "schema": 1}, + ) + write_json( root, "homebrew/main-shell-migration-lock.json", - json.dumps({"catalog": {"tap_commit": TF}}), + {"catalog": {"tap_commit": TF}}, ) - write( + write_json( root, "homebrew/main-shell-homebrew-runtime-support.json", - json.dumps( - { - "catalog": {"tap_commit": TF}, - "lifecycle_installs": [ - { - "tap": "brandonpayton/kandelo-canary", - "repository": "brandonpayton/homebrew-kandelo-canary", - "revision": C, - "formula": "m4-canary", - "phase": "guest-lifecycle", - "image_closure": False, - "reason": ( - "The independent tap is proof of live third-party " - "installation, not a trusted base-image input." - ), - } - ], - } - ), + { + "catalog": {"tap_commit": TF}, + "lifecycle_installs": [ + { + "tap": "brandonpayton/kandelo-canary", + "repository": "brandonpayton/homebrew-kandelo-canary", + "revision": C, + "formula": "m4-canary", + "phase": "guest-lifecycle", + "image_closure": False, + "reason": ( + "The independent tap is proof of live third-party " + "installation, not a trusted base-image input." + ), + } + ], + }, ) - write( + + selection_inputs = { + key: {"path": relative, "sha256": digest(root / relative)} + for key, relative in MODULE.SELECTION_INPUTS.items() + } + descriptor_sha = "d" * 64 + write_json( + root, + "homebrew/main-shell-selection-lock.json", + { + "arch": "wasm32", + "inputs": selection_inputs, + "kind": "kandelo-homebrew-main-shell-closed-selection-lock", + "release": { + "assets": { + "closed-selection.json": { + "bytes": 1234, + "sha256": descriptor_sha, + }, + "closed-selection.zip": { + "bytes": 5678, + "sha256": "e" * 64, + }, + }, + "formula_count": 3, + "prepared_tree_git_oid": "f" * 40, + "repository": "kandelo-dev/homebrew-tap-core", + "roots": ["alpha", "beta"], + "selection_manifest_sha256": "a" * 64, + "tag": f"homebrew-prefix-selection-sha256-{descriptor_sha}", + "target_commitish": TF, + }, + "schema": 1, + "state": "sealed", + }, + ) + artifact_inputs = { + key: digest(root / relative) + for key, relative in MODULE.ARTIFACT_INPUTS.items() + } + write_json( root, "homebrew/main-shell-lazy-artifact-lock.json", - json.dumps( - { - "state": "sealed", - "image": {"sha256": "d" * 64, "bytes": 1234}, - } - ), + { + "image": {"bytes": 1234, "sha256": "1" * 64}, + "inputs": artifact_inputs, + "kind": "kandelo-homebrew-lazy-shell-artifact-lock", + "schema": 3, + "source_date_epoch": 0, + "state": "sealed", + }, ) -def rejected(root: pathlib.Path) -> None: +def rejected(root: pathlib.Path, expected: str | None = None) -> None: try: MODULE.check(root, TF, C) - except MODULE.ContractError: + except MODULE.ContractError as error: + if expected is not None and expected not in str(error): + raise AssertionError( + f"rejection did not contain {expected!r}: {error}" + ) from error return raise AssertionError("invalid release-lock fixture was accepted") @@ -105,15 +194,6 @@ def rejected(root: pathlib.Path) -> None: build_path = root / "packages/registry/shell/build.toml" build = build_path.read_text() - build_path.write_text( - build.replace(f'commit = "{TF}"', f'commit = "{"e" * 40}"', 1) - ) - rejected(root) - build_path.write_text(build) - - # Keep the nested catalog commit correct while making the top-level recipe - # commit look publishable. This is the exact ambiguity an unstructured - # `grep commit = ...` check cannot distinguish. build_path.write_text( build.replace('commit = "UNPUBLISHED"', f'commit = "{TF}"') ) @@ -121,22 +201,20 @@ def rejected(root: pathlib.Path) -> None: build_path.write_text(build) build_path.write_text( - build.replace( - 'publication_state = "ready"', 'publication_state = "pending"' - ) + build.replace('publication_state = "ready"', 'publication_state = "pending"') ) rejected(root) build_path.write_text(build) # A reviewed next-generation source commit may advance the package # revision without changing this reusable workflow implementation. - build_path.write_text(build.replace("revision = 22", "revision = 23")) + build_path.write_text(build.replace("revision = 23", "revision = 24")) MODULE.check(root, TF, C) build_path.write_text(build) - for invalid_revision in ["0", "true", '"22"', "22.5"]: + for invalid_revision in ["0", "true", '"23"', "23.5"]: build_path.write_text( - build.replace("revision = 22", f"revision = {invalid_revision}") + build.replace("revision = 23", f"revision = {invalid_revision}") ) rejected(root) build_path.write_text(build) @@ -146,29 +224,82 @@ def rejected(root: pathlib.Path) -> None: + f"""\ [[git_inputs]] -name = "unexpected" -repository = "https://github.com/example/unexpected.git" +name = "homebrew_tap_core" +repository = "https://github.com/Kandelo-dev/homebrew-tap-core.git" commit = "{TF}" """ ) rejected(root) build_path.write_text(build) + brewfile_path = root / "homebrew/main-shell.Brewfile" + brewfile = brewfile_path.read_text() + brewfile_path.write_text(brewfile + 'brew "drift"\n') + rejected(root) + brewfile_path.write_text(brewfile) + + selection_path = root / "homebrew/main-shell-selection-lock.json" + selection = selection_path.read_text() + selection_path.write_text(selection.replace('"sealed"', '"pending"', 1)) + rejected(root) + selection_path.write_text(selection) + + selection_value = json.loads(selection) + selection_value["release"]["target_commitish"] = "2" * 40 + write_json(root, "homebrew/main-shell-selection-lock.json", selection_value) + rejected(root) + selection_path.write_text(selection) + + selection_value = json.loads(selection) + selection_value["release"]["formula_count"] = 0 + write_json(root, "homebrew/main-shell-selection-lock.json", selection_value) + rejected(root) + selection_path.write_text(selection) + + selection_value = json.loads(selection) + selection_value["release"]["tag"] = ( + "homebrew-prefix-selection-sha256-" + "5" * 64 + ) + write_json(root, "homebrew/main-shell-selection-lock.json", selection_value) + rejected(root, "selection release identity is not exact") + selection_path.write_text(selection) + artifact_path = root / "homebrew/main-shell-lazy-artifact-lock.json" artifact = artifact_path.read_text() - artifact_path.write_text(artifact.replace('"sealed"', '"pending"')) + artifact_value = json.loads(artifact) + artifact_value["inputs"]["selection_lock_sha256"] = "3" * 64 + write_json(root, "homebrew/main-shell-lazy-artifact-lock.json", artifact_value) + rejected(root) + artifact_path.write_text(artifact) + + artifact_value = json.loads(artifact) + artifact_value["state"] = "pending" + artifact_value["image"] = None + write_json(root, "homebrew/main-shell-lazy-artifact-lock.json", artifact_value) rejected(root) artifact_path.write_text(artifact) + demo_path = root / "homebrew/main-shell-demo.json" + demo = demo_path.read_text() + demo_path.write_text('{"drift":true}\n') + rejected(root) + demo_path.write_text(demo) + support_path = root / "homebrew/main-shell-homebrew-runtime-support.json" support = support_path.read_text() - support_path.write_text(support.replace(C, "f" * 40)) - rejected(root) + support_path.write_text(support.replace(C, "4" * 40)) + rebind_locked_inputs(root) + rejected(root, "runtime support does not bind the exact canary lifecycle") support_path.write_text(support) + selection_path.write_text(selection) + artifact_path.write_text(artifact) support_path.write_text(support.replace('"m4-canary"', '"m4"')) - rejected(root) + rebind_locked_inputs(root) + rejected(root, "runtime support does not bind the exact canary lifecycle") support_path.write_text(support) + selection_path.write_text(selection) + artifact_path.write_text(artifact) migration_path = root / "homebrew/main-shell-migration-lock.json" migration = migration_path.read_text() diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index b8cce86564..4ce85925a9 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -1425,9 +1425,9 @@ A stale receipt, different source commit, unrelated Formula, missing dependency, or unavailable bottle fails before any file is replaced. The default preview is read-only. `--apply` without an artifact advances the -catalog, Formula identities, metadata/provenance digests, selection lock, Git -input, and bound artifact inputs together while changing the shell to -`publication_state = "pending"`. +catalog, Formula identities, metadata/provenance digests, selection lock, +source-catalog authority, and bound artifact inputs together while changing +the shell to `publication_state = "pending"`. The review-only `--review-pending-artifact` composer option can then measure the deterministic candidate. The exact pull-request and protected-main checks may also use that option to run a candidate while @@ -1438,13 +1438,12 @@ still require a sealed identity. Rerun the finalizer with the ordinary sealed path, and only then return the recipe to `publication_state = "ready"`. -The checked-in `6ad0e3dbc60e5572c4288c86919238f71c1bc110` tap value is a -reviewed pre-selection catalog reference, not final shell authority. The -selection lock is pending and names no release, the artifact lock is pending -and names no image, and the shell recipe has -`publication_state = "pending"`. A fetched immutable selection must bind the -catalog and move the product to candidate state before independently -reproduced image bytes can be sealed and the recipe can become ready. +The checked-in `6ad0e3dbc60e5572c4288c86919238f71c1bc110` tap value is the +source-catalog coordinate bound by the migration, runtime-support, and +selection-lock contracts. It is not sufficient release authority by itself. +`scripts/homebrew-main-shell-product-state.py` jointly validates the +selection lock, artifact lock, and shell recipe: only `publishable` admits +shipping, while `awaiting-selection` and `candidate` remain review-only. The shell recipe remains `UNPUBLISHED` so archive staging can substitute the exact landed Kandelo commit after that seal exists. The lazy artifact lock diff --git a/scripts/test-finalize-homebrew-main-shell-release.py b/scripts/test-finalize-homebrew-main-shell-release.py index d7bd391fdd..4dce0ef4e1 100755 --- a/scripts/test-finalize-homebrew-main-shell-release.py +++ b/scripts/test-finalize-homebrew-main-shell-release.py @@ -78,7 +78,6 @@ "libcurl": ["openssl", "zlib"], "less": ["ncurses"], "vim": ["ncurses"], - "ruby": ["zlib"], "file-formula": ["libmagic"], } @@ -178,11 +177,21 @@ def package_record( } +def dependencies_for_support(support: dict) -> dict[str, list[str]]: + dependencies = dict(DEPENDENCIES) + libyaml = f"{TAP_NAME}/libyaml" + dependencies["ruby"] = ( + ["libyaml", "zlib"] + if libyaml in support["formula_order"] + else ["zlib"] + ) + return dependencies + + def create_tap( root: pathlib.Path, source: pathlib.Path, omit: str | None = None, - dependency_overrides: dict[str, list[str]] | None = None, ) -> pathlib.Path: tap = root / "tap" (tap / "Kandelo").mkdir(parents=True) @@ -201,7 +210,7 @@ def create_tap( } if omit: names.remove(omit) - dependencies = {**DEPENDENCIES, **(dependency_overrides or {})} + dependencies = dependencies_for_support(support) metadata = { "schema": 1, "generated_at": "2026-07-28T00:00:00Z", @@ -281,7 +290,7 @@ def create_closed_selection( entry["formula"]["name"]: entry["formula"] for entry in migration["packages"] } - dependencies = {**DEPENDENCIES, "ruby": ["zlib", "libyaml"]} + dependencies = {**DEPENDENCIES, "ruby": ["libyaml", "zlib"]} names = { identity.split("/")[-1] for identity in migration["formula_closure"] @@ -423,19 +432,73 @@ def set_shell_revision(source: pathlib.Path, literal: str) -> None: path.write_text(updated) -def add_future_libyaml_shape(source: pathlib.Path) -> None: +def ensure_libyaml_shape(source: pathlib.Path) -> None: support_path = source / "homebrew/main-shell-homebrew-runtime-support.json" support = json.loads(support_path.read_text()) libyaml = f"{TAP_NAME}/libyaml" ruby = f"{TAP_NAME}/ruby" + zlib = f"{TAP_NAME}/zlib" formula_order = support["formula_order"] - formula_order.insert(formula_order.index(ruby), libyaml) - support["additional_formula_order"].insert(0, libyaml) + if libyaml not in formula_order: + formula_order.insert(formula_order.index(zlib), libyaml) + additional = support["additional_formula_order"] + if libyaml not in additional: + additional.insert(additional.index(ruby), libyaml) reusable = support["availability"]["reusable_public_abi42"] - reusable.insert(reusable.index(ruby), libyaml) + if libyaml not in reusable: + reusable.insert(reusable.index(zlib), libyaml) write_json(support_path, support) +def expected_release_shape(source: pathlib.Path) -> dict[str, int]: + migration = json.loads( + (source / "homebrew/main-shell-migration-lock.json").read_text() + ) + support = json.loads( + ( + source / "homebrew/main-shell-homebrew-runtime-support.json" + ).read_text() + ) + policy = json.loads( + ( + source / "homebrew/main-shell-materialization-policy.json" + ).read_text() + ) + availability = support["availability"] + base = len(migration["formula_closure"]) + embedded = len(policy["embedded_package_order"]) + additional = len(support["additional_formula_order"]) + audited = sum( + len(availability[key]) + for key in [ + "reusable_public_abi42", + "requires_rebuild", + "missing_metadata", + "can_be_deferred", + ] + ) + return { + "roots": len(migration["packages"]), + "base_formulae": base, + "embedded": embedded, + "lazy": base - embedded, + "runtime_formulae": len(support["formula_order"]), + "audited_formulae": audited, + "runtime_extra": additional, + "total": base + additional, + } + + +def expected_checker_summary(shape: dict[str, int]) -> str: + return ( + f"{shape['base_formulae']} base Formulae, " + f"{shape['runtime_formulae']} runtime Formulae, and " + f"{shape['audited_formulae']} audited Formulae; the runtime adds " + f"{shape['runtime_extra']} beyond the base, yielding " + f"{shape['total']} total Formulae" + ) + + def run_checker( source: pathlib.Path, tap: pathlib.Path, @@ -496,6 +559,15 @@ def misorder_embedded_formulae(policy: dict) -> None: with tempfile.TemporaryDirectory(prefix="kandelo-shell-finalizer-test.") as temporary: root = pathlib.Path(temporary) source = copy_source(root) + expected_shape = expected_release_shape(source) + initial_migration = json.loads( + (source / "homebrew/main-shell-migration-lock.json").read_text() + ) + expected_rebuilds = { + entry["formula"]["name"]: entry["formula"]["bottle_rebuild"] + 1 + for entry in initial_migration["packages"] + if entry["formula"]["name"] in {"file-formula", "zip"} + } tap = create_tap(root, source) paths = [source / relative for relative in COPIED] before = {path: digest(path) for path in paths} @@ -511,14 +583,8 @@ def misorder_embedded_formulae(policy: dict) -> None: ) applied_json = json.loads(applied.stdout) assert applied_json["applied"] is True - assert applied_json["roots"] == 32 - assert applied_json["base_formulae"] == 38 - assert applied_json["embedded"] == 3 - assert applied_json["lazy"] == 35 - assert applied_json["runtime_formulae"] == 21 - assert applied_json["audited_formulae"] == 25 - assert applied_json["runtime_extra"] == 1 - assert applied_json["total"] == 39 + for key, value in expected_shape.items(): + assert applied_json[key] == value head = subprocess.run( ["git", "-C", str(tap), "rev-parse", "HEAD"], check=True, @@ -565,8 +631,8 @@ def misorder_embedded_formulae(policy: dict) -> None: entry["formula"]["name"]: entry["formula"]["bottle_rebuild"] for entry in migration["packages"] } - assert rebuilt["file-formula"] == 4 - assert rebuilt["zip"] == 2 + assert rebuilt["file-formula"] == expected_rebuilds["file-formula"] + assert rebuilt["zip"] == expected_rebuilds["zip"] shell_config_path = source / "homebrew/main-shell-default.json" first_shell_config_sha = artifact_lock["inputs"]["shell_config_sha256"] @@ -588,11 +654,7 @@ def misorder_embedded_formulae(policy: dict) -> None: assert artifact_lock["image"] is None assert_product_state(source, "awaiting-selection") checker = run_checker(source, tap) - assert ( - "38 base Formulae, 21 runtime Formulae, and 25 audited Formulae; " - "the runtime adds 1 beyond the base, yielding 39 total Formulae" - in checker.stdout - ) + assert expected_checker_summary(expected_shape) in checker.stdout artifact = root / "shell.vfs.zst" artifact.write_bytes(b"reviewed deterministic shell bytes\n") @@ -642,13 +704,9 @@ def misorder_embedded_formulae(policy: dict) -> None: ) as temporary: root = pathlib.Path(temporary) source = copy_source(root) - add_future_libyaml_shape(source) + ensure_libyaml_shape(source) set_shell_revision(source, "23") - tap = create_tap( - root, - source, - dependency_overrides={"ruby": ["zlib", "libyaml"]}, - ) + tap = create_tap(root, source) applied = run( "--source-root", str(source), "--tap-root", str(tap), "--apply" ) @@ -666,6 +724,17 @@ def misorder_embedded_formulae(policy: dict) -> None: (source / "packages/registry/shell/build.toml").read_text(), re.MULTILINE, ) + support_path = source / "homebrew/main-shell-homebrew-runtime-support.json" + baseline = json.loads(support_path.read_text()) + libyaml = f"{TAP_NAME}/libyaml" + ruby = f"{TAP_NAME}/ruby" + zlib = f"{TAP_NAME}/zlib" + for order in [ + baseline["formula_order"], + baseline["availability"]["reusable_public_abi42"], + ]: + assert order.index(libyaml) < order.index(zlib) < order.index(ruby) + assert baseline["additional_formula_order"] == [libyaml, ruby] checker = run_checker(source, tap) assert ( "38 base Formulae, 22 runtime Formulae, and 26 audited Formulae; " @@ -673,11 +742,6 @@ def misorder_embedded_formulae(policy: dict) -> None: in checker.stdout ) - support_path = source / "homebrew/main-shell-homebrew-runtime-support.json" - baseline = json.loads(support_path.read_text()) - libyaml = f"{TAP_NAME}/libyaml" - ruby = f"{TAP_NAME}/ruby" - missing_runtime = json.loads(json.dumps(baseline)) missing_runtime["formula_order"].remove(libyaml) missing_runtime["additional_formula_order"].remove(libyaml) @@ -869,14 +933,18 @@ def misorder_embedded_formulae(policy: dict) -> None: ) libyaml = f"{TAP_NAME}/libyaml" ruby = f"{TAP_NAME}/ruby" + zlib = f"{TAP_NAME}/zlib" assert migration["catalog"]["tap_commit"] == source_commit assert support["catalog"]["tap_commit"] == source_commit assert support["availability"]["audited_catalog"][ "checkout_commit" ] == source_commit - assert support["formula_order"].index(libyaml) < support[ - "formula_order" - ].index(ruby) + formula_order = support["formula_order"] + assert ( + formula_order.index(libyaml) + < formula_order.index(zlib) + < formula_order.index(ruby) + ) assert support["additional_formula_order"] == [libyaml, ruby] assert libyaml in support["availability"]["reusable_public_abi42"] assert selection_lock["state"] == "sealed" diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index dcc8102294..1037ab6827 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -685,8 +685,13 @@ grep -Fq 'assertPackageClosure(' "$IMAGE_CONTRACT" || bash "$LAZY_ARTIFACT_CHECKER" \ --lock "$LAZY_ARTIFACT_LOCK" --expected-source-date-epoch 0 || fail "lazy shell artifact lock is not an exact digest/size/timestamp contract" -[ "$(jq -er '.state' "$SELECTION_LOCK")" = pending ] || - fail "new shell selection authority must begin in review-only pending state" +checked_in_product_state="$( + python3 "$PRODUCT_STATE_TOOL" --root "$REPO_ROOT" +)" || fail "checked-in shell contracts do not form a valid product state" +case "$checked_in_product_state" in + awaiting-selection | candidate | publishable) ;; + *) fail "unsupported checked-in shell product state: $checked_in_product_state" ;; +esac # WHY: preparation runs without write credentials in the workflow's first # job. The publisher receives only that same-run deterministic archive, so a # token-bearing job never has to execute tap-controlled materialization code. @@ -3058,10 +3063,9 @@ expect_failure "lock is invalid or uses a different timestamp epoch" \ --work-dir "$TMP_ROOT/work-extra-lazy-lock-field" --migration-lock "$lock" \ --lazy-artifact-lock "$extra_field_lock" sealed_fixture_lock="$TMP_ROOT/main-shell-sealed-artifact-lock.json" -# WHY: source updates truthfully return the checked-in artifact lock to -# pending. Build the opposite-state fixture explicitly so this rejection test -# covers a sealed lock in both release phases instead of assuming repository -# state happens to be sealed. +# WHY: this rejection must not depend on which release phase is checked in. +# Construct the sealed opposite-state fixture explicitly from the current +# contract instead of treating transient repository state as test authority. jq ' .state = "sealed" | .image = { @@ -3258,7 +3262,13 @@ jq --slurpfile support "$RUNTIME_SUPPORT" ' ["coreutils", "dash", "diffutils", "grep", "less", "libcurl", "openssl", "sed", "vim", "zlib"] elif . == "libcurl" then ["openssl", "zlib"] elif . == "less" or . == "vim" then ["ncurses"] - elif . == "ruby" then ["zlib"] + elif . == "ruby" then + if ($support[0].formula_order | + index("kandelo-dev/tap-core/libyaml")) == null then + ["zlib"] + else + ["libyaml", "zlib"] + end else [] end; . as $lock | diff --git a/scripts/test-homebrew-main-shell-product-state.sh b/scripts/test-homebrew-main-shell-product-state.sh index 27c5e43905..9a2324e53f 100755 --- a/scripts/test-homebrew-main-shell-product-state.sh +++ b/scripts/test-homebrew-main-shell-product-state.sh @@ -29,10 +29,6 @@ fixture="$TMP_ROOT/product" mkdir -p \ "$fixture/homebrew" \ "$fixture/packages/registry/shell" -cp "$REPO_ROOT/homebrew/main-shell-selection-lock.json" \ - "$fixture/homebrew/main-shell-selection-lock.json" -cp "$REPO_ROOT/homebrew/main-shell-lazy-artifact-lock.json" \ - "$fixture/homebrew/main-shell-lazy-artifact-lock.json" for input in \ main-shell.Brewfile \ kandelo-guest-layout.json \ @@ -45,11 +41,39 @@ for input in \ do cp "$REPO_ROOT/homebrew/$input" "$fixture/homebrew/$input" done -cp "$REPO_ROOT/packages/registry/shell/build.toml" \ - "$fixture/packages/registry/shell/build.toml" cp "$REPO_ROOT/packages/registry/shell/package.toml" \ "$fixture/packages/registry/shell/package.toml" +# Construct the first phase explicitly. The repository may be checked out +# before selection, after selection, or after the final image seal; this test +# owns fixtures for all three states instead of treating one phase as eternal. +pending_selection="$TMP_ROOT/pending-selection.json" +jq '.state = "pending" | .release = null' \ + "$REPO_ROOT/homebrew/main-shell-selection-lock.json" \ + >"$pending_selection" +cp "$pending_selection" \ + "$fixture/homebrew/main-shell-selection-lock.json" +selection_sha="$(sha256sum \ + "$fixture/homebrew/main-shell-selection-lock.json")" +selection_sha="${selection_sha%% *}" + +pending_artifact="$TMP_ROOT/pending-artifact.json" +jq --arg sha "$selection_sha" ' + .state = "pending" | + .image = null | + .inputs.selection_lock_sha256 = $sha +' "$REPO_ROOT/homebrew/main-shell-lazy-artifact-lock.json" \ + >"$pending_artifact" +cp "$pending_artifact" \ + "$fixture/homebrew/main-shell-lazy-artifact-lock.json" + +pending_build="$TMP_ROOT/pending-build.toml" +sed -E \ + 's/publication_state = "(pending|ready)"/publication_state = "pending"/' \ + "$REPO_ROOT/packages/registry/shell/build.toml" \ + >"$pending_build" +cp "$pending_build" "$fixture/packages/registry/shell/build.toml" + [ "$(python3 "$STATE_TOOL" --root "$fixture")" = awaiting-selection ] || fail "pending selection was not classified as awaiting-selection" @@ -71,8 +95,7 @@ printf '%s\n' \ >>"$fixture/packages/registry/shell/build.toml" expect_failure "unsupported publication inputs" \ python3 "$STATE_TOOL" --root "$fixture" -cp "$REPO_ROOT/packages/registry/shell/build.toml" \ - "$fixture/packages/registry/shell/build.toml" +cp "$pending_build" "$fixture/packages/registry/shell/build.toml" sed 's/depends_on = \[\]/depends_on = ["legacy@1"]/' \ "$REPO_ROOT/packages/registry/shell/package.toml" \ @@ -83,11 +106,11 @@ cp "$REPO_ROOT/packages/registry/shell/package.toml" \ "$fixture/packages/registry/shell/package.toml" sed 's/"state": "pending"/"state": "pending", "state": "pending"/' \ - "$REPO_ROOT/homebrew/main-shell-selection-lock.json" \ + "$pending_selection" \ >"$fixture/homebrew/main-shell-selection-lock.json" expect_failure "JSON repeats key 'state'" \ python3 "$STATE_TOOL" --root "$fixture" -cp "$REPO_ROOT/homebrew/main-shell-selection-lock.json" \ +cp "$pending_selection" \ "$fixture/homebrew/main-shell-selection-lock.json" jq '.state = "sealed" | .release = { From ac81feae807556f8c576e66c3540761b3ab3a715 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 5 Aug 2026 19:05:56 -0400 Subject: [PATCH 2/3] [Homebrew/Browser] Prove deployed proxy transport before release --- .github/workflows/browser-demos-pages.yml | 5 +++++ .../reusable-homebrew-main-shell-mirror-publish.yml | 6 ++++++ docs/homebrew-publishing.md | 6 ++++++ scripts/check-homebrew-main-shell-mirror-workflow.rb | 4 +++- scripts/ci-check-pages-deployment.sh | 5 +++++ scripts/test-homebrew-main-shell-mirror-workflow.sh | 1 + scripts/test-pages-deployment-contract.sh | 10 ++++++++++ 7 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/browser-demos-pages.yml b/.github/workflows/browser-demos-pages.yml index 6b24955cfc..7a9dff822b 100644 --- a/.github/workflows/browser-demos-pages.yml +++ b/.github/workflows/browser-demos-pages.yml @@ -203,6 +203,10 @@ jobs: working-directory: apps/browser-demos env: VITE_BASE: /kandelo/ + # WHY: Vite preview defaults to its local same-origin relay. Pin the + # deployed proxy so this gate exercises the service-worker transport + # that will remain after the static tree reaches GitHub Pages. + VITE_CORS_PROXY_URL: https://wordpress-playground-cors-proxy.net/? KANDELO_BROWSER_DEMO_INPUTS: main KANDELO_HOMEBREW_MAIN_SHELL_STRICT: "1" KANDELO_HOMEBREW_MAIN_SHELL_SHA256: ${{ steps.shell_product.outputs.image_sha256 }} @@ -216,6 +220,7 @@ jobs: set -euo pipefail bash ../../scripts/dev-shell.sh env \ "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ + "VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL" \ npx playwright test \ test/kandelo-homebrew-main-shell.spec.ts \ --project=chromium diff --git a/.github/workflows/reusable-homebrew-main-shell-mirror-publish.yml b/.github/workflows/reusable-homebrew-main-shell-mirror-publish.yml index 24d054b4c1..086f5fda01 100644 --- a/.github/workflows/reusable-homebrew-main-shell-mirror-publish.yml +++ b/.github/workflows/reusable-homebrew-main-shell-mirror-publish.yml @@ -1081,6 +1081,11 @@ jobs: env: KANDELO_BROWSER_DEMO_INPUTS: main KANDELO_PLAYWRIGHT_SERVE_DIST: "1" + # WHY: Vite preview otherwise substitutes its local same-origin + # relay for the proxy embedded in the production service worker. + # The public proof must exercise the transport the deployed Pages + # product will actually use. + VITE_CORS_PROXY_URL: https://wordpress-playground-cors-proxy.net/? WASM_POSIX_BINARY_CACHE_ROOT: ${{ runner.temp }}/main-shell-public-chromium-proof-cache KANDELO_HOMEBREW_MAIN_SHELL_STRICT: "1" KANDELO_HOMEBREW_MAIN_SHELL_SHA256: ${{ steps.public.outputs.image-sha }} @@ -1100,6 +1105,7 @@ jobs: bash ../../scripts/dev-shell.sh env \ "KANDELO_BROWSER_DEMO_INPUTS=$KANDELO_BROWSER_DEMO_INPUTS" \ "KANDELO_PLAYWRIGHT_SERVE_DIST=$KANDELO_PLAYWRIGHT_SERVE_DIST" \ + "VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL" \ "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ "KANDELO_HOMEBREW_MAIN_SHELL_STRICT=$KANDELO_HOMEBREW_MAIN_SHELL_STRICT" \ "KANDELO_HOMEBREW_MAIN_SHELL_SHA256=$KANDELO_HOMEBREW_MAIN_SHELL_SHA256" \ diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 4ce85925a9..c32b9ac102 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -3990,6 +3990,12 @@ runner loss can prevent cleanup, but cannot let later deployment steps consume that partial runner. Ordinary `prepare-browser` remains the independent bottle-backed path. +Both the public lifecycle proof and the final Pages gate pin and forward the +production CORS proxy while serving the built tree. Vite preview otherwise +replaces the built service worker's external proxy with its development-only +same-origin relay. The required Chromium runs therefore exercise the same +external lazy-bottle transport that remains after deployment. + Pages intentionally continues to run for every `main` push without a path filter. The canonical browser package projection and shared inputs can grow; filtering by a maintained list would allow a new input to change without diff --git a/scripts/check-homebrew-main-shell-mirror-workflow.rb b/scripts/check-homebrew-main-shell-mirror-workflow.rb index dd58caf7b5..b89dc8f632 100755 --- a/scripts/check-homebrew-main-shell-mirror-workflow.rb +++ b/scripts/check-homebrew-main-shell-mirror-workflow.rb @@ -15,7 +15,7 @@ PUBLISH_JOB_DIGEST = "5f38b593eeffd4cacf3d728baa64695e88fe2f0723757628dbc936b6b679c54b" WORKFLOW_DIGEST = - "9dee4c5bb5a12cb06aa25f2c54fe884febf5762cb698772c0c6629283cf9af91" + "b058f9ddf928cd298548b41e38afb76ab5af4102be494da568076de7f5dd7435" NODE_SCOPE_RUNNER_DIGEST = "a351c57bba3b4ad05d58a346ccf2ffa22d6de194d1839c24a78d2b9bc07f1bf8" DOWNLOAD_ACTION = @@ -33,6 +33,8 @@ PUBLIC_CHROMIUM_PLAYWRIGHT_ENV = { "KANDELO_BROWSER_DEMO_INPUTS" => "main", "KANDELO_PLAYWRIGHT_SERVE_DIST" => "1", + "VITE_CORS_PROXY_URL" => + "https://wordpress-playground-cors-proxy.net/?", "WASM_POSIX_BINARY_CACHE_ROOT" => "${{ runner.temp }}/main-shell-public-chromium-proof-cache", "KANDELO_HOMEBREW_MAIN_SHELL_STRICT" => "1", diff --git a/scripts/ci-check-pages-deployment.sh b/scripts/ci-check-pages-deployment.sh index 22293355a9..187d569fec 100755 --- a/scripts/ci-check-pages-deployment.sh +++ b/scripts/ci-check-pages-deployment.sh @@ -290,6 +290,9 @@ sealed_boot_block="$( step_block "$PAGES_WORKFLOW" "Boot the canonical bottled Pages shell in Chromium" )" grep -Fq 'VITE_BASE: /kandelo/' <<<"$sealed_boot_block" && + grep -Fq \ + 'VITE_CORS_PROXY_URL: https://wordpress-playground-cors-proxy.net/?' \ + <<<"$sealed_boot_block" && grep -Fq 'KANDELO_BROWSER_DEMO_INPUTS: main' \ <<<"$sealed_boot_block" && grep -Fq 'KANDELO_HOMEBREW_MAIN_SHELL_STRICT: "1"' \ @@ -314,6 +317,8 @@ grep -Fq 'VITE_BASE: /kandelo/' <<<"$sealed_boot_block" && grep -Fq 'bash ../../scripts/dev-shell.sh env \' <<<"$sealed_boot_block" && grep -Fq '"WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \' \ <<<"$sealed_boot_block" && + grep -Fq '"VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL" \' \ + <<<"$sealed_boot_block" && grep -Fq 'test/kandelo-homebrew-main-shell.spec.ts' \ <<<"$sealed_boot_block" || fail "the Pages preview must prove the public bottled shell at the published base" diff --git a/scripts/test-homebrew-main-shell-mirror-workflow.sh b/scripts/test-homebrew-main-shell-mirror-workflow.sh index ac4fc72821..aabd0977db 100755 --- a/scripts/test-homebrew-main-shell-mirror-workflow.sh +++ b/scripts/test-homebrew-main-shell-mirror-workflow.sh @@ -175,6 +175,7 @@ expect_rejected "$TMP_ROOT/dropped-chromium-input-selection.yml" for forwarded in \ KANDELO_BROWSER_DEMO_INPUTS \ KANDELO_PLAYWRIGHT_SERVE_DIST \ + VITE_CORS_PROXY_URL \ WASM_POSIX_BINARY_CACHE_ROOT \ KANDELO_HOMEBREW_MAIN_SHELL_STRICT \ KANDELO_HOMEBREW_MAIN_SHELL_SHA256 \ diff --git a/scripts/test-pages-deployment-contract.sh b/scripts/test-pages-deployment-contract.sh index 9bea1b16c2..e931171b70 100755 --- a/scripts/test-pages-deployment-contract.sh +++ b/scripts/test-pages-deployment-contract.sh @@ -246,6 +246,16 @@ expect_mutation_rejected \ "must prove the public bottled shell at the published base" \ 's/( - name: Boot the canonical bottled Pages shell in Chromium\n working-directory: apps\/browser-demos\n env:\n) VITE_BASE: \/kandelo\/\n/$1/' +expect_mutation_rejected \ + "bottled preview without deployed CORS proxy" \ + "must prove the public bottled shell at the published base" \ + 's/( - name: Boot the canonical bottled Pages shell in Chromium\n[\s\S]*?)^ VITE_CORS_PROXY_URL: https:\/\/wordpress-playground-cors-proxy\.net\/\?\n/$1/m' + +expect_mutation_rejected \ + "bottled preview drops deployed CORS proxy in dev-shell" \ + "must prove the public bottled shell at the published base" \ + 's/^ "VITE_CORS_PROXY_URL=\$VITE_CORS_PROXY_URL" \\\n//m' + expect_mutation_rejected \ "bottled preview loses package cache root" \ "must prove the public bottled shell at the published base" \ From 024569c7ed1605570991b574aeccf8b53445717c Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 5 Aug 2026 19:26:06 -0400 Subject: [PATCH 3/3] [Homebrew/Browser] Keep Pages proof inputs through dev-shell Forward the complete sealed-product configuration into Playwright and its Vite preview. The dev shell intentionally drops ambient workflow variables; without explicit forwarding, the strict acceptance test can skip while the Pages step succeeds. Require each proof input exactly once in the Pages contract checker. Cover every dropped input with a rejection fixture. --- .github/workflows/browser-demos-pages.yml | 15 +++++++++++- scripts/ci-check-pages-deployment.sh | 27 ++++++++++++++++++---- scripts/test-pages-deployment-contract.sh | 28 +++++++++++++++-------- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/browser-demos-pages.yml b/.github/workflows/browser-demos-pages.yml index 7a9dff822b..cb8f0ec4e3 100644 --- a/.github/workflows/browser-demos-pages.yml +++ b/.github/workflows/browser-demos-pages.yml @@ -218,9 +218,22 @@ jobs: KANDELO_TEST_BASE_URL: http://127.0.0.1:5401/kandelo/ run: | set -euo pipefail + # WHY: dev-shell removes ambient workflow variables. Forward the + # complete exact-product contract so Playwright cannot silently skip + # strict acceptance or preview different bytes, base, or transport. bash ../../scripts/dev-shell.sh env \ - "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ + "VITE_BASE=$VITE_BASE" \ "VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL" \ + "KANDELO_BROWSER_DEMO_INPUTS=$KANDELO_BROWSER_DEMO_INPUTS" \ + "KANDELO_HOMEBREW_MAIN_SHELL_STRICT=$KANDELO_HOMEBREW_MAIN_SHELL_STRICT" \ + "KANDELO_HOMEBREW_MAIN_SHELL_SHA256=$KANDELO_HOMEBREW_MAIN_SHELL_SHA256" \ + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256=$KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256" \ + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES=$KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES" \ + "KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE=$KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE" \ + "KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL=$KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL" \ + "KANDELO_PLAYWRIGHT_SERVE_DIST=$KANDELO_PLAYWRIGHT_SERVE_DIST" \ + "KANDELO_TEST_BASE_URL=$KANDELO_TEST_BASE_URL" \ + "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ npx playwright test \ test/kandelo-homebrew-main-shell.spec.ts \ --project=chromium diff --git a/scripts/ci-check-pages-deployment.sh b/scripts/ci-check-pages-deployment.sh index 187d569fec..a184cefe9d 100755 --- a/scripts/ci-check-pages-deployment.sh +++ b/scripts/ci-check-pages-deployment.sh @@ -315,14 +315,33 @@ grep -Fq 'VITE_BASE: /kandelo/' <<<"$sealed_boot_block" && grep -Fq 'KANDELO_TEST_BASE_URL: http://127.0.0.1:5401/kandelo/' \ <<<"$sealed_boot_block" && grep -Fq 'bash ../../scripts/dev-shell.sh env \' <<<"$sealed_boot_block" && - grep -Fq '"WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \' \ - <<<"$sealed_boot_block" && - grep -Fq '"VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL" \' \ - <<<"$sealed_boot_block" && grep -Fq 'test/kandelo-homebrew-main-shell.spec.ts' \ <<<"$sealed_boot_block" || fail "the Pages preview must prove the public bottled shell at the published base" +# WHY: dev-shell deliberately removes ambient workflow variables. Merely +# declaring an exact-product value in the step does not make it visible to +# Playwright or its Vite child; every proof input must cross that boundary +# exactly once or the strict acceptance case can skip while the step is green. +for proof_variable in \ + VITE_BASE \ + VITE_CORS_PROXY_URL \ + KANDELO_BROWSER_DEMO_INPUTS \ + KANDELO_HOMEBREW_MAIN_SHELL_STRICT \ + KANDELO_HOMEBREW_MAIN_SHELL_SHA256 \ + KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256 \ + KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES \ + KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE \ + KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL \ + KANDELO_PLAYWRIGHT_SERVE_DIST \ + KANDELO_TEST_BASE_URL \ + WASM_POSIX_BINARY_CACHE_ROOT +do + forwarded="\"${proof_variable}=\$${proof_variable}\" \\" + [ "$(grep -Fxc " $forwarded" <<<"$sealed_boot_block")" -eq 1 ] || + fail "the Pages preview must forward $proof_variable through dev-shell exactly once" +done + between_freshness_and_deploy="$( sed -n "${freshness_line},${deploy_line}p" "$PAGES_WORKFLOW" | awk '/^ - name:/ { count += 1 } END { print count + 0 }' diff --git a/scripts/test-pages-deployment-contract.sh b/scripts/test-pages-deployment-contract.sh index e931171b70..f144752412 100755 --- a/scripts/test-pages-deployment-contract.sh +++ b/scripts/test-pages-deployment-contract.sh @@ -251,15 +251,25 @@ expect_mutation_rejected \ "must prove the public bottled shell at the published base" \ 's/( - name: Boot the canonical bottled Pages shell in Chromium\n[\s\S]*?)^ VITE_CORS_PROXY_URL: https:\/\/wordpress-playground-cors-proxy\.net\/\?\n/$1/m' -expect_mutation_rejected \ - "bottled preview drops deployed CORS proxy in dev-shell" \ - "must prove the public bottled shell at the published base" \ - 's/^ "VITE_CORS_PROXY_URL=\$VITE_CORS_PROXY_URL" \\\n//m' - -expect_mutation_rejected \ - "bottled preview loses package cache root" \ - "must prove the public bottled shell at the published base" \ - 's/( - name: Boot the canonical bottled Pages shell in Chromium[\s\S]*?)^ "WASM_POSIX_BINARY_CACHE_ROOT=\$WASM_POSIX_BINARY_CACHE_ROOT" \\\n/$1/m' +for proof_variable in \ + VITE_BASE \ + VITE_CORS_PROXY_URL \ + KANDELO_BROWSER_DEMO_INPUTS \ + KANDELO_HOMEBREW_MAIN_SHELL_STRICT \ + KANDELO_HOMEBREW_MAIN_SHELL_SHA256 \ + KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256 \ + KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES \ + KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE \ + KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL \ + KANDELO_PLAYWRIGHT_SERVE_DIST \ + KANDELO_TEST_BASE_URL \ + WASM_POSIX_BINARY_CACHE_ROOT +do + expect_mutation_rejected \ + "bottled preview drops $proof_variable at the dev-shell boundary" \ + "must forward $proof_variable through dev-shell exactly once" \ + "s/( - name: Boot the canonical bottled Pages shell in Chromium\\n[\\s\\S]*?)^ \"${proof_variable}=\\\$${proof_variable}\" \\\\\\n/\$1/m" +done expect_mutation_rejected \ "bottled preview uses the retired source test" \