Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/events/components/events.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/fixtures/tests/playwright-rendering.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/frame/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export const minimumNotFoundHtml = `
&bull; <a href=https://docs.github.com/site-policy/privacy-policies/github-privacy-statement>Privacy</a>
</small>
`.replace(/\n/g, '')

export const ANALYTICS_ENABLED = true
export const HOVERCARDS_ENABLED = true
90 changes: 68 additions & 22 deletions src/journeys/lib/journey-path-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | 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) {
Expand All @@ -78,6 +85,27 @@ function getJourneyPages(pages: Pages): JourneyPage[] {
return cachedJourneyPages
}

function getGuidePaths(pages: Pages): Set<string> {
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}`
Expand Down Expand Up @@ -133,6 +161,16 @@ export async function resolveJourneyContext(
): Promise<JourneyContext | null> {
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)

Expand Down Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 }) => {
Expand Down
10 changes: 6 additions & 4 deletions src/journeys/middleware/journey-track.ts
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/links/components/LinkPreviewPopover.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -460,6 +463,8 @@ export function LinkPreviewPopover() {
}, [])

useEffect(() => {
if (!HOVERCARDS_ENABLED) return

function showPopover(event: MouseEvent) {
const target = event.currentTarget as HTMLLinkElement
popoverShow(target)
Expand Down
34 changes: 34 additions & 0 deletions src/webhooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading