Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c5e3833
Stop marking search-github and other /search paths as noindex (#63896)
heiskr Oct 19, 2026
1da53a7
List permissionless endpoints on the fine-grained PAT page (#63897)
heiskr Oct 19, 2026
dc5af17
Copy the warmed remotejson cache into .remotejson-cache in Docker (#6…
heiskr Oct 19, 2026
dff4920
Skip publishing steps on sync-sdk-docs dry runs (#63899)
heiskr Oct 19, 2026
e65a2ad
Escape backticks in orphaned files PR body heredoc (#63900)
heiskr Oct 19, 2026
67bdb6c
Run the header Playwright tests in CI (#63908)
heiskr Oct 19, 2026
19fe403
Bump github/gh-base-image/gh-base-noble from 20261007-171807-gaf20716…
dependabot[bot] Oct 19, 2026
dec437a
Fail benchmark check when results are missing or empty (#63909)
heiskr Oct 19, 2026
da9e021
Remember the REST API version picked in the version picker (#63911)
heiskr Oct 19, 2026
92d8842
Resolve /content/ children in the Article API landing lists (#63912)
heiskr Oct 19, 2026
819c39d
Drop out-of-version and unresolved links from Article API landing pag…
heiskr Oct 19, 2026
838b32b
Set version flags and query on Article API body rendering context (#6…
heiskr Oct 19, 2026
8eeef69
Add media type and required query params to Article API curl examples…
heiskr Oct 19, 2026
bb5f552
🤖 src/ghes-releases/lib/enterprise-dates.json update (#64263)
docs-bot Oct 19, 2026
84e5873
GraphQL schema update (#64268)
docs-bot Oct 19, 2026
cec5fdc
Keep parent body params when a REST request body has top-level oneOf …
heiskr Oct 19, 2026
2592c10
Use raw carousels for landing pages in the Article API (#63916)
heiskr Oct 19, 2026
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
9 changes: 9 additions & 0 deletions .github/workflows/benchmark-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,22 @@ jobs:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
echo "Reading benchmark results..."
# The Run step pipes through tee, so a crashed benchmark passes that step and leaves no results file.
if [ ! -s /tmp/benchmark-results.json ]; then
echo "::error::No benchmark results file. The benchmark did not finish, so no issue was closed."
exit 1
fi
ERRORS=$(jq '.errors | length' /tmp/benchmark-results.json 2>/dev/null || echo "0")
SLOW=$(jq '.slow | length' /tmp/benchmark-results.json 2>/dev/null || echo "0")
TOTAL=$(jq '.totalRequests' /tmp/benchmark-results.json 2>/dev/null || echo "0")
P50=$(jq '.p50' /tmp/benchmark-results.json 2>/dev/null || echo "0")
P99=$(jq '.p99' /tmp/benchmark-results.json 2>/dev/null || echo "0")
MAX=$(jq '.max' /tmp/benchmark-results.json 2>/dev/null || echo "0")
echo "Done reading results: $TOTAL pages, $ERRORS errors, $SLOW slow"
if [ "$TOTAL" = "0" ] || [ "$TOTAL" = "null" ]; then
echo "::error::Benchmark ran 0 requests, so no issue was closed."
exit 1
fi

VERSIONS="free-pro-team@latest, enterprise-cloud@latest, enterprise-server@latest"
LANGS="en"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/headless-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
matrix:
node:
- playwright-rendering
- playwright-header
- playwright-a11y
- playwright-secret-scanning
fail-fast: false
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/orphaned-files-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
git push origin $branchname

body=$(cat <<-EOM
Found with the `npm run find-orphaned-assets`, `npm run -s reusables -- find unused`, and `npm run find-orphaned-tables` scripts.
Found with the \`npm run find-orphaned-assets\`, \`npm run -s reusables -- find unused\`, and \`npm run find-orphaned-tables\` scripts.

The orphaned files workflow file .github/workflows/orphaned-files-check.yml runs every Monday at 16:20 UTC / 8:20 PST.

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/sync-sdk-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
# Publishing runs must start from the default branch because preserve-redirects.ts reads
# live URLs from git HEAD. workflow_dispatch from another branch could write stale redirects.
- name: Verify publishing runs start from the default branch
if: github.event_name != 'pull_request' && inputs.dry_run != 'true'
if: github.event_name != 'pull_request' && inputs.dry_run != true
run: |
if [ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]; then
echo "::error::This run would push and open a PR, but it started from" \
Expand Down Expand Up @@ -192,7 +192,7 @@ jobs:
if: >-
env.has_changes == 'true'
&& github.event_name != 'pull_request'
&& inputs.dry_run != 'true'
&& inputs.dry_run != true
env:
GH_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }}
run: |
Expand All @@ -215,7 +215,7 @@ jobs:
if: >-
env.has_changes == 'true'
&& github.event_name != 'pull_request'
&& inputs.dry_run != 'true'
&& inputs.dry_run != true
env:
GH_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }}
run: |
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Update the base image digest from the gh-base-noble package page:
# 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:20261007-171807-gaf2071665@sha256:57e57e0d0dd8cf12cbf047aa5ae7ead850f562bc66bace621598a460b803efc7 AS base
FROM ghcr.io/github/gh-base-image/gh-base-noble:20261016-181622-g7dfd3798b@sha256:d41c754579434b9b6479a3d3786a6a84ad7be50ce64626bbc07579ab4c36302b AS base
# Install curl for NodeSource setup.
# Install git for early-access and translation clones.
# Ubuntu's nodejs package lags the Node LTS release line.
Expand Down Expand Up @@ -79,7 +79,7 @@ COPY --chown=node:node --from=clones $APP_HOME/content content/
COPY --chown=node:node --from=clones $APP_HOME/translations translations/
COPY --chown=node:node --from=prod_deps $APP_HOME/node_modules node_modules/
COPY --chown=node:node --from=build $APP_HOME/.next .next/
COPY --chown=node:node --from=warmup_cache $APP_HOME/.remotejson-cache ./
COPY --chown=node:node --from=warmup_cache $APP_HOME/.remotejson-cache .remotejson-cache/
# Expose the build SHA at runtime when callers pass --build-arg BUILD_SHA=abc123.
ARG BUILD_SHA
ENV BUILD_SHA=$BUILD_SHA
Expand Down
2 changes: 1 addition & 1 deletion content/copilot/tutorials/customization-library/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ spotlight:
image: /assets/images/copilot-landing/generating_unit_tests.png
- article: /prompt-files/your-first-prompt-file
image: /assets/images/copilot-landing/improving_code_readability.png
- article: /custom-instructions/your-first-custom-agent
- article: /custom-agents/your-first-custom-agent
image: /assets/images/copilot-landing/generating_unit_tests.png
filters:
- category
Expand Down
5 changes: 4 additions & 1 deletion src/article-api/lib/get-all-toc-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export async function getAllTocItems(
}))
.filter(
(entry): entry is { childHref: string; childPage: PageWithChildren } =>
entry.childPage !== undefined,
entry.childPage !== undefined &&
// resolvePath can return a page from another version, which the website hides.
(!context.currentVersion ||
entry.childPage.applicableVersions.includes(context.currentVersion)),
)

const items = await Promise.all(
Expand Down
8 changes: 6 additions & 2 deletions src/article-api/lib/get-link-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ export async function getLinkData(
pathname: string,
context: Context,
) => Page | undefined,
): Promise<LinkData> {
): Promise<LinkData | null> {
const linkedPage = resolvePath(href, languageCode, pathname, context)
if (!linkedPage) return { href, title: href }
if (!linkedPage) return null
// resolvePath can return a page from another version, which the website hides.
if (context.currentVersion && !linkedPage.applicableVersions.includes(context.currentVersion)) {
return null
}

const title = await linkedPage.renderTitle(context, { unwrap: true })
const intro = linkedPage.intro
Expand Down
35 changes: 35 additions & 0 deletions src/article-api/lib/resolve-carousel-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import path from 'path'

import type { Context, Page } from '@/types'
import type { LinkData } from '@/article-api/transformers/types'

// Match resolve-carousels and GHD056: try the content root first, then the landing page's directory.
export async function resolveCarouselLinks(
rawPaths: string[],
page: Page,
context: Context,
): Promise<LinkData[]> {
const { default: getPageLinkData } = await import('@/frame/lib/get-link-data')
const options = { title: true, intro: true }
const pageDir = page.relativePath ? path.posix.dirname(page.relativePath) : '.'

const links: LinkData[] = []
for (const rawPath of rawPaths) {
let linkData = await getPageLinkData(rawPath, context, options)
// Liquid can render to an empty path, which would resolve to the landing page itself.
if (!linkData?.length && pageDir !== '.' && !rawPath.includes('{')) {
linkData = await getPageLinkData(path.posix.join('/', pageDir, rawPath), context, options)
}
for (const item of linkData || []) {
// Versionless redirects can resolve to a page that does not exist in the requested version.
if (
context.currentVersion &&
!item.page.applicableVersions.includes(context.currentVersion)
) {
continue
}
links.push({ href: item.href, title: item.title || '', intro: item.intro || '' })
}
}
return links
}
23 changes: 22 additions & 1 deletion src/article-api/lib/resolve-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ export function resolvePath(
const page =
findPage(candidate, pages, redirects) ||
findPage(candidate.replace(/\/?$/, '/'), pages, redirects)
if (page) return page
if (page) {
// Content-root candidates span every version, so drop pages the web page would omit.
if (
href.startsWith('/content/') &&
context.currentVersion &&
!page.applicableVersions?.includes(context.currentVersion)
) {
return undefined
}
return page
}
}

return undefined
Expand All @@ -28,6 +38,17 @@ function* candidates(href: string, lang: string, pathname: string) {
const langPrefix = `/${lang}/`
const cleanPathname = pathname.replace(/\/$/, '')

// Children like /content/actions/workflows point at the content root, matching create-tree.ts.
if (href.startsWith('/content/')) {
const rest = href.slice('/content/'.length).replace(/\/$/, '')
if (!rest) return
yield `${langPrefix}${rest}`
for (const version of allVersionKeys) {
yield `${langPrefix}${version}/${rest}`
}
return
}

if (href.startsWith(langPrefix)) {
yield href
} else if (href.startsWith('/')) {
Expand Down
14 changes: 12 additions & 2 deletions src/article-api/middleware/article-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Response } from 'express'
import { Context } from '@/types'
import { ExtendedRequestWithPageInfo } from '@/article-api/types'
import contextualize from '@/frame/middleware/context/context'
import shortVersions from '@/versions/middleware/short-versions'
import features from '@/versions/middleware/features'
import glossaries from '@/frame/middleware/context/glossaries'
import dataTables from '@/data-directory/middleware/data-tables'
Expand All @@ -12,12 +13,18 @@ import { allVersions } from '@/versions/lib/all-versions'
import type { Page } from '@/types'

// Article Markdown rendering needs a mocked request with page context.
async function createContextualizedRenderingRequest(pathname: string, page: Page) {
async function createContextualizedRenderingRequest(
pathname: string,
page: Page,
apiVersion: string | undefined,
) {
const mockedContext: Context = {}
const renderingReq = {
path: pathname,
language: page.languageCode,
pagePath: pathname,
// Content Liquid filters REST changelog sections on query.apiVersion.
query: apiVersion ? { apiVersion } : {},
cookies: {},
context: mockedContext,
headers: {
Expand All @@ -29,6 +36,9 @@ async function createContextualizedRenderingRequest(pathname: string, page: Page
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
renderingReq.context.page = page

// ifversion with bare fpt, ghec, or ghes reads short version flags from context.
await shortVersions(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})

// ifversion Liquid tags read feature flags from context.
features(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})

Expand All @@ -54,7 +64,7 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
const transformer = transformerRegistry.findTransformer(page)
if (!transformer) throw new Error(`No transformer found for page: ${pathname}`)

const renderingReq = await createContextualizedRenderingRequest(pathname, page)
const renderingReq = await createContextualizedRenderingRequest(pathname, page, apiVersion)

// apiVersionValidationMiddleware rejects invalid explicit API versions before body rendering.
const currentVersion = renderingReq.context.currentVersion
Expand Down
39 changes: 37 additions & 2 deletions src/article-api/tests/article-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { beforeAll, describe, expect, test } from 'vitest'

import { get } from '@/tests/helpers/e2etest'

const makeURL = (pathname: string): string =>
`/api/article/body?${new URLSearchParams({ pathname })}`
const makeURL = (pathname: string, apiVersion?: string): string =>
`/api/article/body?${new URLSearchParams(apiVersion ? { pathname, apiVersion } : { pathname })}`

describe('article body api', () => {
beforeAll(() => {
Expand Down Expand Up @@ -70,6 +70,41 @@ describe('article body api', () => {
expect(res.body).toContain('Foo is an intentionally meaningless placeholder')
})

test('intro ifversion with a bare version name renders for the requested version', async () => {
{
const res = await get(makeURL('/en/get-started/liquid/ifversion'))
expect(res.statusCode).toBe(200)
expect(res.body).toContain('(bare not ghes)')
expect(res.body).not.toContain('(bare ghes)')
}
{
const res = await get(makeURL('/en/enterprise-server@latest/get-started/liquid/ifversion'))
expect(res.statusCode).toBe(200)
expect(res.body).toContain('(bare ghes)')
expect(res.body).not.toContain('(bare not ghes)')
}
})

test('apiVersion query filters Liquid that reads query.apiVersion', async () => {
const pathname = '/en/rest/about-the-rest-api/comparing-githubs-rest-api-and-graphql-api'
const marker = 'This paragraph applies to API version 2026-03-10 and later.'
{
const res = await get(makeURL(pathname))
expect(res.statusCode).toBe(200)
expect(res.body).toContain(marker)
}
{
const res = await get(makeURL(pathname, '2026-03-10'))
expect(res.statusCode).toBe(200)
expect(res.body).toContain(marker)
}
{
const res = await get(makeURL(pathname, '2022-11-28'))
expect(res.statusCode).toBe(200)
expect(res.body).not.toContain(marker)
}
})

test('invalid Referer header does not crash', async () => {
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'), {
headers: {
Expand Down
64 changes: 64 additions & 0 deletions src/article-api/tests/get-all-toc-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from 'vitest'
import { getAllTocItems } from '@/article-api/lib/get-all-toc-items'
import type { Context, Page, Permalink } from '@/types'

function createMockPage(options: {
rawTitle: string
applicableVersions: string[]
permalinks: Partial<Permalink>[]
children?: string[]
}): Page {
return {
languageCode: 'en',
rawTitle: options.rawTitle,
applicableVersions: options.applicableVersions,
permalinks: options.permalinks as Permalink[],
children: options.children,
renderProp: vi.fn().mockResolvedValue(''),
} as unknown as Page
}

describe('getAllTocItems', () => {
test('omits children that are not available in the current version', async () => {
const parent = createMockPage({
rawTitle: 'REST API',
applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'],
permalinks: [{ languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/rest' }],
children: ['/quickstart', '/enterprise-admin'],
})
const quickstart = createMockPage({
rawTitle: 'Quickstart',
applicableVersions: ['free-pro-team@latest', 'enterprise-cloud@latest'],
permalinks: [
{ languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/rest/quickstart' },
],
})
const enterpriseAdmin = createMockPage({
rawTitle: 'Enterprise administration',
applicableVersions: ['enterprise-cloud@latest'],
permalinks: [
{
languageCode: 'en',
pageVersion: 'enterprise-cloud@latest',
href: '/en/enterprise-cloud@latest/rest/enterprise-admin',
},
],
})
const context = {
currentVersion: 'free-pro-team@latest',
pages: {
'/en/rest': parent,
'/en/rest/quickstart': quickstart,
'/en/enterprise-cloud@latest/rest/enterprise-admin': enterpriseAdmin,
},
redirects: {
'/rest/enterprise-admin': '/enterprise-cloud@latest/rest/enterprise-admin',
},
} as unknown as Context

const items = await getAllTocItems(parent, context)

expect(items.map((item) => item.href)).toEqual(['/en/rest/quickstart'])
expect(items[0].title).toBe('Quickstart')
})
})
Loading
Loading