From 02fce127913857a3eac6504867abd3d7da25c771 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Tue, 2 Jun 2026 09:53:37 -0400 Subject: [PATCH 1/3] Move docs checks from git hooks to CI --- .githooks/pre-commit | 9 ----- .githooks/pre-push | 13 ------- .github/workflows/docs-checks.yml | 58 ++++++++++++++++++++++++++++++ .github/workflows/search-index.yml | 20 +++++++---- README.md | 21 ++++++++++- package.json | 27 +++++++------- pnpm-lock.yaml | 47 ++++++++++++------------ scripts/_redirects-lib.ts | 54 ---------------------------- scripts/check-stable-ids.ts | 13 ++++++- scripts/redirects-sync.ts | 52 +++++++++++++++++++++++++-- scripts/setup-hooks.ts | 39 -------------------- 11 files changed, 189 insertions(+), 164 deletions(-) delete mode 100755 .githooks/pre-commit delete mode 100755 .githooks/pre-push create mode 100644 .github/workflows/docs-checks.yml delete mode 100644 scripts/_redirects-lib.ts delete mode 100755 scripts/setup-hooks.ts diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index f081a6bd..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -set -e - -if ! command -v pnpm >/dev/null 2>&1; then - echo "warning: pnpm not found; skipping stable ID hook" >&2 - exit 0 -fi - -pnpm stable-ids:ensure --staged diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index 08e9dd86..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -set -e - -if ! git fetch origin main --quiet; then - echo "warning: failed to fetch origin/main; using local ref for redirect check" >&2 -fi - -if git diff --quiet origin/main...HEAD -- content redirects.json content.config.ts; then - exit 0 -fi - -echo "Checking redirects for docs changes..." -pnpm redirects:check diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml new file mode 100644 index 00000000..d1199bcf --- /dev/null +++ b/.github/workflows/docs-checks.yml @@ -0,0 +1,58 @@ +name: Docs Checks + +on: + push: + branches: + - main + paths: &docs-check-paths + - 'content/**' + - 'content.config.ts' + - 'redirects.json' + - 'scripts/_content-lib.ts' + - 'scripts/check-stable-ids.ts' + - 'scripts/ensure-stable-ids.ts' + - 'scripts/redirects-sync.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/docs-checks.yml' + pull_request: + branches: + - main + paths: *docs-check-paths + workflow_dispatch: + +permissions: + contents: read + +jobs: + docs-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Typecheck scripts + run: pnpm typecheck:scripts + + - name: Check stable IDs + run: pnpm stable-ids:check + + # Redirect check diffs against origin/main, so it only makes sense as a + # pre-merge gate on PRs. On push to main the merge already landed and + # origin/main points at the pushed commit (self-comparison), so skip it. + - name: Fetch base ref for redirect diff + if: github.event_name == 'pull_request' + run: git fetch --depth=1 origin main + + - name: Check redirects + if: github.event_name == 'pull_request' + run: pnpm redirects:check diff --git a/.github/workflows/search-index.yml b/.github/workflows/search-index.yml index 12f49ec8..106b48cd 100644 --- a/.github/workflows/search-index.yml +++ b/.github/workflows/search-index.yml @@ -8,11 +8,13 @@ on: - 'content/**' - 'scripts/index-docs.ts' - 'scripts/index-docs-chunker.ts' + - 'scripts/_content-lib.ts' - 'shared/utils/parseTypesenseUrl.ts' - 'shared/utils/docsSections.ts' - 'app/utils/slugify.ts' - 'server/data/synonyms.ts' - 'lib/typesenseAlias.ts' + - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/search-index.yml' pull_request: @@ -21,10 +23,6 @@ on: paths: *index-paths workflow_dispatch: -concurrency: - group: search-index-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - permissions: contents: read @@ -32,6 +30,11 @@ jobs: preview-index: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + # Cancel superseded preview runs so the alias never ends up pointing at a + # stale index from an out-of-order swap. + concurrency: + group: search-index-preview-${{ github.ref }} + cancel-in-progress: true env: TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} TYPESENSE_PUBLIC_API_KEY: ${{ secrets.TYPESENSE_PUBLIC_API_KEY }} @@ -47,7 +50,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Index preview collection run: pnpm index:docs @@ -55,6 +58,11 @@ jobs: prod-index: if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + # Run prod indexing sequentially; the blue/green slot design tolerates + # re-runs, so never cancel a swap mid-flight. + concurrency: + group: search-index-prod + cancel-in-progress: false env: TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} TYPESENSE_PUBLIC_API_KEY: ${{ secrets.TYPESENSE_PUBLIC_API_KEY }} @@ -70,7 +78,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Index production collection run: pnpm index:docs diff --git a/README.md b/README.md index 8675d90f..36d42b4c 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,26 @@ pnpm index:docs # Build the search index in Typesense pnpm typecheck:scripts # Type check repository scripts ``` -`pnpm install` configures `.githooks` for the repository when no custom `core.hooksPath` is set. The pre-commit hook can add missing `stableId` values to staged docs files. The pre-push hook checks redirects when docs content, redirect configuration, or content configuration changes. +Stable IDs give each public docs page a permanent identity. Nuxt Content derives +its unique page IDs from file paths, so moving a page changes its built-in ID. +Redirect sync compares the current branch to `origin/main`, so moved pages keep +their old URLs working. + +CI runs `pnpm stable-ids:check` and `pnpm redirects:check` for docs changes. + +- New docs page: run `pnpm stable-ids:ensure`, then commit the new `stableId`. +- Moved docs page: keep the existing `stableId`, run `pnpm redirects:sync`, then commit `redirects.json`. +- Deleted, split, or merged docs page: run `pnpm redirects:sync`, review `.docs/redirect-decisions-needed.md`, choose target redirects, then re-run `pnpm redirects:check`. +- Before opening a PR: run `pnpm stable-ids:check` and `pnpm redirects:check`. + +Redirect scripts compare against `origin/main` by default. To check a release branch +or another target, fetch it first, then pass `--base` directly to the script: + +```bash +git fetch origin release/v13 +node scripts/redirects-sync.ts --base origin/release/v13 --no-write --fail-on-unresolved +node scripts/redirects-sync.ts --base origin/release/v13 --write-deterministic --fail-on-unresolved +``` ## ✍️ Authoring Content diff --git a/package.json b/package.json index 0a775639..300cfd2e 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,18 @@ "private": true, "type": "module", "scripts": { - "api-ref:generate": "tsx scripts/generate-api-reference.ts", - "build": "tsx scripts/generate-api-reference.ts && nuxt build", - "dev": "tsx scripts/generate-api-reference.ts && nuxt dev", - "generate": "tsx scripts/generate-api-reference.ts && nuxt generate", + "api-ref:generate": "node scripts/generate-api-reference.ts", + "build": "node scripts/generate-api-reference.ts && nuxt build", + "dev": "node scripts/generate-api-reference.ts && nuxt dev", + "generate": "node scripts/generate-api-reference.ts && nuxt generate", "preview": "nuxt preview", - "postinstall": "node scripts/setup-hooks.ts && tsx scripts/generate-api-reference.ts && nuxt prepare", + "postinstall": "node scripts/generate-api-reference.ts && nuxt prepare", "stable-ids:ensure": "node scripts/ensure-stable-ids.ts", "stable-ids:check": "node scripts/check-stable-ids.ts", "redirects:sync": "node scripts/redirects-sync.ts --write-deterministic --fail-on-unresolved", "redirects:check": "node scripts/redirects-sync.ts --fail-on-unresolved --no-write", "typecheck:scripts": "tsc -p scripts/tsconfig.json", - "index:docs": "tsx scripts/index-docs.ts", + "index:docs": "node scripts/index-docs.ts", "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { @@ -32,19 +32,12 @@ "@nuxtjs/sitemap": "8.0.13", "@vueuse/core": "14.2.1", "@vueuse/nuxt": "14.2.1", - "dotenv": "^17.4.2", - "gray-matter": "^4.0.3", "h3": "1.15.11", - "js-yaml": "^4.1.1", - "lodash-es": "4.18.1", "nuxt": "4.4.2", "nuxt-llms": "0.2.0", "openapi3-ts": "4.5.0", "posthog-js": "1.371.2", "posthog-node": "5.29.7", - "remark": "^15.0.1", - "remark-mdc": "^3.11.0", - "remark-parse": "^11.0.0", "sharp": "^0.34.5", "tailwindcss": "^4.2.4", "typesense": "^3.0.6", @@ -61,8 +54,14 @@ "@types/lodash-es": "4.17.12", "@types/node": "^22", "@vue/test-utils": "^2.4.10", + "dotenv": "^17.4.2", + "gray-matter": "^4.0.3", "happy-dom": "^20.9.0", - "tsx": "^4.22.3", + "js-yaml": "^4.1.1", + "lodash-es": "4.18.1", + "remark": "^15.0.1", + "remark-mdc": "^3.11.0", + "remark-parse": "^11.0.0", "typescript": "6.0.3", "vitest": "^4.1.7", "vue-tsc": "^3.2.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca0a5336..10b72eb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,21 +54,9 @@ importers: '@vueuse/nuxt': specifier: 14.2.1 version: 14.2.1(magicast@0.5.2)(nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.33(typescript@6.0.3)) - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 h3: specifier: 1.15.11 version: 1.15.11 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - lodash-es: - specifier: 4.18.1 - version: 4.18.1 nuxt: specifier: 4.4.2 version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.33)(better-sqlite3@11.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@11.10.0))(esbuild@0.27.7)(eslint@9.28.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.53(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.22.3)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) @@ -84,15 +72,6 @@ importers: posthog-node: specifier: 5.29.7 version: 5.29.7 - remark: - specifier: ^15.0.1 - version: 15.0.1 - remark-mdc: - specifier: ^3.11.0 - version: 3.11.0 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -136,12 +115,30 @@ importers: '@vue/test-utils': specifier: ^2.4.10 version: 2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 happy-dom: specifier: ^20.9.0 version: 20.9.0 - tsx: - specifier: ^4.22.3 - version: 4.22.3 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + lodash-es: + specifier: 4.18.1 + version: 4.18.1 + remark: + specifier: ^15.0.1 + version: 15.0.1 + remark-mdc: + specifier: ^3.11.0 + version: 3.11.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 typescript: specifier: 6.0.3 version: 6.0.3 @@ -11806,6 +11803,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.0 '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + optional: true escalade@3.2.0: {} @@ -15386,6 +15384,7 @@ snapshots: esbuild: 0.28.0 optionalDependencies: fsevents: 2.3.3 + optional: true tunnel-agent@0.6.0: dependencies: diff --git a/scripts/_redirects-lib.ts b/scripts/_redirects-lib.ts deleted file mode 100644 index 9ee6565a..00000000 --- a/scripts/_redirects-lib.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from 'node:fs'; - -export type RedirectStatusCode = 301 | 302 | 307 | 308; - -const VALID_STATUS_CODES: ReadonlySet = new Set([301, 302, 307, 308]); - -export interface RedirectEntry { - to: string; - statusCode: RedirectStatusCode; -} - -export type RedirectRouteRules = Record; - -export function loadRedirects(file: string): Record { - if (!fs.existsSync(file)) return {}; - - const raw = fs.readFileSync(file, 'utf8').trim(); - if (!raw) return {}; - - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${file}: must be a JSON object keyed by source path`); - } - - const entries: Record = {}; - for (const [from, value] of Object.entries(parsed as Record)) { - entries[from] = parseEntry(file, from, value); - } - return entries; -} - -function parseEntry(file: string, from: string, value: unknown): RedirectEntry { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${file}: entry for ${from} must be { to: string, statusCode: 301|302|307|308 }`); - } - const { to, statusCode } = value as { to?: unknown; statusCode?: unknown }; - if (typeof to !== 'string' || typeof statusCode !== 'number' || !VALID_STATUS_CODES.has(statusCode as RedirectStatusCode)) { - throw new Error(`${file}: entry for ${from} must be { to: string, statusCode: 301|302|307|308 }`); - } - return { to, statusCode: statusCode as RedirectStatusCode }; -} - -export function writeRedirects(file: string, entries: Record): void { - const sorted = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b))); - fs.writeFileSync(file, JSON.stringify(sorted, null, 2) + '\n'); -} - -export function toRouteRules(entries: Record, baseURL: string): RedirectRouteRules { - const rules: RedirectRouteRules = {}; - for (const [from, { to, statusCode }] of Object.entries(entries)) { - rules[`${baseURL}${from}`] = { redirect: { to: `${baseURL}${to}`, statusCode } }; - } - return rules; -} diff --git a/scripts/check-stable-ids.ts b/scripts/check-stable-ids.ts index d114f5bc..5f7bce4f 100755 --- a/scripts/check-stable-ids.ts +++ b/scripts/check-stable-ids.ts @@ -65,7 +65,18 @@ function main(): void { console.error(`\nTotal files: ${total}`); console.error(`Passing: ${passing}`); console.error(`Failing: ${failing} (${breakdown})`); - console.error('\nFix the files above and re-run the command.'); + console.error('\nHow to fix:'); + if (counts['missing frontmatter'] > 0) { + console.error('- Add frontmatter to files missing it.'); + } + if (counts['missing stableId'] > 0) { + console.error('- Run `pnpm stable-ids:ensure` to add missing stableId values.'); + } + if (counts['invalid stableId'] > 0) { + console.error('- Replace invalid stableId values with valid UUIDs.'); + console.error('- For new pages, remove invalid stableId values, then run `pnpm stable-ids:ensure`.'); + } + console.error('- Re-run `pnpm stable-ids:check`.'); process.exit(1); } diff --git a/scripts/redirects-sync.ts b/scripts/redirects-sync.ts index 20687c7d..9dff0022 100755 --- a/scripts/redirects-sync.ts +++ b/scripts/redirects-sync.ts @@ -115,7 +115,7 @@ Options: --hints Manual redirect hints JSON (default: .docs/redirect-hints.json) --report Redirect decisions report (default: .docs/redirect-decisions-needed.json) --write-deterministic Auto-write deterministic redirects to the manifest - --fail-on-unresolved Exit non-zero if redirect decisions remain + --fail-on-unresolved Exit non-zero if redirect updates or decisions remain --no-write Do not write manifest or report files `); } @@ -400,6 +400,40 @@ function buildDecisionMarkdown(payload: DecisionPayload): string { return lines.join('\n') + '\n'; } +function reportMarkdownPath(file: string): string { + return file.endsWith('.json') ? file.replace(/\.json$/i, '.md') : `${file}.md`; +} + +function printLimitedRedirects(title: string, redirects: AcceptedResolution[]): void { + if (!redirects.length) return; + + console.error(`\n${title}:`); + for (const item of redirects.slice(0, 20)) { + console.error(`- ${item.from} -> ${item.to}`); + } + if (redirects.length > 20) { + console.error(`- ...and ${redirects.length - 20} more`); + } +} + +function printRedirectFixInstructions(options: CliOptions, hasDeterministicRedirects: boolean, hasUnresolvedRedirects: boolean): void { + console.error('\nHow to fix redirects:'); + console.error('- Run `pnpm redirects:sync`.'); + if (hasDeterministicRedirects) { + console.error(` - Writes safe redirects to ${options.manifest}.`); + } + if (hasUnresolvedRedirects) { + console.error(` - Writes manual decisions to ${reportMarkdownPath(options.report)}.`); + console.error('- For each manual decision, choose a target, then either:'); + console.error(` - Add a manual hint to ${options.hints}, then rerun \`pnpm redirects:sync\`.`); + console.error(' { "manualRedirects": { "/old-path": "/new-path" } }'); + console.error(` - Or add the redirect directly to ${options.manifest}:`); + console.error(' "/old-path": { "to": "/new-path", "statusCode": 301 }'); + } + console.error('- Commit updated redirect files.'); + console.error('- Re-run `pnpm redirects:check`.'); +} + function main(): void { const options = parseArgs(process.argv.slice(2)); assertBaseRefExists(options.base); @@ -427,6 +461,9 @@ function main(): void { const newEntries = options.writeDeterministic ? accepted.filter(row => !manifest.fromSet.has(row.from)) : []; + const missingDeterministicRedirects = options.noWrite + ? accepted.filter(row => !manifest.fromSet.has(row.from)) + : []; const summary: Summary = { baseRef: options.base, @@ -461,17 +498,26 @@ function main(): void { console.log(`Wrote ${newEntries.length} redirect(s) to ${options.manifest}`); } if (unresolved.length && !options.noWrite) { - console.log(`Review redirect decisions in ${options.report.replace(/\.json$/i, '.md')}`); + console.log(`Review redirect decisions in ${reportMarkdownPath(options.report)}`); } + printLimitedRedirects('Missing deterministic redirect entries', missingDeterministicRedirects); + if (unresolved.length) { console.error('\nRedirect decisions needed:'); for (const item of unresolved.slice(0, 20)) { console.error(`- ${item.old.path}: ${item.reason}`); } + if (unresolved.length > 20) { + console.error(`- ...and ${unresolved.length - 20} more`); + } + } + + if (missingDeterministicRedirects.length || unresolved.length) { + printRedirectFixInstructions(options, missingDeterministicRedirects.length > 0, unresolved.length > 0); } - if (options.failOnUnresolved && unresolved.length > 0) { + if (options.failOnUnresolved && (unresolved.length > 0 || missingDeterministicRedirects.length > 0)) { process.exit(1); } } diff --git a/scripts/setup-hooks.ts b/scripts/setup-hooks.ts deleted file mode 100755 index 4757314c..00000000 --- a/scripts/setup-hooks.ts +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; - -if (!fs.existsSync('.git')) { - process.exit(0); -} - -/** - * Hooks are a contributor convenience, not a hard install requirement. - * Warn instead of failing so package installation still works in tarballs, CI, or - * other environments where git metadata is intentionally absent. - */ -if (!fs.existsSync('.githooks')) { - console.error('warning: .githooks directory not found; hooks were not configured'); - process.exit(0); -} - -let currentHooksPath = ''; -try { - currentHooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(); -} -catch { - // No local hooks path is configured. -} - -if (currentHooksPath && currentHooksPath !== '.githooks') { - console.error(`warning: core.hooksPath is already set to ${currentHooksPath}; hooks were not configured`); - process.exit(0); -} - -try { - execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'ignore' }); -} -catch { - console.error('warning: failed to configure git core.hooksPath to .githooks; hooks may not run automatically'); - process.exit(0); -} From ad64a5b6d9e11779c991798b75fb373c2c4cd36d Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Tue, 2 Jun 2026 10:09:00 -0400 Subject: [PATCH 2/3] Clean up Typesense preview indexes --- .github/workflows/search-index.yml | 35 ++++- README.md | 18 +++ lib/typesenseAlias.ts | 7 +- package.json | 1 + scripts/cleanup-typesense-preview.ts | 214 +++++++++++++++++++++++++++ tests/lib/typesenseAlias.test.ts | 11 +- 6 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 scripts/cleanup-typesense-preview.ts diff --git a/.github/workflows/search-index.yml b/.github/workflows/search-index.yml index 106b48cd..dbeb0c0f 100644 --- a/.github/workflows/search-index.yml +++ b/.github/workflows/search-index.yml @@ -20,6 +20,11 @@ on: pull_request: branches: - main + types: + - opened + - synchronize + - reopened + - closed paths: *index-paths workflow_dispatch: @@ -28,12 +33,12 @@ permissions: jobs: preview-index: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest # Cancel superseded preview runs so the alias never ends up pointing at a # stale index from an out-of-order swap. concurrency: - group: search-index-preview-${{ github.ref }} + group: search-index-preview-${{ github.event.pull_request.number }} cancel-in-progress: true env: TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} @@ -55,6 +60,32 @@ jobs: - name: Index preview collection run: pnpm index:docs + cleanup-preview-index: + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: search-index-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: + TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} + TYPESENSE_PRIVATE_API_KEY: ${{ secrets.TYPESENSE_PRIVATE_API_KEY }} + TYPESENSE_PREVIEW_BRANCH: ${{ github.event.pull_request.head.ref }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Delete preview collection + run: pnpm typesense:cleanup-preview --branch "$TYPESENSE_PREVIEW_BRANCH" + prod-index: if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' runs-on: ubuntu-latest diff --git a/README.md b/README.md index 36d42b4c..27c47bc8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ pnpm stable-ids:check # Validate stableId frontmatter pnpm redirects:sync # Update redirects.json for moved pages pnpm redirects:check # Check redirect coverage without writing files pnpm index:docs # Build the search index in Typesense +pnpm typesense:cleanup-preview # Delete stale Typesense preview indexes pnpm typecheck:scripts # Type check repository scripts ``` @@ -157,6 +158,23 @@ For one-off writes, override the index target with `TYPESENSE_INDEX_TARGET=...`. The browser reads from `TYPESENSE_COLLECTION` when set. Otherwise it derives the same branch alias as the indexer. The app reads the alias, never the `-a` / `-b` slot name. +### Preview Cleanup + +PR preview indexes are deleted when same-repo PRs close. The cleanup job deletes the branch alias and both fixed slots: + +```bash +pnpm typesense:cleanup-preview --branch bry/foo +``` + +For one-time cleanup of accumulated preview indexes, run a dry run first: + +```bash +pnpm typesense:cleanup-preview --stale --dry-run +pnpm typesense:cleanup-preview --stale +``` + +Stale cleanup keeps preview aliases for currently open PR branches and deletes the rest. It requires `TYPESENSE_URL`, `TYPESENSE_PRIVATE_API_KEY`, and authenticated `gh`. + ### Ranking Section boosts and personalization live in `buildPersonalizedSortBy` in `app/composables/useDocsSearch.ts`. The same `sectionPriority` array drives both the Typesense `_eval` boost order and the chip-bar render order in the palette. diff --git a/lib/typesenseAlias.ts b/lib/typesenseAlias.ts index f7588216..2c7224a7 100644 --- a/lib/typesenseAlias.ts +++ b/lib/typesenseAlias.ts @@ -2,6 +2,9 @@ import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import process from 'node:process'; +export const TYPESENSE_PROD_ALIAS = 'directus-docs'; +export const TYPESENSE_PREVIEW_ALIAS_PREFIX = 'directus-docs-preview-'; + export function slugifyBranch(branch: string) { const slug = branch .toLowerCase() @@ -38,6 +41,6 @@ export function getTypesenseBranchName() { export function resolveBranchTypesenseAlias(branch = getTypesenseBranchName()) { if (!branch) return null; - if (branch === 'main') return 'directus-docs'; - return `directus-docs-preview-${slugifyBranch(branch)}`; + if (branch === 'main') return TYPESENSE_PROD_ALIAS; + return `${TYPESENSE_PREVIEW_ALIAS_PREFIX}${slugifyBranch(branch)}`; } diff --git a/package.json b/package.json index 300cfd2e..e61a7006 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "redirects:check": "node scripts/redirects-sync.ts --fail-on-unresolved --no-write", "typecheck:scripts": "tsc -p scripts/tsconfig.json", "index:docs": "node scripts/index-docs.ts", + "typesense:cleanup-preview": "node scripts/cleanup-typesense-preview.ts", "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { diff --git a/scripts/cleanup-typesense-preview.ts b/scripts/cleanup-typesense-preview.ts new file mode 100644 index 00000000..88cc96c5 --- /dev/null +++ b/scripts/cleanup-typesense-preview.ts @@ -0,0 +1,214 @@ +import 'dotenv/config'; +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; +import { Client } from 'typesense'; +import { parseTypesenseUrl } from '../shared/utils/parseTypesenseUrl.ts'; +import { + resolveBranchTypesenseAlias, + slugifyBranch, + TYPESENSE_PREVIEW_ALIAS_PREFIX, +} from '../lib/typesenseAlias.ts'; + +interface CollectionAlias { + name: string; + collection_name: string; +} + +interface Options { + branch?: string; + stale: boolean; + dryRun: boolean; +} + +function requiredEnv(name: string) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +function createClient() { + const node = parseTypesenseUrl(requiredEnv('TYPESENSE_URL')); + return new Client({ + nodes: [node], + apiKey: requiredEnv('TYPESENSE_PRIVATE_API_KEY'), + connectionTimeoutSeconds: 300, + }); +} + +function isTypesenseNotFoundError(error: unknown) { + return Boolean( + error + && typeof error === 'object' + && 'httpStatus' in error + && error.httpStatus === 404, + ); +} + +function parseArgs(argv = process.argv.slice(2)): Options { + const options: Options = { + branch: process.env.TYPESENSE_PREVIEW_BRANCH, + stale: false, + dryRun: process.env.TYPESENSE_CLEANUP_DRY_RUN === 'true', + }; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--branch') { + options.branch = argv[++index]; + continue; + } + if (arg === '--stale') { + options.stale = true; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (options.branch && options.stale) throw new Error('Use either --branch or --stale, not both.'); + if (!options.branch && !options.stale) throw new Error('Set --branch or --stale.'); + return options; +} + +async function listAliases(client: Client) { + const response = await client.aliases().retrieve() as { aliases?: CollectionAlias[] }; + return response.aliases ?? []; +} + +async function listCollectionNames(client: Client) { + const collections = await client.collections().retrieve({ exclude_fields: 'fields' }) as Array<{ name: string }>; + return collections.map(collection => collection.name); +} + +async function deleteAlias(client: Client, alias: string, dryRun: boolean) { + if (dryRun) { + console.log(`[dry-run] Delete alias ${alias}`); + return; + } + try { + await client.aliases(alias).delete(); + console.log(`Deleted alias ${alias}`); + } + catch (error) { + if (!isTypesenseNotFoundError(error)) throw error; + } +} + +async function deleteCollection(client: Client, collection: string, dryRun: boolean) { + if (dryRun) { + console.log(`[dry-run] Delete collection ${collection}`); + return; + } + try { + await client.collections(collection).delete(); + console.log(`Deleted collection ${collection}`); + } + catch (error) { + if (!isTypesenseNotFoundError(error)) throw error; + } +} + +async function cleanupAlias(client: Client, alias: string, dryRun: boolean, existingAlias?: CollectionAlias | null) { + if (!alias.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) { + throw new Error(`Refusing to clean non-preview alias: ${alias}`); + } + + const collections = new Set([`${alias}-a`, `${alias}-b`]); + if (existingAlias?.collection_name) collections.add(existingAlias.collection_name); + + await deleteAlias(client, alias, dryRun); + for (const collection of collections) { + await deleteCollection(client, collection, dryRun); + } +} + +function getOpenBranchesFromEnv() { + const raw = process.env.TYPESENSE_OPEN_PREVIEW_BRANCHES; + if (!raw) return null; + return raw + .split(/[\n,]/) + .map(branch => branch.trim()) + .filter(Boolean); +} + +function getOpenPrBranches() { + const envBranches = getOpenBranchesFromEnv(); + if (envBranches) return envBranches; + + const output = execFileSync('gh', [ + 'pr', + 'list', + '--state', + 'open', + '--json', + 'headRefName', + '--jq', + '.[].headRefName', + ], { encoding: 'utf8' }); + + return output + .split('\n') + .map(branch => branch.trim()) + .filter(Boolean); +} + +function aliasFromSlot(collectionName: string) { + if (!collectionName.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) return null; + if (collectionName.endsWith('-a') || collectionName.endsWith('-b')) return collectionName.slice(0, -2); + return null; +} + +async function cleanupStale(client: Client, dryRun: boolean) { + const openAliases = new Set( + getOpenPrBranches() + .map(branch => `${TYPESENSE_PREVIEW_ALIAS_PREFIX}${slugifyBranch(branch)}`), + ); + + const aliases = await listAliases(client); + const previewAliases = aliases.filter(alias => alias.name.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)); + const collectionNames = await listCollectionNames(client); + const orphanAliases = collectionNames + .map(aliasFromSlot) + .filter((alias): alias is string => Boolean(alias)) + .filter(alias => !previewAliases.some(existing => existing.name === alias)); + + const staleAliases = new Map(); + for (const alias of previewAliases) { + if (!openAliases.has(alias.name)) staleAliases.set(alias.name, alias); + } + for (const alias of orphanAliases) { + if (!openAliases.has(alias)) staleAliases.set(alias, null); + } + + if (staleAliases.size === 0) { + console.log('No stale preview aliases found'); + return; + } + + for (const [alias, existingAlias] of staleAliases) { + await cleanupAlias(client, alias, dryRun, existingAlias); + } +} + +async function main() { + const options = parseArgs(); + const client = createClient(); + + if (options.branch) { + const alias = resolveBranchTypesenseAlias(options.branch); + if (!alias) throw new Error(`Could not resolve preview alias for branch: ${options.branch}`); + const existingAlias = (await listAliases(client)).find(candidate => candidate.name === alias) ?? null; + await cleanupAlias(client, alias, options.dryRun, existingAlias); + return; + } + + await cleanupStale(client, options.dryRun); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/tests/lib/typesenseAlias.test.ts b/tests/lib/typesenseAlias.test.ts index 91d7f4be..dc6725d9 100644 --- a/tests/lib/typesenseAlias.test.ts +++ b/tests/lib/typesenseAlias.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { getTypesenseBranchName, resolveBranchTypesenseAlias, slugifyBranch } from '../../lib/typesenseAlias'; +import { + getTypesenseBranchName, + resolveBranchTypesenseAlias, + slugifyBranch, + TYPESENSE_PREVIEW_ALIAS_PREFIX, + TYPESENSE_PROD_ALIAS, +} from '../../lib/typesenseAlias'; const originalEnv = { ...process.env }; @@ -14,7 +20,8 @@ describe('typesense alias helpers', () => { }); it('maps main to the production alias', () => { - expect(resolveBranchTypesenseAlias('main')).toBe('directus-docs'); + expect(resolveBranchTypesenseAlias('main')).toBe(TYPESENSE_PROD_ALIAS); + expect(resolveBranchTypesenseAlias('bry/foo')).toBe(`${TYPESENSE_PREVIEW_ALIAS_PREFIX}bry-foo`); }); it('uses the Vercel branch env when GitHub env is absent', () => { From 1daed80b53954b31914a699cb6b7971cd76f1108 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Tue, 2 Jun 2026 10:36:45 -0400 Subject: [PATCH 3/3] Harden Typesense preview cleanup and split workflow - Add parseArgs/cleanupAlias/aliasFromSlot tests - Validate --branch arg, dedupe orphan filter with a Set - Move PR-close cleanup into its own workflow with dispatch input - Comment --ignore-scripts rationale on CI install steps --- .github/workflows/search-index-cleanup.yml | 45 ++++++++++++++++++ .github/workflows/search-index.yml | 29 +----------- package.json | 2 +- scripts/cleanup-typesense-preview.ts | 42 +++++++++-------- .../scripts/cleanup-typesense-preview.test.ts | 46 +++++++++++++++++++ 5 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/search-index-cleanup.yml create mode 100644 tests/scripts/cleanup-typesense-preview.test.ts diff --git a/.github/workflows/search-index-cleanup.yml b/.github/workflows/search-index-cleanup.yml new file mode 100644 index 00000000..b453c0cb --- /dev/null +++ b/.github/workflows/search-index-cleanup.yml @@ -0,0 +1,45 @@ +name: Search Index Cleanup + +on: + pull_request: + branches: + - main + types: + - closed + workflow_dispatch: + inputs: + branch: + description: Branch name for the preview index to delete + required: true + type: string + +permissions: + contents: read + +jobs: + cleanup-preview-index: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: search-index-preview-${{ github.event.pull_request.number || inputs.branch }} + cancel-in-progress: true + env: + TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} + TYPESENSE_PRIVATE_API_KEY: ${{ secrets.TYPESENSE_PRIVATE_API_KEY }} + TYPESENSE_PREVIEW_BRANCH: ${{ github.event.pull_request.head.ref || inputs.branch }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + # This job runs one repository script only; skip package postinstall/build scripts. + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Delete preview collection + run: pnpm typesense:cleanup-preview --branch "$TYPESENSE_PREVIEW_BRANCH" diff --git a/.github/workflows/search-index.yml b/.github/workflows/search-index.yml index dbeb0c0f..91cc6753 100644 --- a/.github/workflows/search-index.yml +++ b/.github/workflows/search-index.yml @@ -24,7 +24,6 @@ on: - opened - synchronize - reopened - - closed paths: *index-paths workflow_dispatch: @@ -55,37 +54,12 @@ jobs: cache: pnpm - name: Install dependencies + # These jobs run repository scripts only; skip package postinstall/build scripts. run: pnpm install --frozen-lockfile --ignore-scripts - name: Index preview collection run: pnpm index:docs - cleanup-preview-index: - if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - concurrency: - group: search-index-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true - env: - TYPESENSE_URL: ${{ secrets.TYPESENSE_URL }} - TYPESENSE_PRIVATE_API_KEY: ${{ secrets.TYPESENSE_PRIVATE_API_KEY }} - TYPESENSE_PREVIEW_BRANCH: ${{ github.event.pull_request.head.ref }} - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Delete preview collection - run: pnpm typesense:cleanup-preview --branch "$TYPESENSE_PREVIEW_BRANCH" - prod-index: if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' runs-on: ubuntu-latest @@ -109,6 +83,7 @@ jobs: cache: pnpm - name: Install dependencies + # These jobs run repository scripts only; skip package postinstall/build scripts. run: pnpm install --frozen-lockfile --ignore-scripts - name: Index production collection diff --git a/package.json b/package.json index e61a7006..542c5903 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "typecheck:scripts": "tsc -p scripts/tsconfig.json", "index:docs": "node scripts/index-docs.ts", "typesense:cleanup-preview": "node scripts/cleanup-typesense-preview.ts", - "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" + "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/scripts/cleanup-typesense-preview.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { "@directus/openapi": "0.3.0", diff --git a/scripts/cleanup-typesense-preview.ts b/scripts/cleanup-typesense-preview.ts index 88cc96c5..e97561f5 100644 --- a/scripts/cleanup-typesense-preview.ts +++ b/scripts/cleanup-typesense-preview.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import { execFileSync } from 'node:child_process'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; import { Client } from 'typesense'; import { parseTypesenseUrl } from '../shared/utils/parseTypesenseUrl.ts'; import { @@ -9,12 +10,12 @@ import { TYPESENSE_PREVIEW_ALIAS_PREFIX, } from '../lib/typesenseAlias.ts'; -interface CollectionAlias { +export interface CollectionAlias { name: string; collection_name: string; } -interface Options { +export interface Options { branch?: string; stale: boolean; dryRun: boolean; @@ -44,17 +45,19 @@ function isTypesenseNotFoundError(error: unknown) { ); } -function parseArgs(argv = process.argv.slice(2)): Options { +export function parseArgs(argv = process.argv.slice(2), env = process.env): Options { const options: Options = { - branch: process.env.TYPESENSE_PREVIEW_BRANCH, + branch: env.TYPESENSE_PREVIEW_BRANCH, stale: false, - dryRun: process.env.TYPESENSE_CLEANUP_DRY_RUN === 'true', + dryRun: env.TYPESENSE_CLEANUP_DRY_RUN === 'true', }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--branch') { - options.branch = argv[++index]; + const branch = argv[++index]; + if (!branch || branch.startsWith('--')) throw new Error('--branch requires a branch name'); + options.branch = branch; continue; } if (arg === '--stale') { @@ -111,7 +114,7 @@ async function deleteCollection(client: Client, collection: string, dryRun: bool } } -async function cleanupAlias(client: Client, alias: string, dryRun: boolean, existingAlias?: CollectionAlias | null) { +export async function cleanupAlias(client: Client, alias: string, dryRun: boolean, existingAlias?: CollectionAlias | null) { if (!alias.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) { throw new Error(`Refusing to clean non-preview alias: ${alias}`); } @@ -125,8 +128,8 @@ async function cleanupAlias(client: Client, alias: string, dryRun: boolean, exis } } -function getOpenBranchesFromEnv() { - const raw = process.env.TYPESENSE_OPEN_PREVIEW_BRANCHES; +function getOpenBranchesFromEnv(env = process.env) { + const raw = env.TYPESENSE_OPEN_PREVIEW_BRANCHES; if (!raw) return null; return raw .split(/[\n,]/) @@ -134,8 +137,8 @@ function getOpenBranchesFromEnv() { .filter(Boolean); } -function getOpenPrBranches() { - const envBranches = getOpenBranchesFromEnv(); +function getOpenPrBranches(env = process.env) { + const envBranches = getOpenBranchesFromEnv(env); if (envBranches) return envBranches; const output = execFileSync('gh', [ @@ -155,7 +158,7 @@ function getOpenPrBranches() { .filter(Boolean); } -function aliasFromSlot(collectionName: string) { +export function aliasFromSlot(collectionName: string) { if (!collectionName.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)) return null; if (collectionName.endsWith('-a') || collectionName.endsWith('-b')) return collectionName.slice(0, -2); return null; @@ -169,11 +172,12 @@ async function cleanupStale(client: Client, dryRun: boolean) { const aliases = await listAliases(client); const previewAliases = aliases.filter(alias => alias.name.startsWith(TYPESENSE_PREVIEW_ALIAS_PREFIX)); + const previewAliasNames = new Set(previewAliases.map(alias => alias.name)); const collectionNames = await listCollectionNames(client); const orphanAliases = collectionNames .map(aliasFromSlot) .filter((alias): alias is string => Boolean(alias)) - .filter(alias => !previewAliases.some(existing => existing.name === alias)); + .filter(alias => !previewAliasNames.has(alias)); const staleAliases = new Map(); for (const alias of previewAliases) { @@ -193,7 +197,7 @@ async function cleanupStale(client: Client, dryRun: boolean) { } } -async function main() { +export async function main() { const options = parseArgs(); const client = createClient(); @@ -208,7 +212,9 @@ async function main() { await cleanupStale(client, options.dryRun); } -main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/cleanup-typesense-preview.test.ts b/tests/scripts/cleanup-typesense-preview.test.ts new file mode 100644 index 00000000..5c37f0d2 --- /dev/null +++ b/tests/scripts/cleanup-typesense-preview.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { Client } from 'typesense'; +import { aliasFromSlot, cleanupAlias, parseArgs } from '../../scripts/cleanup-typesense-preview'; + +describe('cleanup Typesense preview indexes', () => { + it('parses targeted branch cleanup args', () => { + expect(parseArgs(['--branch', 'bry/foo'], {})).toEqual({ + branch: 'bry/foo', + stale: false, + dryRun: false, + }); + }); + + it('parses stale dry-run cleanup args and env defaults', () => { + expect(parseArgs(['--stale'], { TYPESENSE_CLEANUP_DRY_RUN: 'true' })).toEqual({ + stale: true, + dryRun: true, + }); + + expect(parseArgs([], { TYPESENSE_PREVIEW_BRANCH: 'bry/foo' })).toEqual({ + branch: 'bry/foo', + stale: false, + dryRun: false, + }); + }); + + it('rejects ambiguous or unknown cleanup args', () => { + expect(() => parseArgs(['--branch', 'bry/foo', '--stale'], {})).toThrow('Use either --branch or --stale'); + expect(() => parseArgs(['--branch', '--dry-run'], {})).toThrow('--branch requires a branch name'); + expect(() => parseArgs(['--wat'], {})).toThrow('Unknown argument'); + expect(() => parseArgs([], {})).toThrow('Set --branch or --stale'); + }); + + it('derives preview alias names from fixed slots', () => { + expect(aliasFromSlot('directus-docs-preview-bry-foo-a')).toBe('directus-docs-preview-bry-foo'); + expect(aliasFromSlot('directus-docs-preview-bry-foo-b')).toBe('directus-docs-preview-bry-foo'); + expect(aliasFromSlot('directus-docs-a')).toBeNull(); + expect(aliasFromSlot('directus-docs-preview-bry-foo')).toBeNull(); + }); + + it('refuses to clean non-preview aliases', async () => { + await expect(cleanupAlias({} as Client, 'directus-docs', true)).rejects.toThrow( + 'Refusing to clean non-preview alias: directus-docs', + ); + }); +});