ci(canvas): add Canvas↔app design-token SSOT drift gate (app#86) #3041
@@ -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
|
||||
Reference in New Issue
Block a user