mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 07:01:45 -05:00
[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:
committed by
GitHub
parent
07c71154c9
commit
846b6a6b7a
29
.github/workflows/nightly-theme-catalog-scan.yml
vendored
Normal file
29
.github/workflows/nightly-theme-catalog-scan.yml
vendored
Normal 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
|
||||
210
packages/desktop-client/bin/validate-theme-catalog.mts
Normal file
210
packages/desktop-client/bin/validate-theme-catalog.mts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
6
upcoming-release-notes/7566.md
Normal file
6
upcoming-release-notes/7566.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom Themes: nightly scan to catch broken themes
|
||||
Reference in New Issue
Block a user