mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-19 08:20:39 -05:00
Compare commits
1 Commits
react-quer
...
claude/nig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd4c3a865a |
235
.github/scripts/validate-themes.mjs
vendored
Normal file
235
.github/scripts/validate-themes.mjs
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Nightly theme validation script.
|
||||
*
|
||||
* Reads the theme catalog from packages/desktop-client/src/data/customThemeCatalog.json,
|
||||
* fetches each theme's CSS from its GitHub repo, and validates it against the same
|
||||
* rules used at install time.
|
||||
*
|
||||
* Validation logic is ported from:
|
||||
* packages/desktop-client/src/style/customThemes.ts
|
||||
* Keep these two files in sync when changing validation rules.
|
||||
*
|
||||
* Exit code 0 = all themes pass, 1 = one or more themes failed.
|
||||
* Writes a JSON results file to $GITHUB_OUTPUT (when running in CI) for
|
||||
* downstream steps.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PATH = resolve(
|
||||
__dirname,
|
||||
'../../packages/desktop-client/src/data/customThemeCatalog.json',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation logic (mirrored from customThemes.ts — keep in sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
|
||||
|
||||
function isValidSimpleVarValue(value) {
|
||||
const m = value.trim().match(VAR_ONLY_PATTERN);
|
||||
if (!m) return false;
|
||||
const name = m[1];
|
||||
return name !== '--' && !name.endsWith('-');
|
||||
}
|
||||
|
||||
function validatePropertyValue(value, property) {
|
||||
if (!value || value.length === 0) return;
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (isValidSimpleVarValue(trimmedValue)) return;
|
||||
|
||||
const hexColorPattern =
|
||||
/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?([0-9a-fA-F]{2})?$/;
|
||||
const rgbRgbaPattern =
|
||||
/^rgba?\(\s*\d+%?\s*,\s*\d+%?\s*,\s*\d+%?\s*(,\s*[\d.]+)?\s*\)$/;
|
||||
const hslHslaPattern =
|
||||
/^hsla?\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(,\s*[\d.]+)?\s*\)$/;
|
||||
const lengthPattern =
|
||||
/^(\d+\.?\d*|\d*\.\d+)(px|em|rem|%|vh|vw|vmin|vmax|cm|mm|in|pt|pc|ex|ch)$/;
|
||||
const numberPattern = /^(\d+\.?\d*|\d*\.\d+)$/;
|
||||
const keywordPattern =
|
||||
/^(inherit|initial|unset|revert|transparent|none|auto|normal)$/i;
|
||||
|
||||
if (
|
||||
hexColorPattern.test(trimmedValue) ||
|
||||
rgbRgbaPattern.test(trimmedValue) ||
|
||||
hslHslaPattern.test(trimmedValue) ||
|
||||
lengthPattern.test(trimmedValue) ||
|
||||
numberPattern.test(trimmedValue) ||
|
||||
keywordPattern.test(trimmedValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid value "${trimmedValue}" for property "${property}". Only simple CSS values are allowed (colors, lengths, numbers, keywords, or var(--name)). Other functions, URLs, and complex constructs are not permitted.`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateThemeCss(css) {
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
|
||||
const rootMatch = cleaned.match(/^:root\s*\{/);
|
||||
if (!rootMatch) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const rootStart = cleaned.indexOf(':root');
|
||||
const openBrace = cleaned.indexOf('{', rootStart);
|
||||
if (openBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const closeBrace = cleaned.indexOf('}', openBrace + 1);
|
||||
if (closeBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
|
||||
|
||||
if (/@[a-z-]+/i.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
if (/\{/.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
const afterRoot = cleaned.substring(closeBrace + 1).trim();
|
||||
if (afterRoot.length > 0) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const declarations = rootContent
|
||||
.split(';')
|
||||
.map(d => d.trim())
|
||||
.filter(d => d.length > 0);
|
||||
|
||||
for (const decl of declarations) {
|
||||
const colonIndex = decl.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(`Invalid CSS declaration: "${decl}"`);
|
||||
}
|
||||
|
||||
const property = decl.substring(0, colonIndex).trim();
|
||||
|
||||
if (!property.startsWith('--')) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Only CSS custom properties (starting with --) are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (property === '--' || property === '-') {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot be empty or contain only dashes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const propertyNameAfterDashes = property.substring(2);
|
||||
if (propertyNameAfterDashes.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot be empty after "--".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(propertyNameAfterDashes)) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name contains invalid characters. Only letters, digits, underscores, and dashes are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (property.endsWith('-')) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot end with a dash.`,
|
||||
);
|
||||
}
|
||||
|
||||
const value = decl.substring(colonIndex + 1).trim();
|
||||
validatePropertyValue(value, property);
|
||||
}
|
||||
|
||||
return css.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchThemeCss(repo) {
|
||||
const url = `https://raw.githubusercontent.com/${repo}/refs/heads/main/actual.css`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const catalog = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8'));
|
||||
console.log(`Found ${catalog.length} themes in catalog.\n`);
|
||||
|
||||
const results = [];
|
||||
let hasFailures = false;
|
||||
|
||||
for (const theme of catalog) {
|
||||
const { name, repo } = theme;
|
||||
process.stdout.write(`Checking "${name}" (${repo}) ... `);
|
||||
|
||||
try {
|
||||
const css = await fetchThemeCss(repo);
|
||||
validateThemeCss(css);
|
||||
console.log('PASS');
|
||||
results.push({ name, repo, status: 'pass' });
|
||||
} catch (err) {
|
||||
console.log(`FAIL — ${err.message}`);
|
||||
results.push({ name, repo, status: 'fail', error: err.message });
|
||||
hasFailures = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Write results as JSON for downstream CI steps
|
||||
const resultsJson = JSON.stringify(results);
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (outputFile) {
|
||||
appendFileSync(outputFile, `results=${resultsJson}\n`);
|
||||
}
|
||||
|
||||
// Also write to a temp file so the issue step can read it
|
||||
const tmpPath = resolve(__dirname, '../../theme-validation-results.json');
|
||||
writeFileSync(tmpPath, JSON.stringify(results, null, 2));
|
||||
|
||||
console.log(
|
||||
`\n${hasFailures ? 'FAILED' : 'PASSED'}: ${results.filter(r => r.status === 'pass').length}/${results.length} themes passed validation.`,
|
||||
);
|
||||
|
||||
if (hasFailures) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
124
.github/workflows/nightly-theme-validation.yml
vendored
Normal file
124
.github/workflows/nightly-theme-validation.yml
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
name: Nightly theme validation scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # 3 AM UTC daily
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
validate-themes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.repository.fork == false
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Validate all catalog themes
|
||||
id: validate
|
||||
run: node .github/scripts/validate-themes.mjs
|
||||
|
||||
- name: Open or update GitHub issue on failure
|
||||
if: failure() && steps.validate.outcome == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the results file written by the validation script
|
||||
const resultsPath = path.resolve('theme-validation-results.json');
|
||||
let results = [];
|
||||
try {
|
||||
results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.log('Could not read results file:', e.message);
|
||||
}
|
||||
|
||||
const failures = results.filter(r => r.status === 'fail');
|
||||
if (failures.length === 0) {
|
||||
console.log('No failures found in results — skipping issue creation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const label = 'theme-validation-failure';
|
||||
const title = `[Nightly] Custom theme validation failures detected`;
|
||||
|
||||
// Build the issue body
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const failureRows = failures
|
||||
.map(f => `| ${f.name} | [${f.repo}](https://github.com/${f.repo}) | ${f.error} |`)
|
||||
.join('\n');
|
||||
|
||||
const body = [
|
||||
`## Theme Validation Report — ${date}`,
|
||||
'',
|
||||
`The nightly theme validation scan found **${failures.length}** theme(s) that do not pass the CSS validation rules.`,
|
||||
'',
|
||||
'| Theme | Repository | Error |',
|
||||
'|-------|-----------|-------|',
|
||||
failureRows,
|
||||
'',
|
||||
`**Total scanned:** ${results.length} | **Passed:** ${results.length - failures.length} | **Failed:** ${failures.length}`,
|
||||
'',
|
||||
'These themes are listed in `packages/desktop-client/src/data/customThemeCatalog.json` and their CSS is fetched from the linked repositories.',
|
||||
'',
|
||||
'Please review the failing themes and either:',
|
||||
'- Contact the theme author to fix their CSS',
|
||||
'- Remove the theme from the catalog if it remains non-compliant',
|
||||
'',
|
||||
`_This issue was automatically created by the [nightly theme validation workflow](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})._`,
|
||||
].join('\n');
|
||||
|
||||
// Ensure the label exists
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
});
|
||||
} catch {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: 'e11d48',
|
||||
description: 'Nightly scan found custom themes failing CSS validation',
|
||||
});
|
||||
}
|
||||
|
||||
// Search for an existing open issue with the label
|
||||
const { data: existingIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: label,
|
||||
state: 'open',
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (existingIssues.length > 0) {
|
||||
// Add a comment to the existing issue with updated results
|
||||
const issueNumber = existingIssues[0].number;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: body,
|
||||
});
|
||||
console.log(`Updated existing issue #${issueNumber}`);
|
||||
} else {
|
||||
// Create a new issue
|
||||
const { data: newIssue } = await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: title,
|
||||
body: body,
|
||||
labels: [label],
|
||||
});
|
||||
console.log(`Created new issue #${newIssue.number}`);
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -81,3 +81,6 @@ build/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Theme validation (CI artifact)
|
||||
theme-validation-results.json
|
||||
|
||||
Reference in New Issue
Block a user