From 4c234dfa0cde858c8e567ddab5d62e14d801571e Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 4 Jun 2026 18:05:53 -0400 Subject: [PATCH 1/4] fix(ci): Restore Algolia indexing under tsx and smoke-test it on PRs The Bun->tsx runner swap (#17772) broke the Algolia index workflow on master. tsx loads scripts/algolia.ts via Node's CJS resolver, which fails on rehype-prism-diff (imported transitively via src/mdx.ts) because that package's `exports` map only declares an "import" condition: ERR_PACKAGE_PATH_NOT_EXPORTED. Bun tolerated this; tsx does not. Every docs-touching push has failed this step since the swap. Alias rehype-prism-diff to its real dist file in a scoped scripts/tsconfig.json so tsx resolves it as a plain file and bypasses the exports map, without affecting the Next.js build. The workflow only ran on push to master, so the regression had no pre-merge signal. Add a pull_request trigger gated to the indexing machinery (workflow, script, tsconfig, src/mdx.ts, package.json, lockfile) that builds and dry-runs the indexer with no secrets, so a future runner/dependency change is caught in PR CI. Add an ALGOLIA_DRY_RUN guard to the script to support this without mutating the production index. Co-Authored-By: Claude --- .github/workflows/algolia-index.yml | 52 ++++++++++++- scripts/algolia.ts | 109 ++++++++++++++++------------ scripts/tsconfig.json | 16 ++++ 3 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 scripts/tsconfig.json diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index bb33584a203d8d..0d0b5a718f55e2 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -3,10 +3,24 @@ on: push: branches: - master + # Smoke-test the indexing path on PRs that touch the indexing machinery, so runner/dependency + # regressions (e.g. the Bun->tsx swap that broke module resolution) are caught before merge + # instead of only surfacing on master. Pure docs-content PRs don't change this machinery and + # are validated by the normal build, so they're intentionally excluded to keep CI lean. + pull_request: + paths: + - '.github/workflows/algolia-index.yml' + - 'scripts/algolia.ts' + - 'scripts/tsconfig.json' + - 'src/mdx.ts' + - 'package.json' + - 'pnpm-lock.yaml' jobs: index: name: Update Algolia index runs-on: ubuntu-latest + # Only push events have access to the Algolia secrets and should mutate the live index. + if: github.event_name == 'push' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -40,7 +54,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build index for user docs - run: pnpm enforce-redirects && pnpm generate-doctree && pnpm next build && npx tsx ./scripts/algolia.ts + run: pnpm enforce-redirects && pnpm generate-doctree && pnpm next build && npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts if: steps.filter.outputs.docs == 'true' env: ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} @@ -53,7 +67,7 @@ jobs: NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 - name: Build index for developer docs - run: git submodule init && git submodule update && pnpm enforce-redirects && pnpm generate-doctree && NEXT_PUBLIC_DEVELOPER_DOCS=1 pnpm next build && npx tsx ./scripts/algolia.ts + run: git submodule init && git submodule update && pnpm enforce-redirects && pnpm generate-doctree && NEXT_PUBLIC_DEVELOPER_DOCS=1 pnpm next build && npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts if: steps.filter.outputs.dev-docs == 'true' env: ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} @@ -65,3 +79,37 @@ jobs: SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_DEVELOPER_DOCS: 1 + + smoke-test: + name: Smoke-test Algolia indexing (dry run) + runs-on: ubuntu-latest + # PRs run the build + indexing script in dry-run mode (no secrets, no upload) purely to verify + # the script can be built and its full import graph resolved under the configured runner. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: pnpm/action-setup@02f6c237bd2518259fed6c71566509edfb3f2b74 # v4 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4 + id: setup-node + with: + node-version-file: 'package.json' + cache: 'pnpm' + + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ${{ github.workspace }}/.next/cache + key: nextjs-${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + nextjs-${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}- + + - run: pnpm install --frozen-lockfile + + - name: Build and dry-run index for user docs + run: pnpm enforce-redirects && pnpm generate-doctree && pnpm next build && npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts + env: + ALGOLIA_DRY_RUN: 'true' + SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 + NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 diff --git a/scripts/algolia.ts b/scripts/algolia.ts index af79ec297c67a1..ba300bdae0b7f1 100644 --- a/scripts/algolia.ts +++ b/scripts/algolia.ts @@ -26,19 +26,26 @@ const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID; const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY; const DOCS_INDEX_NAME = process.env.DOCS_INDEX_NAME; const ALOGOLIA_SKIP_ON_ERROR = process.env.ALOGOLIA_SKIP_ON_ERROR === 'true'; +// Dry run generates records but skips all Algolia API calls. Used by PR CI to exercise the +// build + indexing import graph without secrets or mutating the production index. +const DRY_RUN = process.env.ALGOLIA_DRY_RUN === 'true'; -if (!ALGOLIA_APP_ID) { - throw new Error('`ALGOLIA_APP_ID` env var must be configured in repo secrets'); -} -if (!ALGOLIA_API_KEY) { - throw new Error('`ALGOLIA_API_KEY` env var must be configured in repo secrets'); -} -if (!DOCS_INDEX_NAME) { - throw new Error('`DOCS_INDEX_NAME` env var must be configured in repo secrets'); +if (!DRY_RUN) { + if (!ALGOLIA_APP_ID) { + throw new Error('`ALGOLIA_APP_ID` env var must be configured in repo secrets'); + } + if (!ALGOLIA_API_KEY) { + throw new Error('`ALGOLIA_API_KEY` env var must be configured in repo secrets'); + } + if (!DOCS_INDEX_NAME) { + throw new Error('`DOCS_INDEX_NAME` env var must be configured in repo secrets'); + } } -const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY); -const index = client.initIndex(DOCS_INDEX_NAME); +const index = + ALGOLIA_APP_ID && ALGOLIA_API_KEY && DOCS_INDEX_NAME + ? algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY).initIndex(DOCS_INDEX_NAME) + : null; const CONCURRENCY = 50; const CACHE_VERSION = 1; @@ -85,46 +92,52 @@ async function indexAndUpload() { Sentry.metrics.gauge('algolia.cache_hits', cacheHits, {attributes: metricTags}); Sentry.metrics.gauge('algolia.cache_misses', cacheMisses, {attributes: metricTags}); - const existingRecordIds = await fetchExistingRecordIds(index); - console.log( - `๐Ÿ”ฅ Found ${existingRecordIds.length} existing records in \`${DOCS_INDEX_NAME}\`` - ); - - console.log(`๐Ÿ”ฅ Saving records to \`${DOCS_INDEX_NAME}\`...`); - const saveResult = await index.saveObjects(records, { - batchSize: 10000, - autoGenerateObjectIDIfNotExist: true, - }); - const newRecordIDs = new Set(saveResult.objectIDs); - console.log(`๐Ÿ”ฅ Saved ${newRecordIDs.size} records`); + if (DRY_RUN || !index) { + console.log( + `๐Ÿงช Dry run: generated ${records.length} records, skipping Algolia upload` + ); + } else { + const existingRecordIds = await fetchExistingRecordIds(index); + console.log( + `๐Ÿ”ฅ Found ${existingRecordIds.length} existing records in \`${DOCS_INDEX_NAME}\`` + ); + + console.log(`๐Ÿ”ฅ Saving records to \`${DOCS_INDEX_NAME}\`...`); + const saveResult = await index.saveObjects(records, { + batchSize: 10000, + autoGenerateObjectIDIfNotExist: true, + }); + const newRecordIDs = new Set(saveResult.objectIDs); + console.log(`๐Ÿ”ฅ Saved ${newRecordIDs.size} records`); - const recordsToDelete = existingRecordIds.filter(id => !newRecordIDs.has(id)); - if (recordsToDelete.length > 0) { - console.log(`๐Ÿ”ฅ Deleting ${recordsToDelete.length} stale records...`); - await index.deleteObjects(recordsToDelete); - } + const recordsToDelete = existingRecordIds.filter(id => !newRecordIDs.has(id)); + if (recordsToDelete.length > 0) { + console.log(`๐Ÿ”ฅ Deleting ${recordsToDelete.length} stale records...`); + await index.deleteObjects(recordsToDelete); + } - if (!isDeveloperDocs) { - await index.setSettings({ - ...sentryAlgoliaIndexSettings, - searchableAttributes: [ - 'unordered(title)', - 'unordered(section)', - 'unordered(keywords)', - 'text', - ], - ranking: [ - 'filters', - 'typo', - 'words', - 'attribute', - 'exact', - 'proximity', - 'desc(sectionRank)', - 'asc(position)', - 'asc(popularity)', - ], - }); + if (!isDeveloperDocs) { + await index.setSettings({ + ...sentryAlgoliaIndexSettings, + searchableAttributes: [ + 'unordered(title)', + 'unordered(section)', + 'unordered(keywords)', + 'text', + ], + ranking: [ + 'filters', + 'typo', + 'words', + 'attribute', + 'exact', + 'proximity', + 'desc(sectionRank)', + 'asc(position)', + 'asc(popularity)', + ], + }); + } } const totalSeconds = (performance.now() - startTime) / 1000; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 00000000000000..58d3f2f750e907 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,16 @@ +{ + // Used only when running scripts/algolia.ts via `tsx` (see .github/workflows/algolia-index.yml). + // tsx loads .ts files through Node's CJS resolver. `rehype-prism-diff` (imported transitively + // via src/mdx.ts) only declares an "import" condition in its package.json `exports` map, so the + // CJS resolver fails with ERR_PACKAGE_PATH_NOT_EXPORTED. Aliasing the bare specifier to the + // package's real dist file makes tsx resolve it as a plain file and bypass the exports map. + // Bun (the previous runner) tolerated the import-only exports map; tsx does not. + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "..", + "paths": { + "sentry-docs/*": ["src/*"], + "rehype-prism-diff": ["node_modules/rehype-prism-diff/dist/index.js"] + } + } +} From 023fc195465e29d4e541c192fddbb22c99720a50 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 4 Jun 2026 18:22:55 -0400 Subject: [PATCH 2/4] fix(ci): Bound Algolia dry-run smoke test to avoid OOM The PR smoke test ran the indexer over the full ~10k-page corpus with a cold cache, exhausting the heap. The real (push) job survives because its .next/cache/algolia-records content-hash cache stays warm across runs; a cold PR run does not. The smoke test only needs to prove the script builds and its import graph resolves, so cap dry-run processing to a bounded page sample, skip stale-cache cleanup in dry-run (a partial run must not delete real cache entries), and drop the shared cache step from the smoke job so it can never read or poison the push job's warm cache. Co-Authored-By: Claude --- .github/workflows/algolia-index.yml | 10 ++-------- scripts/algolia.ts | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index 0d0b5a718f55e2..f2838e0b5ddd1e 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -97,14 +97,8 @@ jobs: node-version-file: 'package.json' cache: 'pnpm' - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - ${{ github.workspace }}/.next/cache - key: nextjs-${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - nextjs-${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}- - + # No .next/cache restore here: the smoke test runs cold on purpose so it can never read or + # overwrite the shared algolia-records cache that the real (push) index job relies on. - run: pnpm install --frozen-lockfile - name: Build and dry-run index for user docs diff --git a/scripts/algolia.ts b/scripts/algolia.ts index ba300bdae0b7f1..9609b28c38dff6 100644 --- a/scripts/algolia.ts +++ b/scripts/algolia.ts @@ -48,6 +48,9 @@ const index = : null; const CONCURRENCY = 50; +// In dry-run we only need enough pages to exercise the build + import graph, not the full corpus. +// Processing all ~10k pages cold (no warm cache) exhausts the heap, so cap it. +const DRY_RUN_PAGE_LIMIT = 200; const CACHE_VERSION = 1; const CACHE_DIR = join(process.cwd(), '.next', 'cache', 'algolia-records'); @@ -71,10 +74,13 @@ async function indexAndUpload() { ? getDevDocsFrontMatter() : getDocsFrontMatter()); - const pages = pageFrontMatters.filter( + const allPages = pageFrontMatters.filter( frontMatter => !frontMatter.draft && !frontMatter.noindex && frontMatter.title ); - console.log(`๐Ÿ“„ Processing ${pages.length} pages with concurrency ${CONCURRENCY}`); + const pages = DRY_RUN ? allPages.slice(0, DRY_RUN_PAGE_LIMIT) : allPages; + console.log( + `๐Ÿ“„ Processing ${pages.length}${DRY_RUN ? ` of ${allPages.length} (dry-run cap)` : ''} pages with concurrency ${CONCURRENCY}` + ); const {records, cacheHits, cacheMisses} = await generateAlgoliaRecords(pages); const generateTime = performance.now(); @@ -184,13 +190,17 @@ async function generateAlgoliaRecords(pages: FrontMatter[]) { ) ); - const allFiles = fs.readdirSync(CACHE_DIR); - const stale = allFiles.filter(f => !usedCacheFiles.has(f)); - for (const f of stale) { - fs.unlinkSync(join(CACHE_DIR, f)); - } - if (stale.length > 0) { - console.log(`๐Ÿงน Cleaned up ${stale.length} stale cache files`); + // Skip cleanup in dry-run: we only processed a subset of pages, so most cache files would look + // "stale" and get wrongly deleted, poisoning the shared cache. + if (!DRY_RUN) { + const allFiles = fs.readdirSync(CACHE_DIR); + const stale = allFiles.filter(f => !usedCacheFiles.has(f)); + for (const f of stale) { + fs.unlinkSync(join(CACHE_DIR, f)); + } + if (stale.length > 0) { + console.log(`๐Ÿงน Cleaned up ${stale.length} stale cache files`); + } } return {records: results.flat(), cacheHits, cacheMisses}; From 6a87a662292bfb02b38a8fc6adfda7059902a735 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 4 Jun 2026 18:34:47 -0400 Subject: [PATCH 3/4] ci: Drop next build from Algolia smoke test to make it fast The smoke test ran a full ~10k-page `next build` (6-8 min) before the dry-run, but the regression it guards against -- the script and its import graph failing to resolve under the runner -- happens at module load, with no build required. Build-output correctness is already covered by Vercel's PR preview deploy. Run only the dry-run script (with ALGOLIA_SKIP_ON_ERROR so the absent .next HTML is tolerated). The job drops from minutes to ~1 minute while still catching runner/dependency-resolution regressions. Co-Authored-By: Claude --- .github/workflows/algolia-index.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index f2838e0b5ddd1e..d9d404d7039907 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -83,8 +83,12 @@ jobs: smoke-test: name: Smoke-test Algolia indexing (dry run) runs-on: ubuntu-latest - # PRs run the build + indexing script in dry-run mode (no secrets, no upload) purely to verify - # the script can be built and its full import graph resolved under the configured runner. + # PRs run the indexing script in dry-run mode (no secrets, no upload) purely to verify the + # script and its full import graph resolve under the configured runner -- the regression class + # that the Bun->tsx swap introduced, which happens at module load. This deliberately skips + # `next build`: building the ~10k-page site takes minutes and only validates build output, + # which Vercel's PR preview deploy already covers. With no build there are no .next HTML files, + # so ALGOLIA_SKIP_ON_ERROR lets the script tolerate the missing pages and finish in seconds. if: github.event_name == 'pull_request' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -97,13 +101,12 @@ jobs: node-version-file: 'package.json' cache: 'pnpm' - # No .next/cache restore here: the smoke test runs cold on purpose so it can never read or - # overwrite the shared algolia-records cache that the real (push) index job relies on. - run: pnpm install --frozen-lockfile - - name: Build and dry-run index for user docs - run: pnpm enforce-redirects && pnpm generate-doctree && pnpm next build && npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts + - name: Dry-run index for user docs + run: npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts env: ALGOLIA_DRY_RUN: 'true' + ALOGOLIA_SKIP_ON_ERROR: 'true' SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 From 03f59052ea753f4ea10be016301c34e0f45e3c84 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Fri, 5 Jun 2026 12:36:35 -0400 Subject: [PATCH 4/4] fix typos --- .github/workflows/algolia-index.yml | 2 +- scripts/algolia.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index d9d404d7039907..5fb0c491648fa0 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -107,6 +107,6 @@ jobs: run: npx tsx --tsconfig ./scripts/tsconfig.json ./scripts/algolia.ts env: ALGOLIA_DRY_RUN: 'true' - ALOGOLIA_SKIP_ON_ERROR: 'true' + ALGOLIA_SKIP_ON_ERROR: 'true' SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 diff --git a/scripts/algolia.ts b/scripts/algolia.ts index 9609b28c38dff6..c77260e4dd6b52 100644 --- a/scripts/algolia.ts +++ b/scripts/algolia.ts @@ -25,7 +25,7 @@ const staticHtmlFilesPath = join(process.cwd(), '.next', 'server', 'app'); const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID; const ALGOLIA_API_KEY = process.env.ALGOLIA_API_KEY; const DOCS_INDEX_NAME = process.env.DOCS_INDEX_NAME; -const ALOGOLIA_SKIP_ON_ERROR = process.env.ALOGOLIA_SKIP_ON_ERROR === 'true'; +const ALGOLIA_SKIP_ON_ERROR = process.env.ALGOLIA_SKIP_ON_ERROR === 'true'; // Dry run generates records but skips all Algolia API calls. Used by PR CI to exercise the // build + indexing import graph without secrets or mutating the production index. const DRY_RUN = process.env.ALGOLIA_DRY_RUN === 'true'; @@ -303,7 +303,7 @@ async function getRecords( const error = new Error(`๐Ÿ”ด Error processing ${pageFm.slug}: ${e.message}`, { cause: e, }); - if (ALOGOLIA_SKIP_ON_ERROR) { + if (ALGOLIA_SKIP_ON_ERROR) { console.error(error); return {records: [], cached: false}; }