diff --git a/Dockerfile b/Dockerfile
index 2573d0924ce9..9bee3357a106 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@
# ---------------------------------------------------------------
# To update the sha:
# https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble
-FROM ghcr.io/github/gh-base-image/gh-base-noble:20251119-090131-gb27dc275c AS base
+FROM ghcr.io/github/gh-base-image/gh-base-noble:20251217-105955-g05726ec4c AS base
# Install curl for Node install and determining the early access branch
# Install git for cloning docs-early-access & translations repos
diff --git a/src/events/components/events.ts b/src/events/components/events.ts
index 4cdf6a67dbb1..9c37bf255c72 100644
--- a/src/events/components/events.ts
+++ b/src/events/components/events.ts
@@ -1,4 +1,5 @@
import Cookies from '@/frame/components/lib/cookies'
+import { ANALYTICS_ENABLED } from '@/frame/lib/constants'
import { parseUserAgent } from './user-agent'
import { Router } from 'next/router'
import { isLoggedIn } from '@/frame/components/hooks/useHasAccount'
@@ -436,8 +437,7 @@ function initPrintEvent() {
}
export function initializeEvents() {
- return
- // eslint-disable-next-line no-unreachable
+ if (!ANALYTICS_ENABLED) return
if (initialized) return
initialized = true
initPageAndExitEvent() // must come first
diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts
index 385439074e44..d1055d12a3f7 100644
--- a/src/fixtures/tests/playwright-rendering.spec.ts
+++ b/src/fixtures/tests/playwright-rendering.spec.ts
@@ -1,6 +1,7 @@
import dotenv from 'dotenv'
import { test, expect } from '@playwright/test'
import { turnOffExperimentsInPage, dismissCTAPopover } from '../helpers/turn-off-experiments'
+import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED } from '../../frame/lib/constants'
// This exists for the benefit of local testing.
// In GitHub Actions, we rely on setting the environment variable directly
@@ -347,6 +348,8 @@ test('sidebar custom link functionality works', async ({ page }) => {
})
test.describe('hover cards', () => {
+ test.skip(!HOVERCARDS_ENABLED, 'Hovercards are disabled')
+
test('hover over link', async ({ page }) => {
await page.goto('/pages/quickstart')
await turnOffExperimentsInPage(page)
@@ -691,6 +694,8 @@ test.describe('test nav at different viewports', () => {
})
test.describe('survey', () => {
+ test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled')
+
test('happy path, thumbs up and enter comment and email', async ({ page }) => {
let fulfilled = 0
let hasSurveyPressedEvent = false
diff --git a/src/frame/lib/constants.ts b/src/frame/lib/constants.ts
index 250450778ca6..8dd839bd6f3c 100644
--- a/src/frame/lib/constants.ts
+++ b/src/frame/lib/constants.ts
@@ -34,3 +34,6 @@ export const minimumNotFoundHtml = `
• Privacy
`.replace(/\n/g, '')
+
+export const ANALYTICS_ENABLED = true
+export const HOVERCARDS_ENABLED = true
diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts
index b1136fbdece0..ec93200dfe8c 100644
--- a/src/journeys/lib/journey-path-resolver.ts
+++ b/src/journeys/lib/journey-path-resolver.ts
@@ -68,6 +68,13 @@ type ContentContext = {
// Cache for journey pages so we only filter all pages once
let cachedJourneyPages: JourneyPage[] | null = null
+// Cache for guide paths to quickly check if a page is part of any journey
+let cachedGuidePaths: Set | null = null
+let hasDynamicGuides = false
+
+function needsRendering(str: string): boolean {
+ return str.includes('{{') || str.includes('{%') || str.includes('[') || str.includes('<')
+}
function getJourneyPages(pages: Pages): JourneyPage[] {
if (!cachedJourneyPages) {
@@ -78,6 +85,27 @@ function getJourneyPages(pages: Pages): JourneyPage[] {
return cachedJourneyPages
}
+function getGuidePaths(pages: Pages): Set {
+ if (!cachedGuidePaths) {
+ cachedGuidePaths = new Set()
+ const journeyPages = getJourneyPages(pages)
+ for (const page of journeyPages) {
+ if (!page.journeyTracks) continue
+ for (const track of page.journeyTracks) {
+ if (!track.guides) continue
+ for (const guide of track.guides) {
+ if (needsRendering(guide.href)) {
+ hasDynamicGuides = true
+ } else {
+ cachedGuidePaths.add(normalizeGuidePath(guide.href))
+ }
+ }
+ }
+ }
+ }
+ return cachedGuidePaths
+}
+
function normalizeGuidePath(path: string): string {
// First ensure we have a leading slash for consistent processing
const pathWithSlash = path.startsWith('/') ? path : `/${path}`
@@ -133,6 +161,16 @@ export async function resolveJourneyContext(
): Promise {
const normalizedPath = normalizeGuidePath(articlePath)
+ // Optimization: Fast path check
+ // If we are not forcing a specific journey page, check our global cache
+ if (!currentJourneyPage) {
+ const guidePaths = getGuidePaths(pages)
+ // If we have no dynamic guides and this path isn't in our known guides, return null early.
+ if (!hasDynamicGuides && !guidePaths.has(normalizedPath)) {
+ return null
+ }
+ }
+
// Use the current journey page if provided, otherwise find all journey pages
const journeyPages = currentJourneyPage ? [currentJourneyPage] : getJourneyPages(pages)
@@ -165,15 +203,17 @@ export async function resolveJourneyContext(
let renderedGuidePath = guidePath
// Handle Liquid conditionals in guide paths
- try {
- renderedGuidePath = await executeWithFallback(
- context,
- () => renderContent(guidePath, context, { textOnly: true }),
- () => guidePath,
- )
- } catch {
- // If rendering fails, use the original path rather than erroring
- renderedGuidePath = guidePath
+ if (needsRendering(guidePath)) {
+ try {
+ renderedGuidePath = await executeWithFallback(
+ context,
+ () => renderContent(guidePath, context, { textOnly: true }),
+ () => guidePath,
+ )
+ } catch {
+ // If rendering fails, use the original path rather than erroring
+ renderedGuidePath = guidePath
+ }
}
const normalizedGuidePath = normalizeGuidePath(renderedGuidePath)
@@ -189,15 +229,17 @@ export async function resolveJourneyContext(
let renderedAlternativeNextStep = alternativeNextStep
// Handle Liquid conditionals in branching text which likely has links
- try {
- renderedAlternativeNextStep = await executeWithFallback(
- context,
- () => renderContent(alternativeNextStep, context),
- () => alternativeNextStep,
- )
- } catch {
- // If rendering fails, use the original branching text rather than erroring
- renderedAlternativeNextStep = alternativeNextStep
+ if (needsRendering(alternativeNextStep)) {
+ try {
+ renderedAlternativeNextStep = await executeWithFallback(
+ context,
+ () => renderContent(alternativeNextStep, context),
+ () => alternativeNextStep,
+ )
+ } catch {
+ // If rendering fails, use the original branching text rather than erroring
+ renderedAlternativeNextStep = alternativeNextStep
+ }
}
result = {
@@ -278,10 +320,14 @@ export async function resolveJourneyTracks(
const result = await Promise.all(
journeyTracks.map(async (track) => {
// Render Liquid templates in title and description
- const renderedTitle = await renderContent(track.title, context, { textOnly: true })
- const renderedDescription = track.description
- ? await renderContent(track.description, context, { textOnly: true })
- : undefined
+ const renderedTitle = needsRendering(track.title)
+ ? await renderContent(track.title, context, { textOnly: true })
+ : track.title
+
+ const renderedDescription =
+ track.description && needsRendering(track.description)
+ ? await renderContent(track.description, context, { textOnly: true })
+ : track.description
const guides = await Promise.all(
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
diff --git a/src/journeys/middleware/journey-track.ts b/src/journeys/middleware/journey-track.ts
index 481ba58ca64b..38ddfe618aae 100644
--- a/src/journeys/middleware/journey-track.ts
+++ b/src/journeys/middleware/journey-track.ts
@@ -1,20 +1,22 @@
import type { Response, NextFunction } from 'express'
import type { ExtendedRequest, Context } from '@/types'
+import { resolveJourneyTracks, resolveJourneyContext } from '../lib/journey-path-resolver'
+
export default async function journeyTrack(
req: ExtendedRequest & { context: Context },
res: Response,
next: NextFunction,
) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next()
+
if (!req.context) throw new Error('request is not contextualized')
if (!req.context.page) return next()
try {
- const journeyResolver = await import('../lib/journey-path-resolver')
-
// If this page has journey tracks defined, resolve them for the landing page
if ((req.context.page as any).journeyTracks) {
- const resolvedTracks = await journeyResolver.resolveJourneyTracks(
+ const resolvedTracks = await resolveJourneyTracks(
(req.context.page as any).journeyTracks,
req.context,
)
@@ -24,7 +26,7 @@ export default async function journeyTrack(
}
// Always try to resolve journey context (for navigation on guide articles)
- const journeyContext = await journeyResolver.resolveJourneyContext(
+ const journeyContext = await resolveJourneyContext(
req.pagePath || '',
req.context.pages || {},
req.context,
diff --git a/src/links/components/LinkPreviewPopover.tsx b/src/links/components/LinkPreviewPopover.tsx
index 47b5e2d32bc0..c17deda00928 100644
--- a/src/links/components/LinkPreviewPopover.tsx
+++ b/src/links/components/LinkPreviewPopover.tsx
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
+import { HOVERCARDS_ENABLED } from '@/frame/lib/constants'
// We postpone the initial delay a bit in case the user didn't mean to
// hover over the link. Perhaps they just dragged the mouse over on their
@@ -450,6 +451,8 @@ export function LinkPreviewPopover() {
// This is to track if the user entirely tabs out of the window.
// For example if they go to the address bar.
useEffect(() => {
+ if (!HOVERCARDS_ENABLED) return
+
function windowBlur() {
popoverHide()
}
@@ -460,6 +463,8 @@ export function LinkPreviewPopover() {
}, [])
useEffect(() => {
+ if (!HOVERCARDS_ENABLED) return
+
function showPopover(event: MouseEvent) {
const target = event.currentTarget as HTMLLinkElement
popoverShow(target)
diff --git a/src/webhooks/README.md b/src/webhooks/README.md
index b1ea2db302e9..fc33b14a1c3c 100644
--- a/src/webhooks/README.md
+++ b/src/webhooks/README.md
@@ -57,3 +57,37 @@ Slack: `#docs-engineering`
Repo: `github/docs-engineering`
If you have a question about the webhooks pipeline, you can ask in the `#docs-engineering` Slack channel. If you notice a problem with the webhooks pipeline, you can open an issue in the `github/docs-engineering` repository.
+
+## Ownership & Escalation
+
+### Ownership
+- **Team**: Docs Engineering
+- **Source data**: API Platform (github/rest-api-description)
+
+### Escalation path
+1. **Pipeline failures** → #docs-engineering Slack
+2. **OpenAPI schema issues** → #api-platform Slack
+3. **Production incidents** → #docs-engineering
+
+### On-call procedures
+If the webhooks pipeline fails:
+1. Check workflow logs in `.github/workflows/sync-openapi.yml`
+2. Verify access to `github/rest-api-description` repo
+3. Check for OpenAPI schema validation errors
+4. Review changes in generated data files
+5. Check `config.json` SHA tracking
+6. Escalate to API Platform team if schema issue
+
+### Monitoring
+- Pipeline runs automatically on daily schedule (shared with REST/GitHub Apps)
+- PRs created with `github-openapi-bot` label
+- SHA tracking in `config.json` for version history
+- Failures visible in GitHub Actions
+
+This pipeline is in maintenance mode. We will continue to support ongoing improvements incoming from the platform but we are not expecting new functionality moving forward.
+
+### Known limitations
+- **Shared pipeline** - Cannot run webhooks independently of REST/GitHub Apps
+- **Single page** - All events on one page (may impact performance)
+- **Introduction placement** - Manual content must be at start of file
+- **Payload complexity** - Some payloads are very large and complex