Desktop clients had two competing update sources: the runtime pointed at a legacy GitLab registry while the Forgejo packages were filled in by hardcoded, version-pinned scripts. Operators could not tell which feed was authoritative, and no release could be reproduced from a tag. Auto-update now reads a single canonical Forgejo registry feed, updated by a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and GitHub CI alike. Channel, minimum supported version, forced install, full-versus-delta thresholds, staged rollout, and a remote kill switch come from one policy file the client fetches alongside the feed. Tag creation is gated on a clean tree, matching version surfaces, and a changelog section.
208 lines
6.9 KiB
JavaScript
208 lines
6.9 KiB
JavaScript
// scripts/ci/check-design-tokens.mjs
|
|
//
|
|
// Design-token SSOT guard. Fails when a surface hardcodes a color, uses a
|
|
// numeric literal for spacing/radius/control that a token already owns, or
|
|
// re-introduces a bold weight that design.md v3 retired.
|
|
//
|
|
// The point is not zero-hex everywhere: a token *definition* file legitimately
|
|
// holds raw values. Everything else must consume --d3-* / d3ro* tokens.
|
|
//
|
|
// Usage:
|
|
// node scripts/ci/check-design-tokens.mjs # check, exit 1 on violation
|
|
// node scripts/ci/check-design-tokens.mjs --json # machine-readable report
|
|
// node scripts/ci/check-design-tokens.mjs --self-test
|
|
//
|
|
// Allowlisted paths are the ONLY places raw color literals may live.
|
|
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(HERE, '..', '..')
|
|
|
|
// Directories scanned for consumer code (not token definitions).
|
|
const TARGETS = [
|
|
'apps/desktop/src',
|
|
'apps/web/src',
|
|
'apps/admin/src',
|
|
'apps/mobile-rn/src',
|
|
'packages/ui/src',
|
|
'packages/ui-native/src',
|
|
'site/src',
|
|
'site/public',
|
|
]
|
|
|
|
// Token *definition* layers. Raw values are the point here, not a violation.
|
|
const ALLOWLIST = new Set([
|
|
'packages/ui/src/theme.ts',
|
|
'packages/ui/src/theme-vars.ts',
|
|
'packages/ui-native/src/theme.ts',
|
|
'apps/mobile-rn/src/theme/mobile-theme.ts',
|
|
'apps/admin/src/lib/console-theme.ts',
|
|
// Canvas cannot resolve CSS custom properties; these are SSR fallbacks that
|
|
// mirror --d3-gradient-wave1..4 and are never painted on the client.
|
|
'packages/ui/src/components/ds/GradientWave.tsx',
|
|
'packages/ui/src/components/ds/AudioVisualizerBar.tsx',
|
|
'apps/desktop/src/main/services/MeetingModeService.ts',
|
|
'apps/desktop/src/main/services/CloudSyncService.ts',
|
|
'apps/desktop/src/main/windows/WindowManager.ts',
|
|
'site/src/tokens.ts',
|
|
'site/src/index.css',
|
|
'site/tailwind.config.js',
|
|
'site/public/accept-invite.css',
|
|
'site/public/legal.css',
|
|
'apps/desktop/src/renderer/styles/global.css',
|
|
])
|
|
|
|
const IGNORED_DIRS = new Set([
|
|
'node_modules', '.next', 'dist', 'build', 'out', 'coverage',
|
|
'.turbo', 'android', 'ios', '__snapshots__',
|
|
])
|
|
|
|
const SCAN_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html'])
|
|
|
|
// A token definition file may define local `:root` fallbacks for popups.
|
|
const isPopupStyle = (rel) => /apps\/desktop\/src\/renderer\/popups\/.*\/style\.css$/.test(rel)
|
|
const isTestFile = (rel) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(rel)
|
|
|
|
const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g
|
|
const FUNC_COLOR = /\b(?:rgba?|hsla?)\([^)]*\)/g
|
|
const BOLD_WEIGHT = /(font-?weight\s*[:=]\s*['"]?([7-9]\d0)\b|fontWeight\s*:\s*([7-9]\d0)\b)/g
|
|
|
|
function walk(dir, files) {
|
|
let entries
|
|
try {
|
|
entries = readdirSync(dir)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const name of entries) {
|
|
if (IGNORED_DIRS.has(name)) continue
|
|
const full = join(dir, name)
|
|
const st = statSync(full)
|
|
if (st.isDirectory()) walk(full, files)
|
|
else if (SCAN_EXT.has(name.slice(name.lastIndexOf('.')))) files.push(full)
|
|
}
|
|
}
|
|
|
|
function isAllowlisted(rel) {
|
|
if (ALLOWLIST.has(rel)) return true
|
|
if (isPopupStyle(rel)) return true
|
|
if (isTestFile(rel)) return true
|
|
return false
|
|
}
|
|
|
|
function hexLooksLikeColor(match, line, index) {
|
|
const before = line[index - 1]
|
|
// URL fragment / selector boundary: #features, #root, url(#clip)
|
|
if (before && /[A-Za-z0-9_\-/)&(]/.test(before)) return false
|
|
// HTML attribute value: href="#download", id='#x'
|
|
if ((before === '"' || before === "'") && line[index - 2] === '=') return false
|
|
return true
|
|
}
|
|
|
|
// A mask gradient uses white as an opacity stencil, not a painted color.
|
|
const isMaskIdiom = (line) => /#fff 0 0/.test(line)
|
|
// Comment lines describe identifiers like #access_token; they are not colors.
|
|
const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|<!--)/.test(line)
|
|
|
|
function scanFile(full) {
|
|
const rel = relative(ROOT, full).replace(/\\/g, '/')
|
|
if (isAllowlisted(rel)) return []
|
|
const text = readFileSync(full, 'utf8')
|
|
const lines = text.split(/\r?\n/)
|
|
const out = []
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i]
|
|
if (isCommentLine(line) || isMaskIdiom(line)) continue
|
|
HEX.lastIndex = 0
|
|
let m
|
|
while ((m = HEX.exec(line))) {
|
|
if (!hexLooksLikeColor(m[0], line, m.index)) continue
|
|
out.push({ rel, line: i + 1, rule: 'hex-color', text: m[0] })
|
|
}
|
|
FUNC_COLOR.lastIndex = 0
|
|
while ((m = FUNC_COLOR.exec(line))) {
|
|
out.push({ rel, line: i + 1, rule: 'rgb/hsl-literal', text: m[0] })
|
|
}
|
|
BOLD_WEIGHT.lastIndex = 0
|
|
while ((m = BOLD_WEIGHT.exec(line))) {
|
|
out.push({ rel, line: i + 1, rule: 'bold-weight', text: m[0].trim() })
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function collect() {
|
|
const files = []
|
|
for (const t of TARGETS) {
|
|
const full = join(ROOT, t)
|
|
try {
|
|
if (statSync(full).isDirectory()) walk(full, files)
|
|
else files.push(full)
|
|
} catch {
|
|
/* target absent on this platform */
|
|
}
|
|
}
|
|
const violations = []
|
|
for (const f of files) violations.push(...scanFile(f))
|
|
return violations
|
|
}
|
|
|
|
function selfTest() {
|
|
const cases = [
|
|
['const a = "#3b82f6"', true],
|
|
['color: rgba(59,130,246,0.5)', true],
|
|
['fontWeight: 700', true],
|
|
['href="#download"', false],
|
|
['url(#clip)', false],
|
|
['const id = "#root"', false],
|
|
['color: "var(--d3-accent-main)"', false],
|
|
]
|
|
let failed = 0
|
|
for (const [line, shouldFlag] of cases) {
|
|
const flagged = []
|
|
HEX.lastIndex = 0
|
|
FUNC_COLOR.lastIndex = 0
|
|
BOLD_WEIGHT.lastIndex = 0
|
|
let m
|
|
while ((m = HEX.exec(line))) if (hexLooksLikeColor(m[0], line, m.index)) flagged.push(m[0])
|
|
while ((m = FUNC_COLOR.exec(line))) flagged.push(m[0])
|
|
while ((m = BOLD_WEIGHT.exec(line))) flagged.push(m[0])
|
|
const got = flagged.length > 0
|
|
if (got !== shouldFlag) {
|
|
failed += 1
|
|
console.error(`self-test FAIL: ${JSON.stringify(line)} expected=${shouldFlag} got=${got}`)
|
|
}
|
|
}
|
|
if (failed) {
|
|
console.error(`self-test failed (${failed})`)
|
|
process.exit(1)
|
|
}
|
|
console.log('check-design-tokens self-test: OK')
|
|
}
|
|
|
|
const argv = process.argv.slice(2)
|
|
if (argv.includes('--self-test')) {
|
|
selfTest()
|
|
} else {
|
|
const violations = collect()
|
|
if (argv.includes('--json')) {
|
|
console.log(JSON.stringify({ count: violations.length, violations }, null, 2))
|
|
} else {
|
|
const byFile = new Map()
|
|
for (const v of violations) {
|
|
if (!byFile.has(v.rel)) byFile.set(v.rel, [])
|
|
byFile.get(v.rel).push(v)
|
|
}
|
|
for (const [rel, list] of [...byFile.entries()].sort()) {
|
|
console.log(`\n${rel} (${list.length})`)
|
|
for (const v of list.slice(0, 200)) {
|
|
console.log(` ${String(v.line).padStart(4)} ${v.rule.padEnd(15)} ${v.text}`)
|
|
}
|
|
}
|
|
console.log(`\ndesign-token violations: ${violations.length}`)
|
|
}
|
|
if (violations.length > 0) process.exit(1)
|
|
}
|