From 6552612e6a438c90263c93e5c7faa1aaad435a84 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 18 Jun 2026 10:08:01 +0000 Subject: [PATCH 1/3] =?UTF-8?q?ci(canvas):=20add=20Canvas=E2=86=94app=20de?= =?UTF-8?q?sign-token=20SSOT=20drift=20gate=20(app#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an advisory drift gate that compares the shared semantic color tokens in canvas/src/app/globals.css against molecule-ai/molecule-app/app/globals.css. - Mirrors the existing app-side gate. - Skips loud when APP_SSOT_READ_TOKEN is absent. - Real drift emits ::error:: with exact diffs. Refs molecule-ai/molecule-app#86 --- .gitea/scripts/check_app_token_drift.py | 162 +++++++++++++++++++ .gitea/workflows/design-token-drift-gate.yml | 43 +++++ 2 files changed, 205 insertions(+) create mode 100644 .gitea/scripts/check_app_token_drift.py create mode 100644 .gitea/workflows/design-token-drift-gate.yml diff --git a/.gitea/scripts/check_app_token_drift.py b/.gitea/scripts/check_app_token_drift.py new file mode 100644 index 00000000..2ebe8676 --- /dev/null +++ b/.gitea/scripts/check_app_token_drift.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Cross-repo SSOT drift gate: compare molecule-core canvas tokens to molecule-app. + +Fetches molecule-ai/molecule-app/app/globals.css and byte-compares the shared +@theme tokens (surface/ink/line/accent/warm/good/bad, light + dark) against the +local canvas/src/app/globals.css. Real drift -> ::error:: + exit 1 with diffs. + +Advisory by default: the calling CI job uses continue-on-error: true and is NOT +in all-required. The gate skips loud when APP_SSOT_READ_TOKEN is absent. + +Mirrors molecule-ai/molecule-app/.gitea/scripts/check_canvas_token_drift.py. +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +import urllib.request +from typing import Dict, Tuple + +SHARED_TOKEN_NAMES: Tuple[str, ...] = ( + "--color-surface", + "--color-surface-elevated", + "--color-surface-sunken", + "--color-surface-card", + "--color-line", + "--color-line-soft", + "--color-ink", + "--color-ink-mid", + "--color-ink-soft", + "--color-accent", + "--color-accent-strong", + "--color-warm", + "--color-good", + "--color-bad", +) + +APP_FILE_PATH = "app/globals.css" +APP_REPO = "molecule-ai/molecule-app" +GITEA_SERVER = os.environ.get("GITEA_SERVER_URL", "https://git.moleculesai.app") +CANVAS_FILE_PATH = "canvas/src/app/globals.css" + + +def fetch_app_css(token: str) -> str: + """Fetch the molecule-app globals.css raw content.""" + url = f"{GITEA_SERVER}/api/v1/repos/{APP_REPO}/contents/{APP_FILE_PATH}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"token {token}", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read().decode("utf-8") + + payload = json.loads(data) + content = payload.get("content") + if not content: + raise RuntimeError(f"empty content field from {url}") + return base64.b64decode(content).decode("utf-8") + + +def extract_theme_block(css: str) -> str: + """Extract the body of the first @theme block.""" + pattern = re.compile(r"@theme\s*\{([^}]*)\}", re.DOTALL) + match = pattern.search(css) + return match.group(1) if match else "" + + +def extract_data_theme_dark(css: str) -> str: + """Extract the body of the first [data-theme=\"dark\"] block.""" + pattern = re.compile(r'\[data-theme="dark"\]\s*\{([^}]*)\}', re.DOTALL) + match = pattern.search(css) + return match.group(1) if match else "" + + +def parse_tokens(block: str) -> Dict[str, str]: + """Parse --color-* tokens from a CSS block.""" + tokens: Dict[str, str] = {} + for line in block.splitlines(): + line = line.strip() + if not line or line.startswith("/*") or line.startswith("*"): + continue + match = re.match(r"(--color-[a-z-]+):\s*([^;]+);", line) + if match: + tokens[match.group(1)] = match.group(2).strip() + return tokens + + +def extract_shared_tokens(css: str) -> Dict[str, Dict[str, str]]: + """Return {light: tokens, dark: tokens} for the shared SSOT surface.""" + light_block = extract_theme_block(css) + dark_block = extract_data_theme_dark(css) + return { + "light": parse_tokens(light_block), + "dark": parse_tokens(dark_block), + } + + +def compare_tokens( + canvas: Dict[str, Dict[str, str]], + app: Dict[str, Dict[str, str]], +) -> Tuple[bool, Dict[str, Dict[str, str]]]: + """Compare canvas vs app tokens. Returns (ok, drift_by_mode).""" + drift: Dict[str, Dict[str, str]] = {"light": {}, "dark": {}} + ok = True + for mode in ("light", "dark"): + canvas_mode = canvas[mode] + app_mode = app[mode] + for name in SHARED_TOKEN_NAMES: + canvas_val = canvas_mode.get(name) + app_val = app_mode.get(name) + if canvas_val != app_val: + drift[mode][name] = f"app={app_val!r} canvas={canvas_val!r}" + ok = False + return ok, drift + + +def main() -> int: + token = os.environ.get("APP_SSOT_READ_TOKEN") + if not token: + print("::notice::APP_SSOT_READ_TOKEN not set; skipping app token-SSOT drift gate.") + print(" Gate will activate once the molecule-app read PAT is provisioned.") + return 0 + + if not os.path.exists(CANVAS_FILE_PATH): + print(f"::error::{CANVAS_FILE_PATH} not found in working tree") + return 1 + + try: + app_css = fetch_app_css(token) + except Exception as exc: # noqa: BLE001 + print(f"::error::Failed to fetch app SSOT ({APP_REPO}/{APP_FILE_PATH}): {exc}") + return 1 + + with open(CANVAS_FILE_PATH, "r", encoding="utf-8") as f: + canvas_css = f.read() + + canvas_tokens = extract_shared_tokens(canvas_css) + app_tokens = extract_shared_tokens(app_css) + + ok, drift = compare_tokens(canvas_tokens, app_tokens) + if ok: + print("::notice::Canvas↔app token SSOT is aligned.") + return 0 + + print("::error::Canvas↔app token SSOT drift detected.") + for mode in ("light", "dark"): + if drift[mode]: + print(f"\n[{mode} mode]") + for name, detail in drift[mode].items(): + print(f" {name}: {detail}") + print(f"\n{len(drift['light']) + len(drift['dark'])} token(s) differ from app SSOT.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/workflows/design-token-drift-gate.yml b/.gitea/workflows/design-token-drift-gate.yml new file mode 100644 index 00000000..6b90ba6b --- /dev/null +++ b/.gitea/workflows/design-token-drift-gate.yml @@ -0,0 +1,43 @@ +name: design-token-drift + +# Cross-repo SSOT drift gate: compare molecule-core canvas design tokens to +# molecule-app/app/globals.css. Mirrors the app-side gate in +# molecule-ai/molecule-app/.gitea/workflows/ci.yml. +# +# Phase 1 (advisory): continue-on-error: true and NOT in all-required. Once +# the shared token set has been green for a week and APP_SSOT_READ_TOKEN is +# provisioned, promote to required. + +on: + push: + branches: [main, staging] + paths: + - 'canvas/src/app/globals.css' + - '.gitea/scripts/check_app_token_drift.py' + - '.gitea/workflows/design-token-drift-gate.yml' + pull_request: + branches: [main, staging] + paths: + - 'canvas/src/app/globals.css' + - '.gitea/scripts/check_app_token_drift.py' + - '.gitea/workflows/design-token-drift-gate.yml' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + drift: + name: Canvas ↔ app design-token SSOT drift + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Check canvas↔app token SSOT drift + env: + APP_SSOT_READ_TOKEN: ${{ secrets.APP_SSOT_READ_TOKEN }} + run: python3 .gitea/scripts/check_app_token_drift.py -- 2.52.0 From 5d4de2b7def8c533eeb68a91bc9390cfb303fc7c Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 18 Jun 2026 10:17:45 +0000 Subject: [PATCH 2/3] ci(canvas): add tracker + bp-exempt directive to design-token-drift-gate --- .gitea/workflows/design-token-drift-gate.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/design-token-drift-gate.yml b/.gitea/workflows/design-token-drift-gate.yml index 6b90ba6b..1576b15c 100644 --- a/.gitea/workflows/design-token-drift-gate.yml +++ b/.gitea/workflows/design-token-drift-gate.yml @@ -26,12 +26,13 @@ on: permissions: contents: read +# bp-exempt: Phase 1 advisory gate; continue-on-error and not in all-required. jobs: drift: name: Canvas ↔ app design-token SSOT drift runs-on: ubuntu-latest timeout-minutes: 5 - continue-on-error: true + continue-on-error: true # mc#3041 — Phase 1 advisory gate; promote after 1w green steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 -- 2.52.0 From 7b322d0a0687100e169d8588ea6583c365c966ec Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 18 Jun 2026 10:58:47 +0000 Subject: [PATCH 3/3] test(canvas): add unit tests for check_app_token_drift.py (#3041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pytest coverage for the Canvas↔app token SSOT drift gate script: theme/dark block extraction, token parsing, drift detection, skip-when-no-token, missing-canvas-file error, aligned-report, and drift-report paths. --- .../tests/test_check_app_token_drift.py | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .gitea/scripts/tests/test_check_app_token_drift.py diff --git a/.gitea/scripts/tests/test_check_app_token_drift.py b/.gitea/scripts/tests/test_check_app_token_drift.py new file mode 100644 index 00000000..461541b2 --- /dev/null +++ b/.gitea/scripts/tests/test_check_app_token_drift.py @@ -0,0 +1,144 @@ +import importlib.util +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock + +SCRIPT = Path(__file__).resolve().parents[1] / "check_app_token_drift.py" +spec = importlib.util.spec_from_file_location("check_app_token_drift", SCRIPT) +drift = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = drift +spec.loader.exec_module(drift) + + +SAMPLE_CANVAS_CSS = """ +@theme { + --color-surface: #ffffff; + --color-surface-elevated: #fafafa; + --color-surface-sunken: #f5f5f5; + --color-surface-card: #ffffff; + --color-line: #e5e5e5; + --color-line-soft: #f0f0f0; + --color-ink: #111111; + --color-ink-mid: #555555; + --color-ink-soft: #888888; + --color-accent: #0066cc; + --color-accent-strong: #0055aa; + --color-warm: #ff9900; + --color-good: #0c8a52; + --color-bad: #c2403c; +} + +[data-theme="dark"] { + --color-surface: #111111; + --color-surface-elevated: #1a1a1a; + --color-surface-sunken: #0a0a0a; + --color-surface-card: #1a1a1a; + --color-line: #333333; + --color-line-soft: #222222; + --color-ink: #f5f5f5; + --color-ink-mid: #aaaaaa; + --color-ink-soft: #777777; + --color-accent: #4d9fff; + --color-accent-strong: #66adff; + --color-warm: #ffaa33; + --color-good: #2a6e44; + --color-bad: #b0463f; +} +""" + + +def test_extract_theme_block_finds_first_theme_block(): + block = drift.extract_theme_block(SAMPLE_CANVAS_CSS) + assert "--color-surface: #ffffff" in block + assert "[data-theme" not in block + + +def test_extract_data_theme_dark_finds_dark_block(): + block = drift.extract_data_theme_dark(SAMPLE_CANVAS_CSS) + assert "--color-surface: #111111" in block + assert "--color-good: #2a6e44" in block + + +def test_parse_tokens_extracts_shared_tokens(): + block = drift.extract_theme_block(SAMPLE_CANVAS_CSS) + tokens = drift.parse_tokens(block) + assert tokens["--color-surface"] == "#ffffff" + assert tokens["--color-good"] == "#0c8a52" + assert len(tokens) == len(drift.SHARED_TOKEN_NAMES) + + +def test_compare_tokens_detects_no_drift(): + canvas = drift.extract_shared_tokens(SAMPLE_CANVAS_CSS) + ok, differences = drift.compare_tokens(canvas, canvas) + assert ok is True + assert differences == {"light": {}, "dark": {}} + + +def test_compare_tokens_detects_drift(): + canvas = drift.extract_shared_tokens(SAMPLE_CANVAS_CSS) + app_css = SAMPLE_CANVAS_CSS.replace("--color-good: #0c8a52;", "--color-good: #00ff00;") + app = drift.extract_shared_tokens(app_css) + ok, differences = drift.compare_tokens(canvas, app) + assert ok is False + assert "--color-good" in differences["light"] + assert "#0c8a52" in differences["light"]["--color-good"] + assert "#00ff00" in differences["light"]["--color-good"] + + +def test_main_skips_when_token_missing(capsys): + with patch.dict("os.environ", {}, clear=True): + assert drift.main() == 0 + captured = capsys.readouterr() + assert "APP_SSOT_READ_TOKEN not set" in captured.out + + +def test_main_errors_when_canvas_file_missing(capsys): + with patch.dict("os.environ", {"APP_SSOT_READ_TOKEN": "fake"}, clear=True): + with patch.object(drift, "CANVAS_FILE_PATH", "nonexistent/globals.css"): + assert drift.main() == 1 + captured = capsys.readouterr() + assert "not found in working tree" in captured.out + + +def _fake_urlopen_response(body: bytes): + """Return a context-manager-compatible mock response.""" + mock_resp = MagicMock() + mock_resp.read.return_value = body + mock_cm = MagicMock() + mock_cm.__enter__ = MagicMock(return_value=mock_resp) + mock_cm.__exit__ = MagicMock(return_value=False) + return mock_cm + + +def test_main_reports_drift(capsys, tmp_path): + canvas_file = tmp_path / "globals.css" + canvas_file.write_text(SAMPLE_CANVAS_CSS) + app_css = SAMPLE_CANVAS_CSS.replace("--color-good: #0c8a52;", "--color-good: #00ff00;") + + fake_payload = {"content": __import__("base64").b64encode(app_css.encode()).decode()} + fake_response = _fake_urlopen_response(__import__("json").dumps(fake_payload).encode()) + + with patch.dict("os.environ", {"APP_SSOT_READ_TOKEN": "fake"}, clear=True): + with patch.object(drift, "CANVAS_FILE_PATH", str(canvas_file)): + with patch("urllib.request.urlopen", return_value=fake_response): + assert drift.main() == 1 + + captured = capsys.readouterr() + assert "Canvas↔app token SSOT drift detected" in captured.out + assert "--color-good" in captured.out + + +def test_main_reports_aligned(capsys, tmp_path): + canvas_file = tmp_path / "globals.css" + canvas_file.write_text(SAMPLE_CANVAS_CSS) + + fake_payload = {"content": __import__("base64").b64encode(SAMPLE_CANVAS_CSS.encode()).decode()} + fake_response = _fake_urlopen_response(__import__("json").dumps(fake_payload).encode()) + + with patch.dict("os.environ", {"APP_SSOT_READ_TOKEN": "fake"}, clear=True): + with patch.object(drift, "CANVAS_FILE_PATH", str(canvas_file)): + with patch("urllib.request.urlopen", return_value=fake_response): + assert drift.main() == 0 + + captured = capsys.readouterr() + assert "Canvas↔app token SSOT is aligned" in captured.out -- 2.52.0