From 6b2cb002ac37e5f50f31fc8b2d45b027eaf22f3f Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 17 May 2026 19:54:59 -0700 Subject: [PATCH] [codex] add versioned Pages deployment (#73) --- .github/workflows/docs-pages.yml | 76 +++++ changes/docs-versioning.md | 4 + docs-site/.vitepress/config.ts | 276 ++++++++++++++----- docs-site/.vitepress/theme/tui-theme.css | 4 +- docs-site/README.md | 17 +- docs-site/configuration.md | 10 +- docs-site/demos.md | 30 +- docs-site/development.md | 8 + docs-site/docs-sync.test.ts | 8 +- docs-site/index.assets.test.ts | 10 +- docs-site/index.md | 12 +- docs-site/package.json | 2 +- docs-site/plausible.test.ts | 7 + docs-site/seo.test.ts | 48 ++++ docs/RELEASING.md | 4 + package.json | 3 +- scripts/build-versioned-docs.ts | 337 +++++++++++++++++++++++ scripts/docs-versioning.test.ts | 56 ++++ scripts/docs-versioning.ts | 85 ++++++ src/ci-workflow.test.ts | 27 ++ src/release-workflow.test.ts | 10 + tsconfig.typecheck.json | 2 +- 22 files changed, 929 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/docs-pages.yml create mode 100644 changes/docs-versioning.md create mode 100644 scripts/build-versioned-docs.ts create mode 100644 scripts/docs-versioning.test.ts create mode 100644 scripts/docs-versioning.ts diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 00000000..9f5a4976 --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,76 @@ +name: Docs Pages + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - 'v*' + paths: + - 'docs-site/**' + - 'scripts/docs-versioning.ts' + - 'scripts/build-versioned-docs.ts' + - '.github/workflows/docs-pages.yml' + - 'package.json' + - 'bun.lock' + +concurrency: + group: docs-pages-production + cancel-in-progress: false + +jobs: + deploy: + if: ${{ github.ref_type != 'tag' || !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Guard stable docs tag shape + id: tag_guard + if: github.ref_type == 'tag' + run: | + if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}" + echo "stable_tag=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "stable_tag=true" >> "$GITHUB_OUTPUT" + + - name: Setup Bun + if: steps.tag_guard.outputs.stable_tag != 'false' + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + if: steps.tag_guard.outputs.stable_tag != 'false' + run: | + bun install --frozen-lockfile + cd docs-site && bun install --frozen-lockfile + + - name: Cache versioned docs archives + if: steps.tag_guard.outputs.stable_tag != 'false' + uses: actions/cache@v4 + with: + path: .tmp/docs-versioned-archive-cache + key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }} + + - name: Test docs + if: steps.tag_guard.outputs.stable_tag != 'false' + run: bun run docs:test + + - name: Build versioned docs + if: steps.tag_guard.outputs.stable_tag != 'false' + run: bun run docs:build:versioned + + - name: Deploy docs to Cloudflare Pages + if: steps.tag_guard.outputs.stable_tag != 'false' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main diff --git a/changes/docs-versioning.md b/changes/docs-versioning.md new file mode 100644 index 00000000..f1f36ea3 --- /dev/null +++ b/changes/docs-versioning.md @@ -0,0 +1,4 @@ +type: docs +area: docs + +- Published stable docs at the site root with current development docs under `/main/`. diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 04f82d7d..825d15b3 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress'; + const DOCS_HOSTNAME = 'https://docs.subminer.moe'; const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com'; const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js'; @@ -7,20 +11,212 @@ const PLAUSIBLE_INIT_SCRIPT = [ `plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`, ].join('\n'); -function pageToCanonicalHref(page: string): string | null { +type DocsChannel = 'stable-root' | 'stable-archive' | 'main'; + +type VersionManifest = { + latestStable: string; + channels: Array<{ label: string; path: string }>; + versions: Array<{ version: string; path: string }>; +}; + +const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/'); +const outDir = process.env.SUBMINER_DOCS_OUT_DIR; +const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL); +const docsVersion = process.env.SUBMINER_DOCS_VERSION; +const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0'; +const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST); + +function normalizeBase(value: string): string { + if (!value || value === '/') return '/'; + return `/${value.replace(/^\/+|\/+$/g, '')}/`; +} + +function normalizeChannel(value: string | undefined): DocsChannel { + if (value === 'main' || value === 'stable-archive') return value; + return 'stable-root'; +} + +function parseVersionManifest(value: string | undefined): VersionManifest { + if (!value) { + return { + latestStable, + channels: [ + { label: 'Latest stable', path: '/' }, + { label: 'main', path: '/main/' }, + ], + versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }], + }; + } + + return JSON.parse(value) as VersionManifest; +} + +function withDocsBase(path: string): string { + if (/^[a-z]+:\/\//i.test(path)) return path; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + if (base === '/') return normalizedPath; + return `${base.replace(/\/$/, '')}${normalizedPath}`; +} + +function pageToRoute(page: string): string | null { if (page === '404.md') return null; const route = page .replace(/(^|\/)index\.md$/, '') .replace(/\.md$/, '') .replace(/\/$/, ''); - return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`; + return route ? `/${route}` : '/'; } -export default { +function pageToCanonicalHref(page: string): string | null { + const route = pageToRoute(page); + if (!route) return null; + + if (channel === 'main') { + return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`; + } + + if (channel === 'stable-archive' && docsVersion !== latestStable) { + return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`; + } + + return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`; +} + +function canonicalRouteWithBase(route: string): string { + const routeWithBase = withDocsBase(route); + return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, ''); +} + +function transformPageHead({ page }: TransformContext): HeadConfig[] { + const href = pageToCanonicalHref(page); + const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : []; + + if (channel === 'main') { + head.push(['meta', { name: 'robots', content: 'noindex,follow' }]); + } + + return head; +} + +function linkToPagePath(link: string): string | null { + if (!link.startsWith('/') || link.startsWith('/v/') || link.startsWith('/main/')) { + return null; + } + + const withoutHash = link.split('#')[0] ?? '/'; + const withoutQuery = withoutHash.split('?')[0] ?? '/'; + const route = withoutQuery.replace(/^\/+|\/+$/g, ''); + return route ? `${route}.md` : 'index.md'; +} + +function hasPageForLink(link: string): boolean { + const pagePath = linkToPagePath(link); + if (!pagePath) return true; + return existsSync(join(process.cwd(), pagePath)); +} + +function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] { + return items + .map((item) => { + if ('items' in item && item.items) { + return { ...item, items: filterNav(item.items as DefaultTheme.NavItem[]) }; + } + if ('link' in item && item.link && !hasPageForLink(item.link)) { + return null; + } + return item; + }) + .filter((item): item is DefaultTheme.NavItem => Boolean(item)); +} + +function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarItem[] { + return items + .map((item) => { + const filteredChildren = item.items ? filterSidebar(item.items) : undefined; + if (item.link && !hasPageForLink(item.link)) return null; + if (item.items && filteredChildren?.length === 0 && !item.link) return null; + return { ...item, items: filteredChildren }; + }) + .filter((item): item is DefaultTheme.SidebarItem => Boolean(item)); +} + +const versionItems = [ + { + text: `Latest stable (${versionManifest.latestStable})`, + link: '/', + }, + ...versionManifest.channels + .filter((entry) => entry.label !== 'Latest stable') + .map((entry) => ({ text: entry.label, link: entry.path })), + ...versionManifest.versions.map((entry) => ({ + text: entry.version, + link: entry.path, + })), +]; + +const nav: DefaultTheme.NavItem[] = [ + { text: 'Home', link: '/' }, + { text: 'Get Started', link: '/installation' }, + { text: 'Mining', link: '/mining-workflow' }, + { text: 'Configuration', link: '/configuration' }, + { text: 'Changelog', link: '/changelog' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + { text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems }, +]; + +const sidebar: DefaultTheme.SidebarItem[] = [ + { + text: 'Getting Started', + items: [ + { text: 'Overview', link: '/' }, + { text: 'Installation', link: '/installation' }, + { text: 'Usage', link: '/usage' }, + { text: 'Mining Workflow', link: '/mining-workflow' }, + { text: 'Launcher Script', link: '/launcher-script' }, + ], + }, + { + text: 'Reference', + items: [ + { text: 'Configuration', link: '/configuration' }, + { text: 'Keyboard Shortcuts', link: '/shortcuts' }, + { text: 'Subtitle Annotations', link: '/subtitle-annotations' }, + { text: 'Subtitle Sidebar', link: '/subtitle-sidebar' }, + { text: 'Immersion Tracking', link: '/immersion-tracking' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + ], + }, + { + text: 'Integrations', + items: [ + { text: 'MPV Plugin', link: '/mpv-plugin' }, + { text: 'Anki', link: '/anki-integration' }, + { text: 'Jellyfin', link: '/jellyfin-integration' }, + { text: 'YouTube', link: '/youtube-integration' }, + { text: 'Jimaku', link: '/jimaku-integration' }, + { text: 'AniList', link: '/anilist-integration' }, + { text: 'Character Dictionary', link: '/character-dictionary' }, + ], + }, + { + text: 'Development', + items: [ + { text: 'Building & Testing', link: '/development' }, + { text: 'Architecture', link: '/architecture' }, + { text: 'IPC + Runtime Contracts', link: '/ipc-contracts' }, + { text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' }, + { text: 'Changelog', link: '/changelog' }, + ], + }, +]; + +const config: UserConfig = { title: 'SubMiner Docs', description: 'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.', + base, + ...(outDir ? { outDir } : {}), head: [ ['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }], [ @@ -31,13 +227,13 @@ export default { }, ], ['script', {}, PLAUSIBLE_INIT_SCRIPT], - ['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }], + ['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }], [ 'link', { rel: 'icon', type: 'image/png', - href: '/favicon-32x32.png', + href: withDocsBase('/favicon-32x32.png'), sizes: '32x32', }, ], @@ -46,7 +242,7 @@ export default { { rel: 'icon', type: 'image/png', - href: '/favicon-16x16.png', + href: withDocsBase('/favicon-16x16.png'), sizes: '16x16', }, ], @@ -54,7 +250,7 @@ export default { 'link', { rel: 'apple-touch-icon', - href: '/apple-touch-icon.png', + href: withDocsBase('/apple-touch-icon.png'), sizes: '180x180', }, ], @@ -70,10 +266,7 @@ export default { ); }, }, - transformHead({ page }) { - const href = pageToCanonicalHref(page); - return href ? [['link', { rel: 'canonical', href }]] : []; - }, + transformHead: transformPageHead, lastUpdated: true, srcExclude: ['subagents/**'], markdown: { @@ -84,63 +277,12 @@ export default { }, themeConfig: { logo: { - light: '/assets/SubMiner.png', - dark: '/assets/SubMiner.png', + light: withDocsBase('/assets/SubMiner.png'), + dark: withDocsBase('/assets/SubMiner.png'), }, siteTitle: 'SubMiner Docs', - nav: [ - { text: 'Home', link: '/' }, - { text: 'Get Started', link: '/installation' }, - { text: 'Mining', link: '/mining-workflow' }, - { text: 'Configuration', link: '/configuration' }, - { text: 'Changelog', link: '/changelog' }, - { text: 'Troubleshooting', link: '/troubleshooting' }, - ], - sidebar: [ - { - text: 'Getting Started', - items: [ - { text: 'Overview', link: '/' }, - { text: 'Installation', link: '/installation' }, - { text: 'Usage', link: '/usage' }, - { text: 'Mining Workflow', link: '/mining-workflow' }, - { text: 'Launcher Script', link: '/launcher-script' }, - ], - }, - { - text: 'Reference', - items: [ - { text: 'Configuration', link: '/configuration' }, - { text: 'Keyboard Shortcuts', link: '/shortcuts' }, - { text: 'Subtitle Annotations', link: '/subtitle-annotations' }, - { text: 'Subtitle Sidebar', link: '/subtitle-sidebar' }, - { text: 'Immersion Tracking', link: '/immersion-tracking' }, - { text: 'Troubleshooting', link: '/troubleshooting' }, - ], - }, - { - text: 'Integrations', - items: [ - { text: 'MPV Plugin', link: '/mpv-plugin' }, - { text: 'Anki', link: '/anki-integration' }, - { text: 'Jellyfin', link: '/jellyfin-integration' }, - { text: 'YouTube', link: '/youtube-integration' }, - { text: 'Jimaku', link: '/jimaku-integration' }, - { text: 'AniList', link: '/anilist-integration' }, - { text: 'Character Dictionary', link: '/character-dictionary' }, - ], - }, - { - text: 'Development', - items: [ - { text: 'Building & Testing', link: '/development' }, - { text: 'Architecture', link: '/architecture' }, - { text: 'IPC + Runtime Contracts', link: '/ipc-contracts' }, - { text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' }, - { text: 'Changelog', link: '/changelog' }, - ], - }, - ], + nav: filterNav(nav), + sidebar: filterSidebar(sidebar), search: { provider: 'local', }, @@ -159,3 +301,5 @@ export default { socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }], }, }; + +export default config; diff --git a/docs-site/.vitepress/theme/tui-theme.css b/docs-site/.vitepress/theme/tui-theme.css index 3776257b..e9026ff3 100644 --- a/docs-site/.vitepress/theme/tui-theme.css +++ b/docs-site/.vitepress/theme/tui-theme.css @@ -5,7 +5,7 @@ @font-face { font-family: 'M PLUS 1'; - src: url('/assets/fonts/Mplus1-Medium.ttf') format('truetype'); + src: url('../../public/assets/fonts/Mplus1-Medium.ttf') format('truetype'); font-weight: 500; font-style: normal; font-display: swap; @@ -13,7 +13,7 @@ @font-face { font-family: 'Manrope Default'; - src: url('/assets/fonts/manrope-latin-600-normal.ttf') format('truetype'); + src: url('../../public/assets/fonts/manrope-latin-600-normal.ttf') format('truetype'); font-weight: 600; font-style: normal; font-display: swap; diff --git a/docs-site/README.md b/docs-site/README.md index 554c3b66..8386e3b2 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -30,9 +30,16 @@ bun run docs:dev ## Cloudflare Pages - Git repo: `ksyasuda/SubMiner` -- Root directory: `docs-site` -- Build command: `bun run docs:build` -- Build output directory: `.vitepress/dist` -- Build watch paths: `docs-site/*` +- Production branch: `main` +- Automatic production and preview deployments: disabled +- Custom domain: `docs.subminer.moe` attached to Production +- Deployment path: GitHub Actions direct upload with Wrangler -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. +The public docs root is stable-only: + +- `/` serves the latest stable release docs. +- `/main/` serves development docs from `main` and is marked `noindex,follow`. +- `/v//` serves stable release archives. +- Prerelease tags do not update the docs site. + +Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 81eb2dba..7c675413 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -4,6 +4,10 @@ outline: [2, 3] # Configuration + + Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`. When both files exist, SubMiner prefers `config.jsonc` over `config.json`. @@ -1044,12 +1048,12 @@ To refresh roughly once per day, set: `deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate. When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus. - diff --git a/docs-site/package.json b/docs-site/package.json index b0a5947c..181f1d7f 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -8,7 +8,7 @@ "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort", - "test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts" + "test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts ../scripts/docs-versioning.test.ts" }, "dependencies": { "@catppuccin/vitepress": "^0.1.2", diff --git a/docs-site/plausible.test.ts b/docs-site/plausible.test.ts index 1b260133..f762fb82 100644 --- a/docs-site/plausible.test.ts +++ b/docs-site/plausible.test.ts @@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs'; const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url); const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url); const docsPackagePath = new URL('./package.json', import.meta.url); +const versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url); const docsConfigContents = readFileSync(docsConfigPath, 'utf8'); const docsThemeContents = readFileSync(docsThemePath, 'utf8'); const docsPackageContents = readFileSync(docsPackagePath, 'utf8'); +const versionedBuildContents = readFileSync(versionedBuildPath, 'utf8'); test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => { expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'"); @@ -34,3 +36,8 @@ test('docs site loads the docs.subminer.moe Plausible script through the analyti expect(docsThemeContents).not.toContain('initPlausibleTracker'); expect(docsPackageContents).not.toContain('@plausible-analytics/tracker'); }); + +test('versioned docs reuse current VitePress internals for old page snapshots', () => { + expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')"); + expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)'); +}); diff --git a/docs-site/seo.test.ts b/docs-site/seo.test.ts index 0f46a094..f0533c96 100644 --- a/docs-site/seo.test.ts +++ b/docs-site/seo.test.ts @@ -31,6 +31,54 @@ test('docs pages emit stable self-referential canonical URLs', async () => { expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex'); }); +test('main docs canonical uses /main/ and emits noindex', async () => { + const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; + const previousBase = process.env.SUBMINER_DOCS_BASE; + process.env.SUBMINER_DOCS_CHANNEL = 'main'; + process.env.SUBMINER_DOCS_BASE = '/main/'; + const { default: mainDocsConfig } = await import('./.vitepress/config?main-docs'); + + const head = await mainDocsConfig.transformHead?.(makeTransformContext('usage.md')); + const rootHead = await mainDocsConfig.transformHead?.(makeTransformContext('index.md')); + + expect(head).toContainEqual([ + 'link', + { rel: 'canonical', href: 'https://docs.subminer.moe/main/usage' }, + ]); + expect(rootHead).toContainEqual([ + 'link', + { rel: 'canonical', href: 'https://docs.subminer.moe/main/' }, + ]); + expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]); + + process.env.SUBMINER_DOCS_CHANNEL = previousChannel; + process.env.SUBMINER_DOCS_BASE = previousBase; +}); + +test('latest stable archive canonical points to root equivalent', async () => { + const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; + const previousBase = process.env.SUBMINER_DOCS_BASE; + const previousVersion = process.env.SUBMINER_DOCS_VERSION; + const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE; + process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive'; + process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/'; + process.env.SUBMINER_DOCS_VERSION = 'v0.14.0'; + process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0'; + const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive'); + + const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md')); + + expect(head).toContainEqual([ + 'link', + { rel: 'canonical', href: 'https://docs.subminer.moe/usage' }, + ]); + + process.env.SUBMINER_DOCS_CHANNEL = previousChannel; + process.env.SUBMINER_DOCS_BASE = previousBase; + process.env.SUBMINER_DOCS_VERSION = previousVersion; + process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest; +}); + test('docs sitemap excludes duplicate README page from indexable URLs', async () => { const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }]; diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 873ea24d..e014023c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -37,6 +37,7 @@ 8. If `docs-site/` changed, also run: `bun run docs:test` `bun run docs:build` + `bun run docs:build:versioned` 9. Commit release prep. 10. Tag the commit: `git tag v`. 11. Push commit + tag. @@ -66,6 +67,7 @@ 7. Push commit + tag. Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes. +Prerelease tags also do not update `https://docs.subminer.moe/`. Notes: @@ -81,6 +83,8 @@ Notes: - If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. +- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v//` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`. +- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. diff --git a/package.json b/package.json index e8ce491c..04e60c29 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "lint": "bun run lint:stats", "docs:dev": "bun run --cwd docs-site docs:dev", "docs:build": "bun run --cwd docs-site docs:build", + "docs:build:versioned": "bun run scripts/build-versioned-docs.ts", "docs:preview": "bun run --cwd docs-site docs:preview", "docs:test": "bun run --cwd docs-site test", "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", @@ -71,7 +72,7 @@ "test:launcher": "bun run test:launcher:src", "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 run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.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", diff --git a/scripts/build-versioned-docs.ts b/scripts/build-versioned-docs.ts new file mode 100644 index 00000000..575156c7 --- /dev/null +++ b/scripts/build-versioned-docs.ts @@ -0,0 +1,337 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + buildVersionManifest, + stableTagsWithDocs, + versionArchiveCacheName, + versionOutputPath, + versionPath, +} from './docs-versioning'; + +const repoRoot = resolve(__dirname, '..'); +const currentDocsSite = join(repoRoot, 'docs-site'); +const buildRoot = join(repoRoot, '.tmp/docs-versioned-build'); +const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site'); +const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache'); +const maxCloudflareFiles = 20_000; +const maxCloudflareFileBytes = 25 * 1024 * 1024; + +function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + stdio: 'inherit', + }); + + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(' ')}`); + } +} + +function capture(command: string, args: string[]): string { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`); + } + + return result.stdout; +} + +function archiveDocsSite(ref: string, targetDir: string) { + mkdirSync(targetDir, { recursive: true }); + const archive = spawnSync('git', ['archive', '--format=tar', ref, 'docs-site'], { + cwd: repoRoot, + encoding: 'buffer', + maxBuffer: 1024 * 1024 * 1024, + }); + + if (archive.status !== 0 || !archive.stdout) { + throw new Error(`Unable to archive docs-site from ${ref}`); + } + + const extract = spawnSync('tar', ['-x', '-C', targetDir], { + input: archive.stdout, + stdio: ['pipe', 'inherit', 'inherit'], + }); + + if (extract.status !== 0) { + throw new Error(`Unable to extract docs-site archive from ${ref}`); + } +} + +function copyCurrentDocsSite(targetDir: string) { + mkdirSync(targetDir, { recursive: true }); + cpSync(currentDocsSite, join(targetDir, 'docs-site'), { + recursive: true, + dereference: false, + filter: (source) => + !/[\\/]node_modules([\\/]|$)/.test(source) && + !/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source), + }); +} + +function overlayCurrentVitePress(snapshotDocsSite: string) { + const targetVitePress = join(snapshotDocsSite, '.vitepress'); + rmSync(targetVitePress, { recursive: true, force: true }); + cpSync(join(currentDocsSite, '.vitepress'), targetVitePress, { + recursive: true, + filter: (source) => !isGeneratedVitePressPath(source), + }); + + const currentThemeFonts = join(currentDocsSite, 'public/assets/fonts'); + if (existsSync(currentThemeFonts)) { + cpSync(currentThemeFonts, join(snapshotDocsSite, 'public/assets/fonts'), { + recursive: true, + force: true, + }); + } +} + +function linkDocsDependencies(snapshotDocsSite: string) { + const currentNodeModules = join(currentDocsSite, 'node_modules'); + const targetNodeModules = join(snapshotDocsSite, 'node_modules'); + + if (!existsSync(currentNodeModules) || existsSync(targetNodeModules)) { + return; + } + + symlinkSync(currentNodeModules, targetNodeModules, 'dir'); +} + +function prepareSnapshot(name: string, ref?: string): string { + const snapshotRoot = join(buildRoot, name); + rmSync(snapshotRoot, { recursive: true, force: true }); + + if (ref) { + archiveDocsSite(ref, snapshotRoot); + } else { + copyCurrentDocsSite(snapshotRoot); + } + + const snapshotDocsSite = join(snapshotRoot, 'docs-site'); + overlayCurrentVitePress(snapshotDocsSite); + linkDocsDependencies(snapshotDocsSite); + return snapshotDocsSite; +} + +function tagHasDocsSite(tag: string): boolean { + const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], { + cwd: repoRoot, + }); + return result.status === 0; +} + +function getStableVersions(): string[] { + const tags = capture('git', ['tag', '--list', 'v*']) + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean); + return stableTagsWithDocs(tags, tagHasDocsSite); +} + +function buildDocs(options: { + snapshotDocsSite: string; + base: string; + outDir: string; + channel: string; + version?: string; + latestStable: string; + manifestJson: string; +}) { + run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], { + cwd: repoRoot, + env: { + ...process.env, + SUBMINER_DOCS_BASE: options.base, + SUBMINER_DOCS_OUT_DIR: options.outDir, + SUBMINER_DOCS_CHANNEL: options.channel, + SUBMINER_DOCS_VERSION: options.version ?? '', + SUBMINER_DOCS_LATEST_STABLE: options.latestStable, + SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson, + VITE_EXTRA_EXTENSIONS: 'jsonc', + }, + }); +} + +function updateHashWithPath(hash: ReturnType, path: string) { + if (isGeneratedVitePressPath(path)) { + return; + } + + const stat = lstatSync(path); + hash.update(path.replace(repoRoot, '')); + hash.update(String(stat.mode)); + + if (stat.isSymbolicLink()) { + return; + } + + if (stat.isDirectory()) { + for (const entry of readdirSync(path).sort()) { + updateHashWithPath(hash, join(path, entry)); + } + return; + } + + hash.update(readFileSync(path)); +} + +function isGeneratedVitePressPath(path: string): boolean { + return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path); +} + +function computeSharedInternalsHash(): string { + const hash = createHash('sha256'); + const paths = [ + join(currentDocsSite, '.vitepress'), + join(currentDocsSite, 'public/assets/fonts'), + join(currentDocsSite, 'package.json'), + join(currentDocsSite, 'bun.lock'), + join(repoRoot, 'scripts/build-versioned-docs.ts'), + join(repoRoot, 'scripts/docs-versioning.ts'), + ]; + + for (const path of paths) { + if (existsSync(path)) { + updateHashWithPath(hash, path); + } + } + + return hash.digest('hex'); +} + +function archiveCachePath(version: string, sharedInternalsHash: string): string { + return join(archiveCacheRoot, versionArchiveCacheName(version, sharedInternalsHash)); +} + +function restoreCachedArchive(version: string, sharedInternalsHash: string): boolean { + const cachedArchive = archiveCachePath(version, sharedInternalsHash); + if (!existsSync(cachedArchive)) { + return false; + } + + cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), { + recursive: true, + force: true, + }); + return true; +} + +function saveArchiveCache(version: string, sharedInternalsHash: string) { + const outputPath = join(aggregateOutDir, versionOutputPath(version)); + if (!existsSync(outputPath)) { + return; + } + + const cachedArchive = archiveCachePath(version, sharedInternalsHash); + rmSync(cachedArchive, { recursive: true, force: true }); + mkdirSync(archiveCacheRoot, { recursive: true }); + cpSync(outputPath, cachedArchive, { recursive: true, force: true }); +} + +function assertCloudflarePagesLimits(root: string) { + let fileCount = 0; + const oversizedFiles: string[] = []; + + function walk(dir: string) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + continue; + } + if (stat.isDirectory()) { + walk(path); + continue; + } + + fileCount += 1; + if (stat.size > maxCloudflareFileBytes) { + oversizedFiles.push(path); + } + } + } + + walk(root); + + if (fileCount > maxCloudflareFiles) { + throw new Error( + `Versioned docs output has ${fileCount} files; Cloudflare Pages free plan limit is ${maxCloudflareFiles}.`, + ); + } + + if (oversizedFiles.length > 0) { + throw new Error( + `Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`, + ); + } +} + +function main() { + const stableVersions = getStableVersions(); + const latestStable = stableVersions[0]; + + if (!latestStable) { + throw new Error('No stable release tags with docs-site/package.json found.'); + } + + const manifest = buildVersionManifest({ latestStable, stableVersions }); + const manifestJson = JSON.stringify(manifest); + const sharedInternalsHash = computeSharedInternalsHash(); + + rmSync(buildRoot, { recursive: true, force: true }); + rmSync(aggregateOutDir, { recursive: true, force: true }); + mkdirSync(buildRoot, { recursive: true }); + mkdirSync(aggregateOutDir, { recursive: true }); + + const latestStableSnapshot = prepareSnapshot(latestStable, latestStable); + buildDocs({ + snapshotDocsSite: latestStableSnapshot, + base: '/', + outDir: aggregateOutDir, + channel: 'stable-root', + version: latestStable, + latestStable, + manifestJson, + }); + + for (const version of stableVersions) { + if (restoreCachedArchive(version, sharedInternalsHash)) { + continue; + } + + const snapshot = version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version); + buildDocs({ + snapshotDocsSite: snapshot, + base: versionPath(version), + outDir: join(aggregateOutDir, versionOutputPath(version)), + channel: 'stable-archive', + version, + latestStable, + manifestJson, + }); + saveArchiveCache(version, sharedInternalsHash); + } + + const mainSnapshot = prepareSnapshot('main'); + buildDocs({ + snapshotDocsSite: mainSnapshot, + base: '/main/', + outDir: join(aggregateOutDir, 'main'), + channel: 'main', + version: 'main', + latestStable, + manifestJson, + }); + + writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`); + assertCloudflarePagesLimits(aggregateOutDir); +} + +main(); diff --git a/scripts/docs-versioning.test.ts b/scripts/docs-versioning.test.ts new file mode 100644 index 00000000..fbd209c7 --- /dev/null +++ b/scripts/docs-versioning.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildVersionManifest, + compareStableVersionsDesc, + isStableReleaseTag, + stableTagsWithDocs, + versionArchiveCacheName, + versionOutputPath, + versionPath, +} from './docs-versioning'; + +describe('docs versioning helpers', () => { + test('stable tag filtering excludes beta and rc tags', () => { + expect(isStableReleaseTag('v0.14.0')).toBe(true); + expect(isStableReleaseTag('v0.15.0-beta.3')).toBe(false); + expect(isStableReleaseTag('v0.15.0-rc.1')).toBe(false); + }); + + test('latest stable resolves to v0.14.0 when beta tags are present', () => { + const tags = ['v0.13.0', 'v0.15.0-beta.3', 'v0.14.0'].sort(compareStableVersionsDesc); + + expect(tags[0]).toBe('v0.14.0'); + }); + + test('tags before docs-site are skipped', () => { + const tags = ['v0.12.0', 'v0.13.0', 'v0.14.0']; + const hasDocsSite = (tag: string) => tag !== 'v0.12.0'; + + expect(stableTagsWithDocs(tags, hasDocsSite)).toEqual(['v0.14.0', 'v0.13.0']); + }); + + test('version manifest paths are normalized', () => { + expect(versionPath('v0.14.0')).toBe('/v/0.14.0/'); + expect( + buildVersionManifest({ + latestStable: 'v0.14.0', + stableVersions: ['v0.14.0'], + }), + ).toEqual({ + latestStable: 'v0.14.0', + channels: [ + { label: 'Latest stable', path: '/' }, + { label: 'main', path: '/main/' }, + ], + versions: [{ version: 'v0.14.0', path: '/v/0.14.0/' }], + }); + }); + + test('archive cache names are normalized by version and shared internals hash', () => { + expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0'); + }); + + test('archive output paths stay relative for filesystem joins', () => { + expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0'); + }); +}); diff --git a/scripts/docs-versioning.ts b/scripts/docs-versioning.ts new file mode 100644 index 00000000..39b94b3c --- /dev/null +++ b/scripts/docs-versioning.ts @@ -0,0 +1,85 @@ +export type DocsVersionEntry = { + version: string; + path: string; +}; + +export type DocsChannelEntry = { + label: string; + path: string; +}; + +export type DocsVersionManifest = { + latestStable: string; + channels: DocsChannelEntry[]; + versions: DocsVersionEntry[]; +}; + +const STABLE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/; + +export function isStableReleaseTag(tag: string): boolean { + return STABLE_TAG_PATTERN.test(tag); +} + +function parseStableVersion(tag: string): [number, number, number] { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag); + if (!match) { + throw new Error(`Invalid stable SubMiner version tag: ${tag}`); + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function compareStableVersionsDesc(a: string, b: string): number { + if (!isStableReleaseTag(a) && !isStableReleaseTag(b)) return a.localeCompare(b); + if (!isStableReleaseTag(a)) return 1; + if (!isStableReleaseTag(b)) return -1; + + const parsedA = parseStableVersion(a); + const parsedB = parseStableVersion(b); + + for (let index = 0; index < parsedA.length; index += 1) { + const difference = parsedB[index]! - parsedA[index]!; + if (difference !== 0) return difference; + } + + return 0; +} + +export function versionPath(version: string): string { + return `/v/${version.replace(/^v/, '')}/`; +} + +export function versionOutputPath(version: string): string { + return `v/${version.replace(/^v/, '')}`; +} + +export function versionArchiveCacheName(version: string, sharedInternalsHash: string): string { + return `${sharedInternalsHash.slice(0, 12)}-${version}`; +} + +export function stableTagsWithDocs( + tags: string[], + hasDocsSite: (tag: string) => boolean, +): string[] { + return tags + .filter(isStableReleaseTag) + .filter(hasDocsSite) + .sort(compareStableVersionsDesc); +} + +export function buildVersionManifest(options: { + latestStable: string; + stableVersions: string[]; +}): DocsVersionManifest { + return { + latestStable: options.latestStable, + channels: [ + { label: 'Latest stable', path: '/' }, + { label: 'main', path: '/main/' }, + ], + versions: options.stableVersions.map((version) => ({ + version, + path: versionPath(version), + })), + }; +} diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts index fb43a249..4ffc8165 100644 --- a/src/ci-workflow.test.ts +++ b/src/ci-workflow.test.ts @@ -5,6 +5,8 @@ import { resolve } from 'node:path'; const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml'); const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8'); +const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); +const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); const packageJsonPath = resolve(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { scripts: Record; @@ -36,3 +38,28 @@ test('ci workflow runs the maintained source coverage lane and uploads lcov outp assert.match(ciWorkflow, /name: Upload coverage artifact/); assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/); }); + +test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => { + assert.match(docsPagesWorkflow, /name: Docs Pages/); + assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/); + assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/); + assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/); + assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/); + assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/); + assert.match(docsPagesWorkflow, /pages deploy \.tmp\/docs-versioned-site/); + assert.match(docsPagesWorkflow, /--branch main/); +}); + +test('docs deploy caches stable archive builds between runs', () => { + assert.match(docsPagesWorkflow, /actions\/cache@v4/); + assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/); + assert.match(docsPagesWorkflow, /docs-versioned-archives-/); + assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/); +}); + +test('docs deploy skips invalid release tags without failing the workflow', () => { + assert.match(docsPagesWorkflow, /id:\s*tag_guard/); + assert.match(docsPagesWorkflow, /stable_tag=false/); + assert.doesNotMatch(docsPagesWorkflow, /exit 78/); + assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 64e89d21..95076c17 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -5,6 +5,8 @@ import { resolve } from 'node:path'; const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); +const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); +const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); const makefilePath = resolve(__dirname, '../Makefile'); const makefile = readFileSync(makefilePath, 'utf8'); const packageJsonPath = resolve(__dirname, '../package.json'); @@ -38,6 +40,14 @@ test('stable release workflow excludes prerelease beta and rc tags', () => { assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/); }); +test('stable release tags publish docs and prereleases do not update stable docs', () => { + assert.match(docsPagesWorkflow, /tags:\s*\n\s*-\s*'v\*'/); + assert.match(docsPagesWorkflow, /github\.ref_name/); + assert.match(docsPagesWorkflow, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/); + assert.match(docsPagesWorkflow, /bun run docs:build:versioned/); + assert.doesNotMatch(docsPagesWorkflow, /beta/); +}); + test('publish release forces an existing draft tag release to become public', () => { assert.ok(releaseWorkflow.includes('--draft=false')); }); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index cf4f0e63..e887982b 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -8,5 +8,5 @@ "sourceMap": false }, "include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"], - "exclude": ["node_modules", "dist", "vendor"] + "exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"] }