From 7eacf46e9f9f9590d07a1faaba68f8d98a254f74 Mon Sep 17 00:00:00 2001
From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com>
Date: Tue, 10 Nov 2026 13:32:46 -0500
Subject: [PATCH 1/4] Apply Launchplane production use through native website
head settings
---
docker/scripts/odoo_website_settings.py | 102 +++++++++++++++-
docs/tooling/workspace-cli.md | 45 +++++++
.../website_settings_checks/tests/__init__.py | 2 +-
.../tests/test_website_indexing.py | 113 ++++++++++++++++++
tests/test_website_indexing.py | 111 +++++++++++++++++
5 files changed, 371 insertions(+), 2 deletions(-)
create mode 100644 tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
create mode 100644 tests/test_website_indexing.py
diff --git a/docker/scripts/odoo_website_settings.py b/docker/scripts/odoo_website_settings.py
index 937233d..ce9ba81 100644
--- a/docker/scripts/odoo_website_settings.py
+++ b/docker/scripts/odoo_website_settings.py
@@ -20,6 +20,106 @@
}
)
NATIVE_FIELDS = ("google_analytics_key", "cookies_bar", "block_third_party_domains")
+MANAGED_NOINDEX = ''
+INDEXING_FIELDS = frozenset({"website_id", "public_origin", "production_use"})
+
+
+def indexing_head(head: str, production_use: str) -> str:
+ unmanaged = head.removeprefix(MANAGED_NOINDEX)
+ if "data-launchplane-production-use" in unmanaged.lower():
+ raise RuntimeError("Native website head has an ambiguous managed indexing element.")
+ return MANAGED_NOINDEX + unmanaged if production_use == "prelaunch" else unmanaged
+
+
+def head_digest(head: str) -> str:
+ return hashlib.sha256(head.encode()).hexdigest()
+
+
+def indexing_website(env: Any, raw: dict[str, Any]) -> Any:
+ website_id = raw["website_id"]
+ if type(website_id) is not int or website_id < 1:
+ raise RuntimeError("Website indexing requires one native website ID.")
+ origin = raw["public_origin"]
+ if not isinstance(origin, str) or any(character.isspace() for character in origin):
+ raise RuntimeError("Website indexing requires an exact public HTTPS origin.")
+ try:
+ parsed = urlsplit(origin)
+ valid_port = parsed.port is None or 0 < parsed.port <= 65535
+ except ValueError as error:
+ raise RuntimeError("Website indexing requires an exact public HTTPS origin.") from error
+ if (
+ parsed.scheme != "https"
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.path not in {"", "/"}
+ or parsed.query
+ or parsed.fragment
+ or not valid_port
+ ):
+ raise RuntimeError("Website indexing requires an exact public HTTPS origin.")
+ websites = env["website"].sudo()
+ host = str(parsed.netloc).lower().encode("idna")
+ matches = [
+ website for website in websites.search([]) if str(urlsplit(str(website.domain or "")).netloc).lower().encode("idna") == host
+ ]
+ if len(matches) != 1 or matches[0].id != website_id:
+ raise RuntimeError("Website indexing requires an unambiguous native website/origin binding.")
+ website = matches[0]
+ if str(website.domain or "").rstrip("/") != origin.rstrip("/"):
+ raise RuntimeError("Website indexing origin does not match the native website domain.")
+ if "custom_code_head" not in website._fields:
+ raise RuntimeError("Native website head setting is unavailable.")
+ return website
+
+
+def plan_website_indexing(env: Any, payload: dict[str, object] | None, *, instance: str) -> dict[str, Any] | None:
+ raw = payload.get("website_indexing") if payload else None
+ if raw is None:
+ return None
+ if (
+ not payload
+ or instance.strip().lower() not in PRODUCTION_INSTANCE_NAMES
+ or payload.get("instance") not in PRODUCTION_INSTANCE_NAMES
+ ):
+ raise RuntimeError("Website indexing requires a matching production payload.")
+ if not isinstance(raw, dict) or set(raw) != INDEXING_FIELDS or raw["production_use"] not in ("prelaunch", "live"):
+ raise RuntimeError("Invalid Launchplane website indexing actuation data.")
+ if not website_is_loaded(env):
+ raise RuntimeError("Website indexing requires the installed website addon.")
+ website = indexing_website(env, raw)
+ before = str(website.custom_code_head or "")
+ after = indexing_head(before, raw["production_use"])
+ return {
+ **raw,
+ "field": "custom_code_head",
+ "before_sha256": head_digest(before),
+ "after_sha256": head_digest(after),
+ "changed": before != after,
+ "managed_noindex_before": before.startswith(MANAGED_NOINDEX),
+ "managed_noindex_after": after.startswith(MANAGED_NOINDEX),
+ }
+
+
+def apply_website_settings(env: Any, payload: dict[str, object] | None, *, instance: str) -> str | None:
+ plan = plan_website_indexing(env, payload, instance=instance)
+ if plan is None:
+ return apply_legacy_website_settings(env, payload, instance=instance)
+ website = indexing_website(env, plan)
+ with env.cr.savepoint():
+ digest = apply_legacy_website_settings(env, payload, instance=instance)
+ before = str(website.custom_code_head or "")
+ if head_digest(before) != plan["before_sha256"]:
+ raise RuntimeError("Native website head changed after indexing planning.")
+ after = indexing_head(before, plan["production_use"])
+ if before != after:
+ website.write({"custom_code_head": after or False})
+ website.flush_recordset(["custom_code_head"])
+ website.invalidate_recordset(["custom_code_head"])
+ if head_digest(str(website.custom_code_head or "")) != plan["after_sha256"]:
+ raise RuntimeError("Native website indexing readback failed.")
+ print("website_indexing=" + json.dumps({**plan, "readback_verified": True}, sort_keys=True, separators=(",", ":")))
+ return digest
def read_string_parameter(parameters: Any, key: str) -> str:
@@ -60,7 +160,7 @@ def exclude_collection(websites: Any, parameters: Any) -> None:
raise RuntimeError("Nonproduction website indexing exclusion readback failed.")
-def apply_website_settings(env: Any, payload: dict[str, object] | None, *, instance: str) -> str | None:
+def apply_legacy_website_settings(env: Any, payload: dict[str, object] | None, *, instance: str) -> str | None:
"""Call in the existing environment-overrides transaction; caller commits.
Empty/unknown instances are nonproduction. Clearing every copied website is
diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md
index 0efad34..c0ab886 100644
--- a/docs/tooling/workspace-cli.md
+++ b/docs/tooling/workspace-cli.md
@@ -455,6 +455,51 @@ Notes
lacks Website logs the skip and continues. These flags are
runtime assertions supplied by Launchplane-managed records or operator input;
local runtimes remain optional unless a caller explicitly sets them.
+- Launchplane may include `website_indexing` alongside the unchanged
+ `website_settings` in the existing post-deploy payload:
+
+ ```json
+ {
+ "instance": "prod",
+ "website_indexing": {
+ "website_id": 1,
+ "public_origin": "https://site.example.test",
+ "production_use": "prelaunch"
+ }
+ }
+ ```
+
+ These three fields are required and no extra fields are accepted. They are
+ actuation data derived from Launchplane's current production state and exact
+ production website binding, never an operator SEO flag. Only `prelaunch` and
+ `live` are accepted, on a production instance with a matching production
+ envelope. Omission preserves legacy behavior. The selected native website
+ must have that exact HTTPS domain (an optional trailing slash is ignored),
+ with no second website competing for the host even under another scheme.
+ Missing addons, fields, selectors and ambiguous managed markup refuse.
+
+ `plan_website_indexing(env, payload, instance=...)` is the read-only dry run.
+ It returns the selector, origin, supplied state, native field, SHA-256 of the
+ current and proposed head, `changed`, and managed-element presence before and
+ after. `apply_website_settings` uses that same plan, preserves the caller's
+ transaction, verifies native database readback and prints `website_indexing=`
+ followed by that JSON plus `readback_verified`. It never commits. This is
+ tentative transaction evidence until the caller commits successfully; the
+ legacy return digest and `website_settings_sha256` retain their meaning.
+ A savepoint rolls back this consumer's writes on failure, and an outer
+ transaction rollback also restores the prior head. A rollback after an
+ already committed transition must supply the authoritative restored state
+ through this same consumer; no recorded production state is inferred here.
+
+ Prelaunch prepends one identifiable robots `noindex` meta element to native
+ `website.custom_code_head`; live removes only that exact managed prefix.
+ All pre-existing head content is retained. Indexing-only payloads do not
+ change analytics, consent, robots.txt, domain, routes, pages or other sites.
+ The setting covers HTML pages rendering the native website layout, not every
+ downloadable resource. It neither removes sitemap entries nor proves removal
+ from search results. Existing robots rules remain unchanged: crawlers must
+ be able to fetch a page to observe its noindex directive, as described by
+ [Google](https://developers.google.com/search/docs/crawling-indexing/block-indexing).
- Legacy setting-shaped inputs such as `ENV_OVERRIDE_CONFIG_PARAM__*`,
`ENV_OVERRIDE_AUTHENTIK__*`, and `ENV_OVERRIDE_SHOPIFY__*` are still accepted
as a compatibility input and converted into the same typed payload, but they
diff --git a/tests/live_odoo/addons/website_settings_checks/tests/__init__.py b/tests/live_odoo/addons/website_settings_checks/tests/__init__.py
index 5c23aea..acc39c4 100644
--- a/tests/live_odoo/addons/website_settings_checks/tests/__init__.py
+++ b/tests/live_odoo/addons/website_settings_checks/tests/__init__.py
@@ -1 +1 @@
-from . import test_website_settings
+from . import test_website_indexing, test_website_settings
diff --git a/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py b/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
new file mode 100644
index 0000000..712d3b4
--- /dev/null
+++ b/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
@@ -0,0 +1,113 @@
+import hashlib
+import io
+import json
+from contextlib import redirect_stdout
+from typing import Any
+from unittest.mock import patch
+
+from lxml import html
+from odoo import tools
+from odoo.tests import HttpCase, tagged
+from odoo_website_settings import apply_website_settings, plan_website_indexing
+
+
+@tagged("post_install", "-at_install")
+class TestNativeWebsiteIndexing(HttpCase):
+ def test_public_noindex_preserves_routes_robots_and_unmanaged_head(self) -> None:
+ self.enterContext(patch.dict(tools.config.options, {"proxy_mode": True}))
+ original = ''
+ website = self.env["website"].create(
+ {
+ "name": "Synthetic indexing",
+ "domain": "https://indexing.example.test",
+ "custom_code_head": original,
+ "robots_txt": "User-agent: *\nDisallow: /private-fixture\n",
+ "google_analytics_key": "G-SYNTHETIC",
+ "cookies_bar": True,
+ "block_third_party_domains": True,
+ }
+ )
+ other = self.env["website"].create(
+ {
+ "name": "Unrelated synthetic",
+ "domain": "https://unrelated.example.test",
+ "custom_code_head": original,
+ }
+ )
+ payload: dict[str, Any] = {
+ "instance": "prod",
+ "website_indexing": {
+ "website_id": website.id,
+ "public_origin": website.domain,
+ "production_use": "prelaunch",
+ },
+ }
+
+ def page(host: str = "indexing.example.test", path: str = "/contactus") -> str:
+ response = self.url_open(path, headers={"X-Forwarded-Host": host, "X-Forwarded-Proto": "https"})
+ self.assertEqual(response.status_code, 200)
+ return response.text
+
+ def managed_count(document: str) -> int:
+ return len(html.fromstring(document).xpath('//head/meta[@name="robots" and @content="noindex"]'))
+
+ def apply() -> dict[str, object]:
+ output = io.StringIO()
+ with redirect_stdout(output):
+ apply_website_settings(self.env, payload, instance="prod")
+ return json.loads(output.getvalue().split("website_indexing=")[1].splitlines()[0])
+
+ baseline = page()
+ self.assertEqual(managed_count(baseline), 0)
+ canonical = html.fromstring(baseline).xpath('//link[@rel="canonical"]/@href')
+ self.assertTrue(canonical)
+ robots = page(path="/robots.txt")
+ self.assertNotIn("Disallow: /\n", robots)
+ before = website.read(
+ ["domain", "custom_code_head", "google_analytics_key", "cookies_bar", "block_third_party_domains", "robots_txt"]
+ )
+ plan = plan_website_indexing(self.env, payload, instance="prod")
+ self.assertEqual(website.read(list(before[0])), before)
+ receipt = apply()
+ self.assertEqual(receipt["after_sha256"], plan["after_sha256"])
+ self.env.cr.execute("SELECT custom_code_head FROM website WHERE id=%s", (website.id,))
+ protected = self.env.cr.fetchone()[0]
+ self.assertEqual(hashlib.sha256(protected.encode()).hexdigest(), receipt["after_sha256"])
+ protected_page = page()
+ self.assertEqual(managed_count(protected_page), 1)
+ self.assertEqual(html.fromstring(protected_page).xpath('//head/meta[@content="nofollow"]/@name'), ["robots"])
+ self.assertIn('window.synthetic = "preserved";', protected_page)
+ self.assertEqual(html.fromstring(protected_page).xpath('//link[@rel="canonical"]/@href'), canonical)
+ self.assertEqual(page(path="/robots.txt"), robots)
+ self.assertEqual(managed_count(page("unrelated.example.test")), 0)
+ self.assertEqual(other.custom_code_head, original)
+ self.assertFalse(apply()["changed"])
+ payload["website_indexing"]["production_use"] = "live"
+ with patch.object(type(website), "write", return_value=True), self.assertRaisesRegex(RuntimeError, "readback failed"):
+ apply()
+ self.assertEqual(website.custom_code_head, protected)
+ self.assertEqual(managed_count(page()), 1)
+ try:
+ with self.env.cr.savepoint():
+ apply()
+ raise RuntimeError("synthetic release failure")
+ except RuntimeError as error:
+ self.assertEqual(str(error), "synthetic release failure")
+ self.assertEqual(website.custom_code_head, protected)
+ self.assertEqual(managed_count(page()), 1)
+ apply()
+ self.assertEqual(website.read(list(before[0])), before)
+ self.assertEqual(managed_count(page()), 0)
+ self.assertEqual(page(path="/robots.txt"), robots)
+ payload["website_indexing"]["production_use"] = "prelaunch"
+ apply()
+ self.assertEqual(managed_count(page()), 1)
+ duplicate = self.env["website"].create({"name": "Ambiguous synthetic", "domain": "http://indexing.example.test"})
+ with self.assertRaisesRegex(RuntimeError, "unambiguous"):
+ apply()
+ duplicate.unlink()
+ payload["website_indexing"]["public_origin"] = other.domain
+ with self.assertRaisesRegex(RuntimeError, "binding"):
+ apply()
+ self.assertEqual(website.custom_code_head, protected)
+ print("NATIVE_INDEXING_HTTP_PROOF " + json.dumps(receipt, sort_keys=True), flush=True)
diff --git a/tests/test_website_indexing.py b/tests/test_website_indexing.py
new file mode 100644
index 0000000..a29cd08
--- /dev/null
+++ b/tests/test_website_indexing.py
@@ -0,0 +1,111 @@
+import copy
+import hashlib
+import io
+import json
+import unittest
+from contextlib import contextmanager, redirect_stdout
+from typing import Any
+
+from tests.test_odoo_website_settings import Environment, consumer, settings
+
+
+class Cursor:
+ def __init__(self, env: Environment) -> None:
+ self.env = env
+
+ @contextmanager
+ def savepoint(self) -> Any:
+ before = copy.deepcopy(self.env.websites.records)
+ parameters = copy.deepcopy(self.env.parameters.values)
+ try:
+ yield
+ except Exception:
+ self.env.websites.records = before
+ self.env.parameters.values = parameters
+ raise
+
+
+class IndexingEnvironment(Environment):
+ def __init__(self) -> None:
+ super().__init__()
+ self.cr = Cursor(self)
+
+
+class WebsiteIndexingTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.env = IndexingEnvironment()
+ self.original = ''
+ for website in self.env.websites.records:
+ website._fields.add("custom_code_head")
+ website.custom_code_head = self.original
+ self.env.websites.records[1].domain = "https://unrelated.example"
+ self.payload: dict[str, Any] = {
+ "instance": "prod",
+ "website_indexing": {"website_id": 1, "public_origin": "https://site.example", "production_use": "prelaunch"},
+ }
+
+ def apply(self) -> dict[str, Any]:
+ output = io.StringIO()
+ with redirect_stdout(output):
+ consumer.apply_website_settings(self.env, self.payload, instance="prod")
+ return json.loads(output.getvalue().split("website_indexing=")[1].splitlines()[0])
+
+ def test_plan_is_read_only_and_apply_preserves_every_unmanaged_setting(self) -> None:
+ before = copy.deepcopy([vars(website) for website in self.env.websites.records])
+ plan = consumer.plan_website_indexing(self.env, self.payload, instance="prod")
+ self.assertEqual(before, [vars(website) for website in self.env.websites.records])
+ self.assertEqual(plan["before_sha256"], hashlib.sha256(self.original.encode()).hexdigest())
+ receipt = self.apply()
+ website = self.env.websites.records[0]
+ self.assertEqual(receipt["after_sha256"], hashlib.sha256(website.custom_code_head.encode()).hexdigest())
+ self.assertEqual(receipt["after_sha256"], plan["after_sha256"])
+ self.assertIn('content="noindex"', website.custom_code_head)
+ self.assertTrue(website.custom_code_head.endswith(self.original))
+ self.assertEqual(website.google_analytics_key, "G-COPIED")
+ self.assertEqual(website.domain, "https://site.example")
+ self.assertEqual(vars(self.env.websites.records[1]), before[1])
+ self.assertEqual(self.env.parameters.values, {consumer.INDEXING_PARAMETER: "1"})
+ self.assertFalse(self.apply()["changed"])
+ self.payload["website_indexing"]["production_use"] = "live"
+ self.apply()
+ self.assertEqual(website.custom_code_head, self.original)
+
+ def test_invalid_or_ambiguous_bindings_refuse_before_legacy_writes(self) -> None:
+ self.payload["website_settings"] = settings()
+ for change in (
+ {"website_id": True},
+ {"website_id": 99},
+ {"public_origin": "https://wrong.example"},
+ {"public_origin": "https://site.example/path"},
+ {"production_use": "unknown"},
+ {"extra": True},
+ ):
+ with self.subTest(change=change):
+ payload = copy.deepcopy(self.payload)
+ payload["website_indexing"].update(change)
+ with self.assertRaises(RuntimeError):
+ consumer.apply_website_settings(self.env, payload, instance="prod")
+ self.assertFalse(any(website.writes for website in self.env.websites.records))
+ self.env.websites.records[1].domain = "https://site.example/"
+ with self.assertRaises(RuntimeError):
+ consumer.apply_website_settings(self.env, self.payload, instance="prod")
+ self.assertFalse(any(website.writes for website in self.env.websites.records))
+
+ def test_failed_native_write_rolls_back_legacy_settings_and_keeps_protection(self) -> None:
+ self.apply()
+ protected = self.env.websites.records[0].custom_code_head
+ self.payload["website_indexing"]["production_use"] = "live"
+ self.payload["website_settings"] = settings()
+ self.env.websites.records[0].persist_writes = False
+ with self.assertRaises(RuntimeError):
+ self.apply()
+ self.assertEqual(self.env.websites.records[0].custom_code_head, protected)
+ self.assertEqual(self.env.websites.records[0].google_analytics_key, "G-COPIED")
+
+ def test_nonproduction_payload_cannot_remove_protection(self) -> None:
+ self.apply()
+ protected = self.env.websites.records[0].custom_code_head
+ self.payload["website_indexing"]["production_use"] = "live"
+ with self.assertRaises(RuntimeError):
+ consumer.apply_website_settings(self.env, self.payload, instance="testing")
+ self.assertEqual(self.env.websites.records[0].custom_code_head, protected)
From 469dcb1b0dd9c10083da12caf69fa23955923bb6 Mon Sep 17 00:00:00 2001
From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com>
Date: Tue, 10 Nov 2026 13:41:50 -0500
Subject: [PATCH 2/4] fix: bind combined website settings and prove native
rollback
---
docker/scripts/odoo_website_settings.py | 53 ++++++++++---------
docs/tooling/workspace-cli.md | 11 +++-
.../tests/test_website_indexing.py | 40 ++++++++++++--
tests/test_website_indexing.py | 12 ++++-
4 files changed, 82 insertions(+), 34 deletions(-)
diff --git a/docker/scripts/odoo_website_settings.py b/docker/scripts/odoo_website_settings.py
index ce9ba81..e410923 100644
--- a/docker/scripts/odoo_website_settings.py
+++ b/docker/scripts/odoo_website_settings.py
@@ -4,7 +4,7 @@
import json
import re
from typing import Any
-from urllib.parse import urlsplit
+from urllib.parse import SplitResult, urlsplit
PRODUCTION_INSTANCE_NAMES = frozenset({"prod", "production"})
INDEXING_PARAMETER = "cm_website.public_indexing_enabled"
@@ -35,6 +35,23 @@ def head_digest(head: str) -> str:
return hashlib.sha256(head.encode()).hexdigest()
+def parse_public_origin(origin: object) -> SplitResult:
+ if not isinstance(origin, str):
+ raise RuntimeError("Website settings require a public HTTPS origin.")
+ parsed = urlsplit(origin)
+ if (
+ parsed.scheme != "https"
+ or not parsed.hostname
+ or parsed.username
+ or parsed.password
+ or parsed.path not in {"", "/"}
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise RuntimeError("Website settings require a public HTTPS origin.")
+ return parsed
+
+
def indexing_website(env: Any, raw: dict[str, Any]) -> Any:
website_id = raw["website_id"]
if type(website_id) is not int or website_id < 1:
@@ -43,20 +60,11 @@ def indexing_website(env: Any, raw: dict[str, Any]) -> Any:
if not isinstance(origin, str) or any(character.isspace() for character in origin):
raise RuntimeError("Website indexing requires an exact public HTTPS origin.")
try:
- parsed = urlsplit(origin)
+ parsed = parse_public_origin(origin)
valid_port = parsed.port is None or 0 < parsed.port <= 65535
except ValueError as error:
raise RuntimeError("Website indexing requires an exact public HTTPS origin.") from error
- if (
- parsed.scheme != "https"
- or not parsed.hostname
- or parsed.username is not None
- or parsed.password is not None
- or parsed.path not in {"", "/"}
- or parsed.query
- or parsed.fragment
- or not valid_port
- ):
+ if parsed.username is not None or not valid_port:
raise RuntimeError("Website indexing requires an exact public HTTPS origin.")
websites = env["website"].sudo()
host = str(parsed.netloc).lower().encode("idna")
@@ -85,6 +93,13 @@ def plan_website_indexing(env: Any, payload: dict[str, object] | None, *, instan
raise RuntimeError("Website indexing requires a matching production payload.")
if not isinstance(raw, dict) or set(raw) != INDEXING_FIELDS or raw["production_use"] not in ("prelaunch", "live"):
raise RuntimeError("Invalid Launchplane website indexing actuation data.")
+ legacy = payload.get("website_settings")
+ if legacy is not None and (
+ not isinstance(legacy, dict)
+ or legacy.get("website_id") != raw["website_id"]
+ or str(legacy.get("public_origin", "")).rstrip("/") != str(raw["public_origin"]).rstrip("/")
+ ):
+ raise RuntimeError("Website settings and indexing must select the same native website and origin.")
if not website_is_loaded(env):
raise RuntimeError("Website indexing requires the installed website addon.")
website = indexing_website(env, raw)
@@ -207,19 +222,7 @@ def apply_legacy_website_settings(env: Any, payload: dict[str, object] | None, *
if analytics and not (raw["cookies_bar"] and raw["block_third_party_domains"]):
raise RuntimeError("Website analytics requires the cookie bar and third-party blocking.")
origin = raw["public_origin"]
- if not isinstance(origin, str):
- raise RuntimeError("Website settings require a public HTTPS origin.")
- parsed = urlsplit(origin)
- if (
- parsed.scheme != "https"
- or not parsed.hostname
- or parsed.username
- or parsed.password
- or parsed.path not in {"", "/"}
- or parsed.query
- or parsed.fragment
- ):
- raise RuntimeError("Website settings require a public HTTPS origin.")
+ parse_public_origin(origin)
if operation == "disable_collection":
# Historical recovery has no verified selector. Disable collection across
# the database while preserving every website's consent/embedding choices.
diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md
index c0ab886..1161d70 100644
--- a/docs/tooling/workspace-cli.md
+++ b/docs/tooling/workspace-cli.md
@@ -475,10 +475,15 @@ Notes
`live` are accepted, on a production instance with a matching production
envelope. Omission preserves legacy behavior. The selected native website
must have that exact HTTPS domain (an optional trailing slash is ignored),
- with no second website competing for the host even under another scheme.
+ with no second website competing for the host and port even under another
+ scheme. When both payload objects are present, their website ID and origin
+ must agree before either can write.
Missing addons, fields, selectors and ambiguous managed markup refuse.
- `plan_website_indexing(env, payload, instance=...)` is the read-only dry run.
+ `plan_website_indexing(env, payload, instance=...)` is the read-only dry-run
+ function for Launchplane's generator to invoke; this does not add a CLI or
+ enable a target operation. The producer must accept the new payload object
+ and regenerate its embedded consumer from the reviewed devkit commit.
It returns the selector, origin, supplied state, native field, SHA-256 of the
current and proposed head, `changed`, and managed-element presence before and
after. `apply_website_settings` uses that same plan, preserves the caller's
@@ -495,6 +500,8 @@ Notes
`website.custom_code_head`; live removes only that exact managed prefix.
All pre-existing head content is retained. Indexing-only payloads do not
change analytics, consent, robots.txt, domain, routes, pages or other sites.
+ Live removes only this protection; independent existing indexing choices,
+ including the legacy accepted setting, continue to apply.
The setting covers HTML pages rendering the native website layout, not every
downloadable resource. It neither removes sitemap entries nor proves removal
from search results. Existing robots rules remain unchanged: crawlers must
diff --git a/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py b/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
index 712d3b4..8debde2 100644
--- a/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
+++ b/tests/live_odoo/addons/website_settings_checks/tests/test_website_indexing.py
@@ -8,7 +8,13 @@
from lxml import html
from odoo import tools
from odoo.tests import HttpCase, tagged
-from odoo_website_settings import apply_website_settings, plan_website_indexing
+from odoo_website_settings import (
+ INDEXING_PARAMETER,
+ apply_website_settings,
+ plan_website_indexing,
+ read_string_parameter,
+ write_string_parameter,
+)
@tagged("post_install", "-at_install")
@@ -49,7 +55,10 @@ def page(host: str = "indexing.example.test", path: str = "/contactus") -> str:
return response.text
def managed_count(document: str) -> int:
- return len(html.fromstring(document).xpath('//head/meta[@name="robots" and @content="noindex"]'))
+ return sum(
+ "noindex" in content.lower().replace(",", " ").split()
+ for content in html.fromstring(document).xpath('//head/meta[@name="robots"]/@content')
+ )
def apply() -> dict[str, object]:
output = io.StringIO()
@@ -83,9 +92,30 @@ def apply() -> dict[str, object]:
self.assertEqual(other.custom_code_head, original)
self.assertFalse(apply()["changed"])
payload["website_indexing"]["production_use"] = "live"
- with patch.object(type(website), "write", return_value=True), self.assertRaisesRegex(RuntimeError, "readback failed"):
+ parameters = self.env["ir.config_parameter"].sudo()
+ write_string_parameter(parameters, INDEXING_PARAMETER, "0")
+ payload["website_settings"] = {
+ "operation": "apply",
+ "website_id": website.id,
+ "public_origin": website.domain,
+ "public_indexing_enabled": True,
+ "google_analytics_key": "G-CHANGED",
+ "cookies_bar": True,
+ "block_third_party_domains": True,
+ }
+ native_write = type(website).write
+
+ def ignore_head(records: Any, values: dict[str, object]) -> bool:
+ if "custom_code_head" in values:
+ return True
+ return native_write(records, values)
+
+ with patch.object(type(website), "write", ignore_head), self.assertRaises(RuntimeError):
apply()
self.assertEqual(website.custom_code_head, protected)
+ self.assertEqual(website.google_analytics_key, before[0]["google_analytics_key"])
+ self.assertEqual(read_string_parameter(parameters, INDEXING_PARAMETER), "0")
+ del payload["website_settings"]
self.assertEqual(managed_count(page()), 1)
try:
with self.env.cr.savepoint():
@@ -103,11 +133,11 @@ def apply() -> dict[str, object]:
apply()
self.assertEqual(managed_count(page()), 1)
duplicate = self.env["website"].create({"name": "Ambiguous synthetic", "domain": "http://indexing.example.test"})
- with self.assertRaisesRegex(RuntimeError, "unambiguous"):
+ with self.assertRaises(RuntimeError):
apply()
duplicate.unlink()
payload["website_indexing"]["public_origin"] = other.domain
- with self.assertRaisesRegex(RuntimeError, "binding"):
+ with self.assertRaises(RuntimeError):
apply()
self.assertEqual(website.custom_code_head, protected)
print("NATIVE_INDEXING_HTTP_PROOF " + json.dumps(receipt, sort_keys=True), flush=True)
diff --git a/tests/test_website_indexing.py b/tests/test_website_indexing.py
index a29cd08..da5bbae 100644
--- a/tests/test_website_indexing.py
+++ b/tests/test_website_indexing.py
@@ -59,7 +59,7 @@ def test_plan_is_read_only_and_apply_preserves_every_unmanaged_setting(self) ->
website = self.env.websites.records[0]
self.assertEqual(receipt["after_sha256"], hashlib.sha256(website.custom_code_head.encode()).hexdigest())
self.assertEqual(receipt["after_sha256"], plan["after_sha256"])
- self.assertIn('content="noindex"', website.custom_code_head)
+ self.assertTrue(receipt["managed_noindex_after"])
self.assertTrue(website.custom_code_head.endswith(self.original))
self.assertEqual(website.google_analytics_key, "G-COPIED")
self.assertEqual(website.domain, "https://site.example")
@@ -96,11 +96,19 @@ def test_failed_native_write_rolls_back_legacy_settings_and_keeps_protection(sel
protected = self.env.websites.records[0].custom_code_head
self.payload["website_indexing"]["production_use"] = "live"
self.payload["website_settings"] = settings()
- self.env.websites.records[0].persist_writes = False
+ self.env.parameters.values[consumer.INDEXING_PARAMETER] = "0"
+ self.env.websites.records[0].ignored_write_fields = {"custom_code_head"}
with self.assertRaises(RuntimeError):
self.apply()
self.assertEqual(self.env.websites.records[0].custom_code_head, protected)
self.assertEqual(self.env.websites.records[0].google_analytics_key, "G-COPIED")
+ self.assertEqual(self.env.parameters.values[consumer.INDEXING_PARAMETER], "0")
+
+ def test_combined_payload_cannot_select_different_websites(self) -> None:
+ self.payload["website_settings"] = {**settings(), "website_id": 2, "public_origin": "https://unrelated.example"}
+ with self.assertRaises(RuntimeError):
+ consumer.apply_website_settings(self.env, self.payload, instance="prod")
+ self.assertFalse(any(website.writes for website in self.env.websites.records))
def test_nonproduction_payload_cannot_remove_protection(self) -> None:
self.apply()
From feaa13b9463c1068648d0407b89f50e747098484 Mon Sep 17 00:00:00 2001
From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com>
Date: Tue, 10 Nov 2026 13:44:45 -0500
Subject: [PATCH 3/4] fix: clear native parameter cache after consumer rollback
---
docker/scripts/odoo_website_settings.py | 28 ++++++++++++++-----------
tests/test_website_indexing.py | 3 +++
2 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/docker/scripts/odoo_website_settings.py b/docker/scripts/odoo_website_settings.py
index e410923..0c8a621 100644
--- a/docker/scripts/odoo_website_settings.py
+++ b/docker/scripts/odoo_website_settings.py
@@ -121,18 +121,22 @@ def apply_website_settings(env: Any, payload: dict[str, object] | None, *, insta
if plan is None:
return apply_legacy_website_settings(env, payload, instance=instance)
website = indexing_website(env, plan)
- with env.cr.savepoint():
- digest = apply_legacy_website_settings(env, payload, instance=instance)
- before = str(website.custom_code_head or "")
- if head_digest(before) != plan["before_sha256"]:
- raise RuntimeError("Native website head changed after indexing planning.")
- after = indexing_head(before, plan["production_use"])
- if before != after:
- website.write({"custom_code_head": after or False})
- website.flush_recordset(["custom_code_head"])
- website.invalidate_recordset(["custom_code_head"])
- if head_digest(str(website.custom_code_head or "")) != plan["after_sha256"]:
- raise RuntimeError("Native website indexing readback failed.")
+ try:
+ with env.cr.savepoint():
+ digest = apply_legacy_website_settings(env, payload, instance=instance)
+ before = str(website.custom_code_head or "")
+ if head_digest(before) != plan["before_sha256"]:
+ raise RuntimeError("Native website head changed after indexing planning.")
+ after = indexing_head(before, plan["production_use"])
+ if before != after:
+ website.write({"custom_code_head": after or False})
+ website.flush_recordset(["custom_code_head"])
+ website.invalidate_recordset(["custom_code_head"])
+ if head_digest(str(website.custom_code_head or "")) != plan["after_sha256"]:
+ raise RuntimeError("Native website indexing readback failed.")
+ except BaseException:
+ env.registry.clear_cache("stable")
+ raise
print("website_indexing=" + json.dumps({**plan, "readback_verified": True}, sort_keys=True, separators=(",", ":")))
return digest
diff --git a/tests/test_website_indexing.py b/tests/test_website_indexing.py
index da5bbae..b560d04 100644
--- a/tests/test_website_indexing.py
+++ b/tests/test_website_indexing.py
@@ -5,6 +5,7 @@
import unittest
from contextlib import contextmanager, redirect_stdout
from typing import Any
+from unittest.mock import MagicMock
from tests.test_odoo_website_settings import Environment, consumer, settings
@@ -29,6 +30,8 @@ class IndexingEnvironment(Environment):
def __init__(self) -> None:
super().__init__()
self.cr = Cursor(self)
+ self.registry = MagicMock()
+ self.registry.__contains__.side_effect = {"website"}.__contains__
class WebsiteIndexingTests(unittest.TestCase):
From 57014bfa9cd4e0f773e81c1b01fa1b45d302ea2b Mon Sep 17 00:00:00 2001
From: shiny-code-bot <332303085+shiny-code-app[bot]@users.noreply.github.com>
Date: Tue, 10 Nov 2026 13:46:23 -0500
Subject: [PATCH 4/4] fix: retain Odoo 20 native rollback cache handling
---
docker/scripts/odoo_website_settings.py | 3 ++-
tests/test_website_indexing.py | 5 +----
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/docker/scripts/odoo_website_settings.py b/docker/scripts/odoo_website_settings.py
index 0c8a621..fcb77ec 100644
--- a/docker/scripts/odoo_website_settings.py
+++ b/docker/scripts/odoo_website_settings.py
@@ -135,7 +135,8 @@ def apply_website_settings(env: Any, payload: dict[str, object] | None, *, insta
if head_digest(str(website.custom_code_head or "")) != plan["after_sha256"]:
raise RuntimeError("Native website indexing readback failed.")
except BaseException:
- env.registry.clear_cache("stable")
+ if clear_cache := getattr(env.registry, "clear_cache", None):
+ clear_cache("stable")
raise
print("website_indexing=" + json.dumps({**plan, "readback_verified": True}, sort_keys=True, separators=(",", ":")))
return digest
diff --git a/tests/test_website_indexing.py b/tests/test_website_indexing.py
index b560d04..b409e19 100644
--- a/tests/test_website_indexing.py
+++ b/tests/test_website_indexing.py
@@ -5,7 +5,6 @@
import unittest
from contextlib import contextmanager, redirect_stdout
from typing import Any
-from unittest.mock import MagicMock
from tests.test_odoo_website_settings import Environment, consumer, settings
@@ -20,7 +19,7 @@ def savepoint(self) -> Any:
parameters = copy.deepcopy(self.env.parameters.values)
try:
yield
- except Exception:
+ except BaseException:
self.env.websites.records = before
self.env.parameters.values = parameters
raise
@@ -30,8 +29,6 @@ class IndexingEnvironment(Environment):
def __init__(self) -> None:
super().__init__()
self.cr = Cursor(self)
- self.registry = MagicMock()
- self.registry.__contains__.side_effect = {"website"}.__contains__
class WebsiteIndexingTests(unittest.TestCase):