[AI] Add nightly CI scan for custom theme catalog (#7566)

* [AI] Add nightly CI scan for custom theme catalog

Adds a scheduled GitHub Actions workflow that fetches `actual.css` from
every repo in `customThemeCatalog.json` and runs it through the same
`embedThemeFonts` + `validateThemeCss` pipeline the app uses at install
time. Failing themes fail the job so maintainers get an alert when a
third-party repo introduces a regression.

The scan treats fetched CSS as opaque text: never executed, never
injected into a DOM, size-capped at 512 KB per file, 15s per fetch,
restricted to raw.githubusercontent.com with redirects disabled, and
run with `contents: read` permissions only. Each catalog `repo` is
schema-checked against `owner/repo` before being interpolated into
the URL.

* [AI] Simplify theme catalog scan

- Reuse `CatalogTheme` type from customThemes instead of duplicating as
  `CatalogEntry` in the script.
- Hoist `appendFileSync` to the static `node:fs` import; drop the dynamic
  import inside `writeStepSummary`.
- Drop the narrative header docstring and the trailing `// ...` comments
  that just restated constant names.
- Drop the redundant URL-prefix re-check inside the CSS fetch helper;
  the single call site constructs the URL from a pinned literal.
- Drop the 250 ms inter-request delay (GitHub Raw rate limits are not
  relevant for 21 requests, and the trailing delay was idle wall-clock
  against the 10-min job budget).
- Give each font fetch inside `embedThemeFonts` its own 15 s timeout
  via `AbortSignal.any`, instead of sharing one signal across every
  font in a theme. Drop the now-unnecessary caller-supplied signal
  from the CI call site.

* [AI] Fix lint on theme catalog scan imports
This commit is contained in:
Matiss Janis Aboltins
2026-04-21 22:18:21 +01:00
committed by GitHub
parent 07c71154c9
commit 846b6a6b7a
5 changed files with 255 additions and 1 deletions

View File

@@ -0,0 +1,29 @@
name: Nightly theme catalog scan
on:
schedule:
# 05:15 UTC daily — runs after the i18n extract job (04:00) and well
# before the nightly Electron/npm publishes (00:00 UTC the next day).
- cron: '15 5 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
validate-theme-catalog:
name: Validate custom theme catalog
runs-on: ubuntu-latest
if: github.repository == 'actualbudget/actual'
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Validate themes
run: yarn workspace @actual-app/web validate:theme-catalog

View File

@@ -0,0 +1,210 @@
import { appendFileSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { CatalogTheme } from '../src/style/customThemes.ts';
import {
embedThemeFonts,
validateThemeCss,
} from '../src/style/customThemes.ts';
const MAX_CSS_BYTES = 512 * 1024;
const FETCH_TIMEOUT_MS = 15_000;
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
type ThemeResult = {
name: string;
repo: string;
status: 'ok' | 'error';
error?: string;
};
const here = dirname(fileURLToPath(import.meta.url));
const catalogPath = resolve(
here,
'..',
'src',
'data',
'customThemeCatalog.json',
);
function readCatalog(): CatalogTheme[] {
const raw = readFileSync(catalogPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Catalog JSON must be an array.');
}
return parsed.map((entry, i) => validateCatalogEntry(entry, i));
}
function validateCatalogEntry(value: unknown, index: number): CatalogTheme {
if (!value || typeof value !== 'object') {
throw new Error(`Catalog entry #${index} is not an object.`);
}
const e = value as Record<string, unknown>;
if (typeof e.name !== 'string' || !e.name.trim()) {
throw new Error(`Catalog entry #${index} is missing a valid "name".`);
}
// Schema-check the repo before it gets interpolated into a fetch URL.
if (typeof e.repo !== 'string' || !REPO_PATTERN.test(e.repo)) {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "repo" (expected "owner/repo"): ${JSON.stringify(e.repo)}`,
);
}
if (e.mode !== 'light' && e.mode !== 'dark') {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "mode" (expected "light" or "dark").`,
);
}
if (
e.colors !== undefined &&
(!Array.isArray(e.colors) ||
!e.colors.every((c: unknown) => typeof c === 'string'))
) {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "colors" (expected string[]).`,
);
}
return {
name: e.name,
repo: e.repo,
mode: e.mode,
colors: e.colors as string[] | undefined,
};
}
async function fetchCss(url: string): Promise<string> {
const response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
redirect: 'error',
headers: { Accept: 'text/css, text/plain, */*' },
});
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
);
}
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const size = Number.parseInt(contentLength, 10);
if (Number.isFinite(size) && size > MAX_CSS_BYTES) {
throw new Error(
`CSS at ${url} is ${size} bytes; max allowed is ${MAX_CSS_BYTES} bytes.`,
);
}
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`Response from ${url} has no body.`);
}
const decoder = new TextDecoder('utf-8');
let received = 0;
let text = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAX_CSS_BYTES) {
await reader.cancel();
throw new Error(
`CSS at ${url} exceeds max allowed size of ${MAX_CSS_BYTES} bytes.`,
);
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
}
async function validateOne(entry: CatalogTheme): Promise<ThemeResult> {
try {
const url = `https://raw.githubusercontent.com/${entry.repo}/refs/heads/main/actual.css`;
const css = await fetchCss(url);
// Embed fonts before validation: the validator only accepts data: URIs in
// @font-face, and embedThemeFonts is what turns relative url() refs into
// data: URIs. Matches ThemeInstaller's install flow.
const embedded = await embedThemeFonts(css, entry.repo);
validateThemeCss(embedded);
return { name: entry.name, repo: entry.repo, status: 'ok' };
} catch (err) {
return {
name: entry.name,
repo: entry.repo,
status: 'error',
error: err instanceof Error ? err.message : String(err),
};
}
}
function escapeForMarkdown(s: string): string {
return s.replace(/[`<>|]/g, c => `\\${c}`).replace(/\r?\n/g, ' ');
}
function writeStepSummary(results: ThemeResult[]): void {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) return;
const okCount = results.filter(r => r.status === 'ok').length;
const failCount = results.length - okCount;
const lines: string[] = [];
lines.push('# Custom theme catalog scan');
lines.push('');
lines.push(`- Total themes: ${results.length}`);
lines.push(`- Passing: ${okCount}`);
lines.push(`- Failing: ${failCount}`);
lines.push('');
lines.push('| Status | Theme | Repo | Error |');
lines.push('| --- | --- | --- | --- |');
for (const r of results) {
const status = r.status === 'ok' ? 'pass' : 'FAIL';
const err = r.error ? escapeForMarkdown(r.error) : '';
lines.push(
`| ${status} | ${escapeForMarkdown(r.name)} | ${escapeForMarkdown(r.repo)} | ${err} |`,
);
}
lines.push('');
appendFileSync(summaryPath, lines.join('\n') + '\n');
}
async function main(): Promise<void> {
const catalog = readCatalog();
console.log(`Validating ${catalog.length} theme(s) from the catalog…`);
const results: ThemeResult[] = [];
for (const entry of catalog) {
const result = await validateOne(entry);
if (result.status === 'ok') {
console.log(` ok ${entry.repo.padEnd(55)} ${entry.name}`);
} else {
console.log(
` FAIL ${entry.repo.padEnd(55)} ${entry.name}\n → ${result.error}`,
);
}
results.push(result);
}
const failed = results.filter(r => r.status === 'error');
console.log('');
console.log(
`Summary: ${results.length - failed.length}/${results.length} passing, ${failed.length} failing.`,
);
writeStepSummary(results);
process.exit(failed.length === 0 ? 0 : 1);
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -106,6 +106,7 @@
"build:browser": "cross-env ./bin/build-browser",
"generate:i18n": "i18next",
"test": "vitest --run",
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
"e2e": "npx playwright test --browser=chromium",
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
"playwright": "playwright",

View File

@@ -216,6 +216,9 @@ export const MAX_FONT_FILE_SIZE = 2 * 1024 * 1024;
/** Maximum total size of all embedded font data across all @font-face blocks. 10 MB. */
export const MAX_TOTAL_FONT_SIZE = 10 * 1024 * 1024;
/** Per-font-file fetch timeout so a hung font host can't stall theme install. */
const FONT_FETCH_TIMEOUT_MS = 15_000;
/**
* Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
* Only matches top-level @font-face blocks (not nested inside other rules).
@@ -514,6 +517,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
export async function embedThemeFonts(
css: string,
repo: string,
signal?: AbortSignal,
): Promise<string> {
const baseUrl = `https://raw.githubusercontent.com/${repo}/refs/heads/main/`;
@@ -591,7 +595,11 @@ export async function embedThemeFonts(
let totalBytes = 0;
for (const ref of fontRefs) {
const fontUrl = baseUrl + ref.cleanPath;
const response = await fetch(fontUrl);
const perFontTimeout = AbortSignal.timeout(FONT_FETCH_TIMEOUT_MS);
const fetchSignal = signal
? AbortSignal.any([signal, perFontTimeout])
: perFontTimeout;
const response = await fetch(fontUrl, { signal: fetchSignal });
if (!response.ok) {
throw new Error(
`Failed to fetch font file "${ref.cleanPath}" from ${fontUrl}: ${response.status} ${response.statusText}`,

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Custom Themes: nightly scan to catch broken themes