From 9c7e02cbf01a89de472d58fcf680fdf7eb9e90aa Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 10 Mar 2026 20:06:41 -0700 Subject: [PATCH] Enforce config example drift checks in CI and release - add `verify:config-example` script with tests to fail on missing/stale generated config artifacts - run the verification in CI and release workflows, and document it in release/docs guidance - fix docs-site Cloudflare Pages watch path to `docs-site/*` with regression coverage --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + Makefile | 1 - ...oudflare-Pages-watch-path-for-docs-site.md | 34 +++++++ ...e-generated-config-example-drift-checks.md | 44 +++++++++ changes/config-example-drift-check.md | 4 + config.example.jsonc | 94 +++++++++++-------- docs-site/README.md | 4 +- docs-site/development.md | 4 +- docs-site/docs-sync.test.ts | 2 + docs-site/public/config.example.jsonc | 4 +- docs/RELEASING.md | 1 + package.json | 7 +- src/ci-workflow.test.ts | 4 + src/release-workflow.test.ts | 8 ++ src/verify-config-example.test.ts | 93 ++++++++++++++++++ src/verify-config-example.ts | 80 ++++++++++++++++ 17 files changed, 346 insertions(+), 44 deletions(-) create mode 100644 backlog/tasks/task-157 - Fix-Cloudflare-Pages-watch-path-for-docs-site.md create mode 100644 backlog/tasks/task-158 - Enforce-generated-config-example-drift-checks.md create mode 100644 changes/config-example-drift-check.md create mode 100644 src/verify-config-example.test.ts create mode 100644 src/verify-config-example.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42f18e..3c5fdcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: # Keep explicit typecheck for fast fail before full build/bundle. run: bun run typecheck + - name: Verify generated config examples + run: bun run verify:config-example + - name: Test suite (source) run: bun run test:fast diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 745ba63..f4c88c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -295,6 +295,9 @@ jobs: - name: Enforce generated launcher workflow run: bash scripts/verify-generated-launcher.sh + - name: Verify generated config examples + run: bun run verify:config-example + - name: Package optional assets bundle run: | tar -czf "release/subminer-assets.tar.gz" \ diff --git a/Makefile b/Makefile index 8655e65..f968142 100644 --- a/Makefile +++ b/Makefile @@ -165,7 +165,6 @@ generate-config: ensure-bun @bun run electron . --generate-config generate-example-config: ensure-bun - @bun run build @bun run generate:config-example dev-start: ensure-bun diff --git a/backlog/tasks/task-157 - Fix-Cloudflare-Pages-watch-path-for-docs-site.md b/backlog/tasks/task-157 - Fix-Cloudflare-Pages-watch-path-for-docs-site.md new file mode 100644 index 0000000..566af3c --- /dev/null +++ b/backlog/tasks/task-157 - Fix-Cloudflare-Pages-watch-path-for-docs-site.md @@ -0,0 +1,34 @@ +--- +id: TASK-157 +title: Fix Cloudflare Pages watch path for docs-site +status: In Progress +assignee: [] +created_date: '2026-03-10 20:15' +updated_date: '2026-03-10 20:15' +labels: + - docs-site + - cloudflare +dependencies: [] +priority: medium +--- + +## Description + + +Cloudflare Pages skipped a docs-site deployment after the docs repo moved into the main `SubMiner` repository. The documented/configured watch path uses `docs-site/**`, but Pages monorepo watch paths use a single `*` wildcard pattern. Correct the documented setting and leave a regression test so future repo moves or docs rewrites do not reintroduce the bad pattern. + + +## Acceptance Criteria + +- [ ] #1 Docs contributor guidance points Cloudflare Pages watch paths at `docs-site/*`, not `docs-site/**`. +- [ ] #2 Regression coverage fails if the docs revert to the incorrect watch-path string. +- [ ] #3 Implementation notes record that the Cloudflare dashboard setting must be updated manually and the docs deploy retriggered. + + +## Implementation Notes + + +Added docs regression coverage in `docs-site/docs-sync.test.ts` for the Pages watch-path string, then corrected the Cloudflare Pages instructions in `docs-site/README.md` and `docs-site/development.md`. + +Manual follow-up still required outside git: update the Cloudflare Pages project include path from `docs-site/**` to `docs-site/*`, then trigger a fresh deployment against `main`. + diff --git a/backlog/tasks/task-158 - Enforce-generated-config-example-drift-checks.md b/backlog/tasks/task-158 - Enforce-generated-config-example-drift-checks.md new file mode 100644 index 0000000..4af52d2 --- /dev/null +++ b/backlog/tasks/task-158 - Enforce-generated-config-example-drift-checks.md @@ -0,0 +1,44 @@ +--- +id: TASK-158 +title: Enforce generated config example drift checks +status: Done +assignee: [] +created_date: '2026-03-10 20:35' +updated_date: '2026-03-10 20:35' +labels: + - config + - docs-site +dependencies: [] +priority: medium +--- + +## Description + + +The generated `config.example.jsonc` artifact is covered by generation-path tests, but there is no hard gate that fails when the checked-in example drifts from the canonical template. The in-repo docs-site copy can also drift silently. + +Scope: + +- add a first-party verification path that compares generated config-example content against committed artifacts +- fail fast when repo-root `config.example.jsonc` is stale or missing +- fail fast when `docs-site/public/config.example.jsonc` is stale or missing, when the docs site exists +- wire the verification into the normal gate and release flow + + +## Acceptance Criteria + + +- [x] #1 Automated verification fails when repo-root `config.example.jsonc` is missing or stale. +- [x] #2 Automated verification fails when in-repo docs-site `public/config.example.jsonc` is missing or stale, when docs-site exists. +- [x] #3 CI/release or equivalent project gates run the verification automatically. + + +## Final Summary + + +Added `src/verify-config-example.ts`, which renders the canonical config template and compares it against the checked-in repo-root `config.example.jsonc` plus `docs-site/public/config.example.jsonc` when the docs site exists. + +Wired the new verification into `package.json` as `bun run verify:config-example`, added regression coverage for missing and stale artifacts, and enforced the new check in both CI and release workflows. + +Regenerated the checked-in config example artifacts so the new gate passes in the repo-local docs-site layout, and documented the release-step expectation in `docs/RELEASING.md`. + diff --git a/changes/config-example-drift-check.md b/changes/config-example-drift-check.md new file mode 100644 index 0000000..4a5bd56 --- /dev/null +++ b/changes/config-example-drift-check.md @@ -0,0 +1,4 @@ +type: internal +area: config + +- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently diff --git a/config.example.jsonc b/config.example.jsonc index 259ae0a..29b79f4 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -5,6 +5,7 @@ * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { + // ========================================== // Overlay Auto-Start // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. @@ -17,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": true, // Open browser setting. Values: true | false + "openBrowser": true // Open browser setting. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -27,7 +28,7 @@ // ========================================== "websocket": { "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677, // Built-in subtitle websocket server port. + "port": 6677 // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -37,7 +38,7 @@ // ========================================== "annotationWebsocket": { "enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false - "port": 6678, // Annotated subtitle websocket server port. + "port": 6678 // Annotated subtitle websocket server port. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. // ========================================== @@ -46,7 +47,7 @@ // Set to debug for full runtime diagnostics. // ========================================== "logging": { - "level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error }, // Controls logging verbosity. // ========================================== @@ -60,7 +61,7 @@ "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false - "jellyfinRemoteSession": true, // Warm up Jellyfin remote session at startup. Values: true | false + "jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // ========================================== @@ -81,7 +82,7 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. + "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -101,7 +102,7 @@ "secondarySub": { "secondarySubLanguages": [], // Secondary sub languages setting. "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover", // Default mode setting. + "defaultMode": "hover" // Default mode setting. }, // Dual subtitle track options. // ========================================== @@ -113,7 +114,7 @@ "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync path setting. "ffmpeg_path": "", // Ffmpeg path setting. - "replace": true, // Replace the active subtitle file when sync completes. Values: true | false + "replace": true // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. // ========================================== @@ -121,7 +122,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10, // Y percent setting. + "yPercent": 10 // Y percent setting. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -158,7 +159,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#a6e3a1", // N4 setting. - "N5": "#8aadf4", // N5 setting. + "N5": "#8aadf4" // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -167,7 +168,13 @@ "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. - "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + "bandedColors": [ + "#ed8796", + "#f5a97f", + "#f9e2af", + "#8bd5ca", + "#8aadf4" + ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). }, // Frequency dictionary setting. "secondary": { "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. @@ -182,8 +189,8 @@ "backgroundColor": "rgba(20, 22, 34, 0.78)", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. "fontWeight": "600", // Font weight setting. - "fontStyle": "normal", // Font style setting. - }, // Secondary setting. + "fontStyle": "normal" // Font style setting. + } // Secondary setting. }, // Primary and secondary subtitle styling. // ========================================== @@ -197,7 +204,7 @@ "model": "openai/gpt-4o-mini", // Model setting. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. - "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests. + "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // ========================================== @@ -215,20 +222,22 @@ "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy. - "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. + "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. }, // Proxy setting. - "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": [ + "SubMiner" + ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "fields": { "audio": "ExpressionAudio", // Audio setting. "image": "Picture", // Image setting. "sentence": "Sentence", // Sentence setting. "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText", // Translation setting. + "translation": "SelectionText" // Translation setting. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows. - "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows. + "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { "generateAudio": true, // Generate audio setting. Values: true | false @@ -241,7 +250,7 @@ "animatedCrf": 35, // Animated crf setting. "audioPadding": 0.5, // Audio padding setting. "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30, // Max media duration setting. + "maxMediaDuration": 30 // Max media duration setting. }, // Media setting. "behavior": { "overwriteAudio": true, // Overwrite audio setting. Values: true | false @@ -249,7 +258,7 @@ "mediaInsertMode": "append", // Media insert mode setting. "highlightWord": true, // Highlight word setting. Values: true | false "notificationType": "osd", // Notification type setting. - "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false @@ -258,20 +267,20 @@ "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. - "knownWord": "#a6da95", // Color used for legacy known-word highlights. + "knownWord": "#a6da95" // Color used for legacy known-word highlights. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)", // Pattern setting. + "pattern": "[SubMiner] %f (%t)" // Pattern setting. }, // Metadata setting. "isLapis": { "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences", // Sentence card model setting. + "sentenceCardModel": "Japanese sentences" // Sentence card model setting. }, // Is lapis setting. "isKiku": { "enabled": false, // Enabled setting. Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false - }, // Is kiku setting. + "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + } // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -281,7 +290,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Api base url setting. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10, // Maximum Jimaku search results returned. + "maxEntryResults": 10 // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -296,9 +305,12 @@ "fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false "ai": { "model": "", // Optional model override for YouTube subtitle AI post-processing. - "systemPrompt": "", // Optional system prompt override for YouTube subtitle AI post-processing. + "systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing. }, // Ai setting. - "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher. + "primarySubLanguages": [ + "ja", + "jpn" + ] // Comma-separated primary subtitle language priority used by the launcher. }, // Defaults for SubMiner YouTube subtitle generation. // ========================================== @@ -319,9 +331,9 @@ "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false - "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - }, // Collapsible sections setting. - }, // Character dictionary setting. + "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + } // Collapsible sections setting. + } // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -345,8 +357,16 @@ "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": [ + "mkv", + "mp4", + "webm", + "mov", + "flac", + "mp3", + "aac" + ], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== @@ -357,7 +377,7 @@ "discordPresence": { "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "updateIntervalMs": 3000, // Minimum interval between presence payload updates. - "debounceMs": 750, // Debounce delay used to collapse bursty presence updates. + "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. // ========================================== @@ -379,7 +399,7 @@ "telemetryDays": 30, // Telemetry retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. - "vacuumIntervalDays": 7, // Minimum days between VACUUM runs. - }, // Retention setting. - }, // Enable/disable immersion tracking. + "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. + } // Retention setting. + } // Enable/disable immersion tracking. } diff --git a/docs-site/README.md b/docs-site/README.md index a60d7b4..e5d4a53 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -31,4 +31,6 @@ bun run docs:dev - Root directory: `docs-site` - Build command: `bun run docs:build` - Build output directory: `.vitepress/dist` -- Build watch paths: `docs-site/**` +- Build watch paths: `docs-site/*` + +Cloudflare Pages watch paths use a single `*` wildcard for monorepo subdirectories. `docs-site/*` matches nested files under the docs site; `docs-site/**` can cause docs-only pushes to be skipped. diff --git a/docs-site/development.md b/docs-site/development.md index 9ce8387..d484623 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -186,7 +186,9 @@ Cloudflare Pages deploy settings: - Root directory: `docs-site` - Build command: `bun run docs:build` - Build output directory: `.vitepress/dist` -- Build watch paths: `docs-site/**` +- Build watch paths: `docs-site/*` + +Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` covers nested docs-site changes in the repo; `docs-site/**` is not the correct Pages pattern and may skip docs-only pushes. ## Makefile Reference diff --git a/docs-site/docs-sync.test.ts b/docs-site/docs-sync.test.ts index 8a004a2..e38976d 100644 --- a/docs-site/docs-sync.test.ts +++ b/docs-site/docs-sync.test.ts @@ -22,9 +22,11 @@ test('docs reflect current launcher and release surfaces', () => { expect(readmeContents).toContain('Root directory: `docs-site`'); expect(readmeContents).toContain('Build output directory: `.vitepress/dist`'); + expect(readmeContents).toContain('Build watch paths: `docs-site/*`'); expect(developmentContents).not.toContain('../subminer-docs'); expect(developmentContents).toContain('bun run docs:build'); expect(developmentContents).toContain('bun run docs:test'); + expect(developmentContents).toContain('Build watch paths: `docs-site/*`'); expect(developmentContents).not.toContain('test:subtitle:dist'); expect(developmentContents).toContain('bun run build:win'); diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 321a461..29b79f4 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -2,7 +2,7 @@ * SubMiner Example Configuration File * * This file is auto-generated from src/config/definitions.ts. - * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. + * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { @@ -201,7 +201,9 @@ "enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false "apiKey": "", // Static API key for the shared OpenAI-compatible AI provider. "apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key. + "model": "openai/gpt-4o-mini", // Model setting. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting. "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f8d9f69..22fd88b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -9,6 +9,7 @@ 4. Review `CHANGELOG.md`. 5. Run release gate locally: `bun run changelog:check --version ` + `bun run verify:config-example` `bun run test:fast` `bun run typecheck` 6. Commit release prep. diff --git a/package.json b/package.json index c6e5f9b..1fca685 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "docs:build": "bun run --cwd docs-site docs:build", "docs:preview": "bun run --cwd docs-site docs:preview", "docs:test": "bun run --cwd docs-site test", - "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts", - "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js", + "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", + "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", @@ -55,7 +55,8 @@ "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", "test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", - "generate:config-example": "bun run build && bun dist/generate-config-example.js", + "generate:config-example": "bun run src/generate-config-example.ts", + "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", "dev": "bun run build && electron . --start --dev", "stop": "electron . --stop", diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts index 3a6e45b..7c8e47b 100644 --- a/src/ci-workflow.test.ts +++ b/src/ci-workflow.test.ts @@ -14,3 +14,7 @@ test('ci workflow checks pull requests for required changelog fragments', () => assert.match(ciWorkflow, /bun run changelog:pr-check/); assert.match(ciWorkflow, /skip-changelog/); }); + +test('ci workflow verifies generated config examples stay in sync', () => { + assert.match(ciWorkflow, /bun run verify:config-example/); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 6fa9525..8c15a2d 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -24,6 +24,10 @@ test('release workflow verifies a committed changelog section before publish', ( assert.match(releaseWorkflow, /bun run changelog:check/); }); +test('release workflow verifies generated config examples before packaging artifacts', () => { + assert.match(releaseWorkflow, /bun run verify:config-example/); +}); + test('release workflow generates release notes from committed changelog output', () => { assert.match(releaseWorkflow, /bun run changelog:release-notes/); assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"')); @@ -47,6 +51,10 @@ test('release package scripts disable implicit electron-builder publishing', () assert.match(packageJson.scripts['build:win:unsigned'] ?? '', /build-win-unsigned\.mjs/); }); +test('config example generation runs directly from source without unrelated bundle prerequisites', () => { + assert.equal(packageJson.scripts['generate:config-example'], 'bun run src/generate-config-example.ts'); +}); + test('windows release workflow publishes unsigned artifacts directly without SignPath', () => { assert.match(releaseWorkflow, /Build unsigned Windows artifacts/); assert.match(releaseWorkflow, /run: bun run build:win:unsigned/); diff --git a/src/verify-config-example.test.ts b/src/verify-config-example.test.ts new file mode 100644 index 0000000..4964b0a --- /dev/null +++ b/src/verify-config-example.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { + verifyConfigExampleArtifacts, + type ConfigExampleVerificationResult, +} from './verify-config-example'; + +function createWorkspace(name: string): string { + const baseDir = path.join(process.cwd(), '.tmp', 'verify-config-example-test'); + fs.mkdirSync(baseDir, { recursive: true }); + return fs.mkdtempSync(path.join(baseDir, `${name}-`)); +} + +function assertResult( + result: ConfigExampleVerificationResult, + expected: { + missingPaths?: string[]; + stalePaths?: string[]; + }, +) { + assert.deepEqual(result.missingPaths, expected.missingPaths ?? []); + assert.deepEqual(result.stalePaths, expected.stalePaths ?? []); +} + +test('verifyConfigExampleArtifacts reports repo config example when missing', () => { + const workspace = createWorkspace('missing-root'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(projectRoot, { recursive: true }); + + try { + const result = verifyConfigExampleArtifacts({ cwd: projectRoot }); + + assertResult(result, { + missingPaths: [path.join(projectRoot, 'config.example.jsonc')], + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('verifyConfigExampleArtifacts reports stale docs-site artifact when docs site exists', () => { + const workspace = createWorkspace('stale-docs-site'); + const projectRoot = path.join(workspace, 'SubMiner'); + const docsSiteRoot = path.join(projectRoot, 'docs-site'); + + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(docsSiteRoot, 'public'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'config.example.jsonc'), 'fresh\n'); + fs.writeFileSync(path.join(docsSiteRoot, 'public', 'config.example.jsonc'), 'stale\n'); + + try { + const result = verifyConfigExampleArtifacts({ + cwd: projectRoot, + template: 'fresh\n', + }); + + assertResult(result, { + stalePaths: [path.join(docsSiteRoot, 'public', 'config.example.jsonc')], + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('verifyConfigExampleArtifacts passes when repo and docs-site artifacts match', () => { + const workspace = createWorkspace('matching-artifacts'); + const projectRoot = path.join(workspace, 'SubMiner'); + const docsSiteRoot = path.join(projectRoot, 'docs-site'); + const template = '{\n "ok": true\n}\n'; + + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(docsSiteRoot, 'public'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'config.example.jsonc'), template); + fs.writeFileSync(path.join(docsSiteRoot, 'public', 'config.example.jsonc'), template); + + try { + const result = verifyConfigExampleArtifacts({ + cwd: projectRoot, + template, + }); + + assertResult(result, {}); + assert.deepEqual(result.outputPaths, [ + path.join(projectRoot, 'config.example.jsonc'), + path.join(docsSiteRoot, 'public', 'config.example.jsonc'), + ]); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/src/verify-config-example.ts b/src/verify-config-example.ts new file mode 100644 index 0000000..2b5360b --- /dev/null +++ b/src/verify-config-example.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import { DEFAULT_CONFIG, generateConfigTemplate } from './config'; +import { resolveConfigExampleOutputPaths } from './generate-config-example'; + +export type ConfigExampleVerificationResult = { + docsSiteDetected: boolean; + missingPaths: string[]; + outputPaths: string[]; + stalePaths: string[]; + template: string; +}; + +export function verifyConfigExampleArtifacts(options?: { + cwd?: string; + docsSiteDirName?: string; + template?: string; + deps?: { + existsSync?: (candidate: string) => boolean; + readFileSync?: (candidate: string, encoding: BufferEncoding) => string; + }; +}): ConfigExampleVerificationResult { + const existsSync = options?.deps?.existsSync ?? fs.existsSync; + const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; + const template = options?.template ?? generateConfigTemplate(DEFAULT_CONFIG); + const outputPaths = resolveConfigExampleOutputPaths({ + cwd: options?.cwd, + docsSiteDirName: options?.docsSiteDirName, + existsSync, + }); + const missingPaths: string[] = []; + const stalePaths: string[] = []; + + for (const outputPath of outputPaths) { + if (!existsSync(outputPath)) { + missingPaths.push(outputPath); + continue; + } + + if (readFileSync(outputPath, 'utf-8') !== template) { + stalePaths.push(outputPath); + } + } + + return { + docsSiteDetected: outputPaths.length > 1, + missingPaths, + outputPaths, + stalePaths, + template, + }; +} + +function main(): void { + const result = verifyConfigExampleArtifacts(); + + if (result.missingPaths.length === 0 && result.stalePaths.length === 0) { + console.log('[OK] config example artifacts verified'); + for (const outputPath of result.outputPaths) { + console.log(` ${outputPath}`); + } + if (!result.docsSiteDetected) { + console.log(' docs-site not present; skipped docs artifact check'); + } + return; + } + + console.error('[FAIL] config example artifacts are out of sync'); + for (const missingPath of result.missingPaths) { + console.error(` missing: ${missingPath}`); + } + for (const stalePath of result.stalePaths) { + console.error(` stale: ${stalePath}`); + } + console.error(' run: bun run generate:config-example'); + process.exitCode = 1; +} + +if (require.main === module) { + main(); +}