From 846b6a6b7ae6d080d1a581532e1bf95bd9bbc843 Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Tue, 21 Apr 2026 22:18:21 +0100 Subject: [PATCH] [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 --- .../workflows/nightly-theme-catalog-scan.yml | 29 +++ .../bin/validate-theme-catalog.mts | 210 ++++++++++++++++++ packages/desktop-client/package.json | 1 + .../desktop-client/src/style/customThemes.ts | 10 +- upcoming-release-notes/7566.md | 6 + 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/nightly-theme-catalog-scan.yml create mode 100644 packages/desktop-client/bin/validate-theme-catalog.mts create mode 100644 upcoming-release-notes/7566.md diff --git a/.github/workflows/nightly-theme-catalog-scan.yml b/.github/workflows/nightly-theme-catalog-scan.yml new file mode 100644 index 0000000000..2c975909ff --- /dev/null +++ b/.github/workflows/nightly-theme-catalog-scan.yml @@ -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 diff --git a/packages/desktop-client/bin/validate-theme-catalog.mts b/packages/desktop-client/bin/validate-theme-catalog.mts new file mode 100644 index 0000000000..5ff8b795fe --- /dev/null +++ b/packages/desktop-client/bin/validate-theme-catalog.mts @@ -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; + + 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 { + 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 { + 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 { + 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); +}); diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index cf281c5324..c9872ae3fb 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -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", diff --git a/packages/desktop-client/src/style/customThemes.ts b/packages/desktop-client/src/style/customThemes.ts index 6a1242bc09..71bcd37df7 100644 --- a/packages/desktop-client/src/style/customThemes.ts +++ b/packages/desktop-client/src/style/customThemes.ts @@ -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 { 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}`, diff --git a/upcoming-release-notes/7566.md b/upcoming-release-notes/7566.md new file mode 100644 index 0000000000..102589ebc6 --- /dev/null +++ b/upcoming-release-notes/7566.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Custom Themes: nightly scan to catch broken themes