Skip to content

Commit 1456def

Browse files
authored
Merge pull request #43550 from github/repo-sync
Repo sync
2 parents 10fca1f + 415d649 commit 1456def

File tree

6 files changed

+452
-121
lines changed

6 files changed

+452
-121
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
name: 'Weekly page benchmark'
2+
3+
# **What it does**: Benchmarks all pages via the article API, flags errors and slow pages
4+
# **Why we have it**: Catch perf regressions and broken pages before users hit them
5+
# **Who does it impact**: Docs engineering
6+
7+
on:
8+
workflow_dispatch:
9+
schedule:
10+
- cron: '20 16 * * 1' # Every Monday at 16:20 UTC / 8:20 PST
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
benchmark:
17+
if: github.repository == 'github/docs-internal'
18+
runs-on: ubuntu-latest
19+
env:
20+
BENCHMARK_LABEL: benchmark-regression
21+
ISSUE_REPO: github/docs-engineering
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
25+
with:
26+
persist-credentials: 'false'
27+
28+
- uses: ./.github/actions/node-npm-setup
29+
30+
- name: Build
31+
run: npm run build
32+
33+
- name: Start server
34+
env:
35+
NODE_ENV: production
36+
PORT: 4000
37+
run: |
38+
npm run start-for-ci &
39+
sleep 5
40+
curl --retry-connrefused --retry 6 -I http://localhost:4000/
41+
42+
- name: Run benchmark
43+
run: |
44+
npx tsx src/workflows/benchmark-pages.ts \
45+
--versions "free-pro-team@latest,enterprise-cloud@latest,enterprise-server@latest" \
46+
--modes article-body \
47+
--slow 500 \
48+
--json /tmp/benchmark-results.json | tee /tmp/benchmark-output.txt
49+
50+
- name: Check results and create issue if needed
51+
if: always()
52+
env:
53+
GH_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }}
54+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
55+
run: |
56+
echo "Reading benchmark results..."
57+
ERRORS=$(jq '.errors | length' /tmp/benchmark-results.json 2>/dev/null || echo "0")
58+
SLOW=$(jq '.slow | length' /tmp/benchmark-results.json 2>/dev/null || echo "0")
59+
TOTAL=$(jq '.totalRequests' /tmp/benchmark-results.json 2>/dev/null || echo "0")
60+
P50=$(jq '.p50' /tmp/benchmark-results.json 2>/dev/null || echo "0")
61+
P99=$(jq '.p99' /tmp/benchmark-results.json 2>/dev/null || echo "0")
62+
MAX=$(jq '.max' /tmp/benchmark-results.json 2>/dev/null || echo "0")
63+
echo "Done reading results: $TOTAL pages, $ERRORS errors, $SLOW slow"
64+
65+
VERSIONS="free-pro-team@latest, enterprise-cloud@latest, enterprise-server@latest"
66+
LANGS="en"
67+
68+
if [ "$ERRORS" = "0" ] && [ "$SLOW" = "0" ]; then
69+
echo "✅ All clear — $TOTAL pages, p50=${P50}ms, p99=${P99}ms, max=${MAX}ms"
70+
71+
echo "Checking for existing open issue..."
72+
existing=$(gh issue list \
73+
--repo "$ISSUE_REPO" \
74+
--label "$BENCHMARK_LABEL" \
75+
--state open \
76+
--json number \
77+
--jq '.[0].number // empty' 2>/dev/null || true)
78+
if [ -n "$existing" ]; then
79+
echo "Closing issue #$existing..."
80+
gh issue close "$existing" \
81+
--repo "$ISSUE_REPO" \
82+
--comment "All clear as of $RUN_URL — closing."
83+
echo "Done closing issue #$existing"
84+
else
85+
echo "No existing issue to close"
86+
fi
87+
exit 0
88+
fi
89+
90+
PROBLEM_COUNT=$((ERRORS + SLOW))
91+
echo "Found $ERRORS errors and $SLOW slow pages ($PROBLEM_COUNT total problems)"
92+
93+
echo "Ensuring label exists..."
94+
gh label create "$BENCHMARK_LABEL" \
95+
--repo "$ISSUE_REPO" \
96+
--description "Weekly page benchmark found slow or errored pages" \
97+
--color "e16f24" 2>/dev/null || true
98+
echo "Done ensuring label"
99+
100+
echo "Building issue body..."
101+
BODY_FILE=/tmp/benchmark-issue-body.md
102+
{
103+
echo "## Weekly page benchmark found issues"
104+
echo ""
105+
echo "**Run:** $RUN_URL"
106+
echo "**Languages:** $LANGS"
107+
echo "**Versions:** $VERSIONS"
108+
echo "**Total pages:** $TOTAL"
109+
echo "**Stats:** p50=${P50}ms · p99=${P99}ms · max=${MAX}ms"
110+
echo "**Errors:** $ERRORS"
111+
echo "**Slow (≥500ms):** $SLOW"
112+
} > "$BODY_FILE"
113+
114+
if [ "$ERRORS" -gt 0 ]; then
115+
{
116+
echo ""
117+
echo "### Errors"
118+
echo ""
119+
echo "| Status | Mode | Path |"
120+
echo "|--------|------|------|"
121+
jq -r '.errors[] | "| \(.status) | \(.mode) | \(.path) |"' /tmp/benchmark-results.json
122+
} >> "$BODY_FILE"
123+
fi
124+
125+
if [ "$SLOW" -gt 0 ]; then
126+
{
127+
echo ""
128+
echo "### Slow pages"
129+
echo ""
130+
echo "| Time | Mode | Path |"
131+
echo "|------|------|------|"
132+
jq -r '.slow[] | "| \(.timeMs)ms | \(.mode) | \(.path) |"' /tmp/benchmark-results.json
133+
} >> "$BODY_FILE"
134+
fi
135+
echo "Done building issue body"
136+
137+
echo "Checking for existing open issue..."
138+
existing=$(gh issue list \
139+
--repo "$ISSUE_REPO" \
140+
--label "$BENCHMARK_LABEL" \
141+
--state open \
142+
--json number \
143+
--jq '.[0].number // empty' 2>/dev/null || true)
144+
145+
if [ -n "$existing" ]; then
146+
echo "Commenting on existing issue #$existing..."
147+
gh issue comment "$existing" \
148+
--repo "$ISSUE_REPO" \
149+
--body-file "$BODY_FILE"
150+
echo "Done commenting on issue #$existing"
151+
else
152+
echo "Creating new issue..."
153+
gh issue create \
154+
--repo "$ISSUE_REPO" \
155+
--label "$BENCHMARK_LABEL" \
156+
--title "[Benchmark] ${PROBLEM_COUNT} slow or errored pages detected" \
157+
--body-file "$BODY_FILE"
158+
echo "Done creating issue"
159+
fi
160+
161+
- uses: ./.github/actions/slack-alert
162+
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
163+
with:
164+
slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }}
165+
slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
166+
167+
- uses: ./.github/actions/create-workflow-failure-issue
168+
if: ${{ failure() && github.event_name != 'workflow_dispatch' }}
169+
with:
170+
token: ${{ secrets.DOCS_BOT_PAT_BASE }}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"all-documents": "tsx src/content-render/scripts/all-documents/cli.ts",
1717
"analyze-text": "tsx src/search/scripts/analyze-text.ts",
1818
"analyze-comment": "tsx src/events/scripts/analyze-comment-cli.ts",
19+
"benchmark-pages": "tsx src/workflows/benchmark-pages.ts",
1920
"build": "next build --webpack",
2021
"check-content-type": "tsx src/workflows/check-content-type.ts",
2122
"check-github-github-links": "tsx src/links/scripts/check-github-github-links.ts",

src/article-api/lib/get-all-toc-items.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Context, Page } from '@/types'
22
import type { LinkData } from '@/article-api/transformers/types'
3-
import { renderLiquid } from '@/content-render/liquid/index'
43
import { resolvePath } from './resolve-path'
54

65
interface PageWithChildren extends Page {
@@ -29,16 +28,12 @@ export async function getAllTocItems(
2928
options: {
3029
recurse?: boolean
3130
renderIntros?: boolean
32-
/** Use Liquid-only rendering for titles and intros instead of the full
33-
* Markdown/unified pipeline. Resolves {% data %} variables without
34-
* the cost of Markdown parsing, unified processing, and Cheerio unwrap. */
35-
liquidOnly?: boolean
3631
/** Only recurse into children whose resolved path starts with this prefix.
3732
* Prevents cross-product traversal (e.g. /en/rest listing /enterprise-admin). */
3833
basePath?: string
3934
} = {},
4035
): Promise<TocItem[]> {
41-
const { recurse = true, renderIntros = true, liquidOnly = false } = options
36+
const { recurse = true, renderIntros = true } = options
4237
const pageWithChildren = page as PageWithChildren
4338
const languageCode = page.languageCode || 'en'
4439

@@ -75,32 +70,11 @@ export async function getAllTocItems(
7570
)
7671
const href = childPermalink ? childPermalink.href : childHref
7772

78-
let title: string
79-
if (liquidOnly) {
80-
const raw = childPage.shortTitle || childPage.title
81-
try {
82-
title = await renderLiquid(raw, context)
83-
} catch {
84-
// Fall back to raw frontmatter string if Liquid rendering fails
85-
// (e.g. translation errors in non-English pages)
86-
title = raw
87-
}
88-
} else {
89-
title = await childPage.renderTitle(context, { unwrap: true })
90-
}
73+
const title = await childPage.renderTitle(context, { unwrap: true })
9174

9275
let intro = ''
9376
if (renderIntros && childPage.intro) {
94-
if (liquidOnly) {
95-
const rawIntro = childPage.rawIntro || childPage.intro
96-
try {
97-
intro = await renderLiquid(rawIntro, context)
98-
} catch {
99-
intro = rawIntro
100-
}
101-
} else {
102-
intro = await childPage.renderProp('intro', context, { textOnly: true })
103-
}
77+
intro = await childPage.renderProp('intro', context, { textOnly: true })
10478
}
10579

10680
const category = childPage.category || []
Lines changed: 41 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,62 @@
1+
import findPage from '@/frame/lib/find-page'
2+
import { allVersionKeys } from '@/versions/lib/all-versions'
13
import type { Context, Page } from '@/types'
24

35
/**
4-
* Resolves an href to a Page object from the context
6+
* Resolves an href to a Page object from the context.
57
*
6-
* This function handles various href formats:
7-
* - External URLs (http/https) - returns undefined
8-
* - Language-prefixed absolute paths (/en/copilot/...) - direct lookup
9-
* - Absolute paths without language (/copilot/...) - adds language prefix
10-
* - Relative paths (get-started) - resolved relative to pathname
11-
*
12-
* The function searches through context.pages using multiple strategies:
13-
* 1. Direct key lookup with language prefix
14-
* 2. Relative path joining with current pathname
15-
* 3. endsWith matching for versioned keys (e.g., /en/enterprise-cloud@latest/...)
16-
*
17-
* @param href - The href to resolve
18-
* @param languageCode - The language code (e.g., 'en')
19-
* @param pathname - The current page's pathname (e.g., '/en/copilot')
20-
* @param context - The rendering context containing all pages
21-
* @returns The resolved Page object, or undefined if not found
22-
*
23-
* @example
24-
* ```typescript
25-
* // Absolute path with language
26-
* resolvePath('/en/copilot/quickstart', 'en', '/en/copilot', context)
27-
*
28-
* // Absolute path without language (adds /en/)
29-
* resolvePath('/copilot/quickstart', 'en', '/en/copilot', context)
30-
*
31-
* // Relative path (resolves to /en/copilot/quickstart)
32-
* resolvePath('quickstart', 'en', '/en/copilot', context)
33-
*
34-
* // Relative path with leading slash (resolves relative to pathname)
35-
* resolvePath('/quickstart', 'en', '/en/copilot', context) // -> /en/copilot/quickstart
36-
* ```
8+
* Normalizes various href formats (relative, absolute, with/without language
9+
* prefix) to canonical paths, then delegates to findPage for lookup with
10+
* redirect support and English fallback.
3711
*/
3812
export function resolvePath(
3913
href: string,
4014
languageCode: string,
4115
pathname: string,
4216
context: Context,
4317
): Page | undefined {
44-
// External URLs cannot be resolved
45-
if (href.startsWith('http://') || href.startsWith('https://')) {
46-
return undefined
47-
}
48-
49-
if (!context.pages) {
50-
return undefined
51-
}
18+
if (href.startsWith('http://') || href.startsWith('https://')) return undefined
19+
if (!context.pages) return undefined
5220

53-
// Normalize href to start with /
54-
const normalizedHref = href.startsWith('/') ? href : `/${href}`
21+
const { pages, redirects } = context
5522

56-
// Build full path with language prefix if needed
57-
let fullPath: string
58-
if (normalizedHref.startsWith(`/${languageCode}/`)) {
59-
// Already has language prefix
60-
fullPath = normalizedHref
61-
} else if (href.startsWith('/') && !href.startsWith(`/${languageCode}/`)) {
62-
// Path with leading slash but no language prefix - treat as relative to pathname
63-
// e.g., pathname='/en/copilot', href='/get-started' -> '/en/copilot/get-started'
64-
fullPath = pathname + href
65-
} else {
66-
// Relative path - add language prefix
67-
// e.g., href='quickstart' -> '/en/quickstart'
68-
fullPath = `/${languageCode}${normalizedHref}`
23+
for (const candidate of candidates(href, languageCode, pathname)) {
24+
const page =
25+
findPage(candidate, pages, redirects) ||
26+
findPage(candidate.replace(/\/?$/, '/'), pages, redirects)
27+
if (page) return page
6928
}
7029

71-
// Clean up trailing slashes
72-
const cleanPath = fullPath.replace(/\/$/, '')
73-
74-
// Strategy 1: Direct lookup
75-
if (context.pages[cleanPath]) {
76-
return context.pages[cleanPath]
77-
}
78-
79-
// Strategy 2: Try relative to current pathname
80-
const currentPath = pathname.replace(/\/$/, '')
81-
const relativeHref = href.startsWith('/') ? href.slice(1) : href
82-
const joinedPath = `${currentPath}/${relativeHref}`
83-
84-
if (context.pages[joinedPath]) {
85-
return context.pages[joinedPath]
86-
}
30+
return undefined
31+
}
8732

88-
// Strategy 3: Search for keys that end with the path (handles versioned keys)
89-
// e.g., key='/en/enterprise-cloud@latest/copilot' should match path='/en/copilot'
90-
for (const [key, page] of Object.entries(context.pages)) {
91-
if (key.endsWith(cleanPath) || key.endsWith(`${cleanPath}/`)) {
92-
return page
93-
}
33+
// Lazily yields candidate paths in priority order, stopping at first match.
34+
function* candidates(href: string, lang: string, pathname: string) {
35+
const langPrefix = `/${lang}/`
36+
const cleanPathname = pathname.replace(/\/$/, '')
37+
38+
if (href.startsWith(langPrefix)) {
39+
// Already has language prefix — use as-is
40+
yield href
41+
} else if (href.startsWith('/')) {
42+
// Leading slash without lang prefix — try relative to pathname first,
43+
// then as a direct path with lang prefix
44+
yield `${cleanPathname}${href}`
45+
yield `${langPrefix.slice(0, -1)}${href}`
46+
} else {
47+
// Relative path — try relative to pathname, then with lang prefix
48+
yield `${cleanPathname}/${href}`
49+
yield `${langPrefix}${href}`
9450
}
9551

96-
// Strategy 4: If href started with /, try endsWith matching on that too
97-
if (href.startsWith('/')) {
98-
const hrefClean = href.replace(/\/$/, '')
99-
for (const [key, page] of Object.entries(context.pages)) {
100-
if (key.endsWith(hrefClean) || key.endsWith(`${hrefClean}/`)) {
101-
return page
102-
}
52+
// Versioned fallback: try inserting each version slug for
53+
// enterprise-only pages that don't exist on FPT.
54+
const suffix = href.startsWith(langPrefix)
55+
? href.slice(langPrefix.length).replace(/\/$/, '')
56+
: href.replace(/^\//, '').replace(/\/$/, '')
57+
if (suffix) {
58+
for (const version of allVersionKeys) {
59+
yield `${langPrefix}${version}/${suffix}`
10360
}
10461
}
105-
106-
return undefined
10762
}

0 commit comments

Comments
 (0)