diff --git a/.github/actions/node-npm-setup/action.yml b/.github/actions/node-npm-setup/action.yml
index e25ce75a1f94..da7c0e2c875e 100644
--- a/.github/actions/node-npm-setup/action.yml
+++ b/.github/actions/node-npm-setup/action.yml
@@ -20,6 +20,8 @@ runs:
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: 'package.json'
+ # Resolve from the Node manifest so CI uses the newest allowed version, not an older cached match.
+ check-latest: true
package-manager-cache: false
cache: ${{ steps.cache-node_modules.outputs.cache-hit != 'true' && 'npm' || '' }}
diff --git a/.github/workflows/changelog-agent.yml b/.github/workflows/changelog-agent.yml
index 1d02b16ef51d..e32621cb71a8 100644
--- a/.github/workflows/changelog-agent.yml
+++ b/.github/workflows/changelog-agent.yml
@@ -393,11 +393,22 @@ jobs:
fs.writeFileSync('prompt.txt', userPrompt);
core.setOutput('date_str', dateStr);
+ # Use a subdirectory: checkout would clear the workspace and the prompt files above.
+ - name: Check out package.json for the Node version
+ if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ path: node-version-source
+ sparse-checkout: /package.json
+ sparse-checkout-cone-mode: false
+ persist-credentials: false
+
- name: Set up Node.js for Copilot CLI
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
- node-version: 24
+ node-version-file: node-version-source/package.json
+ check-latest: true
- name: Install GitHub Copilot CLI
if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false'
diff --git a/.github/workflows/package-lock-lint.yml b/.github/workflows/package-lock-lint.yml
index 43333e453355..516abe89df30 100644
--- a/.github/workflows/package-lock-lint.yml
+++ b/.github/workflows/package-lock-lint.yml
@@ -43,6 +43,7 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: 'package.json'
+ check-latest: true
cache: npm
- name: Run check
diff --git a/.github/workflows/ready-for-doc-review.yml b/.github/workflows/ready-for-doc-review.yml
index 552ecc7fade4..ab5613f1f064 100644
--- a/.github/workflows/ready-for-doc-review.yml
+++ b/.github/workflows/ready-for-doc-review.yml
@@ -29,6 +29,7 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: 'package.json'
+ check-latest: true
cache: npm
- name: Install dependencies
diff --git a/data/release-notes/enterprise-server/3-18/15.yml b/data/release-notes/enterprise-server/3-18/15.yml
index 9b7e274dc51c..ca805d3ffaec 100644
--- a/data/release-notes/enterprise-server/3-18/15.yml
+++ b/data/release-notes/enterprise-server/3-18/15.yml
@@ -84,8 +84,6 @@ sections:
When restoring from a backup snapshot, a large number of `mapper_parsing_exception` errors may be displayed.
- |
When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- - |
- When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- |
In a cluster, the host running restore requires access the storage nodes via their private IPs.
- |
diff --git a/data/release-notes/enterprise-server/3-18/16.yml b/data/release-notes/enterprise-server/3-18/16.yml
index f7827ae5ab16..aae46f8d36ec 100644
--- a/data/release-notes/enterprise-server/3-18/16.yml
+++ b/data/release-notes/enterprise-server/3-18/16.yml
@@ -20,6 +20,8 @@ sections:
- |
To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted.
known_issues:
+ - |
+ After a restart, an instance can remain unavailable with "Starting..." displayed in the web interface. To recover, an administrator can connect using administrative SSH and rerun `ghe-config-apply`. This provides temporary recovery but does not prevent the issue from recurring. This issue is resolved in {% data variables.product.prodname_ghe_server %} 3.21.0 and later. [Updated: 2026-10-30]
- |
During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading.
- |
diff --git a/data/release-notes/enterprise-server/3-19/12.yml b/data/release-notes/enterprise-server/3-19/12.yml
index 720035ec4ace..2f8ac4c746b2 100644
--- a/data/release-notes/enterprise-server/3-19/12.yml
+++ b/data/release-notes/enterprise-server/3-19/12.yml
@@ -90,8 +90,6 @@ sections:
When restoring from a backup snapshot, a large number of `mapper_parsing_exception` errors may be displayed.
- |
When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- - |
- When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- |
In a cluster, the host running restore requires access the storage nodes via their private IPs.
- |
diff --git a/data/release-notes/enterprise-server/3-19/13.yml b/data/release-notes/enterprise-server/3-19/13.yml
index 740600e85193..dde2a1b04851 100644
--- a/data/release-notes/enterprise-server/3-19/13.yml
+++ b/data/release-notes/enterprise-server/3-19/13.yml
@@ -28,6 +28,8 @@ sections:
- |
To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted.
known_issues:
+ - |
+ After a restart, an instance can remain unavailable with "Starting..." displayed in the web interface. To recover, an administrator can connect using administrative SSH and rerun `ghe-config-apply`. This provides temporary recovery but does not prevent the issue from recurring. This issue is resolved in {% data variables.product.prodname_ghe_server %} 3.21.0 and later. [Updated: 2026-10-30]
- |
During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading.
- |
diff --git a/data/release-notes/enterprise-server/3-20/8.yml b/data/release-notes/enterprise-server/3-20/8.yml
index 2c50abb16478..72eadf2898eb 100644
--- a/data/release-notes/enterprise-server/3-20/8.yml
+++ b/data/release-notes/enterprise-server/3-20/8.yml
@@ -93,8 +93,6 @@ sections:
When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a scheduled job that runs on the first Saturday of each month. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`.
- |
When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- - |
- When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- |
In a cluster, the host running restore requires access the storage nodes via their private IPs.
- |
diff --git a/data/release-notes/enterprise-server/3-20/9.yml b/data/release-notes/enterprise-server/3-20/9.yml
index 093a6d487c76..1e234cd9734a 100644
--- a/data/release-notes/enterprise-server/3-20/9.yml
+++ b/data/release-notes/enterprise-server/3-20/9.yml
@@ -35,6 +35,8 @@ sections:
- |
To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted.
known_issues:
+ - |
+ After a restart, an instance can remain unavailable with "Starting..." displayed in the web interface. To recover, an administrator can connect using administrative SSH and rerun `ghe-config-apply`. This provides temporary recovery but does not prevent the issue from recurring. This issue is resolved in {% data variables.product.prodname_ghe_server %} 3.21.0 and later. [Updated: 2026-10-30]
- |
During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading.
- |
diff --git a/data/release-notes/enterprise-server/3-21/6.yml b/data/release-notes/enterprise-server/3-21/6.yml
index 7e988bc19621..bf24a813ede3 100644
--- a/data/release-notes/enterprise-server/3-21/6.yml
+++ b/data/release-notes/enterprise-server/3-21/6.yml
@@ -101,8 +101,6 @@ sections:
When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a scheduled job that runs on the first Saturday of each month. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`.
- |
When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- - |
- When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- |
In a cluster, the host running restore requires access the storage nodes via their private IPs.
- |
diff --git a/data/release-notes/enterprise-server/3-22/1.yml b/data/release-notes/enterprise-server/3-22/1.yml
index 1ddee99b1ad5..9c9a5e5e3b69 100644
--- a/data/release-notes/enterprise-server/3-22/1.yml
+++ b/data/release-notes/enterprise-server/3-22/1.yml
@@ -95,8 +95,6 @@ sections:
When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a scheduled job that runs on the first Saturday of each month. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`.
- |
When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- - |
- When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration.
- |
In a cluster, the host running restore requires access the storage nodes via their private IPs.
- |
diff --git a/next.config.ts b/next.config.ts
index 2ae56cb8e80b..dad105d8397e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -40,9 +40,10 @@ const config: NextConfig = {
quietDeps: true,
silenceDeprecations: ['legacy-js-api', 'import', 'global-builtin', 'color-4-api'],
},
- // Don't use automatic Next.js logging in dev unless the log level is `debug` or higher
+ // Don't use automatic Next.js request logging unless the log level is `debug` or higher.
+ // Our own request logger already covers it. `false` would also stop `[browser]` console forwarding.
// See `src/observability/logger/README.md` for log levels
- logging: getLogLevelNumber() < 3 ? undefined : {},
+ logging: getLogLevelNumber() < 3 ? { incomingRequests: false } : {},
async rewrites() {
const DEFAULT_VERSION = 'free-pro-team@latest'
return productIds.map((productId) => {
@@ -101,6 +102,12 @@ const config: NextConfig = {
// the CDN marks the cached content as "fresh".
generateEtags: false,
+ experimental: {
+ // getServerSideProps returns rendered Markdown, so many pages exceed the 128 kB default.
+ // https://nextjs.org/docs/messages/large-page-data
+ largePageDataBytes: 1024 * 1024, // 1 MB
+ },
+
// Disable Next.js's in-memory data cache. We don't use ISR or cached
// fetch — all pages render via Express middleware with getServerSideProps.
// The default 50 MB cache is unnecessary overhead during a memory-leak
diff --git a/package-lock.json b/package-lock.json
index 59513645ecbd..f5d8c377e41d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -86,7 +86,7 @@
"strip-ansi": "7.1.0",
"swr": "^2.4.0",
"tcp-port-used": "1.0.2",
- "tsx": "^4.21.0",
+ "tsx": "^4.23.15",
"unified": "^11.0.5",
"unist-util-find": "^3.0.0",
"unist-util-visit": "^5.1.0",
@@ -8926,6 +8926,7 @@
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -13365,6 +13366,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
@@ -14726,13 +14728,12 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "version": "4.23.15",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
+ "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"license": "MIT",
"dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
+ "esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
diff --git a/package.json b/package.json
index 2d382fc6f400..9ba51ad81cf8 100644
--- a/package.json
+++ b/package.json
@@ -245,7 +245,7 @@
"strip-ansi": "7.1.0",
"swr": "^2.4.0",
"tcp-port-used": "1.0.2",
- "tsx": "^4.21.0",
+ "tsx": "^4.23.15",
"unified": "^11.0.5",
"unist-util-find": "^3.0.0",
"unist-util-visit": "^5.1.0",
diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts
index 6a4451cfdee6..2a24fd4cf2dc 100644
--- a/src/archives/middleware/archived-enterprise-versions.ts
+++ b/src/archives/middleware/archived-enterprise-versions.ts
@@ -278,8 +278,6 @@ export default async function archivedEnterpriseVersions(
versionSatisfiesRange(requestedVersion, `>=${firstReleaseStoredInBlobStorage}`) &&
versionSatisfiesRange(requestedVersion, `<=3.9`)
) {
- // Fastly sets x-host, and GLB removes x-forwarded-host.
- const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host')
const modifiedBody = body
.replaceAll(
`${OLD_AZURE_BLOB_ENTERPRISE_DIR}/${requestedVersion}/assets/cb-`,
@@ -287,7 +285,8 @@ export default async function archivedEnterpriseVersions(
)
.replaceAll(
`${OLD_AZURE_BLOB_ENTERPRISE_DIR}/${requestedVersion}/`,
- `${req.protocol}://${host}/enterprise-server@${requestedVersion}/`,
+ // Root-relative, so request headers never end up in the page.
+ `/enterprise-server@${requestedVersion}/`,
)
return res.send(modifiedBody)
diff --git a/src/archives/tests/deprecated-enterprise-versions.ts b/src/archives/tests/deprecated-enterprise-versions.ts
index 614cca0cef83..8e43625189b8 100644
--- a/src/archives/tests/deprecated-enterprise-versions.ts
+++ b/src/archives/tests/deprecated-enterprise-versions.ts
@@ -68,6 +68,23 @@ describe('enterprise deprecation', () => {
expect(res.headers['set-cookie']).toBeUndefined()
})
+ test('does not put request host headers into deprecated Enterprise pages ( 3.2-3.9 )', async () => {
+ const res = await get(
+ '/en/enterprise-server@3.5/admin/overview/about-github-enterprise-server',
+ {
+ headers: {
+ 'x-host': 'evil.example">',
+ 'x-forwarded-host': 'evil.example',
+ 'x-forwarded-proto': 'javascript',
+ },
+ },
+ )
+ expect(res.statusCode).toBe(200)
+ expect(res.body).not.toContain('evil.example')
+ expect(res.body).not.toContain('javascript://')
+ expect(res.body).toContain('src="/enterprise-server@3.5/_next/')
+ })
+
test('handles requests for deprecated Enterprise pages ( <2.13 )', async () => {
expect(enterpriseServerReleases.deprecated.includes('2.12')).toBe(true)
const { $, res } = await getDOM('/enterprise/2.12/user/articles/about-branches')
diff --git a/src/frame/lib/create-tree.ts b/src/frame/lib/create-tree.ts
index 507864b7eba1..90dfaecb746d 100644
--- a/src/frame/lib/create-tree.ts
+++ b/src/frame/lib/create-tree.ts
@@ -8,6 +8,9 @@ const logger = createLogger(import.meta.url)
const isProduction = process.env.NODE_ENV === 'production'
+// Development rebuilds the tree on every request, so each missing path warns once until it appears.
+const warnedMissingPaths = new Set()
+
export default async function createTree(
originalPath: string,
rootPath?: string,
@@ -41,12 +44,16 @@ export default async function createTree(
originalPath === 'content/early-access' ||
originalPath.startsWith('content/early-access/')
) {
- logger.warn(msg, { path: originalPath })
+ if (!warnedMissingPaths.has(originalPath)) {
+ warnedMissingPaths.add(originalPath)
+ logger.warn(msg, { path: originalPath })
+ }
return
}
throw new Error(msg)
}
}
+ warnedMissingPaths.delete(originalPath)
const relativePath = filepath.replace(`${basePath}/`, '')
diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts
index dadb83ab637e..1d28b2489a72 100644
--- a/src/frame/middleware/helmet.ts
+++ b/src/frame/middleware/helmet.ts
@@ -30,7 +30,6 @@ const DEFAULT_OPTIONS = {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
- prefetchSrc: ["'self'"],
// Safari local development needs ws: for Next.js hot module reloading.
connectSrc: ["'self'", 'https://collector.githubapp.com', isDev && 'ws:'].filter(
Boolean,
diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts
index 41aa501ce6a8..d911cc29babf 100644
--- a/src/frame/middleware/index.ts
+++ b/src/frame/middleware/index.ts
@@ -49,7 +49,7 @@ import features from '@/versions/middleware/features'
import productGroups from './context/product-groups'
import featuredLinks from '@/landings/middleware/featured-links'
import journeyTrack from '@/journeys/middleware/journey-track'
-import next from './next'
+import next, { renderNextDevEndpoints } from './next'
import renderPage from './render-page'
import assetPreprocessing from '@/assets/middleware/asset-preprocessing'
import archivedAssetRedirects from '@/archives/middleware/archived-asset-redirects'
@@ -160,6 +160,10 @@ export default function index(app: Express) {
)
}
+ if (process.env.NODE_ENV === 'development') {
+ app.use(renderNextDevEndpoints)
+ }
+
app.use(shielding)
app.use(handleNextDataPath)
diff --git a/src/frame/middleware/next.ts b/src/frame/middleware/next.ts
index a467ddffc588..f51c2c6ad4c8 100644
--- a/src/frame/middleware/next.ts
+++ b/src/frame/middleware/next.ts
@@ -20,4 +20,13 @@ function renderPageWithNext(req: ExtendedRequest, res: Response, nextFn: NextFun
return nextFn()
}
+// Dev overlay endpoints, like /__nextjs_original-stack-frames, read the raw body, so they run before express.json().
+export function renderNextDevEndpoints(req: ExtendedRequest, res: Response, nextFn: NextFunction) {
+ if (req.path.startsWith('/__nextjs_')) {
+ return nextHandleRequest(req, res)
+ }
+
+ return nextFn()
+}
+
export default renderPageWithNext
diff --git a/src/frame/start-server.ts b/src/frame/start-server.ts
index ec5f6620269e..30b415cf7aff 100644
--- a/src/frame/start-server.ts
+++ b/src/frame/start-server.ts
@@ -14,7 +14,7 @@ import createApp from './lib/app'
import warmServer from './lib/warm-server'
import { createLogger } from '@/observability/logger'
-dotenv.config()
+dotenv.config({ quiet: true })
checkNodeVersion()
diff --git a/src/frame/tests/create-tree.ts b/src/frame/tests/create-tree.ts
index c0ba7396c1f1..09d991165eda 100644
--- a/src/frame/tests/create-tree.ts
+++ b/src/frame/tests/create-tree.ts
@@ -1,11 +1,14 @@
import fs from 'fs/promises'
-import { describe, expect, test } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
import createTree from '@/frame/lib/create-tree'
import { setCategoryApplicableVersions } from '@/frame/lib/page-data'
import type { Page, UnversionedTree } from '@/types'
+const logger = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }))
+vi.mock('@/observability/logger', () => ({ createLogger: () => logger }))
+
const contentDir = 'src/fixtures/fixtures/content'
function flattenPages(tree: UnversionedTree): Page[] {
@@ -55,6 +58,16 @@ describe('createTree', () => {
expect(reused.length).toBe(declared.length - 1)
})
+ test('warns once for missing early-access content across rebuilds', async () => {
+ const missingPath = 'content/early-access/does-not-exist'
+ logger.warn.mockClear()
+
+ expect(await createTree(missingPath)).toBeUndefined()
+ expect(await createTree(missingPath)).toBeUndefined()
+
+ expect(logger.warn).toHaveBeenCalledTimes(1)
+ })
+
test('stores the file mtime on the page', async () => {
const tree = (await createTree(contentDir)) as UnversionedTree
const { mtimeMs } = await fs.stat(tree.page.fullPath)
diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx
index 8e9a821cc0dd..c72db2031cd9 100644
--- a/src/landings/components/shared/LandingCarousel.tsx
+++ b/src/landings/components/shared/LandingCarousel.tsx
@@ -5,7 +5,7 @@ import { Card } from '@primer/react-brand'
import cx from 'clsx'
import type { ResolvedArticle } from '@/types'
import { useTranslation } from '@/languages/components/useTranslation'
-import { useVersion } from '@/versions/components/useVersion'
+import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
import styles from './LandingCarousel.module.scss'
import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML'
@@ -120,6 +120,7 @@ export const LandingCarousel = ({
}, 100)
}
+ const versionPrefix = currentVersion !== DEFAULT_VERSION ? `/${currentVersion}` : ''
const startIndex = currentPage * itemsPerView
const visibleItems = processedItems.slice(startIndex, startIndex + itemsPerView)
@@ -164,7 +165,7 @@ export const LandingCarousel = ({
{visibleItems.map((article: ResolvedArticle, index) => (
({
+ locale: 'en',
+ query: {} as Record,
+}))
+
+vi.mock('next/router', () => ({ useRouter: () => router }))
+vi.mock('@/languages/components/useTranslation', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}))
+// Primer packages import CSS files, which Node cannot load outside a bundler.
+vi.mock('@primer/react-brand', () => {
+ const passthrough = ({ children }: { children?: ReactNode }) =>
+ createElement('div', null, children)
+ const Card = ({ href, children }: { href: string; children?: ReactNode }) =>
+ createElement('a', { href }, children)
+ Card.Heading = passthrough
+ Card.Description = passthrough
+ return { Card }
+})
+vi.mock('@/frame/components/ui/RenderedHTML/RenderedHTML', () => ({ RenderedHTML: () => null }))
+
+const { LandingCarousel } = await import('@/landings/components/shared/LandingCarousel')
+
+function renderHrefs() {
+ const carouselArticles = [
+ { href: '/actions/quickstart', title: 'Quickstart', intro: '', category: [] },
+ ]
+ const $ = load(renderToStaticMarkup(createElement(LandingCarousel, { carouselArticles })))
+ return $('a')
+ .map((_, el) => $(el).attr('href'))
+ .get()
+}
+
+describe('LandingCarousel card links', () => {
+ beforeEach(() => {
+ router.locale = 'en'
+ router.query = {}
+ })
+
+ test('omits the default version', () => {
+ expect(renderHrefs()).toEqual(['/en/actions/quickstart'])
+ })
+
+ test('includes a non-default version', () => {
+ router.locale = 'ja'
+ router.query = { versionId: 'enterprise-server@3.16' }
+ expect(renderHrefs()).toEqual(['/ja/enterprise-server@3.16/actions/quickstart'])
+ })
+})
diff --git a/src/observability/logger/middleware/get-automatic-request-logger.ts b/src/observability/logger/middleware/get-automatic-request-logger.ts
index 5cef1caecd88..0fd1c527575f 100644
--- a/src/observability/logger/middleware/get-automatic-request-logger.ts
+++ b/src/observability/logger/middleware/get-automatic-request-logger.ts
@@ -44,8 +44,8 @@ export function getAutomaticRequestLogger() {
} else if (shouldEnableAutomaticDevLogging()) {
const logLevelNum = getLogLevelNumber()
- // Suppress /_next/ requests unless LOG_LEVEL is debug or more verbose.
- if (url?.startsWith('/_next/') && logLevelNum < 3) {
+ // Suppress /_next/ and /__nextjs_ requests unless LOG_LEVEL is debug or more verbose.
+ if ((url?.startsWith('/_next/') || url?.startsWith('/__nextjs_')) && logLevelNum < 3) {
return originalEnd.apply(this, args as Parameters)
}
diff --git a/src/observability/tests/get-automatic-request-logger.ts b/src/observability/tests/get-automatic-request-logger.ts
index 1204552d4591..a33ee21da31e 100644
--- a/src/observability/tests/get-automatic-request-logger.ts
+++ b/src/observability/tests/get-automatic-request-logger.ts
@@ -164,20 +164,23 @@ describe('getAutomaticRequestLogger', () => {
}
})
- it('should filter out _next requests unless debug level', async () => {
- vi.stubEnv('LOG_LEVEL', 'info') // info level = 2, debug = 3
+ it.each(['/_next/static/file.js', '/__nextjs_font/geist-latin.woff2'])(
+ 'should filter out %s unless debug level',
+ async (url) => {
+ vi.stubEnv('LOG_LEVEL', 'info') // info level = 2, debug = 3
- mockReq.url = '/_next/static/file.js'
- mockReq.originalUrl = '/_next/static/file.js'
+ mockReq.url = url
+ mockReq.originalUrl = url
- const middleware = getAutomaticRequestLogger()
- middleware(mockReq as Request, mockRes as Response, mockNext)
- ;(mockRes as MockResponseWithEnd).end()
+ const middleware = getAutomaticRequestLogger()
+ middleware(mockReq as Request, mockRes as Response, mockNext)
+ ;(mockRes as MockResponseWithEnd).end()
- await new Promise((resolve) => setTimeout(resolve, 20))
+ await new Promise((resolve) => setTimeout(resolve, 20))
- expect(consoleLogs).toHaveLength(0)
- })
+ expect(consoleLogs).toHaveLength(0)
+ },
+ )
it('should log _next requests when debug level is set', async () => {
vi.stubEnv('LOG_LEVEL', 'debug') // debug level = 3
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index 92d962b3d572..226a488cd35a 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -6,10 +6,13 @@ import { colorModeScript } from '@/color-schemes/lib/color-mode-script'
// MyDocument leaves SSR theme attributes at defaultCSSTheme so the HTML stays shared-cacheable.
// colorModeScript updates them from the color_mode cookie before the browser's first paint.
// colorModeScript injects executable JS, not content HTML, so RenderedHTML and hast do not apply.
+// Primer Brand sets smooth scrolling on . data-scroll-behavior lets Next jump instantly on
+// route changes and keep smooth scrolling for in-page anchors.
export default class MyDocument extends Document {
render() {
return (
{
).resolves.not.toThrow()
}
})
+
+ // PatchNotes uses each note as its React key.
+ test('has no duplicate notes in a list', () => {
+ for (const [key, section] of Object.entries(yamlContent.sections || {})) {
+ const lists = [section.filter((item) => typeof item === 'string')]
+ for (const item of section) {
+ if (typeof item !== 'string' && Array.isArray(item.notes)) lists.push(item.notes)
+ }
+ for (const notes of lists) {
+ const duplicates = notes.filter((note, i) => notes.indexOf(note) !== i)
+ expect(duplicates, `sections.${key} has duplicate notes`).toEqual([])
+ }
+ }
+ })
})
})