ci(canvas): add Canvas↔app design-token SSOT drift gate (app#86) #3041

Merged
devops-engineer merged 3 commits from feat/canvas-app-token-drift-gate into main 2026-06-19 06:21:31 +00:00
3 changed files with 350 additions and 0 deletions
+162
View File
@@ -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())
@@ -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
@@ -0,0 +1,44 @@
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
# 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 # 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
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