From 0eb277f1b41d6528438176ff1d53fdb416c752a4 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:07 +0000 Subject: [PATCH 01/71] Revert featured and homepage links when undoing move-content (#64161) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .github/workflows/move-content.yml | 25 ++++++++++++++++++++++ src/content-render/scripts/move-content.ts | 9 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/move-content.yml b/.github/workflows/move-content.yml index b0a817c3b0c7..d1ae254ca5a6 100644 --- a/.github/workflows/move-content.yml +++ b/.github/workflows/move-content.yml @@ -46,6 +46,31 @@ jobs: src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md git log | head -n 100 + - name: Undo moving hello-world.md to hello-wurld.md + env: + ROOT: src/fixtures/fixtures + run: | + # The fixtures start with hello-world, so confirm the move rewrote them before testing undo. + for file in src/fixtures/fixtures/content/index.md src/fixtures/fixtures/content/get-started/index.md; do + grep -q -- '- /get-started/start-your-journey/hello-wurld$' "$file" + done + + npm run move-content -- --undo \ + src/fixtures/fixtures/content/get-started/start-your-journey/hello-world.md \ + src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md + + test -f src/fixtures/fixtures/content/get-started/start-your-journey/hello-world.md + test ! -e src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md + # Undo must revert featuredLinks too, or they point at a page that no longer exists. + for file in src/fixtures/fixtures/content/index.md src/fixtures/fixtures/content/get-started/index.md; do + grep -q -- '- /get-started/start-your-journey/hello-world$' "$file" + if grep -q hello-wurld "$file"; then + echo "$file still links to hello-wurld" + exit 1 + fi + done + git log | head -n 100 + - name: Move code-security/getting-started to code-security/got-started env: ROOT: src/fixtures/fixtures diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts index 07aab6e2096e..da4b94b4290e 100755 --- a/src/content-render/scripts/move-content.ts +++ b/src/content-render/scripts/move-content.ts @@ -7,7 +7,7 @@ // Run npm run move-content --help for options. // Run file: npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md. // Run folder: npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME. -// Undo: npm run move-content --undo PATH/TO/OLD PATH/TO/NEW. +// Undo: npm run move-content -- --undo PATH/TO/OLD PATH/TO/NEW. import fs from 'fs' import path from 'path' @@ -161,10 +161,13 @@ async function main(opts: MoveOptions, nameTuple: string[]) { } } + // oldHref and newHref keep the forward direction for undoFiles, so swap them to revert links. + const [fromHref, toHref] = undo ? [newHref, oldHref] : [oldHref, newHref] + // featuredLinks updates need old and new hrefs, not whether the path is a file or folder. - changeFeaturedLinks(oldHref, newHref) + changeFeaturedLinks(fromHref, toHref) - changeHomepageLinks(oldHref, newHref, verbose) + changeHomepageLinks(fromHref, toHref, verbose) if (!undo) { if (verbose) { From 695c8009582f2ee9bfc28c8bbb9565a3543393f8 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:14 +0000 Subject: [PATCH 02/71] Let modifier-clicks on tool and platform tabs open a new tab (#64173) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../tests/playwright-rendering.spec.ts | 25 +++++++++++++++++++ src/tools/components/InArticlePicker.tsx | 2 ++ 2 files changed, 27 insertions(+) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 99d7846315d0..e365a1095505 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -6,6 +6,7 @@ import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED, COLOR_MODE_COOKIE_NAME, + TOOL_PREFERRED_COOKIE_NAME, } from '../../frame/lib/constants' // Local Playwright loads .env so tests read ELASTICSEARCH_URL independently of start-server.ts. @@ -1266,6 +1267,30 @@ test.describe('tool picker', () => { await expect(page.getByText('This is webui content')).toBeVisible() }) + test('modifier-click opens the tool in a new tab', async ({ page }) => { + await page.goto('/get-started/liquid/tool-specific') + await turnOffExperimentsInPage(page) + await page.getByTestId('tool-picker').getByRole('link', { name: 'Web browser' }).click() + await expect(page).toHaveURL(/\?tool=webui/) + + // Chromium opens modifier-click tabs without an opener, so the page never emits 'popup'. + const newTabPromise = page.context().waitForEvent('page') + await page + .getByTestId('tool-picker') + .getByRole('link', { name: 'GitHub CLI' }) + .click({ modifiers: ['ControlOrMeta'] }) + const newTab = await newTabPromise + await expect(newTab).toHaveURL(/\?tool=cli/) + await newTab.close() + + await expect(page).toHaveURL(/\?tool=webui/) + await expect(page.getByText('This is webui content')).toBeVisible() + await expect(page.getByText('This is cli content')).not.toBeVisible() + const cookies = await page.context().cookies() + const toolCookie = cookies.find((cookie) => cookie.name === TOOL_PREFERRED_COOKIE_NAME) + expect(toolCookie?.value).toBe('webui') + }) + test('minitoc matches picker', async ({ page }) => { // Avoid the drawer's exact reveal breakpoint. await page.setViewportSize({ width: 1440, height: 900 }) diff --git a/src/tools/components/InArticlePicker.tsx b/src/tools/components/InArticlePicker.tsx index 27c0f2c2097c..9117ebb4fd00 100644 --- a/src/tools/components/InArticlePicker.tsx +++ b/src/tools/components/InArticlePicker.tsx @@ -155,6 +155,8 @@ export const InArticlePicker = ({ key={option.value} aria-current={option.value === currentValue ? 'page' : undefined} onSelect={(event: React.MouseEvent | React.KeyboardEvent) => { + // Leave new-tab and new-window gestures to the browser and the href. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() onClickChoice(option.value) }} From 49fd997ed49d96b6bc898013cac718e8b30a4268 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:21 +0000 Subject: [PATCH 03/71] Keep custom feature headings when rerunning the release notes generator (#64180) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/ghes-releases/lib/parse-release-notes.ts | 11 +++-- .../scripts/generate-release-notes.ts | 1 + .../tests/generate-release-notes.ts | 44 ++++++++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/ghes-releases/lib/parse-release-notes.ts b/src/ghes-releases/lib/parse-release-notes.ts index 080232749eb8..d68b02fa61d0 100644 --- a/src/ghes-releases/lib/parse-release-notes.ts +++ b/src/ghes-releases/lib/parse-release-notes.ts @@ -271,8 +271,10 @@ export function buildReleaseNotesYaml( lines.push('sections:') - const featureEntries = noteEntries.filter((e) => featureHeadings.includes(e.heading)) - const otherEntries = noteEntries.filter((e) => !featureHeadings.includes(e.heading)) + // Writers add their own feature headings, so only section headings stay out of features. + const sectionHeadings = ['Changes', 'Closing down', 'Retired'] + const featureEntries = noteEntries.filter((e) => !sectionHeadings.includes(e.heading)) + const otherEntries = noteEntries.filter((e) => sectionHeadings.includes(e.heading)) lines.push('') lines.push(' features:') @@ -285,7 +287,8 @@ export function buildReleaseNotesYaml( byHeading.set(entry.heading, existing) } - for (const heading of featureHeadings) { + const extraHeadings = [...byHeading.keys()].filter((h) => !featureHeadings.includes(h)) + for (const heading of [...featureHeadings, ...extraHeadings]) { const entries = byHeading.get(heading) if (!entries) continue @@ -298,7 +301,7 @@ export function buildReleaseNotesYaml( lines.push(' # TODO: Add feature notes') } - const changeEntries = otherEntries.filter((e) => !['Closing down', 'Retired'].includes(e.heading)) + const changeEntries = otherEntries.filter((e) => e.heading === 'Changes') if (changeEntries.length > 0) { lines.push('') lines.push(' changes:') diff --git a/src/ghes-releases/scripts/generate-release-notes.ts b/src/ghes-releases/scripts/generate-release-notes.ts index a034e0a179b7..c8826c06d436 100644 --- a/src/ghes-releases/scripts/generate-release-notes.ts +++ b/src/ghes-releases/scripts/generate-release-notes.ts @@ -683,6 +683,7 @@ program console.log( ` ⚠ Unknown heading "${entry.heading}" — will be placed in changes section`, ) + entry.heading = 'Changes' } } } diff --git a/src/ghes-releases/tests/generate-release-notes.ts b/src/ghes-releases/tests/generate-release-notes.ts index 6d78f7826177..4f66473d9ddd 100644 --- a/src/ghes-releases/tests/generate-release-notes.ts +++ b/src/ghes-releases/tests/generate-release-notes.ts @@ -340,9 +340,9 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('Repo note.') }) - test('places non-feature entries in changes section', () => { + test('places Changes entries in changes section', () => { const entries = [ - { heading: 'Some Other Heading', notes: ['A change.'], sourceUrl: 'https://example.com/1' }, + { heading: 'Changes', notes: ['A change.'], sourceUrl: 'https://example.com/1' }, ] const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) @@ -352,6 +352,46 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('A change.') }) + test('keeps feature headings that are not in the template after template headings', () => { + const entries = [ + { heading: 'Copilot', notes: ['Copilot note.'], sourceUrl: 'https://example.com/1' }, + { heading: 'APIs', notes: ['API note.'], sourceUrl: 'https://example.com/2' }, + ] + const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) + + const apisIdx = yaml.indexOf(' - heading: APIs') + const copilotIdx = yaml.indexOf(' - heading: Copilot') + expect(apisIdx).toBeGreaterThan(-1) + expect(copilotIdx).toBeGreaterThan(apisIdx) + expect(copilotIdx).toBeLessThan(yaml.indexOf(' known_issues:')) + expect(yaml).toContain('Copilot note.') + expect(yaml).not.toContain(' changes:') + }) + + test('preserves feature headings that are not in the template on reload', () => { + const entries = [ + { + heading: 'Code scanning', + notes: ['Code scanning note.'], + sourceUrl: 'https://github.com/github/releases/issues/1', + }, + { + heading: 'Secret scanning', + notes: ['Secret scanning note.'], + sourceUrl: 'https://github.com/github/releases/issues/2', + }, + { + heading: 'Changes', + notes: ['A change.'], + sourceUrl: 'https://github.com/github/releases/issues/3', + }, + ] + const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) + const reloaded = loadExistingEntriesFromString(yaml) + + expect(reloaded.entries).toEqual(entries) + }) + test('places Closing down and Retired entries in their own sections', () => { const entries = [ { From ea81eb5821e9504bd53ef9ddc750c89688c869ee Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:27 +0000 Subject: [PATCH 04/71] Run REST anchor redirects on versioned REST pages (#64183) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/rest/components/ClientSideRedirects.tsx | 6 +++-- src/rest/lib/is-rest-path.ts | 7 ++++++ src/rest/tests/is-rest-path.ts | 28 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/rest/lib/is-rest-path.ts create mode 100644 src/rest/tests/is-rest-path.ts diff --git a/src/rest/components/ClientSideRedirects.tsx b/src/rest/components/ClientSideRedirects.tsx index bed8d7d6ac64..5fdb327f5bd8 100644 --- a/src/rest/components/ClientSideRedirects.tsx +++ b/src/rest/components/ClientSideRedirects.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react' import dynamic from 'next/dynamic' import { useRouter } from 'next/router' +import { isRestPath } from '@/rest/lib/is-rest-path' + const ClientSideRedirectExceptions = dynamic( () => import('@/rest/components/ClientSideRedirectExceptions'), { @@ -18,8 +20,8 @@ export function ClientSideRedirects() { useEffect(() => { const { hash } = window.location - // Redirect exceptions apply only under /rest. - if (hash && asPath.startsWith('/rest')) { + // Redirect exceptions apply only to REST pages, including versioned URLs. + if (hash && isRestPath(asPath)) { setLoad(true) } }, []) diff --git a/src/rest/lib/is-rest-path.ts b/src/rest/lib/is-rest-path.ts new file mode 100644 index 000000000000..477810086452 --- /dev/null +++ b/src/rest/lib/is-rest-path.ts @@ -0,0 +1,7 @@ +// Next.js strips the locale from asPath but keeps the version segment, so allow an optional +// version prefix such as /enterprise-server@3.18 before /rest. +const REST_PATH_REGEX = /^(\/[^/#?]+@[^/#?]+)?\/rest(\/|#|\?|$)/ + +export function isRestPath(asPath: string): boolean { + return REST_PATH_REGEX.test(asPath) +} diff --git a/src/rest/tests/is-rest-path.ts b/src/rest/tests/is-rest-path.ts new file mode 100644 index 000000000000..76ca3d4fdbd2 --- /dev/null +++ b/src/rest/tests/is-rest-path.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' + +import { isRestPath } from '@/rest/lib/is-rest-path' + +describe('isRestPath', () => { + test('matches unversioned REST paths', () => { + expect(isRestPath('/rest')).toBe(true) + expect(isRestPath('/rest#statuses')).toBe(true) + expect(isRestPath('/rest/repos#statuses')).toBe(true) + expect(isRestPath('/rest/repos?apiVersion=2022-11-28#statuses')).toBe(true) + }) + + test('matches versioned REST paths', () => { + expect( + isRestPath('/enterprise-cloud@latest/rest/enterprise-admin#code-security-and-analysis'), + ).toBe(true) + expect(isRestPath('/enterprise-server@3.18/rest/repos#statuses')).toBe(true) + expect(isRestPath('/free-pro-team@latest/rest/repos#deploy-keys')).toBe(true) + }) + + test('does not match non-REST paths', () => { + expect(isRestPath('/')).toBe(false) + expect(isRestPath('/enterprise-cloud@latest/admin#x')).toBe(false) + expect(isRestPath('/restricted#x')).toBe(false) + expect(isRestPath('/enterprise-server@3.18/restricted#x')).toBe(false) + expect(isRestPath('/graphql/rest#x')).toBe(false) + }) +}) From ea0970e06c4db983208638328a4ebcebf7b21397 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:38 +0000 Subject: [PATCH 05/71] Drop undefined content type from REST example labels (#64184) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/rest/lib/code-example-utils.ts | 20 +++++-------- src/rest/tests/code-example-logic.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/rest/lib/code-example-utils.ts b/src/rest/lib/code-example-utils.ts index f8fcdadddadb..e5b4c30d6fa7 100644 --- a/src/rest/lib/code-example-utils.ts +++ b/src/rest/lib/code-example-utils.ts @@ -5,7 +5,7 @@ export interface CodeExample { contentType?: string } response: { - contentType: string + contentType?: string } description: string } @@ -33,23 +33,19 @@ export function shouldShowResponseContentType(examples: CodeExample[]): boolean export function generateExampleOptions(examples: CodeExample[]): ExampleOption[] { const responseContentTypesDiffer = shouldShowResponseContentType(examples) const requestContentTypesDiffer = shouldShowRequestContentType(examples) - const showExampleOptionMediaType = responseContentTypesDiffer || requestContentTypesDiffer return examples.map((example, index) => { const requestContentType = example.request?.contentType const responseContentType = example.response.contentType - let text = example.description + // Examples without a body, such as 204 responses, have no content type to show. + const contentTypes: string[] = [] + if (requestContentTypesDiffer && requestContentType) contentTypes.push(requestContentType) + if (responseContentTypesDiffer && responseContentType) contentTypes.push(responseContentType) - if (showExampleOptionMediaType) { - if (requestContentTypesDiffer && responseContentTypesDiffer) { - text = `${example.description} (${requestContentType} → ${responseContentType})` - } else if (requestContentTypesDiffer) { - text = `${example.description} (${requestContentType})` - } else if (responseContentTypesDiffer) { - text = `${example.description} (${responseContentType})` - } - } + const text = contentTypes.length + ? `${example.description} (${contentTypes.join(' → ')})` + : example.description return { text, diff --git a/src/rest/tests/code-example-logic.ts b/src/rest/tests/code-example-logic.ts index 73717c31b17d..3788b256dd3a 100644 --- a/src/rest/tests/code-example-logic.ts +++ b/src/rest/tests/code-example-logic.ts @@ -128,6 +128,50 @@ describe('Request Content Type Logic', () => { expect(options).toEqual(['First example', 'Second example']) }) + test('omits the content type suffix for an example with no response body', () => { + const noContentExamples: CodeExample[] = [ + { + description: 'Example 1: Status Code 201', + request: { contentType: 'application/json' }, + response: { contentType: 'application/json' }, + }, + { + description: 'Example 2: Status Code 204', + request: { contentType: 'application/json' }, + response: {}, + }, + ] + + const options = generateExampleOptionTexts(noContentExamples) + + expect(options).toEqual([ + 'Example 1: Status Code 201 (application/json)', + 'Example 2: Status Code 204', + ]) + }) + + test('omits a missing request content type when request and response types differ', () => { + const mixedExamples: CodeExample[] = [ + { + description: 'JSON request', + request: { contentType: 'application/json' }, + response: { contentType: 'application/json' }, + }, + { + description: 'No request body', + request: {}, + response: { contentType: 'text/html' }, + }, + ] + + const options = generateExampleOptionTexts(mixedExamples) + + expect(options).toEqual([ + 'JSON request (application/json → application/json)', + 'No request body (text/html)', + ]) + }) + test('handles single example correctly', () => { const singleExample: CodeExample[] = [ { From 6325fb6e662c593890e502a9aae05a4ad5fe14f1 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:45 +0000 Subject: [PATCH 06/71] Show Basic auth on Basic-auth REST endpoints in Article API Markdown (#64185) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../templates/rest-page.template.md | 10 ++- .../tests/rest-transformer-auth.test.ts | 84 +++++++++++++++++++ .../transformers/rest-transformer.ts | 2 + 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/article-api/tests/rest-transformer-auth.test.ts diff --git a/src/article-api/templates/rest-page.template.md b/src/article-api/templates/rest-page.template.md index 754c7c2598b5..da5372bc577f 100644 --- a/src/article-api/templates/rest-page.template.md +++ b/src/article-api/templates/rest-page.template.md @@ -17,6 +17,13 @@ {{ operation.description }} +{% if operation.basicAuth %} + +> [!NOTE] +> This endpoint requires [Basic authentication](/rest/authentication/authenticating-to-the-rest-api#using-basic-authentication). Use the application's `client_id` as the username and the `client_secret` as the password. Do not send an `Authorization: Bearer` header with this request, because it replaces the Basic credentials. + +{% endif %} + {% if operation.hasParameters %} ### Parameters @@ -84,7 +91,8 @@ ```curl curl -L \ -X {{ operation.verb | upcase }} \ - {{ example.request.url }}{% if example.request.acceptHeader and example.request.acceptHeader != 'application/vnd.github+json' and example.request.acceptHeader != 'application/vnd.github.v3+json' %} \ + {{ example.request.url }}{% if operation.basicAuth %} \ + -u ":"{% endif %}{% if example.request.acceptHeader and example.request.acceptHeader != 'application/vnd.github+json' and example.request.acceptHeader != 'application/vnd.github.v3+json' %} \ -H "Accept: {{ example.request.acceptHeader }}"{% endif %}{% if example.request.needsContentTypeHeader %} \ -H "Content-Type: {{ example.request.contentType }}"{% endif %}{% if example.request.bodyParameters.size > 0 %}{% for bodyParameter in example.request.bodyParameters %} \ {{ bodyParameter }}{% endfor %}{% endif %} diff --git a/src/article-api/tests/rest-transformer-auth.test.ts b/src/article-api/tests/rest-transformer-auth.test.ts new file mode 100644 index 000000000000..10961ef31086 --- /dev/null +++ b/src/article-api/tests/rest-transformer-auth.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, vi } from 'vitest' + +import { RestTransformer } from '@/article-api/transformers/rest-transformer' +import getRest from '@/rest/lib/index' +import { allVersions } from '@/versions/lib/all-versions' +import type { CodeSample, Operation } from '@/rest/components/types' +import type { Context, Page } from '@/types' + +vi.mock('@/rest/lib/index', () => ({ default: vi.fn() })) + +const stubPage = { + autogenerated: 'rest', + relativePath: 'rest/apps/oauth-applications.md', + title: 'OAuth authorizations', + intro: '', + markdown: '', + renderProp: vi.fn().mockResolvedValue(''), +} as unknown as Page + +const context = { + currentVersion: 'free-pro-team@latest', + currentVersionObj: allVersions['free-pro-team@latest'], + allVersions, +} as unknown as Context + +const makeOperation = (title: string, requestPath: string, basicAuth: boolean): Operation => + ({ + serverUrl: 'https://api.github.com', + verb: 'post', + title, + requestPath, + descriptionHTML: '', + category: 'apps', + subcategory: 'oauth-applications', + statusCodes: [], + bodyParameters: [], + parameters: [], + progAccess: { permissions: [], ...(basicAuth ? { basicAuth: true } : {}) }, + codeExamples: [ + { + request: { description: 'Example', acceptHeader: 'application/vnd.github.v3+json' }, + response: { statusCode: '200' }, + } as unknown as CodeSample, + ], + }) as unknown as Operation + +async function renderSections() { + vi.mocked(getRest).mockResolvedValue({ + 'oauth-applications': [ + makeOperation('Check a token', '/applications/CLIENT_ID/token', true), + makeOperation('Create a thing', '/things', false), + ], + } as never) + const body = await new RestTransformer().transform( + stubPage, + '/en/rest/apps/oauth-applications', + context, + ) + const [, basicAuthSection, otherSection] = body.split(/^## /m) + return { basicAuthSection, otherSection } +} + +describe('RestTransformer basic authentication', () => { + test('adds Basic auth credentials to the curl example for basicAuth operations', async () => { + const { basicAuthSection, otherSection } = await renderSections() + + expect(basicAuthSection).toContain( + 'https://api.github.com/applications/CLIENT_ID/token \\\n -u ":"', + ) + expect(otherSection).not.toContain('-u "') + }) + + test('adds a Basic authentication note to basicAuth operations', async () => { + const { basicAuthSection, otherSection } = await renderSections() + + expect(basicAuthSection).toContain('Basic authentication') + expect(basicAuthSection).toContain('`client_id`') + expect(basicAuthSection).toContain('`client_secret`') + expect(basicAuthSection).toContain('Do not send an `Authorization: Bearer` header') + expect(otherSection).not.toContain('Do not send an `Authorization: Bearer` header') + expect(otherSection).not.toContain('Basic authentication') + expect(otherSection).not.toContain('client_secret') + }) +}) diff --git a/src/article-api/transformers/rest-transformer.ts b/src/article-api/transformers/rest-transformer.ts index 8f0a22315896..8d5aeacdae2c 100644 --- a/src/article-api/transformers/rest-transformer.ts +++ b/src/article-api/transformers/rest-transformer.ts @@ -46,6 +46,7 @@ type PreparedOperation = Omit & { hasParameters: boolean showHeaders: boolean needsContentTypeHeader: boolean + basicAuth: boolean statusCodes?: Array<{ httpStatusCode: string; description?: string }> codeExamples: PreparedCodeExample[] } @@ -262,6 +263,7 @@ export class RestTransformer implements PageTransformer { hasParameters, showHeaders, needsContentTypeHeader, + basicAuth: Boolean(operation.progAccess?.basicAuth), statusCodes, codeExamples, } From 0233863f5c28677d8593dc53b1e09438711fceef Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:52 +0000 Subject: [PATCH 07/71] Indent every line of REST parameter descriptions in markdown output (#64186) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/article-api/liquid-renderers/rest-tags.ts | 14 +++- src/article-api/tests/rest-tags.test.ts | 83 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/article-api/tests/rest-tags.test.ts diff --git a/src/article-api/liquid-renderers/rest-tags.ts b/src/article-api/liquid-renderers/rest-tags.ts index b7fc29a222c2..464fac7a7ff4 100644 --- a/src/article-api/liquid-renderers/rest-tags.ts +++ b/src/article-api/liquid-renderers/rest-tags.ts @@ -37,7 +37,7 @@ export class RestParameter { if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(` ${description}`) + lines.push(indentLines(description, ' ')) } if (param.schema?.default !== undefined) { @@ -87,7 +87,7 @@ export class RestBodyParameter { if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(`${prefix} ${description}`) + lines.push(indentLines(description, `${prefix} `)) } if (param.default !== undefined) { @@ -160,7 +160,7 @@ async function renderChildParameter( if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(`${prefix} ${description}`) + lines.push(indentLines(description, `${prefix} `)) } if (param.default !== undefined) { @@ -180,6 +180,14 @@ async function renderChildParameter( return lines.join('\n') } +// Description text keeps newlines between blocks, and a column-0 line after a blank line ends the list item. +function indentLines(text: string, indent: string): string { + return text + .split('\n') + .map((line) => (line.trim() ? `${indent}${line}` : '')) + .join('\n') +} + async function htmlToMarkdown(html: string, context: Context): Promise { if (!html) return '' diff --git a/src/article-api/tests/rest-tags.test.ts b/src/article-api/tests/rest-tags.test.ts new file mode 100644 index 000000000000..3081e7f62c77 --- /dev/null +++ b/src/article-api/tests/rest-tags.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest' +import { Liquid } from 'liquidjs' +import { fromMarkdown } from 'mdast-util-from-markdown' +import type { List } from 'mdast' + +import { restTags } from '@/article-api/liquid-renderers/rest-tags' + +const engine = new Liquid() +for (const [tagName, tagClass] of Object.entries(restTags)) { + engine.registerTag(tagName, tagClass as unknown as Parameters[1]) +} + +const multiLineDescription = + '

Intro.

\n
    \n
  • a: first value
  • \n
  • b: second value
  • \n
\n

Closing note.

' + +async function render(template: string, param: unknown): Promise { + return engine.parseAndRender(template, { param, context: {} }) +} + +function linesAfterFirst(output: string): string[] { + return output + .split('\n') + .slice(1) + .filter((line) => line.trim() !== '') +} + +describe('REST Liquid tags with multi-line descriptions', () => { + test('rest_parameter indents every description line under the list item', async () => { + const output = await render('{% rest_parameter param %}', { + name: 'scope', + in: 'query', + required: false, + description: multiLineDescription, + schema: { type: 'string', enum: ['a', 'b'] }, + }) + + expect(output).toContain('a: first value') + expect(output).toContain('Closing note.') + for (const line of linesAfterFirst(output)) { + expect(line).toMatch(/^ {2}\S/) + } + + const tree = fromMarkdown(output) + expect(tree.children.map((node) => node.type)).toEqual(['list']) + }) + + test('rest_body_parameter keeps child parameters nested under their parent', async () => { + const output = await render('{% rest_body_parameter param %}', { + name: 'budget_scope', + type: 'object', + isRequired: true, + description: multiLineDescription, + enum: ['a', 'b'], + childParamsGroups: [ + { + name: 'value', + type: 'string', + description: multiLineDescription, + }, + ], + }) + + const lines = output.split('\n') + const childIndex = lines.findIndex((line) => line.includes('**`value`**')) + expect(childIndex).toBeGreaterThan(0) + for (const line of lines.slice(1, childIndex).filter((l) => l.trim() !== '')) { + expect(line).toMatch(/^ {2}\S/) + } + expect(lines[childIndex]).toMatch(/^ {2}- /) + for (const line of lines.slice(childIndex + 1).filter((l) => l.trim() !== '')) { + expect(line).toMatch(/^ {4}\S/) + } + + const tree = fromMarkdown(output) + expect(tree.children.map((node) => node.type)).toEqual(['list']) + const topList = tree.children[0] as List + expect(topList.children).toHaveLength(1) + const nestedLists = topList.children[0].children.filter( + (node): node is List => node.type === 'list' && node.children.length === 1, + ) + expect(nestedLists.some((list) => JSON.stringify(list).includes('"value":"value"'))).toBe(true) + }) +}) From a22af715bcbf65b65b004c42bfebb7b00ded45cd Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 20:31:58 +0000 Subject: [PATCH 08/71] Fix reusables CLI crash when the repo path has a space (#64189) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/content-render/scripts/reusables-cli/shared.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index 454e0477159e..6d36e1ffb53c 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -1,10 +1,11 @@ import walk from 'walk-sync' import path from 'path' +import { fileURLToPath } from 'url' import { TokenizationError, TokenKind } from 'liquidjs' import type { TagToken } from 'liquidjs' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(__dirname, '../../../../') const contentDirectory = path.resolve(__dirname, repoRoot, 'content/') From 596d3984f243cf07c0f15b7e3a5a594d838f6d65 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:22 +0000 Subject: [PATCH 09/71] Render Liquid in headings before mapping English heading IDs (#63928) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../unified/use-english-headings.ts | 9 +- src/frame/lib/page.ts | 2 +- src/languages/lib/get-english-headings.ts | 36 +++++-- src/languages/tests/get-english-headings.ts | 94 +++++++++++++++++-- 4 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/content-render/unified/use-english-headings.ts b/src/content-render/unified/use-english-headings.ts index aef89ec7333a..7035f9a3f162 100644 --- a/src/content-render/unified/use-english-headings.ts +++ b/src/content-render/unified/use-english-headings.ts @@ -3,8 +3,6 @@ import { toString } from 'hast-util-to-string' import { visit } from 'unist-util-visit' import type { Element, Root } from 'hast' -const slugger = new GithubSlugger() - interface UseEnglishHeadingsOptions { englishHeadings?: Record } @@ -13,11 +11,14 @@ interface UseEnglishHeadingsOptions { export default function useEnglishHeadings({ englishHeadings }: UseEnglishHeadingsOptions) { if (!englishHeadings) return return (tree: Root) => { + // One slugger per document gives repeated headings the same suffixes as the English page. + const slugger = new GithubSlugger() visit(tree, 'element', (node: Element) => { if (!['h2', 'h3', 'h4'].includes(node.tagName)) return - slugger.reset() const text: string = toString(node) - const englishHeading: string = englishHeadings[text] + const englishHeading: string | undefined = englishHeadings[text] + // Unmatched headings would otherwise register an empty slug and shift later suffixes. + if (!englishHeading) return const englishSlug: string = slugger.slug(englishHeading) if (englishSlug) { // Leave the existing ID when no English slug exists. diff --git a/src/frame/lib/page.ts b/src/frame/lib/page.ts index b8aa960970ff..77fd93f680bb 100644 --- a/src/frame/lib/page.ts +++ b/src/frame/lib/page.ts @@ -286,7 +286,7 @@ class Page { private async _render(context: Context): Promise { // English heading IDs keep translated links stable. if (this.languageCode !== 'en') { - const englishHeadings = getEnglishHeadings(this, context) + const englishHeadings = await getEnglishHeadings(this, context) context.englishHeadings = englishHeadings } diff --git a/src/languages/lib/get-english-headings.ts b/src/languages/lib/get-english-headings.ts index 0faf7839bf8b..935d0a41d49d 100644 --- a/src/languages/lib/get-english-headings.ts +++ b/src/languages/lib/get-english-headings.ts @@ -3,6 +3,7 @@ import { toString } from 'mdast-util-to-string' import { visit } from 'unist-util-visit' import type { Node } from 'unist' +import { renderLiquid } from '@/content-render/liquid/index' import findPage from '@/frame/lib/find-page' import Permalink from '@/frame/lib/permalink' import { trailingSlash } from '@/frame/lib/patterns' @@ -24,10 +25,10 @@ interface GlossaryTerm { slug: string } -export default function getEnglishHeadings( +export default async function getEnglishHeadings( page: PageWithMarkdown, context: Context, -): Record | undefined { +): Promise | undefined> { // Glossary headings come from data, not page markdown. if (page.relativePath.endsWith('/github-glossary.md')) { const languageGlossary = getDataByLanguage('glossaries.external', 'en') as GlossaryTerm[] @@ -37,7 +38,7 @@ export default function getEnglishHeadings( }, {}) } - const translatedHeadings = getHeadings(page.markdown) + const translatedHeadings = await getHeadings(page.markdown, context) if (!translatedHeadings.length) return // Page keys drop the trailing slash that relativePathToSuffix can leave. @@ -49,7 +50,10 @@ export default function getEnglishHeadings( if (!englishPage) return // English heading changes can misalign with unsynced translations. - const englishHeadings = getHeadings(englishPage.markdown) + const englishHeadings = await getHeadings(englishPage.markdown, { + ...context, + currentLanguage: 'en', + }) if (!englishHeadings.length) return const headingMap: Record = {} @@ -60,16 +64,32 @@ export default function getEnglishHeadings( return headingMap } -function getHeadings(markdown: string): string[] { +// Keys and values must match the heading text after Liquid renders, which is what the page shows. +async function getHeadings(markdown: string, context: Context): Promise { const ast = fromMarkdown(markdown) - const headings: string[] = [] + const headingNodes: HeadingNode[] = [] visit(ast, (node: Node) => { if (node.type !== 'heading') return const headingNode = node as HeadingNode if (![2, 3, 4].includes(headingNode.depth)) return - headings.push(toString(node)) + headingNodes.push(headingNode) }) - return headings + return Promise.all(headingNodes.map((node) => renderHeading(node, markdown, context))) +} + +async function renderHeading(node: HeadingNode, markdown: string, context: Context) { + const text = toString(node) + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) return text + const source = markdown.slice(start, end) + if (!source.includes('{%') && !source.includes('{{')) return text + try { + return toString(fromMarkdown(await renderLiquid(source, context))) + } catch { + // A heading can hold half of a Liquid block that only parses as part of the whole page. + return text + } } diff --git a/src/languages/tests/get-english-headings.ts b/src/languages/tests/get-english-headings.ts index 565b533c0467..25a053f72137 100644 --- a/src/languages/tests/get-english-headings.ts +++ b/src/languages/tests/get-english-headings.ts @@ -1,8 +1,18 @@ import { describe, expect, test } from 'vitest' +import { renderContent } from '@/content-render/index' import getEnglishHeadings from '@/languages/lib/get-english-headings' import type { Context, Page } from '@/types' +function makeContext(englishMarkdown: string): Context { + return { + currentLanguage: 'en', + currentVersion: 'free-pro-team@latest', + pages: { '/en/foo/bar': { markdown: englishMarkdown } as unknown as Page }, + redirects: {}, + } as Context +} + const pages = { '/en': { relativePath: 'index.md', @@ -18,21 +28,91 @@ const pages = { }, } as unknown as Record -const context = { pages, redirects: {} } as Context +const indexContext = { pages, redirects: {} } as Context describe('getEnglishHeadings', () => { - test('maps headings on index.md pages', () => { + test('maps headings on index.md pages', async () => { const page = { relativePath: 'rest/git/index.md', markdown: '## 見出し' } - expect(getEnglishHeadings(page, context)).toEqual({ 見出し: 'About Git database' }) + expect(await getEnglishHeadings(page, indexContext)).toEqual({ 見出し: 'About Git database' }) }) - test('maps headings on non-index pages', () => { + test('maps headings on non-index pages', async () => { const page = { relativePath: 'rest/git/blobs.md', markdown: '## 見出し' } - expect(getEnglishHeadings(page, context)).toEqual({ 見出し: 'About Git blobs' }) + expect(await getEnglishHeadings(page, indexContext)).toEqual({ 見出し: 'About Git blobs' }) }) - test('maps headings on the root index.md page', () => { + test('maps headings on the root index.md page', async () => { const page = { relativePath: 'index.md', markdown: '## 見出し' } - expect(getEnglishHeadings(page, context)).toEqual({ 見出し: 'Get started' }) + expect(await getEnglishHeadings(page, indexContext)).toEqual({ 見出し: 'Get started' }) + }) + + test('renders Liquid in translated and English headings', async () => { + const page = { + relativePath: 'foo/bar.md', + markdown: [ + '## {% data variables.product.prodname_dependabot_alerts %} の有効化', + '## {% data variables.product.prodname_actions %} について', + '## Plain & simple', + '## Acerca de Copilot', + ].join('\n\n'), + } + const context = makeContext( + [ + '## Enabling {% data variables.product.prodname_dependabot_alerts %}', + '## About {% data variables.product.prodname_actions %}', + '## Plain and simple', + '## About {% data variables.product.prodname_copilot %}', + ].join('\n\n'), + ) + const headings = await getEnglishHeadings(page, context) + expect(headings).toEqual({ + 'Dependabot alerts の有効化': 'Enabling Dependabot alerts', + 'GitHub Actions について': 'About GitHub Actions', + 'Plain & simple': 'Plain and simple', + 'Acerca de Copilot': 'About GitHub Copilot', + }) + }) + + test('falls back to the raw heading text when Liquid fails to render', async () => { + const page = { relativePath: 'foo/bar.md', markdown: '## Roto {% ifversion fpt %}' } + const context = makeContext('## Broken {% ifversion fpt %}') + const headings = await getEnglishHeadings(page, context) + expect(headings).toEqual({ 'Roto {% ifversion fpt %}': 'Broken {% ifversion fpt %}' }) + }) + + test('gives translated headings with Liquid their English IDs', async () => { + const page = { + relativePath: 'foo/bar.md', + markdown: '## {% data variables.product.prodname_dependabot_alerts %} の有効化', + } + const context = makeContext( + '## Enabling {% data variables.product.prodname_dependabot_alerts %}', + ) + context.englishHeadings = await getEnglishHeadings(page, context) + const html = await renderContent(page.markdown, context) + expect(html).toContain('id="enabling-dependabot-alerts"') + }) + + test('keeps English suffixes for repeated translated headings with Liquid', async () => { + const page = { + relativePath: 'foo/bar.md', + markdown: [ + '## {% data variables.product.prodname_actions %} の使用', + '## 概要', + '## {% data variables.product.prodname_actions %} の使用', + ].join('\n\n'), + } + const context = makeContext( + [ + '## Using {% data variables.product.prodname_actions %}', + '## Overview', + '## Using {% data variables.product.prodname_actions %}', + ].join('\n\n'), + ) + context.englishHeadings = await getEnglishHeadings(page, context) + const html = await renderContent(page.markdown, context) + expect(html).toContain('id="using-github-actions"') + expect(html).toContain('id="overview"') + expect(html).toContain('id="using-github-actions-1"') }) }) From 0fe63e03a68f574335819a7efc311a709e6f2b59 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:29 +0000 Subject: [PATCH 10/71] Validate the Copilot model comparison table against its schema (#64153) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Hector A. Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/data-directory/lib/data-schemas/index.ts | 3 ++ .../tables/copilot/model-comparison.ts | 49 ++++++++----------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/data-directory/lib/data-schemas/index.ts b/src/data-directory/lib/data-schemas/index.ts index 8d7b75ce9e60..871ce19bab99 100644 --- a/src/data-directory/lib/data-schemas/index.ts +++ b/src/data-directory/lib/data-schemas/index.ts @@ -55,6 +55,9 @@ const manualSchemas: DataSchemas = { 'data/tables/copilot/model-supported-plans.yml': resolveSchemaPath( 'tables/copilot/model-supported-plans.ts', ), + 'data/tables/copilot/model-comparison.yml': resolveSchemaPath( + 'tables/copilot/model-comparison.ts', + ), 'data/tables/copilot/auto-model-selection.yml': resolveSchemaPath( 'tables/copilot/auto-model-selection.ts', ), diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts index 6189c00e9ff6..f768c02e6aba 100644 --- a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts +++ b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts @@ -1,32 +1,25 @@ const modelComparisonSchema = { - type: 'object', - additionalProperties: false, - required: ['models'], - properties: { - models: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['name', 'task_area', 'excels_at', 'further_reading'], - properties: { - name: { - type: 'string', - lintable: true, - }, - task_area: { - type: 'string', - lintable: true, - }, - excels_at: { - type: 'string', - lintable: true, - }, - further_reading: { - type: 'string', - lintable: true, - }, - }, + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'task_area', 'excels_at', 'further_reading'], + properties: { + name: { + type: 'string', + lintable: true, + }, + task_area: { + type: 'string', + lintable: true, + }, + excels_at: { + type: 'string', + lintable: true, + }, + further_reading: { + type: 'string', + lintable: true, }, }, }, From 7b1cf855aee6912db2a67f534c130f2c7199174d Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:35 +0000 Subject: [PATCH 11/71] Show types for oneOf path and query parameters in REST docs (#64187) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/article-api/liquid-renderers/rest-tags.ts | 4 +- src/rest/scripts/utils/operation.ts | 7 +-- src/rest/scripts/utils/parameter-schema.ts | 45 +++++++++++++++ src/rest/tests/parameter-schema.ts | 56 +++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/rest/scripts/utils/parameter-schema.ts create mode 100644 src/rest/tests/parameter-schema.ts diff --git a/src/article-api/liquid-renderers/rest-tags.ts b/src/article-api/liquid-renderers/rest-tags.ts index 464fac7a7ff4..38ca91d86af6 100644 --- a/src/article-api/liquid-renderers/rest-tags.ts +++ b/src/article-api/liquid-renderers/rest-tags.ts @@ -31,7 +31,9 @@ export class RestParameter { const lines: string[] = [] const required = param.required ? ' (required)' : '' - const type = param.schema?.type || 'string' + const type = Array.isArray(param.schema?.type) + ? param.schema.type.join(' or ') + : param.schema?.type || 'string' lines.push(`- **\`${param.name}\`** (${type})${required}`) diff --git a/src/rest/scripts/utils/operation.ts b/src/rest/scripts/utils/operation.ts index 0b56297e87d7..e06c4b18ad12 100644 --- a/src/rest/scripts/utils/operation.ts +++ b/src/rest/scripts/utils/operation.ts @@ -9,6 +9,7 @@ import getCodeSamples from './create-rest-examples' import operationSchema from './operation-schema' import { validateJson } from '@/tests/lib/validate-json-schema' import { getBodyParams, type TransformedParam } from './get-body-params' +import { simplifyParameterSchema } from './parameter-schema' import type { OpenApiOperation, OpenApiParameter, OpenApiServer } from './openapi-types' import type { MergedExample } from './create-rest-examples' @@ -155,12 +156,8 @@ export default class Operation { delete param.example delete param.examples delete param['x-multi-segment'] - // Keep only parameter schema subfields that renderers consume: type, default, and enum. if (param.schema && typeof param.schema === 'object') { - const { type, default: defaultVal, enum: enumVal } = param.schema - param.schema = { type } - if (defaultVal !== undefined) param.schema.default = defaultVal - if (enumVal !== undefined) param.schema.enum = enumVal + param.schema = simplifyParameterSchema(param.schema) } return param }), diff --git a/src/rest/scripts/utils/parameter-schema.ts b/src/rest/scripts/utils/parameter-schema.ts new file mode 100644 index 000000000000..4458df15e1cb --- /dev/null +++ b/src/rest/scripts/utils/parameter-schema.ts @@ -0,0 +1,45 @@ +import type { OpenApiParameterSchema } from './openapi-types' + +interface SchemaVariant { + type?: string | string[] + enum?: unknown[] + items?: { type?: unknown; enum?: unknown[] } +} + +// Keep only the schema fields that parameter renderers read: type, default, and enum. +// Multi-type parameters use oneOf or anyOf with no top-level type, so their type +// and allowed values come from the variants instead. +export function simplifyParameterSchema(schema: OpenApiParameterSchema): OpenApiParameterSchema { + const { type, default: defaultVal } = schema + let enumVal = schema.enum + const simplified: OpenApiParameterSchema = { type } + + const variants = (schema.oneOf ?? schema.anyOf) as SchemaVariant[] | undefined + if (type === undefined && Array.isArray(variants)) { + const types = new Set() + const enums = new Set() + let everyVariantRestricted = variants.length > 0 + for (const variant of variants) { + const variantTypes = Array.isArray(variant.type) ? variant.type : [variant.type] + for (const variantType of variantTypes) { + if (variantType === undefined) continue + // Match the "array of s" wording that body parameters use. + types.add( + variantType === 'array' && typeof variant.items?.type === 'string' + ? `array of ${variant.items.type}s` + : variantType, + ) + } + const variantEnum = variant.enum ?? variant.items?.enum + if (!variantEnum?.length) everyVariantRestricted = false + for (const value of variantEnum ?? []) enums.add(value) + } + if (types.size > 0) simplified.type = types.size === 1 ? [...types][0] : [...types] + // A variant without an enum accepts any value, so a combined list would wrongly narrow the parameter. + if (enumVal === undefined && everyVariantRestricted) enumVal = [...enums] + } + + if (defaultVal !== undefined) simplified.default = defaultVal + if (enumVal !== undefined) simplified.enum = enumVal + return simplified +} diff --git a/src/rest/tests/parameter-schema.ts b/src/rest/tests/parameter-schema.ts new file mode 100644 index 000000000000..643cdc646028 --- /dev/null +++ b/src/rest/tests/parameter-schema.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest' + +import { simplifyParameterSchema } from '@/rest/scripts/utils/parameter-schema' + +describe('simplifyParameterSchema', () => { + test('keeps only type, default, and enum for a typed schema', () => { + expect( + simplifyParameterSchema({ + type: 'string', + default: 'open', + enum: ['open', 'closed'], + example: 'open', + }), + ).toEqual({ type: 'string', default: 'open', enum: ['open', 'closed'] }) + }) + + test('derives the type from oneOf variants', () => { + expect( + simplifyParameterSchema({ + oneOf: [{ type: 'integer' }, { type: 'string' }], + }), + ).toEqual({ type: ['integer', 'string'] }) + }) + + test('describes array variants by item type and omits enum when a variant is unrestricted', () => { + expect( + simplifyParameterSchema({ + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string', enum: ['patch'] } }], + }), + ).toEqual({ type: ['string', 'array of strings'] }) + }) + + test('combines enum values when every variant is restricted', () => { + expect( + simplifyParameterSchema({ + oneOf: [ + { type: 'string', enum: ['patch'] }, + { type: 'array', items: { type: 'string', enum: ['patch', 'deployment'] } }, + ], + }), + ).toEqual({ type: ['string', 'array of strings'], enum: ['patch', 'deployment'] }) + }) + + test('derives the type from anyOf variants and drops duplicate types', () => { + expect( + simplifyParameterSchema({ + anyOf: [{ type: 'string' }, { type: 'string', enum: ['all'] }], + default: 'all', + }), + ).toEqual({ type: 'string', default: 'all' }) + }) + + test('leaves the type undefined for an empty schema', () => { + expect(simplifyParameterSchema({})).toEqual({ type: undefined }) + }) +}) From 40d586b705696e85ca43b6015deff02a0900a5aa Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:41 +0000 Subject: [PATCH 12/71] Count indented_data_reference uses in reusables find used and top-used (#64190) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../scripts/reusables-cli/README.md | 2 +- .../scripts/reusables-cli/find/used.ts | 6 ++-- .../scripts/reusables-cli/shared.ts | 5 ++- src/content-render/tests/reusables-cli.ts | 31 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/content-render/tests/reusables-cli.ts diff --git a/src/content-render/scripts/reusables-cli/README.md b/src/content-render/scripts/reusables-cli/README.md index eae841f38b02..75dd77bf6473 100644 --- a/src/content-render/scripts/reusables-cli/README.md +++ b/src/content-render/scripts/reusables-cli/README.md @@ -20,7 +20,7 @@ Helps find where reusables are already used, and where they could be used. ### Command: `npm run reusables -- find used ` -Find where a specific reusable is used +Find where a specific reusable is used. This counts both `{% data %}` and `{% indented_data_reference %}` tags. #### Example diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts index 23e44a37d38f..1578e99d2811 100644 --- a/src/content-render/scripts/reusables-cli/find/used.ts +++ b/src/content-render/scripts/reusables-cli/find/used.ts @@ -57,8 +57,10 @@ export function findTopUsed(numberOfMostUsedToFind: number, { absolute }: { abso ) for (const token of liquidTokens) { const { args, name } = token - if (name === 'data' && args.startsWith('reusables.')) { - reusableCounts.set(args, (reusableCounts.get(args) || 0) + 1) + if (name !== 'data' && name !== 'indented_data_reference') continue + const key = args.trim().split(/\s+/)[0] + if (key.startsWith('reusables.')) { + reusableCounts.set(key, (reusableCounts.get(key) || 0) + 1) } } } diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index 6d36e1ffb53c..5f30e7a3972e 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -61,7 +61,10 @@ export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents: (token): token is TagToken => token.kind === TokenKind.Tag, ) for (const token of tokens) { - if (token.name === 'data' && token.args.trim() === liquidVariable) { + if ( + (token.name === 'data' || token.name === 'indented_data_reference') && + token.args.trim().split(/\s+/)[0] === liquidVariable + ) { indices.push(token.begin) } } diff --git a/src/content-render/tests/reusables-cli.ts b/src/content-render/tests/reusables-cli.ts new file mode 100644 index 000000000000..3258788a03e1 --- /dev/null +++ b/src/content-render/tests/reusables-cli.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' + +import { getIndicesOfLiquidVariable } from '@/content-render/scripts/reusables-cli/shared' + +describe('getIndicesOfLiquidVariable', () => { + test('finds data and indented_data_reference tags', () => { + const contents = [ + '{% data reusables.foo.bar %}', + '1. Step', + ' {% indented_data_reference reusables.foo.bar spaces=3 %}', + ].join('\n') + const indices = getIndicesOfLiquidVariable('reusables.foo.bar', contents) + expect(indices).toEqual([0, contents.indexOf('{% indented_data_reference')]) + }) + + test('does not match a reusable that shares a prefix', () => { + const contents = [ + '{% data reusables.foo.barbaz %}', + '{% indented_data_reference reusables.foo.barbaz spaces=3 %}', + ].join('\n') + expect(getIndicesOfLiquidVariable('reusables.foo.bar', contents)).toEqual([]) + }) + + test('ignores other tags', () => { + const contents = [ + '{% octicon "check" aria-label="reusables.foo.bar" %}', + '{% other_tag reusables.foo.bar spaces=3 %}', + ].join('\n') + expect(getIndicesOfLiquidVariable('reusables.foo.bar', contents)).toEqual([]) + }) +}) From 0a0a880e70c6c0f487b88d06f0af642b7d7dc6b1 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:48 +0000 Subject: [PATCH 13/71] Redirect /index.md to the homepage instead of an empty Location (#64192) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/fixtures/tests/bad-urls.ts | 2 +- .../middleware/handle-invalid-paths.ts | 4 ++- src/shielding/tests/handle-invalid-paths.ts | 32 +++++++++++++++++++ src/shielding/tests/shielding.ts | 12 +++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/shielding/tests/handle-invalid-paths.ts diff --git a/src/fixtures/tests/bad-urls.ts b/src/fixtures/tests/bad-urls.ts index eefeeb7f879b..1eb7950e4396 100644 --- a/src/fixtures/tests/bad-urls.ts +++ b/src/fixtures/tests/bad-urls.ts @@ -8,7 +8,7 @@ describe('bad URLs', () => { for (const url of URLs) { const res = await head(url) expect(res.statusCode).toBe(302) - expect(res.headers.location).toBe(url.replace(/\/index\.md$/, '')) + expect(res.headers.location).toBe(url.replace(/\/index\.md$/, '') || '/') expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) } diff --git a/src/shielding/middleware/handle-invalid-paths.ts b/src/shielding/middleware/handle-invalid-paths.ts index 6988c6f4adba..43ac8c7a2a5e 100644 --- a/src/shielding/middleware/handle-invalid-paths.ts +++ b/src/shielding/middleware/handle-invalid-paths.ts @@ -78,7 +78,9 @@ export default function handleInvalidPaths( if (req.path.endsWith('/index.md')) { defaultCacheControl(res) - const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, '')) + // An empty path for /index.md would make the Location resolve back to /index.md. + const newPath = req.path.replace(/\/index\.md$/, '') || '/' + const newUrl = req.originalUrl.replace(req.path, newPath) return res.safeRedirect(newUrl) } diff --git a/src/shielding/tests/handle-invalid-paths.ts b/src/shielding/tests/handle-invalid-paths.ts new file mode 100644 index 000000000000..2da489598124 --- /dev/null +++ b/src/shielding/tests/handle-invalid-paths.ts @@ -0,0 +1,32 @@ +import type { NextFunction, Response } from 'express' +import { describe, expect, test, vi } from 'vitest' + +import handleInvalidPaths from '@/shielding/middleware/handle-invalid-paths' +import type { ExtendedRequest } from '@/types' + +describe('handleInvalidPaths /index.md redirects', () => { + test.each([ + ['/index.md', '/index.md', '/'], + ['/index.md', '/index.md?foo=bar', '/?foo=bar'], + ['/en/index.md', '/en/index.md', '/en'], + ['/get-started/index.md', '/get-started/index.md?foo=bar', '/get-started?foo=bar'], + ])('%s with URL %s redirects to %s', (path, originalUrl, expected) => { + const req = { path, originalUrl } as ExtendedRequest + const res = { + headers: {} as Record, + hasHeader: vi.fn(() => false), + set(name: string, value: string) { + res.headers[name] = value + return res + }, + safeRedirect: vi.fn(), + } + const next = vi.fn() as NextFunction + + handleInvalidPaths(req, res as unknown as Response, next) + + expect(next).not.toHaveBeenCalled() + expect(res.safeRedirect).toHaveBeenCalledWith(expected) + expect(res.headers['cache-control']).toMatch('public') + }) +}) diff --git a/src/shielding/tests/shielding.ts b/src/shielding/tests/shielding.ts index 43d297158c67..5d78bac64cb7 100644 --- a/src/shielding/tests/shielding.ts +++ b/src/shielding/tests/shielding.ts @@ -89,6 +89,18 @@ describe('index.md and .md suffixes', () => { expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/get-started?foo=bar') } + // Root + { + const res = await get('/index.md') + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/') + } + // Root with query string + { + const res = await get('/index.md?foo=bar') + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/?foo=bar') + } }) test('any URL that ends with .md serves markdown directly', async () => { From 20bae77517ba3f7477622ff49f74417d1a7ea48f Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:03:54 +0000 Subject: [PATCH 14/71] Send external search analytics without blocking the search (#64196) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../lib/helpers/external-search-analytics.ts | 13 +++++++++-- src/search/tests/external-search-analytics.ts | 22 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/search/lib/helpers/external-search-analytics.ts b/src/search/lib/helpers/external-search-analytics.ts index bdbbafc28d70..5c39fc8cd266 100644 --- a/src/search/lib/helpers/external-search-analytics.ts +++ b/src/search/lib/helpers/external-search-analytics.ts @@ -1,5 +1,5 @@ import { ExtendedRequest } from '@/types' -import { publish } from '@/events/lib/hydro' +import { publish, type EventT } from '@/events/lib/hydro' import { hydroNames } from '@/events/lib/schema' import { createLogger } from '@/observability/logger' @@ -77,7 +77,8 @@ export async function handleExternalSearchAnalytics( }, } - await publish(analyticsPayload) + // Publish in the background so Hydro latency never delays the search response. + sendAnalytics(analyticsPayload) } catch (error) { // Don't fail the request if analytics fails logger.error('Failed to send search analytics:', { error }) @@ -86,6 +87,14 @@ export async function handleExternalSearchAnalytics( return null } +async function sendAnalytics(payload: EventT) { + try { + await publish(payload) + } catch (error) { + logger.error('Failed to send search analytics:', { error }) + } +} + // Reduces a user agent down to its primary client identifier. function sanitizeUserAgent(userAgent: string | undefined): string { if (!userAgent) return 'unknown' diff --git a/src/search/tests/external-search-analytics.ts b/src/search/tests/external-search-analytics.ts index 484b60babd23..6c8676c9ffd9 100644 --- a/src/search/tests/external-search-analytics.ts +++ b/src/search/tests/external-search-analytics.ts @@ -5,7 +5,7 @@ import { handleExternalSearchAnalytics } from '@/search/lib/helpers/external-sea import type { ExtendedRequest } from '@/types' vi.mock('@/events/lib/hydro', () => ({ - publish: vi.fn(), + publish: vi.fn(() => Promise.resolve()), })) function buildRequest(headers: Record, query: Record = {}) { @@ -45,4 +45,24 @@ describe('handleExternalSearchAnalytics', () => { ) expect(publish).not.toHaveBeenCalled() }) + + test('returns without waiting for publish to finish', async () => { + vi.mocked(publish).mockReturnValueOnce(new Promise(() => {})) + const result = await handleExternalSearchAnalytics( + buildRequest({ 'user-agent': 'curl/8.0' }, { client_name: 'my-tool' }), + 'general-search', + ) + expect(result).toBeNull() + expect(publish).toHaveBeenCalledTimes(1) + }) + + test('returns null when publish rejects', async () => { + vi.mocked(publish).mockReturnValueOnce(Promise.reject(new Error('Hydro unavailable'))) + const result = await handleExternalSearchAnalytics( + buildRequest({ 'user-agent': 'curl/8.0' }, { client_name: 'my-tool' }), + 'general-search', + ) + expect(result).toBeNull() + expect(publish).toHaveBeenCalledTimes(1) + }) }) From 196ead00a1df3d1745903e11c4f1ba461f4a83fc Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:04:01 +0000 Subject: [PATCH 15/71] Run docs search on Enter after a Copilot error in search (#64197) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../tests/playwright-rendering.spec.ts | 21 +++++++++++++++++++ src/search/components/input/SearchOverlay.tsx | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index e365a1095505..a1c03b56c17a 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1053,6 +1053,27 @@ test.describe('search dialog', () => { await expect(rule).toHaveCSS('background-image', 'none') }) + test('Enter with nothing selected runs the docs search after a Copilot error', async ({ + page, + }) => { + // No upstreamStatus, so the overlay treats it as a Copilot error and shows the banner. + await page.route('**/api/ai-search/v1', (route) => route.fulfill({ status: 500, body: '{}' })) + await mockSearch(page) + await page.goto('/') + await turnOffExperimentsInPage(page) + + await page.getByTestId('toggle-search').click() + const input = page.getByTestId('overlay-search-input') + await input.fill('foo') + await expect(page.locator('#search-option-general-0')).toBeVisible() + await input.press('Enter') // nothing selected: ask Copilot + await expect(page.getByText('There was an error loading Copilot.')).toBeVisible() + + await input.fill('bar') + await input.press('Enter') + await expect(page).toHaveURL(/\/search\?.*query=bar/) + }) + test.describe('in a German-language browser', () => { test.use({ locale: 'de-DE' }) diff --git a/src/search/components/input/SearchOverlay.tsx b/src/search/components/input/SearchOverlay.tsx index 50ee754d8f02..ecb15425a9a4 100644 --- a/src/search/components/input/SearchOverlay.tsx +++ b/src/search/components/input/SearchOverlay.tsx @@ -407,6 +407,12 @@ export function SearchOverlay({ if (!urlSearchInputQuery.trim()) { return } + // Copilot stays off after an error, so Enter runs the docs search the banner offers. + if (aiSearchError) { + sendKeyboardEvent(event.key, 'view-all', searchEventGroupId, SEARCH_OVERLAY_EVENT_GROUP) + performGeneralSearch() + return + } sendKeyboardEvent(event.key, AI_SEARCH_CONTEXT, askAIEventGroupId, ASK_AI_EVENT_GROUP) aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit) return From e898cb287837084f6e44dc5f77e08de48d5eda4e Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:04:07 +0000 Subject: [PATCH 16/71] List valid version values for general search index runs (#64198) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .github/workflows/index-general-search.yml | 2 +- src/search/README.md | 2 +- src/search/scripts/index/index-cli.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/index-general-search.yml b/.github/workflows/index-general-search.yml index 6ec3b423bf42..50ef546ed3ba 100644 --- a/.github/workflows/index-general-search.yml +++ b/.github/workflows/index-general-search.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to exclusively generate the search index for. E.g. 'dotcom', 'ghes-3.12'" + description: "Version to exclusively generate the search index for: 'fpt', 'ghec', or a supported 'ghes-X.Y'. Leave empty for all." required: false default: '' languages: diff --git a/src/search/README.md b/src/search/README.md index d7e787f4cc49..a921e87dca50 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -51,7 +51,7 @@ The workflow runs are only accessible to GitHub employees using internal resourc ## Manually triggering the search index update workflow -You can manually run the workflow to generate the indexes after you push your changes to `main` to speed up the indexing when needed. It's recommended to do this for only the `free-pro-team@latest` version and the `en` language because running all languages and versions takes about 40 minutes. To run it manually, click "Run workflow" button in the Actions tab. Enter the language and version you'd like to generate the indexes for as inputs to the workflow. By default, all languages and versions are generated. +You can manually run the workflow to generate the indexes after you push your changes to `main` to speed up the indexing when needed. It's recommended to do this for only the `fpt` version and the `en` language because running all languages and versions takes about 40 minutes. To run it manually, click "Run workflow" button in the Actions tab. Enter the language and version you'd like to generate the indexes for as inputs to the workflow. By default, all languages and versions are generated. ### Build and sync diff --git a/src/search/scripts/index/index-cli.ts b/src/search/scripts/index/index-cli.ts index 92564524d8f9..4ed5ea0d2d26 100644 --- a/src/search/scripts/index/index-cli.ts +++ b/src/search/scripts/index/index-cli.ts @@ -7,6 +7,7 @@ import { languageKeys } from '@/languages/lib/languages-server' import { indexGeneralSearch } from './lib/index-general-search' import { allIndexVersionKeys, + allIndexVersionOptions, supportedAutocompletePlanVersions, versionToIndexVersionMap, } from '@/search/lib/elasticsearch-versions' @@ -25,7 +26,10 @@ const generalSearchCommand = new Command('general-search') ) .option('-v, --verbose', 'Verbose outputs') .addOption( - new Option('-V, --version [VERSION...]', 'Specific versions').choices(allVersionKeysWithAll), + new Option('-V, --version [VERSION...]', 'Specific versions').choices([ + ...allIndexVersionOptions, + 'all', + ]), ) .addOption( new Option('-l, --language ', 'Which languages to focus on').choices(languageKeys), From b8921b03d45b717462ac8b01aab77b5d4bd57511 Mon Sep 17 00:00:00 2001 From: Salil Date: Tue, 20 Oct 2026 21:30:05 +0000 Subject: [PATCH 17/71] Updating the Agentic Workflows docs (#64258) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 54a5e5f3-b258-4016-bf42-7e3ef2e50fd1 --- .../about-github-agentic-workflows.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md b/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md index e7b93f90cfda..8022260619e3 100644 --- a/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md +++ b/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md @@ -16,7 +16,7 @@ category: ## About {% data variables.copilot.agentic_workflows_short %} -{% data variables.copilot.github_agentic_workflows %} are AI-powered repository automations that you define in markdown and run as {% data variables.product.prodname_actions %} workflows. Unlike traditional automation with fixed if-then rules, {% data variables.copilot.agentic_workflows_short %} use coding agents to understand context, make decisions, and take meaningful actions—all from natural language instructions. +{% data variables.copilot.github_agentic_workflows %} are AI-powered repository automations that you define in markdown and run as {% data variables.product.prodname_actions %} workflows. Unlike traditional automation with fixed if-then rules, {% data variables.copilot.agentic_workflows_short %} use coding agents to understand context, evaluate decisions, and propose meaningful actions—all from natural language instructions. In practice, compared with traditional workflows that execute predefined steps: @@ -35,7 +35,7 @@ With {% data variables.copilot.agentic_workflows_short %}, you can automate task * **Automate repetitive repository work**. Define issue triage, CI investigation, documentation updates, and reporting in natural language. * **Reduce workflow complexity**. Write markdown instructions instead of building complex procedural scripts for every scenario. -* **Keep human review in the loop**. {% data variables.copilot.agentic_workflows_short_cap %} can generate ready-to-review outputs, such as issues, comments, and pull requests, while you control approvals and merges. +* **Keep human review in the loop**. {% data variables.copilot.agentic_workflows_short_cap %} can generate reviewable outputs, such as issues, comments, and pull requests, while you control approvals and merges. * **Run agents with layered security**. Agents run in firewalled containers with read-only tokens by default. Write actions are limited to declared "safe outputs" that you have defined, and checked by agentic threat detection. ## Requirements @@ -56,7 +56,7 @@ Each workflow markdown file has two parts: At a high level, the process to create and use {% data variables.copilot.agentic_workflows_short %} is: 1. Define the agentic workflow `.md` file, including YAML frontmatter and markdown instructions. -1. Compile the markdown workflow file into a hardened `.lock.yml` {% data variables.product.prodname_actions %} workflow file. +1. Compile the markdown workflow file into a `.lock.yml` {% data variables.product.prodname_actions %} workflow file. 1. Commit and push both files to the default branch of your repository. 1. Run the workflow like any other {% data variables.product.prodname_actions %} workflow, on a trigger or in the {% data variables.product.github %} web interface for your repository. You can also run it from the {% data variables.product.prodname_cli %}. From f1e045786674dbf4403d0ab2961f8c45553c4477 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:31:04 +0000 Subject: [PATCH 18/71] Report links to missing pages as broken in the link checker (#63946) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- ...leshooting-resource-allocation-problems.md | 2 +- src/links/lib/extract-links.ts | 36 +++++++++-- src/links/tests/extract-links.ts | 64 ++++++++++++++++++- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md b/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md index 75a63202ba2a..2a3c8d9f2fc2 100644 --- a/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md +++ b/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md @@ -22,7 +22,7 @@ category: We recommend using the monitor dashboard to stay informed on your appliance's resource health and make decisions on how to fix high usage issues, such as the ones outlined on this page. -For system-critical issues, and prior to making modifications to your appliance, we highly recommend contacting us by visiting {% data variables.contact.contact_ent_support %} and including your support bundle. For more information, see [AUTOTITLE](/enterprise/{{ currentVersion}}/support/contacting-github-support/providing-data-to-github-support#creating-and-sharing-support-bundles). +For system-critical issues, and prior to making modifications to your appliance, we highly recommend contacting us by visiting {% data variables.contact.contact_ent_support %} and including your support bundle. For more information, see [AUTOTITLE](/support/contacting-github-support/providing-data-to-github-support#creating-and-sharing-support-bundles). ## High CPU usage diff --git a/src/links/lib/extract-links.ts b/src/links/lib/extract-links.ts index 18f13f34188d..039806eea743 100644 --- a/src/links/lib/extract-links.ts +++ b/src/links/lib/extract-links.ts @@ -7,6 +7,7 @@ import { latestStable } from '@/versions/lib/enterprise-server-releases' import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path' import { getDataByLanguage } from '@/data-directory/lib/get-data' import getRedirect from '@/redirects/lib/get-redirect' +import { applyGraphqlCategoryRedirect } from '@/redirects/lib/graphql-category-redirect' import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version' import { getFeaturesByVersion } from '@/versions/middleware/features' import type { Context, Page } from '@/types' @@ -473,6 +474,28 @@ function versionedPageKey( return { key: `/${language}${withoutLanguage}`, withoutLanguage } } +// getRedirect guesses some destinations without checking pages, and the redirect middleware +// skips those when the page is missing, so an unverified target is a broken link. +function redirectTargetExists( + redirect: string, + redirectTarget: string, + pageMap: Record, + language: string, +): boolean { + if (redirect.includes('://')) return true + // The redirect middleware looks up pages without query strings, fragments, or .md. + const toLookupKey = (uri: string) => uri.split(/[?#]/)[0].replace(/\.md$/, '') + const target = toLookupKey(redirectTarget) + // Non-English runs load only their own pages, so the target must exist in the checked language. + const localizedTarget = target === '/' ? `/${language}` : `/${language}${target}` + return Boolean( + pageMap[toLookupKey(redirect)] || + pageMap[target] || + pageMap[localizedTarget] || + isArchivedVersionByPath(redirect).isArchived, + ) +} + // Resolve versionless links inside the checked version first, matching runtime rendering. // Without version context, non-FPT versionless links can fall through to fallback redirects // and look like links that need updating. @@ -572,13 +595,14 @@ export function checkInternalLink( userLanguage: 'en', pages: pageMap, } - const redirect = getRedirect(withEn, context as unknown as Context) - if (redirect) { + const resolvedRedirect = getRedirect(withEn, context as unknown as Context) + if (resolvedRedirect) { + // The redirect middleware rewrites legacy GraphQL kind URLs before it checks the target. + const redirect = applyGraphqlCategoryRedirect(resolvedRedirect) || resolvedRedirect // Strip locale prefixes to match flat-map branches; a bare locale root normalizes to /. - return { - exists: true, - isRedirect: true, - redirectTarget: redirect.replace(/^\/[a-z]{2}(-[a-z]{2})?(?=\/|$)/, '') || '/', + const redirectTarget = redirect.replace(/^\/[a-z]{2}(-[a-z]{2})?(?=\/|$)/, '') || '/' + if (redirectTargetExists(redirect, redirectTarget, pageMap, language)) { + return { exists: true, isRedirect: true, redirectTarget } } } } catch { diff --git a/src/links/tests/extract-links.ts b/src/links/tests/extract-links.ts index a47294423d65..ef2d8a046080 100644 --- a/src/links/tests/extract-links.ts +++ b/src/links/tests/extract-links.ts @@ -9,7 +9,7 @@ import { createLiquidContext, extractLinksWithLiquid, } from '../lib/extract-links' -import { latestStable } from '@/versions/lib/enterprise-server-releases' +import { latest, latestStable } from '@/versions/lib/enterprise-server-releases' import { getFeaturesByVersion } from '@/versions/middleware/features' import type { Page } from '@/types' @@ -533,6 +533,7 @@ describe('normalizeLinkPath', () => { describe('checkInternalLink', () => { const pageMap = { + '/en': {} as unknown as Page, '/en/actions/getting-started': {} as unknown as Page, '/en/repositories/overview': {} as unknown as Page, '/actions/guides': {} as unknown as Page, @@ -797,6 +798,67 @@ describe('checkInternalLink', () => { expect(result.isRedirect).toBe(true) expect(result.redirectTarget).toBe('/') }) + + test.each([ + '/admin/does-not-exist', + '/admin/guides/does-not-exist', + '/desktop/guides/does-not-exist', + '/free-pro-team@latest/does-not-exist', + '/enterprise-server/does-not-exist', + '/enterprise/user/does-not-exist', + ])('reports %s as broken when the redirect resolver target is missing', (href) => { + const result = checkInternalLink(href, pageMap, redirects) + expect(result.exists).toBe(false) + expect(result.isRedirect).toBe(false) + }) + + test('resolves an /admin/guides link through a versioned redirect to a real page', () => { + const result = checkInternalLink( + '/admin/guides/old-guide', + { [`/en/enterprise-server@${latest}/admin/new-guide`]: {} as unknown as Page }, + { + [`/enterprise-server@${latest}/admin/old-guide`]: `/enterprise-server@${latest}/admin/new-guide`, + }, + ) + expect(result.exists).toBe(true) + expect(result.isRedirect).toBe(true) + expect(result.redirectTarget).toBe(`/enterprise-server@${latest}/admin/new-guide`) + }) + + test('ignores .md when checking a fallback redirect target', () => { + const result = checkInternalLink( + '/free-pro-team@latest/actions/getting-started.md', + pageMap, + redirects, + ) + expect(result.exists).toBe(true) + expect(result.isRedirect).toBe(true) + expect(result.redirectTarget).toBe('/actions/getting-started.md') + }) + + test('applies the GraphQL category rewrite before checking a fallback redirect target', () => { + const result = checkInternalLink( + '/free-pro-team@latest/v4/scalar/boolean', + { '/en/graphql/reference/other': {} as unknown as Page }, + { '/v4/scalar/boolean': '/graphql/reference/scalars#boolean' }, + ) + expect(result.exists).toBe(true) + expect(result.isRedirect).toBe(true) + expect(result.redirectTarget).toBe('/graphql/reference/other#scalar-boolean') + }) + + test('resolves fallback redirects in the checked language', () => { + const result = checkInternalLink( + '/free-pro-team@latest', + { '/ja': {} as unknown as Page }, + {}, + undefined, + 'ja', + ) + expect(result.exists).toBe(true) + expect(result.isRedirect).toBe(true) + expect(result.redirectTarget).toBe('/') + }) }) describe('resolveInternalLinkKey', () => { From 9801575680e417f0a49ae44f3f0194c299ca2ab2 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:31:11 +0000 Subject: [PATCH 19/71] Include required query params in Octokit REST code examples (#63990) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/rest/components/get-rest-code-samples.ts | 19 ++- src/rest/tests/get-rest-code-samples-2.ts | 116 +++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/rest/components/get-rest-code-samples.ts b/src/rest/components/get-rest-code-samples.ts index 63b000ca93ac..74ee7cd3296d 100644 --- a/src/rest/components/get-rest-code-samples.ts +++ b/src/rest/components/get-rest-code-samples.ts @@ -344,6 +344,13 @@ export function getJSExample( } } + // Code examples only carry path params, so required query params need explicit values. + for (const [name, value] of Object.entries(getRequiredQueryParams(operation, codeSample))) { + if (!Object.hasOwn(parameters, name)) { + parameters[name] = value + } + } + let queryParameters = '' if ( @@ -405,17 +412,21 @@ function findMatchingQueryKey(exampleObj: CodeExamples | CodeExamples[], matchKe return match } -export function getRequiredQueryParamsPath(operation: Operation, codeSample: CodeSample) { - const requiredQueryParams = new URLSearchParams() +function getRequiredQueryParams(operation: Operation, codeSample: CodeSample) { + const requiredQueryParams: Record = {} for (const param of operation.parameters) { if (param.in === 'query' && param.required === true) { const codeExamples = codeSample.response?.example const match = findMatchingQueryKey(codeExamples, param.name) - requiredQueryParams.append(param.name, match || param.name.toUpperCase()) + requiredQueryParams[param.name] = match || param.name.toUpperCase() } } - return requiredQueryParams.toString() + return requiredQueryParams +} + +export function getRequiredQueryParamsPath(operation: Operation, codeSample: CodeSample) { + return new URLSearchParams(getRequiredQueryParams(operation, codeSample)).toString() } export function getAcceptHeader(codeSample: CodeSample) { diff --git a/src/rest/tests/get-rest-code-samples-2.ts b/src/rest/tests/get-rest-code-samples-2.ts index 82420df02509..4565996826e8 100644 --- a/src/rest/tests/get-rest-code-samples-2.ts +++ b/src/rest/tests/get-rest-code-samples-2.ts @@ -549,6 +549,122 @@ describe('REST code samples authentication header handling', () => { expect(result).toContain("id: 'MVS-2026-001'") expect(result).not.toMatch(/["']0["']\s*:/) }) + + test('includes required query params for GET requests', () => { + const searchOperation: Operation = { + ...standardOperation, + verb: 'get', + title: 'Search code', + requestPath: '/search/code', + parameters: [ + { + in: 'query', + name: 'q', + description: '', + required: true, + schema: { type: 'string' }, + }, + ], + } + const searchCodeSample = { + request: { + description: 'Example', + acceptHeader: 'application/vnd.github+json', + }, + response: { + statusCode: '200', + contentType: 'application/json', + description: 'Response', + example: { total_count: 0, items: [] }, + }, + } as unknown as CodeSample + + const result = getJSExample( + searchOperation, + searchCodeSample, + 'free-pro-team@latest', + mockVersions, + ) + + expect(result).toContain("await octokit.request('GET /search/code', {") + expect(result).toContain("q: 'Q'") + }) + + test('fills required query params in the URL template for mutating requests', () => { + const deleteCacheOperation: Operation = { + ...standardOperation, + verb: 'delete', + title: 'Delete GitHub Actions caches for a repository (using a cache key)', + requestPath: '/repos/{owner}/{repo}/actions/caches', + parameters: [ + { + in: 'path', + name: 'owner', + description: '', + required: true, + schema: { type: 'string' }, + }, + { + in: 'path', + name: 'repo', + description: '', + required: true, + schema: { type: 'string' }, + }, + { + in: 'query', + name: 'key', + description: '', + required: true, + schema: { type: 'string' }, + }, + { + in: 'query', + name: 'ref', + description: '', + required: false, + schema: { type: 'string' }, + }, + ], + } + const deleteCacheCodeSample = { + request: { + description: 'Example', + acceptHeader: 'application/vnd.github+json', + parameters: { owner: 'OWNER', repo: 'REPO' }, + }, + response: { + statusCode: '200', + contentType: 'application/json', + description: 'Response', + example: { + total_count: 1, + actions_caches: [{ id: 505, ref: 'refs/heads/main', key: 'Linux-node-958aff96' }], + }, + }, + } as unknown as CodeSample + + const jsResult = getJSExample( + deleteCacheOperation, + deleteCacheCodeSample, + 'free-pro-team@latest', + mockVersions, + ) + const shellResult = getShellExample( + deleteCacheOperation, + deleteCacheCodeSample, + 'free-pro-team@latest', + mockVersions, + ) + + expect(jsResult).toContain( + "await octokit.request('DELETE /repos/{owner}/{repo}/actions/caches{?key,ref}'", + ) + expect(jsResult).toContain("owner: 'OWNER'") + expect(jsResult).toContain("key: 'Linux-node-958aff96'") + expect(jsResult).not.toContain('ref:') + expect(shellResult).toContain('/repos/OWNER/REPO/actions/caches?key=Linux-node-958aff96') + }) }) describe('edge cases and special scenarios', () => { From 0bb87cef7dab5eb111aaf810f45346c785d9c4c5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:31:17 +0000 Subject: [PATCH 20/71] Show intro links in Article API markdown for bespoke landing pages (#64023) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 Copilot-Session: fb5d8be8-479c-4950-8197-ef0220dbdcb0 --- .../lib/get-intro-links-section.ts | 25 ++++ .../tests/bespoke-landing-transformer.ts | 18 +++ .../tests/landing-intro-links.test.ts | 112 ++++++++++++++++++ .../bespoke-landing-transformer.ts | 5 + .../discovery-landing-transformer.ts | 42 +------ .../get-started/article-grid-bespoke/index.md | 3 + 6 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 src/article-api/lib/get-intro-links-section.ts create mode 100644 src/article-api/tests/landing-intro-links.test.ts diff --git a/src/article-api/lib/get-intro-links-section.ts b/src/article-api/lib/get-intro-links-section.ts new file mode 100644 index 000000000000..4311a73d01b4 --- /dev/null +++ b/src/article-api/lib/get-intro-links-section.ts @@ -0,0 +1,25 @@ +import type { Context, Page } from '@/types' +import type { LinkData, Section } from '@/article-api/transformers/types' + +interface PageWithIntroLinks extends Page { + rawIntroLinks?: Record +} + +// Reads rawIntroLinks because Page.render overwrites introLinks on the Page that every version shares. +export async function getIntroLinksSection(page: Page, context: Context): Promise
{ + const { rawIntroLinks } = page as PageWithIntroLinks + if (!rawIntroLinks) return null + + const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') + const links: LinkData[] = [] + for (const rawHref of Object.values(rawIntroLinks)) { + if (typeof rawHref !== 'string') continue + const [item] = (await getPageLinkData(rawHref, context)) ?? [] + if (item?.href) { + links.push({ href: item.href, title: item.title || '', intro: item.intro || '' }) + } + } + + if (links.length === 0) return null + return { title: 'Links', groups: [{ title: 'Getting started', links }] } +} diff --git a/src/article-api/tests/bespoke-landing-transformer.ts b/src/article-api/tests/bespoke-landing-transformer.ts index d6f2827ee5a3..f3c0edad7078 100644 --- a/src/article-api/tests/bespoke-landing-transformer.ts +++ b/src/article-api/tests/bespoke-landing-transformer.ts @@ -26,4 +26,22 @@ describe('bespoke landing transformer', () => { expect(res.body).toContain('[Grid Article Three]') expect(res.body).toContain('[Grid Article Four]') }) + + test('renders intro links as a getting started section', async () => { + const res = await get(makeURL('/en/get-started/article-grid-bespoke')) + expect(res.statusCode).toBe(200) + + const start = res.body.indexOf('## Links') + expect(start).toBeGreaterThan(-1) + const end = res.body.indexOf('\n## ', start) + const linksSection = res.body.slice(start, end === -1 ? undefined : end) + expect(linksSection).toContain('### Getting started') + expect(linksSection).toContain( + '[Grid Article One](/en/get-started/article-grid-bespoke/grid-category-one/grid-article-one)', + ) + expect(linksSection).toContain( + '[Grid Article Three](/en/get-started/article-grid-bespoke/grid-category-two/grid-article-three)', + ) + expect(linksSection).not.toContain('[Grid Article Two]') + }) }) diff --git a/src/article-api/tests/landing-intro-links.test.ts b/src/article-api/tests/landing-intro-links.test.ts new file mode 100644 index 000000000000..4b2b1dfd9b32 --- /dev/null +++ b/src/article-api/tests/landing-intro-links.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest' + +import { BespokeLandingTransformer } from '@/article-api/transformers/bespoke-landing-transformer' +import { DiscoveryLandingTransformer } from '@/article-api/transformers/discovery-landing-transformer' +import shortVersionsMiddleware from '@/versions/middleware/short-versions' +import { allVersions } from '@/versions/lib/all-versions' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' +import type { Context, ExtendedRequest, Page } from '@/types' + +const ghesVersion = `enterprise-server@${enterpriseServerReleases.latest}` +const overviewPath = '/billing/get-started/how-billing-works' +const ghesOverviewPath = '/billing/concepts/enterprise-billing/billing-for-enterprises' +const rawOverview = `{% ifversion fpt or ghec %}${overviewPath} {% elsif ghes %}${ghesOverviewPath}{% endif %}` + +const makeLinkedPage = (title: string) => + ({ + title, + intro: '', + // resolveCarouselLinks drops links whose page does not apply to the requested version. + applicableVersions: Object.keys(allVersions), + renderTitle: async () => title, + renderProp: async () => '', + }) as unknown as Page + +const pages: Record = { + [`/en${overviewPath}`]: makeLinkedPage('How billing works'), + [`/en${ghesOverviewPath}`]: makeLinkedPage('Billing for enterprises'), + [`/en/${ghesVersion}${ghesOverviewPath}`]: makeLinkedPage('Billing for enterprises'), +} + +// staleOverview stands in for the introLinks value that Page.render leaves on the shared Page. +const makeLandingPage = (layout: string, staleOverview: string) => + ({ + layout, + title: 'Billing', + intro: '', + rawIntroLinks: { overview: rawOverview }, + introLinks: { overview: staleOverview }, + rawCarousels: { recommended: [ghesOverviewPath] }, + renderTitle: async () => 'Billing', + renderProp: async () => '', + }) as unknown as Page + +const buildContext = async (currentVersion: string): Promise => { + const req = { language: 'en', query: {} } as ExtendedRequest + req.context = { + currentLanguage: 'en', + currentVersion, + currentVersionObj: allVersions[currentVersion], + allVersions, + enterpriseServerReleases, + pages, + redirects: {}, + } as Context + await shortVersionsMiddleware(req, null, () => {}) + return req.context +} + +const getLinksSection = (body: string) => { + const start = body.indexOf('## Links') + if (start === -1) return '' + const end = body.indexOf('\n## ', start) + return end === -1 ? body.slice(start) : body.slice(start, end) +} + +describe.each([ + { + name: 'discovery', + layout: 'discovery-landing', + transformer: new DiscoveryLandingTransformer(), + }, + { name: 'bespoke', layout: 'bespoke-landing', transformer: new BespokeLandingTransformer() }, +])('$name landing intro links', ({ layout, transformer }) => { + test('renders raw intro links for fpt when the page holds a GHES render', async () => { + const page = makeLandingPage(layout, ghesOverviewPath) + const body = await transformer.transform( + page, + '/en/billing', + await buildContext('free-pro-team@latest'), + ) + + const linksSection = getLinksSection(body) + expect(linksSection).toContain('### Getting started') + expect(linksSection).toContain(`[How billing works](/en${overviewPath})`) + expect(linksSection).not.toContain('Billing for enterprises') + }) + + test('renders raw intro links for GHES when the page holds an fpt render', async () => { + const page = makeLandingPage(layout, overviewPath) + const body = await transformer.transform(page, '/en/billing', await buildContext(ghesVersion)) + + const linksSection = getLinksSection(body) + expect(linksSection).toContain( + `[Billing for enterprises](/en/${ghesVersion}${ghesOverviewPath})`, + ) + expect(body).not.toContain('How billing works') + }) +}) + +describe('bespoke landing intro links order', () => { + test('lists intro links before carousels, like the page hero', async () => { + const page = makeLandingPage('bespoke-landing', overviewPath) + const body = await new BespokeLandingTransformer().transform( + page, + '/en/billing', + await buildContext('free-pro-team@latest'), + ) + + expect(body.indexOf('## Links')).toBeGreaterThan(-1) + expect(body.indexOf('## Links')).toBeLessThan(body.indexOf('## Recommended')) + }) +}) diff --git a/src/article-api/transformers/bespoke-landing-transformer.ts b/src/article-api/transformers/bespoke-landing-transformer.ts index 2d95160ba0bf..3927bf6c936a 100644 --- a/src/article-api/transformers/bespoke-landing-transformer.ts +++ b/src/article-api/transformers/bespoke-landing-transformer.ts @@ -3,6 +3,7 @@ import type { PageTransformer, TemplateData, Section } from './types' import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' +import { getIntroLinksSection } from '@/article-api/lib/get-intro-links-section' import { resolveCarouselLinks } from '@/article-api/lib/resolve-carousel-links' interface BespokeLandingPage extends Omit { @@ -42,6 +43,10 @@ export class BespokeLandingTransformer implements PageTransformer { const bespokePage = page as BespokeLandingPage const sections: Section[] = [] + // Intro links come first to match the hero, which renders above the carousels. + const introLinksSection = await getIntroLinksSection(page, context) + if (introLinksSection) sections.push(introLinksSection) + // resolve-carousels overwrites page.carousels on the shared Page with another version's links. const carousels = bespokePage.rawCarousels if (carousels && typeof carousels === 'object') { diff --git a/src/article-api/transformers/discovery-landing-transformer.ts b/src/article-api/transformers/discovery-landing-transformer.ts index 331b6f46e006..03d5a9cc8e7e 100644 --- a/src/article-api/transformers/discovery-landing-transformer.ts +++ b/src/article-api/transformers/discovery-landing-transformer.ts @@ -1,13 +1,12 @@ import type { Context, Page } from '@/types' -import type { PageTransformer, TemplateData, Section, LinkData } from './types' +import type { PageTransformer, TemplateData, Section } from './types' import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' +import { getIntroLinksSection } from '@/article-api/lib/get-intro-links-section' import { resolveCarouselLinks } from '@/article-api/lib/resolve-carousel-links' interface DiscoveryPage extends Page { - rawIntroLinks?: Record - introLinks?: Record rawCarousels?: Record includedCategories?: string[] children?: string[] @@ -60,41 +59,8 @@ export class DiscoveryLandingTransformer implements PageTransformer { } } - const rawIntroLinks = discoveryPage.introLinks ?? discoveryPage.rawIntroLinks - if (rawIntroLinks) { - const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') - const links = await Promise.all( - Object.values(rawIntroLinks).map(async (href): Promise => { - if (typeof href === 'string') { - const linkData = await getPageLinkData(href, context) - if (Array.isArray(linkData) && linkData.length > 0) { - const item = linkData[0] - return { href: item.href || '', title: item.title || '', intro: item.intro || '' } - } else if ( - linkData && - typeof linkData === 'object' && - !Array.isArray(linkData) && - 'href' in linkData - ) { - const item = linkData as { href?: string; title?: string; intro?: string } - return { - href: item.href || '', - title: item.title || '', - intro: item.intro || '', - } - } - } - return { href: '', title: '' } - }), - ) - const validLinks = links.filter((l) => l.href) - if (validLinks.length > 0) { - sections.push({ - title: 'Links', - groups: [{ title: 'Getting started', links: validLinks }], - }) - } - } + const introLinksSection = await getIntroLinksSection(page, context) + if (introLinksSection) sections.push(introLinksSection) // getAllTocItems sets basePath to keep /rest from recursing into /enterprise-admin children. if (discoveryPage.children && discoveryPage.children.length > 0) { diff --git a/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md b/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md index 44447fafcc3a..fa16125b4202 100644 --- a/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md +++ b/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md @@ -6,6 +6,9 @@ versions: ghes: "*" ghec: "*" layout: bespoke-landing +introLinks: + overview: "{% ifversion fpt or ghec %}/get-started/article-grid-bespoke/grid-category-one/grid-article-one{% elsif ghes %}/get-started/article-grid-bespoke/grid-category-one/grid-article-two{% endif %}" + quickstart: /get-started/article-grid-bespoke/grid-category-two/grid-article-three carousels: recommended: - /grid-category-one/grid-article-one From 567fa4a087abe380e137868aa69db4b274623301 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:31:24 +0000 Subject: [PATCH 21/71] Skip hidden pages in Article API landing page article lists (#64044) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/article-api/lib/get-all-toc-items.ts | 2 ++ src/article-api/tests/get-all-toc-items.ts | 41 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/article-api/lib/get-all-toc-items.ts b/src/article-api/lib/get-all-toc-items.ts index d4e8ca5bc7db..51ed67bd1ec1 100644 --- a/src/article-api/lib/get-all-toc-items.ts +++ b/src/article-api/lib/get-all-toc-items.ts @@ -16,6 +16,7 @@ interface TocItem extends LinkData { } // Mirrors getTocItems() in src/frame/middleware/context/generic-toc.ts for frontmatter children. +// The site shows hidden children only in early access TOCs, so skip them here. export async function getAllTocItems( page: Page, context: Context, @@ -49,6 +50,7 @@ export async function getAllTocItems( .filter( (entry): entry is { childHref: string; childPage: PageWithChildren } => entry.childPage !== undefined && + !entry.childPage.hidden && // resolvePath can return a page from another version, which the website hides. (!context.currentVersion || entry.childPage.applicableVersions.includes(context.currentVersion)), diff --git a/src/article-api/tests/get-all-toc-items.ts b/src/article-api/tests/get-all-toc-items.ts index de877a40154e..a024c3e78de5 100644 --- a/src/article-api/tests/get-all-toc-items.ts +++ b/src/article-api/tests/get-all-toc-items.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest' -import { getAllTocItems } from '@/article-api/lib/get-all-toc-items' +import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' import type { Context, Page, Permalink } from '@/types' function createMockPage(options: { @@ -7,6 +7,7 @@ function createMockPage(options: { applicableVersions: string[] permalinks: Partial[] children?: string[] + hidden?: boolean }): Page { return { languageCode: 'en', @@ -14,6 +15,7 @@ function createMockPage(options: { applicableVersions: options.applicableVersions, permalinks: options.permalinks as Permalink[], children: options.children, + hidden: options.hidden, renderProp: vi.fn().mockResolvedValue(''), } as unknown as Page } @@ -61,4 +63,41 @@ describe('getAllTocItems', () => { expect(items.map((item) => item.href)).toEqual(['/en/rest/quickstart']) expect(items[0].title).toBe('Quickstart') }) + + test('skips hidden children and does not recurse into hidden categories', async () => { + const VERSION = 'free-pro-team@latest' + const page = ( + href: string, + rawTitle: string, + extra: { children?: string[]; hidden?: boolean } = {}, + ) => + createMockPage({ + rawTitle, + applicableVersions: [VERSION], + permalinks: [{ languageCode: 'en', pageVersion: VERSION, href }], + ...extra, + }) + const landing = page('/en/policy', 'Policy', { + children: ['/visible-article', '/hidden-article', '/hidden-category'], + }) + const pages = [ + landing, + page('/en/policy/visible-article', 'Visible Article'), + page('/en/policy/hidden-article', 'Hidden Article', { hidden: true }), + page('/en/policy/hidden-category', 'Hidden Category', { + children: ['/visible-leaf'], + hidden: true, + }), + // A visible leaf proves a hidden category drops its whole subtree. + page('/en/policy/hidden-category/visible-leaf', 'Visible Leaf'), + ] + const byHref: Record = {} + for (const p of pages) byHref[p.permalinks[0].href] = p + const context = { pages: byHref, redirects: {}, currentVersion: VERSION } as unknown as Context + + const tocItems = await getAllTocItems(landing, context) + const titles = flattenTocItems(tocItems).map((item) => item.title) + + expect(titles).toEqual(['Visible Article']) + }) }) From e4ef78791a19eeb9a9f3a07a4479bb82048f92f5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 21:31:30 +0000 Subject: [PATCH 22/71] Keep links in English fallback content in the reader's language (#64100) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../unified/rewrite-local-links.ts | 16 ++++- src/languages/lib/render-with-fallback.ts | 15 +++- .../tests/translation-error-comments.ts | 71 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/content-render/unified/rewrite-local-links.ts b/src/content-render/unified/rewrite-local-links.ts index 924b01a925e5..c8eeaa65b616 100644 --- a/src/content-render/unified/rewrite-local-links.ts +++ b/src/content-render/unified/rewrite-local-links.ts @@ -197,7 +197,21 @@ async function getNewTitle(href: string, context: Context, child: Text, original const line = child.position?.start.line || 1 throw autotitleResolutionError(originalHref || href, line, context) } - return await page.renderProp('title', context, { textOnly: true }) + return await page.renderProp('title', getTitleContext(page, href, context), { textOnly: true }) +} + +// Translated title fallbacks need the linked page's English version, not the rendered page's. +function getTitleContext(page: Page, href: string, context: Context): Context { + if (!context.getEnglishPage) return context + const enPage = + page.languageCode === 'en' + ? page + : findPage( + path.posix.join('/en', getPathWithoutLanguage(href)), + context.pages, + context.redirects, + ) + return enPage ? { ...context, enPage } : context } export function autotitleResolutionError( diff --git a/src/languages/lib/render-with-fallback.ts b/src/languages/lib/render-with-fallback.ts index 54b419516664..f863680d5b3a 100644 --- a/src/languages/lib/render-with-fallback.ts +++ b/src/languages/lib/render-with-fallback.ts @@ -131,8 +131,12 @@ export async function renderContentWithFallback( const enPage = getEnglishFallbackPage(page, context) if (!enPage) throw error const englishTemplate = (enPage as unknown as Record)[property] - // Set currentLanguage to en so Liquid plugins such as data.ts read English data. - const enContext = Object.assign({}, context, { currentLanguage: 'en' }) + // data.ts reads English data while rewriteLocalLinks keeps links in the reader's language. + const enContext = { + ...context, + currentLanguage: 'en', + autotitleLanguage: context.autotitleLanguage || context.currentLanguage, + } const fallbackContent = await renderContent(englishTemplate, enContext, options) @@ -165,7 +169,12 @@ export async function executeWithFallback( return await Promise.resolve(callable(context)) } catch (error) { if (isFallbackableError(error) && context.currentLanguage !== 'en') { - const enContext = Object.assign({}, context, { currentLanguage: 'en' }) + // data.ts reads English data while rewriteLocalLinks keeps links in the reader's language. + const enContext = { + ...context, + currentLanguage: 'en', + autotitleLanguage: context.autotitleLanguage || context.currentLanguage, + } const fallbackContent = await Promise.resolve(fallback(enContext)) // Only HTML fallback content can carry a comment without changing plain-text output. diff --git a/src/languages/tests/translation-error-comments.ts b/src/languages/tests/translation-error-comments.ts index 5619ebd84b5a..790fc0562511 100644 --- a/src/languages/tests/translation-error-comments.ts +++ b/src/languages/tests/translation-error-comments.ts @@ -8,6 +8,7 @@ import { } from '../lib/render-with-fallback' import { TitleFromAutotitleError } from '@/content-render/unified/rewrite-local-links' import Page from '@/frame/lib/page' +import type { Context } from '@/types' describe('Translation Error Comments', () => { let mockRenderContent: MockedFunction< @@ -361,6 +362,57 @@ describe('Translation Error Comments', () => { expect(result).toBe('English Title') }) + test('keeps fallback links in the reader language', async () => { + const mockPage = Object.create(Page.prototype) + mockPage.markdown = '{% badtag %} [x](/actions/foo)' + + const context = { + currentLanguage: 'ja', + currentVersion: 'free-pro-team@latest', + getEnglishPage: () => { + const enPage = Object.create(Page.prototype) + enPage.markdown = 'English [link](/actions/foo)' + return enPage + }, + } + + const result = await renderContentWithFallback(mockPage, 'markdown', context) + + expect(result).toContain('|)+\s*$/ // A Liquid-only cell is whitespace, a Liquid tag, then whitespace. const LIQUID_ONLY_CELL_REGEX = /^\s*{%\s*(ifversion|else|endif|elsif|for|endfor).*%}\s*$/ // Escaped pipes (\|) are cell content, not separators. @@ -34,6 +37,12 @@ function countColumns(row: string): number { return cells.length } +// Without a closing pipe, a trailing comment can be the content of the last cell, +// so return the count without trailing comments and the count with them. +function countColumnOptions(row: string): [number, number] { + return [countColumns(row.replace(TRAILING_HTML_COMMENTS_REGEX, '')), countColumns(row)] +} + function isLiquidOnlyRow(row: string): boolean { const trimmed = row.trim() if (!trimmed.includes('|')) return false @@ -66,7 +75,7 @@ export const tableColumnIntegrity = { let expectedColumnCount: number | null = null for (let i = 0; i < lines.length; i++) { - const line = lines[i] + const line = lines[i].replace(TRAILING_HTML_COMMENTS_REGEX, '') if (line.trim().startsWith('```')) { inCodeFence = !inCodeFence @@ -82,10 +91,15 @@ export const tableColumnIntegrity = { if (!inTable && isTableRow) { // A following separator row confirms the current row starts a table. - const nextLine = lines[i + 1] + const nextLine = lines[i + 1]?.replace(TRAILING_HTML_COMMENTS_REGEX, '') if (nextLine && TABLE_SEPARATOR_REGEX.test(nextLine)) { inTable = true - expectedColumnCount = countColumns(line) + const [headerCount, headerCountWithComments] = countColumnOptions(lines[i]) + // GFM requires the header and separator to have the same number of cells. + expectedColumnCount = + headerCountWithComments === countColumns(nextLine) + ? headerCountWithComments + : headerCount continue } } @@ -102,9 +116,12 @@ export const tableColumnIntegrity = { continue } - const actualColumnCount = countColumns(line) + const [actualColumnCount, actualCountWithComments] = countColumnOptions(lines[i]) - if (actualColumnCount !== expectedColumnCount) { + if ( + actualColumnCount !== expectedColumnCount && + actualCountWithComments !== expectedColumnCount + ) { const range = getRange(line, line.trim()) let errorMessage diff --git a/src/content-linter/tests/unit/table-column-integrity-simple.ts b/src/content-linter/tests/unit/table-column-integrity-simple.ts index 14137e8709e8..bca8bc9cc861 100644 --- a/src/content-linter/tests/unit/table-column-integrity-simple.ts +++ b/src/content-linter/tests/unit/table-column-integrity-simple.ts @@ -52,6 +52,91 @@ describe(tableColumnIntegrity.names.join(' - '), () => { } }) + test('Rows after a row without a closing pipe are still checked', async () => { + const markdown = ['| A | B |', '| --- | --- |', '| x | y', '| p | q | r |'].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Tables whose header and separator lack closing pipes are checked', async () => { + const markdown = ['| A | B', '| --- | ---', '| p | q | r |'].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(3) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Trailing HTML comments are not counted as cells', async () => { + const markdown = [ + '| Option | Description |', + '|--------|-------------|', + '| `--json` | Emit JSON. | ', + '| `--help` | Show help. | Extra |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Multiple trailing HTML comments are not counted as cells', async () => { + const markdown = [ + '| A | B | ', + '| --- | --- |', + '| x | y | ', + '| p | q | r |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Comment openers inside code spans do not hide cells', async () => { + const markdown = [ + '| Syntax | Meaning |', + '| --- | --- |', + '| `', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + + test('Comment-only final cells without a closing pipe are counted', async () => { + const markdown = [ + '| A | ', + '| --- | --- |', + '| value | ', + '| x | y |', + '| p | q | r |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(5) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Valid table mixing rows with and without closing pipes passes', async () => { + const markdown = [ + '| Name | Status', + '| ---- | ------', + '| Alpha | Active |', + '| Beta | Inactive', + '| Gamma | Active |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + test('Liquid-only rows are ignored', async () => { const markdown = [ '| Feature | Status |', From 28323034b4fd4c790ac4bdd9e7f7112cd37549f8 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:16:42 +0000 Subject: [PATCH 39/71] Show body params for REST request bodies with a top-level anyOf (#64219) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/rest/scripts/utils/get-body-params.ts | 41 +++++++++- .../utils/tests/get-body-params.test.ts | 78 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/rest/scripts/utils/get-body-params.ts b/src/rest/scripts/utils/get-body-params.ts index 787f414bd12b..1b3a3fd49a5b 100644 --- a/src/rest/scripts/utils/get-body-params.ts +++ b/src/rest/scripts/utils/get-body-params.ts @@ -93,6 +93,40 @@ async function getTopLevelOneOfProperty( return { properties, required } } +// A top-level anyOf of objects, like Review custom deployment protection rules for a workflow +// run, has no properties on the schema object, so getTopLevelAnyOfProperty merges all options. +// A request can match any option, so only fields every option requires are marked required, +// plus fields the parent requires, since parent constraints apply alongside anyOf. +// The first option that defines a property keeps its description. +function getTopLevelAnyOfProperty( + anyOf: Schema[], + parentRequired: string[] = [], +): { + properties: Record + required: string[] +} { + const properties: Record = {} + for (const each of anyOf) { + for (const [key, value] of Object.entries(each.properties || {})) { + if (!Object.hasOwn(properties, key)) properties[key] = value + } + } + const sharedRequired = (anyOf[0].required || []).filter((name) => + anyOf.every((each) => each.required?.includes(name)), + ) + const required = [...new Set([...parentRequired, ...sharedRequired])] + return { properties, required } +} + +function isTopLevelObjectAnyOf(schema: Schema): schema is Schema & { anyOf: Schema[] } { + return ( + !schema.properties && + Array.isArray(schema.anyOf) && + schema.anyOf.length > 0 && + schema.anyOf.every((elem) => elem.type === 'object') + ) +} + async function handleObjectOnlyOneOf( param: Schema, paramType: string[], @@ -120,7 +154,12 @@ function getArrayItemTypes(items: Schema): string[] { // while mixed oneOf adds types and descriptions without creating child parameter groups. export async function getBodyParams(schema: Schema, topLevel = false): Promise { const bodyParametersParsed: TransformedParam[] = [] - const schemaObject = schema.oneOf && topLevel ? await getTopLevelOneOfProperty(schema) : schema + let schemaObject: Schema = schema + if (schema.oneOf && topLevel) { + schemaObject = await getTopLevelOneOfProperty(schema) + } else if (topLevel && isTopLevelObjectAnyOf(schema)) { + schemaObject = getTopLevelAnyOfProperty(schema.anyOf, schema.required) + } const properties = schemaObject.properties || {} const required = schemaObject.required || [] diff --git a/src/rest/scripts/utils/tests/get-body-params.test.ts b/src/rest/scripts/utils/tests/get-body-params.test.ts index c39779f2d727..e2314801e599 100644 --- a/src/rest/scripts/utils/tests/get-body-params.test.ts +++ b/src/rest/scripts/utils/tests/get-body-params.test.ts @@ -104,6 +104,84 @@ describe('getBodyParams — OAS 3.1 nullable handling', () => { ]) }) + it('merges object-only top-level anyOf alternatives and requires only shared fields', async () => { + const schema: Schema = { + anyOf: [ + { + type: 'object', + properties: { + environment_name: { type: 'string', description: 'The environment name' }, + comment: { type: 'string', description: 'Required when state is not provided.' }, + }, + required: ['environment_name', 'comment'], + }, + { + type: 'object', + properties: { + environment_name: { type: 'string', description: 'The environment name' }, + state: { type: 'string', description: 'The state', enum: ['approved', 'rejected'] }, + comment: { type: 'string', description: 'Optional comment.' }, + }, + required: ['environment_name', 'state'], + }, + ], + } + const params = await getBodyParams(schema, true) + expect(params.map(({ name, isRequired }) => [name, isRequired])).toEqual([ + ['environment_name', true], + ['comment', undefined], + ['state', undefined], + ]) + expect(params[1].description).toBe('Required when state is not provided.') + }) + + it('keeps parent required fields alongside a top-level object anyOf', async () => { + const schema: Schema = { + type: 'object', + required: ['environment_name'], + anyOf: [ + { + type: 'object', + properties: { + environment_name: { type: 'string', description: 'The environment name' }, + comment: { type: 'string', description: 'A comment' }, + }, + required: ['comment'], + }, + { + type: 'object', + properties: { + environment_name: { type: 'string', description: 'The environment name' }, + state: { type: 'string', description: 'The state' }, + }, + required: ['state'], + }, + ], + } + const params = await getBodyParams(schema, true) + expect(params.map(({ name, isRequired }) => [name, isRequired])).toEqual([ + ['environment_name', true], + ['comment', undefined], + ['state', undefined], + ]) + }) + + it('keeps parent properties when a top-level anyOf only lists required fields', async () => { + const schema: Schema = { + type: 'object', + properties: { + first: { type: 'string', description: 'First value' }, + second: { type: 'string', description: 'Second value' }, + }, + anyOf: [{ required: ['first'] }, { required: ['second'] }], + } + const params = await getBodyParams(schema, true) + expect(params.map(({ name, isRequired }) => [name, isRequired])).toEqual([ + ['first', undefined], + ['second', undefined], + ]) + }) + it('keeps parent properties when top-level oneOf options only constrain them', async () => { const schema: Schema = { type: 'object', From c18b5ae685b70fe76e039d9d0cf633e88872a8c5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:16:49 +0000 Subject: [PATCH 40/71] Set feature flags when counting translation corruptions (#64220) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../scripts/count-translation-corruptions.ts | 9 ++++--- .../tests/count-translation-corruptions.ts | 27 ++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/languages/scripts/count-translation-corruptions.ts b/src/languages/scripts/count-translation-corruptions.ts index f0a27eb21b58..c8729160f6c9 100644 --- a/src/languages/scripts/count-translation-corruptions.ts +++ b/src/languages/scripts/count-translation-corruptions.ts @@ -6,6 +6,7 @@ import walk from 'walk-sync' import { engine } from '@/content-render/liquid/engine' import { allVersions } from '@/versions/lib/all-versions' +import { getFeaturesByVersion } from '@/versions/middleware/features' import languages from '@/languages/lib/languages-server' import warmServer from '@/frame/lib/warm-server' import type { Site } from '@/types' @@ -172,11 +173,13 @@ function getReusables(): Reusables { // Build a minimal Liquid render context for validating translated content. // ifversion needs currentVersionObj; data tags call getDataByLanguage internally. -// Mirror shortVersions middleware so bare conditionals such as {% ifversion ghes %} resolve. -// Without the short-name flag, Liquid sees an undefined variable and hides real corruptions. -function buildRenderContext(languageCode: string, version: string) { +// Mirror shortVersions and features middleware so bare conditionals such as +// {% ifversion ghes %} or {% ifversion some-feature %} resolve. +// Without these flags, Liquid sees an undefined variable and hides real corruptions. +export function buildRenderContext(languageCode: string, version: string) { const currentVersionObj = allVersions[version] return { + ...getFeaturesByVersion(version), currentLanguage: languageCode, currentVersion: version, currentVersionObj, diff --git a/src/languages/tests/count-translation-corruptions.ts b/src/languages/tests/count-translation-corruptions.ts index 2d1601aceca1..1bc10009bb7a 100644 --- a/src/languages/tests/count-translation-corruptions.ts +++ b/src/languages/tests/count-translation-corruptions.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'vitest' import { allVersions } from '@/versions/lib/all-versions' -import { resolveRequestedVersions } from '@/languages/scripts/count-translation-corruptions' +import { getFeaturesByVersion } from '@/versions/middleware/features' +import { engine } from '@/content-render/liquid/engine' +import { + buildRenderContext, + resolveRequestedVersions, +} from '@/languages/scripts/count-translation-corruptions' // Pick a real GHES key at runtime rather than hard-coding one. allVersions // only contains supported releases, so enterprise-server@3.16 fails after @@ -35,3 +40,23 @@ describe('resolveRequestedVersions', () => { expect(() => resolveRequestedVersions('free-pro-team@latest,nope')).toThrow(/nope/) }) }) + +describe('buildRenderContext', () => { + const fptFeature = Object.entries(getFeaturesByVersion('free-pro-team@latest')).find( + ([, enabled]) => enabled, + )![0] + + test('sets feature flags for the version', () => { + const ctx = buildRenderContext('es', 'free-pro-team@latest') + expect(ctx[fptFeature]).toBe(true) + expect(ctx.currentVersion).toBe('free-pro-team@latest') + expect(ctx.fpt).toBe(true) + }) + + test('renders feature-gated branches so render-time errors surface', async () => { + const ctx = buildRenderContext('es', 'free-pro-team@latest') + await expect( + engine.parseAndRender(`{% ifversion ${fptFeature} %}{% octicon "lapiz" %}{% endif %}`, ctx), + ).rejects.toThrow(/Octicon lapiz does not exist/) + }) +}) From 6622cb99edd126275dc7289fcae12fa246764075 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:16:57 +0000 Subject: [PATCH 41/71] Match whole path segments in update-filepaths --paths (#64227) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/content-render/lib/is-path-within.ts | 10 +++++ .../scripts/update-filepaths.ts | 4 +- src/content-render/tests/is-path-within.ts | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/content-render/lib/is-path-within.ts create mode 100644 src/content-render/tests/is-path-within.ts diff --git a/src/content-render/lib/is-path-within.ts b/src/content-render/lib/is-path-within.ts new file mode 100644 index 000000000000..ea6ac727d5f6 --- /dev/null +++ b/src/content-render/lib/is-path-within.ts @@ -0,0 +1,10 @@ +import path from 'path' + +// Compare whole path segments so content/foo does not also match content/foo-bar. +export function isPathWithin(file: string, target: string): boolean { + const normalizedFile = path.normalize(file) + const normalizedTarget = path.normalize(target).replace(/[\\/]+$/, '') + return ( + normalizedFile === normalizedTarget || normalizedFile.startsWith(normalizedTarget + path.sep) + ) +} diff --git a/src/content-render/scripts/update-filepaths.ts b/src/content-render/scripts/update-filepaths.ts index 45bc147c26d8..0a93b486c511 100755 --- a/src/content-render/scripts/update-filepaths.ts +++ b/src/content-render/scripts/update-filepaths.ts @@ -10,6 +10,7 @@ import { execFileSync } from 'child_process' import walkFiles from '@/workflows/walk-files' import frontmatter from '@/frame/lib/read-frontmatter' import { renderContent } from '@/content-render/index' +import { isPathWithin } from '@/content-render/lib/is-path-within' import fpt from '@/versions/lib/non-enterprise-default-version' import { allVersions } from '@/versions/lib/all-versions' import type { PageFrontmatter, Context } from '@/types' @@ -205,8 +206,7 @@ function filterFiles(contentDir: string, scriptOptions: ScriptOptions) { console.error(`${p} not found`) process.exit(1) } - if (path.relative(process.cwd(), file).startsWith(p)) return true - return false + return isPathWithin(path.relative(process.cwd(), file), p) }) }) } diff --git a/src/content-render/tests/is-path-within.ts b/src/content-render/tests/is-path-within.ts new file mode 100644 index 000000000000..0db9ac69db46 --- /dev/null +++ b/src/content-render/tests/is-path-within.ts @@ -0,0 +1,37 @@ +import path from 'path' +import { describe, expect, test } from 'vitest' +import { isPathWithin } from '@/content-render/lib/is-path-within' + +const p = (...segments: string[]) => path.join(...segments) + +describe('isPathWithin', () => { + test('matches files inside the target directory', () => { + expect(isPathWithin(p('content', 'search', 'foo.md'), p('content', 'search'))).toBe(true) + expect(isPathWithin(p('content', 'search', 'a', 'index.md'), p('content', 'search'))).toBe(true) + }) + + test('matches the target file itself', () => { + const file = p('content', 'search', 'foo.md') + expect(isPathWithin(file, file)).toBe(true) + }) + + test('does not match sibling directories that share a name prefix', () => { + expect(isPathWithin(p('content', 'search-github', 'foo.md'), p('content', 'search'))).toBe( + false, + ) + expect( + isPathWithin(p('content', 'rest', 'gitignore', 'x.md'), p('content', 'rest', 'git')), + ).toBe(false) + }) + + test('does not match sibling files that share a name prefix', () => { + expect(isPathWithin(p('content', 'a', 'foo-bar.md'), p('content', 'a', 'foo'))).toBe(false) + }) + + test('accepts a trailing separator on the target', () => { + expect(isPathWithin(p('content', 'search', 'foo.md'), `${p('content', 'search')}/`)).toBe(true) + expect( + isPathWithin(p('content', 'search-github', 'foo.md'), `${p('content', 'search')}/`), + ).toBe(false) + }) +}) From 5d8713df7d5abac53b7ce35e32bc44791b07406b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:35:59 +0000 Subject: [PATCH 42/71] Check PR links in every version the changed page supports (#63993) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/links/lib/extract-links.ts | 5 ++ src/links/lib/link-report.ts | 41 +++++++++++++ src/links/scripts/check-links-pr.ts | 92 ++++++++++++++++------------- src/links/tests/extract-links.ts | 29 +++++++++ src/links/tests/link-report.ts | 64 ++++++++++++++++++++ 5 files changed, 189 insertions(+), 42 deletions(-) diff --git a/src/links/lib/extract-links.ts b/src/links/lib/extract-links.ts index 039806eea743..216470d96856 100644 --- a/src/links/lib/extract-links.ts +++ b/src/links/lib/extract-links.ts @@ -347,6 +347,11 @@ export function createLiquidContext( } as Context } +// Data and reusable files have no versions of their own, so they render in every current version. +export function getLinkCheckVersions(page?: Pick): string[] { + return page?.applicableVersions?.length ? page.applicableVersions : Object.keys(allVersions) +} + // Cache renderLiquid after a dynamic import to avoid circular imports and repeated import overhead. type RenderLiquidModule = (template: string, context: Context) => Promise let _renderLiquid: RenderLiquidModule | null = null diff --git a/src/links/lib/link-report.ts b/src/links/lib/link-report.ts index 4ffe824e4f3e..82aaccb7642f 100644 --- a/src/links/lib/link-report.ts +++ b/src/links/lib/link-report.ts @@ -433,6 +433,47 @@ export function mergeInternalLinkReports( return { ...report, versionsChecked, summary: report.summary + scope } } +// One href can redirect in one version and miss in another, so a missing target outranks a +// redirect warning and the PR check still fails on it. +export function mergeVersionedLinkFlaws(flaws: BrokenLink[]): { + brokenLinks: BrokenLink[] + redirectLinks: BrokenLink[] +} { + const merged = new Map() + + for (const flaw of flaws) { + const key = `${flaw.href}\u0000${flaw.file}` + const existing = merged.get(key) + const lines = [...new Set([...(existing?.lines ?? []), ...flaw.lines])].sort((a, b) => a - b) + if (!existing) { + merged.set(key, { ...flaw, lines }) + } else if (existing.isRedirect && !flaw.isRedirect) { + const { href, file, text, isAutotitle } = existing + merged.set(key, { href, file, lines, text, isAutotitle }) + } else { + existing.lines = lines + if (existing.isRedirect && flaw.isRedirect) { + existing.requiresVersionContext = + existing.requiresVersionContext || flaw.requiresVersionContext + if ( + existing.redirectTarget && + flaw.redirectTarget && + !sameDestination(existing.redirectTarget, flaw.redirectTarget) + ) { + existing.hasConflictingRedirectTargets = true + } + existing.redirectTarget = existing.redirectTarget ?? flaw.redirectTarget + } + } + } + + const all = [...merged.values()] + return { + brokenLinks: all.filter((flaw) => !flaw.isRedirect), + redirectLinks: all.filter((flaw) => flaw.isRedirect), + } +} + export function generateInternalLinkReport( brokenLinks: BrokenLink[], options: { diff --git a/src/links/scripts/check-links-pr.ts b/src/links/scripts/check-links-pr.ts index 3499a5e7bb39..93d9075ba6fb 100644 --- a/src/links/scripts/check-links-pr.ts +++ b/src/links/scripts/check-links-pr.ts @@ -19,6 +19,8 @@ import { checkAssetLink, isAssetLink, getRelativePath, + getLinkCheckVersions, + type ExtractedLink, } from '@/links/lib/extract-links' import { buildRenderContext, @@ -33,6 +35,7 @@ import { type CrossPageAnchorFlaw, generatePRComment, groupBrokenLinks, + mergeVersionedLinkFlaws, } from '@/links/lib/link-report' import { uploadArtifact } from '@/links/scripts/upload-artifact' import github from '@/workflows/github' @@ -50,13 +53,11 @@ interface CheckResult { async function checkFile( filePath: string, + sourcePage: Page | undefined, pageMap: Record, redirects: Record, - version: string = 'free-pro-team@latest', language: string = 'en', ): Promise { - const brokenLinks: BrokenLink[] = [] - const redirectLinks: BrokenLink[] = [] let totalLinksChecked = 0 let content: string @@ -64,53 +65,60 @@ async function checkFile( content = fs.readFileSync(filePath, 'utf-8') } catch { console.warn(`Could not read file: ${filePath}`) - return { file: filePath, brokenLinks, redirectLinks, totalLinksChecked } + return { file: filePath, brokenLinks: [], redirectLinks: [], totalLinksChecked } } - const context = createLiquidContext(version, language) + const file = getRelativePath(filePath) + const flaws: BrokenLink[] = [] + // Liquid shifts rendered lines, so use raw source lines unless a Liquid-built href has none. + const flawFor = (link: ExtractedLink): BrokenLink => { + const lines = findLinkLines( + content, + link.fragment ? `${link.href}#${link.fragment}` : link.href, + ) + return { + href: link.href, + file, + lines: lines.length ? lines : [link.line], + text: link.text, + isAutotitle: link.isAutotitle, + } + } - const { internalLinks } = await extractLinksWithLiquid(content, context) + // ifversion blocks and versionless links depend on the version, so check each one. + for (const version of getLinkCheckVersions(sourcePage)) { + // Pages use the anchor check's context so version names and feature flags evaluate like the site. + const context = sourcePage + ? buildRenderContext(sourcePage, version, pageMap, redirects) + : createLiquidContext(version, language) - // imageLinks are excluded: they're static assets, not docs pages. - totalLinksChecked = internalLinks.length + const { internalLinks } = await extractLinksWithLiquid(content, context) - for (const link of internalLinks) { - if (isAssetLink(link.href)) { - if (!checkAssetLink(link.href)) { - brokenLinks.push({ - href: link.href, - file: getRelativePath(filePath), - lines: [link.line], - text: link.text, - isAutotitle: link.isAutotitle, - }) + // imageLinks are excluded: they're static assets, not docs pages. + totalLinksChecked = Math.max(totalLinksChecked, internalLinks.length) + + for (const link of internalLinks) { + if (isAssetLink(link.href)) { + if (!checkAssetLink(link.href)) flaws.push(flawFor(link)) + continue } - continue - } - const result = checkInternalLink(link.href, pageMap, redirects) - - if (!result.exists) { - brokenLinks.push({ - href: link.href, - file: getRelativePath(filePath), - lines: [link.line], - text: link.text, - isAutotitle: link.isAutotitle, - }) - } else if (result.isRedirect) { - redirectLinks.push({ - href: link.href, - file: getRelativePath(filePath), - lines: [link.line], - text: link.text, - isAutotitle: link.isAutotitle, - isRedirect: true, - redirectTarget: result.redirectTarget, - }) + const result = checkInternalLink(link.href, pageMap, redirects, version, language) + + if (!result.exists) { + flaws.push(flawFor(link)) + } else if (result.isRedirect) { + flaws.push({ + ...flawFor(link), + isRedirect: true, + redirectTarget: result.redirectTarget, + requiresVersionContext: result.requiresVersionContext, + }) + } } } + const { brokenLinks, redirectLinks } = mergeVersionedLinkFlaws(flaws) return { file: filePath, brokenLinks, redirectLinks, totalLinksChecked } } @@ -369,13 +377,13 @@ async function main() { console.log(` Checking: ${file}`) } - const result = await checkFile(filePath, pageMap, redirects) + const sourcePage = pageByRelativePath.get(getRelativePath(filePath)) + const result = await checkFile(filePath, sourcePage, pageMap, redirects) allBrokenLinks.push(...result.brokenLinks) allRedirectLinks.push(...result.redirectLinks) totalLinksChecked += result.totalLinksChecked // Skip changed files outside pageByRelativePath because data and reusables have no versions of their own. - const sourcePage = pageByRelativePath.get(getRelativePath(filePath)) if (checkAnchors && sourcePage) { allBrokenAnchors.push( ...(await checkFileAnchors(filePath, sourcePage, pageMap, redirects, headingCache)), diff --git a/src/links/tests/extract-links.ts b/src/links/tests/extract-links.ts index ef2d8a046080..64b80ff47dc5 100644 --- a/src/links/tests/extract-links.ts +++ b/src/links/tests/extract-links.ts @@ -6,10 +6,12 @@ import { resolveInternalLinkKey, checkAssetLink, isAssetLink, + getLinkCheckVersions, createLiquidContext, extractLinksWithLiquid, } from '../lib/extract-links' import { latest, latestStable } from '@/versions/lib/enterprise-server-releases' +import { allVersions } from '@/versions/lib/all-versions' import { getFeaturesByVersion } from '@/versions/middleware/features' import type { Page } from '@/types' @@ -973,6 +975,33 @@ describe('resolveInternalLinkKey version precedence', () => { }) }) +describe('getLinkCheckVersions', () => { + test('returns every version the page applies to', () => { + const versions = ['free-pro-team@latest', 'enterprise-cloud@latest'] + expect(getLinkCheckVersions({ applicableVersions: versions })).toEqual(versions) + }) + + test('returns every current version for files without a page', () => { + expect(getLinkCheckVersions(undefined)).toEqual(Object.keys(allVersions)) + expect(getLinkCheckVersions({ applicableVersions: [] })).toEqual(Object.keys(allVersions)) + }) + + test('resolves versionless links on GHEC-only pages without a redirect', () => { + // Pages without FPT get a versionless fallback redirect to their first version. + const pageMap = { + '/en/enterprise-cloud@latest/admin/create-account': {} as unknown as Page, + } + const redirects = { + '/admin/create-account': '/enterprise-cloud@latest/admin/create-account', + } + const versions = getLinkCheckVersions({ applicableVersions: ['enterprise-cloud@latest'] }) + for (const version of versions) { + const result = checkInternalLink('/admin/create-account', pageMap, redirects, version) + expect(result).toEqual({ exists: true, isRedirect: false }) + } + }) +}) + describe('extractLinksWithLiquid with createLiquidContext', () => { const fptFeatures = getFeaturesByVersion('free-pro-team@latest') const fptFeature = Object.keys(fptFeatures).find((name) => fptFeatures[name]) diff --git a/src/links/tests/link-report.ts b/src/links/tests/link-report.ts index 8bdb0689021a..b0db40b7d3ce 100644 --- a/src/links/tests/link-report.ts +++ b/src/links/tests/link-report.ts @@ -11,6 +11,7 @@ import { generateSampleReports, classifyFixStrategy, mergeInternalLinkReports, + mergeVersionedLinkFlaws, describeVersions, } from '../lib/link-report' @@ -1137,6 +1138,69 @@ describe('merging redirect targets across versions', () => { }) }) +describe('mergeVersionedLinkFlaws', () => { + const file = 'content/admin/page.md' + + test('reports a missing target even when an earlier version redirects', () => { + const { brokenLinks, redirectLinks } = mergeVersionedLinkFlaws([ + { + href: '/admin/old', + file, + lines: [4], + isRedirect: true, + redirectTarget: '/enterprise-cloud@latest/admin/new', + }, + { href: '/admin/old', file, lines: [9] }, + ]) + expect(redirectLinks).toEqual([]) + expect(brokenLinks).toEqual([{ href: '/admin/old', file, lines: [4, 9] }]) + }) + + test('keeps a missing target when a later version redirects', () => { + const { brokenLinks, redirectLinks } = mergeVersionedLinkFlaws([ + { href: '/admin/old', file, lines: [9] }, + { href: '/admin/old', file, lines: [9], isRedirect: true, redirectTarget: '/admin/new' }, + ]) + expect(redirectLinks).toEqual([]) + expect(brokenLinks).toEqual([{ href: '/admin/old', file, lines: [9] }]) + }) + + test('merges redirect occurrences and flags conflicting targets', () => { + const { brokenLinks, redirectLinks } = mergeVersionedLinkFlaws([ + { href: '/old', file, lines: [7], isRedirect: true, redirectTarget: '/new' }, + { + href: '/old', + file, + lines: [3, 7], + isRedirect: true, + redirectTarget: '/enterprise-server@3.15/other', + requiresVersionContext: true, + }, + ]) + expect(brokenLinks).toEqual([]) + expect(redirectLinks).toHaveLength(1) + expect(redirectLinks[0]).toMatchObject({ + lines: [3, 7], + redirectTarget: '/new', + requiresVersionContext: true, + hasConflictingRedirectTargets: true, + }) + }) + + test('keeps different hrefs and files separate', () => { + const { brokenLinks } = mergeVersionedLinkFlaws([ + { href: '/a', file, lines: [1] }, + { href: '/b', file, lines: [2] }, + { href: '/a', file: 'content/other.md', lines: [1] }, + ]) + expect(brokenLinks.map((link) => `${link.file}${link.href}`)).toEqual([ + `${file}/a`, + `${file}/b`, + 'content/other.md/a', + ]) + }) +}) + describe('replacement targets in the JSON report', () => { const redirectOccurrence = ( href: string, From 53f9cdeeddecca96d85f3b5035b47beed1c8fbd5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:36:05 +0000 Subject: [PATCH 43/71] Drop cross-product children from Article API landing lists (#64047) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/article-api/lib/get-all-toc-items.ts | 24 ++++++------ src/article-api/tests/get-all-toc-items.ts | 45 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/article-api/lib/get-all-toc-items.ts b/src/article-api/lib/get-all-toc-items.ts index 51ed67bd1ec1..591880f387e1 100644 --- a/src/article-api/lib/get-all-toc-items.ts +++ b/src/article-api/lib/get-all-toc-items.ts @@ -41,15 +41,20 @@ export async function getAllTocItems( const basePath = options.basePath ?? pathname const resolvedChildren = pageWithChildren.children - .map((childHref) => ({ - childHref, - childPage: resolvePath(childHref, languageCode, pathname, context) as + .map((childHref) => { + const childPage = resolvePath(childHref, languageCode, pathname, context) as | PageWithChildren - | undefined, - })) + | undefined + const childPermalink = childPage?.permalinks.find( + (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, + ) + return { href: childPermalink?.href, childPage } + }) + // Frontmatter children live under the parent, so other matches are wrong-product fallbacks. .filter( - (entry): entry is { childHref: string; childPage: PageWithChildren } => + (entry): entry is { href: string; childPage: PageWithChildren } => entry.childPage !== undefined && + !!entry.href?.startsWith(`${pathname}/`) && !entry.childPage.hidden && // resolvePath can return a page from another version, which the website hides. (!context.currentVersion || @@ -57,12 +62,7 @@ export async function getAllTocItems( ) const items = await Promise.all( - resolvedChildren.map(async ({ childHref, childPage }) => { - const childPermalink = childPage.permalinks.find( - (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, - ) - const href = childPermalink ? childPermalink.href : childHref - + resolvedChildren.map(async ({ href, childPage }) => { const title = await renderPropFast(childPage, 'title', context) const intro = await renderPropFast(childPage, 'intro', context) diff --git a/src/article-api/tests/get-all-toc-items.ts b/src/article-api/tests/get-all-toc-items.ts index a024c3e78de5..b1021f1720be 100644 --- a/src/article-api/tests/get-all-toc-items.ts +++ b/src/article-api/tests/get-all-toc-items.ts @@ -100,4 +100,49 @@ describe('getAllTocItems', () => { expect(titles).toEqual(['Visible Article']) }) + + test('drops children that resolve to another product', async () => { + const ghes = 'enterprise-server@3.19' + const parent = createMockPage({ + rawTitle: 'REST API', + applicableVersions: [ghes], + permalinks: [{ languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/rest` }], + children: ['/actions', '/copilot', '/codespaces'], + }) + const actions = createMockPage({ + rawTitle: 'Actions', + applicableVersions: [ghes], + permalinks: [{ languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/rest/actions` }], + }) + const copilot = createMockPage({ + rawTitle: 'GitHub Copilot documentation', + applicableVersions: ['free-pro-team@latest', ghes], + permalinks: [ + { languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/copilot' }, + { languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/copilot` }, + ], + }) + const codespaces = createMockPage({ + rawTitle: 'GitHub Codespaces documentation', + applicableVersions: ['free-pro-team@latest'], + permalinks: [ + { languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/codespaces' }, + ], + }) + const context = { + currentVersion: ghes, + pages: { + [`/en/${ghes}/rest`]: parent, + [`/en/${ghes}/rest/actions`]: actions, + '/en/copilot': copilot, + '/en/codespaces': codespaces, + }, + redirects: {}, + } as unknown as Context + + const items = await getAllTocItems(parent, context) + + expect(items.map((item) => item.href)).toEqual([`/en/${ghes}/rest/actions`]) + expect(items[0].title).toBe('Actions') + }) }) From 706124246aa7b6b092a51fb435b9e8dba0d03eba Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 20 Oct 2026 22:36:12 +0000 Subject: [PATCH 44/71] Skip translation fallback comment when unwrapping titles (#64101) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- src/languages/README-translation-comments.md | 1 + src/languages/lib/render-with-fallback.ts | 5 ++- .../tests/translation-error-comments.ts | 39 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/languages/README-translation-comments.md b/src/languages/README-translation-comments.md index 7e6c577f02ad..5a94c2552e66 100644 --- a/src/languages/README-translation-comments.md +++ b/src/languages/README-translation-comments.md @@ -86,6 +86,7 @@ Comments are added when: Comments are skipped for: - English content (no fallback needed) - Text-only rendering (to avoid breaking plain text output) +- Unwrapped rendering, such as Markdown titles from the Article API (the comment would stop the outer tag from being removed) - Non-fallbackable errors (these throw and stop rendering) ### Error Message Sanitization diff --git a/src/languages/lib/render-with-fallback.ts b/src/languages/lib/render-with-fallback.ts index f863680d5b3a..641d3f878470 100644 --- a/src/languages/lib/render-with-fallback.ts +++ b/src/languages/lib/render-with-fallback.ts @@ -24,6 +24,7 @@ export class LiquidError extends Error { interface RenderOptions { throwIfEmpty?: boolean textOnly?: boolean + unwrap?: boolean cache?: boolean | ((template: string, context: Context) => string) [key: string]: unknown } @@ -140,8 +141,8 @@ export async function renderContentWithFallback( const fallbackContent = await renderContent(englishTemplate, enContext, options) - // HTML fallback comments break textOnly output, so add them only for non-English HTML. - if (context.currentLanguage !== 'en' && !options?.textOnly) { + // HTML fallback comments break textOnly output and stop unwrap from removing the outer tag. + if (context.currentLanguage !== 'en' && !options?.textOnly && !options?.unwrap) { const errorComment = createTranslationFallbackComment(error as Error, property) return `${errorComment}\n${fallbackContent}` } diff --git a/src/languages/tests/translation-error-comments.ts b/src/languages/tests/translation-error-comments.ts index 790fc0562511..a08b53562be4 100644 --- a/src/languages/tests/translation-error-comments.ts +++ b/src/languages/tests/translation-error-comments.ts @@ -362,6 +362,45 @@ describe('Translation Error Comments', () => { expect(result).toBe('English Title') }) + test('does not add comment for unwrapped rendering', async () => { + const mockPage = Object.create(Page.prototype) + mockPage.rawTitle = '{% badtag %}' + + const context = { + currentLanguage: 'ja', + getEnglishPage: () => { + const enPage = Object.create(Page.prototype) + enPage.rawTitle = 'English Title' + return enPage + }, + } + + const result = await renderContentWithFallback(mockPage, 'rawTitle', context, { + unwrap: true, + }) + + expect(result).not.toContain(' -* Anthropic PBC: {% data variables.product.github %} maintains a [zero data retention agreement](https://privacy.anthropic.com/en/articles/8956058-i-have-a-zero-retention-agreement-with-anthropic-what-products-does-it-apply-to) with Anthropic for generally available Anthropic features in {% data variables.product.prodname_copilot %}. {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} are not covered by this agreement by default. Enterprises that meet eligibility requirements can use {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention through the end of 2026 under a separate time-bound exemption; contact your account manager to learn more. Some Anthropic features in beta or {% data variables.release-phases.public_preview %}—including tool search via the Messages API—are also not covered by this agreement. For these features, data may be retained by Anthropic in accordance with [Anthropic's ZDR documentation](https://platform.claude.com/docs/en/build-with-claude/zero-data-retention). {% data variables.product.github %} will update this page as ZDR coverage changes. +* Anthropic PBC: {% data variables.product.github %} maintains a [zero data retention agreement](https://privacy.anthropic.com/en/articles/8956058-i-have-a-zero-retention-agreement-with-anthropic-what-products-does-it-apply-to) with Anthropic for generally available Anthropic features in {% data variables.product.prodname_copilot %}. {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} are not covered by this agreement by default. Enterprises that meet eligibility requirements can use {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} with zero data retention through the end of 2026 under a separate time-bound exemption; contact your account manager to learn more. Some Anthropic features in beta or {% data variables.release-phases.public_preview %}—including tool search via the Messages API—are also not covered by this agreement. For these features, data may be retained by Anthropic in accordance with [Anthropic's ZDR documentation](https://platform.claude.com/docs/en/build-with-claude/zero-data-retention). {% data variables.product.github %} will update this page as ZDR coverage changes. * Google Cloud: [Google commits to not training on {% data variables.product.github %} data as part of their service terms](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance). {% data variables.product.github %} is additionally not subject to prompt logging for abuse monitoring. To provide better service quality and reduce latency, {% data variables.product.github %} uses [prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching). You can read more about prompt caching on [Anthropic PBC](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching), [Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html), and [Google Cloud](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude-prompt-caching). -When using {% data variables.copilot.copilot_claude %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.claude %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## Google models Used for: -* {% data variables.copilot.copilot_gemini_38_flash %} -* {% data variables.copilot.copilot_gemini_4_argon %} +* {% data variables.copilot.gemini_38_flash %} +* {% data variables.copilot.gemini_4_argon %} -{% data variables.product.prodname_copilot %} uses {% data variables.copilot.copilot_gemini %} models hosted on Google Cloud Platform (GCP). When using {% data variables.copilot.copilot_gemini %} models, prompts and metadata are sent to GCP, which makes the [following data commitment](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance): _{% data variables.copilot.copilot_gemini %} doesn't use your prompts, or its responses, as data to train its models._ +{% data variables.product.prodname_copilot %} uses {% data variables.copilot.gemini %} models hosted on Google Cloud Platform (GCP). When using {% data variables.copilot.gemini %} models, prompts and metadata are sent to GCP, which makes the [following data commitment](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance): _{% data variables.copilot.gemini %} doesn't use your prompts, or its responses, as data to train its models._ To provide better service quality and reduce latency, {% data variables.product.github %} uses [Google's Durable Caching](https://docs.cloud.google.com/gemini-enterprise-agent-platform/resources/zero-data-retention#durable-data-caching). Cached data is encrypted and held for up to 24 hours. Cached data is never used to train or fine-tune models. -When using {% data variables.copilot.copilot_gemini %} models, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.gemini %} models, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## xAI models Used for: -* {% data variables.copilot.copilot_grok_45 %} -* {% data variables.copilot.copilot_grok_46 %} -* {% data variables.copilot.copilot_grok_47 %} +* {% data variables.copilot.grok_45 %} +* {% data variables.copilot.grok_46 %} +* {% data variables.copilot.grok_47 %} These models are hosted on xAI. xAI operates these models in {% data variables.product.prodname_copilot %} under a zero data retention API policy. This means xAI commits that user content (both inputs sent to the model and outputs generated by the model): @@ -114,15 +114,15 @@ When using xAI, input prompts and output completions continue to run through {% Used for: -* {% data variables.copilot.copilot_mai_code_1_1_flash %} +* {% data variables.copilot.mai_code_1_1_flash %} -{% data variables.copilot.copilot_mai_code_1_1_flash %} is a first-party Microsoft model hosted on Azure in {% data variables.product.github %}'s tenant. +{% data variables.copilot.mai_code_1_1_flash %} is a first-party Microsoft model hosted on Azure in {% data variables.product.github %}'s tenant. {% data variables.product.github %} does not use {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} customer data to train AI models. For individual subscribers—{% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %} users—{% data variables.product.github %} may use {% data variables.product.prodname_copilot_short %} interaction data, including prompts (inputs), suggestions (outputs), and code snippets generated during {% data variables.product.prodname_copilot_short %} sessions to train and improve AI models, in accordance with our [AUTOTITLE](/free-pro-team@latest/site-policy/privacy-policies/github-general-privacy-statement) and applicable user settings. Individual subscribers can opt out of having their data used for AI model training. To manage this setting, see [AUTOTITLE](/copilot/how-tos/manage-your-account/manage-policies#model-training-and-improvements). -{% data variables.copilot.copilot_mai_code_1_1_flash %} is served on Microsoft Azure AI Foundry within {% data variables.product.github %}'s tenant and is subject to {% data variables.product.github %}'s data handling configuration for that deployment. For details about how data is processed, retained, and secured for models served on Azure AI Foundry, see [Data, privacy, and security for Foundry Models sold by Azure](https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/openai/data-privacy?tabs=azure-portal) in the Microsoft documentation. +{% data variables.copilot.mai_code_1_1_flash %} is served on Microsoft Azure AI Foundry within {% data variables.product.github %}'s tenant and is subject to {% data variables.product.github %}'s data handling configuration for that deployment. For details about how data is processed, retained, and secured for models served on Azure AI Foundry, see [Data, privacy, and security for Foundry Models sold by Azure](https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/openai/data-privacy?tabs=azure-portal) in the Microsoft documentation. -When using {% data variables.copilot.copilot_mai_code_1_1_flash %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.mai_code_1_1_flash %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## Open-weight models @@ -132,17 +132,17 @@ Open-weight models have publicly available weights. {% data reusables.copilot.op Used for: -* {% data variables.copilot.copilot_kimi_k3 %} +* {% data variables.copilot.kimi_k3 %} -{% data variables.copilot.copilot_kimi_k3 %} was developed by Moonshot AI. It is an open-weight model that may be less aligned than other {% data variables.product.prodname_copilot_short %} models, with an elevated risk of geographic bias. Before you enable the model, review the [{% data variables.copilot.copilot_kimi_k3 %} model card](https://huggingface.co/moonshotai/Kimi-K3) and run your own evaluations. +{% data variables.copilot.kimi_k3 %} was developed by Moonshot AI. It is an open-weight model that may be less aligned than other {% data variables.product.prodname_copilot_short %} models, with an elevated risk of geographic bias. Before you enable the model, review the [{% data variables.copilot.kimi_k3 %} model card](https://huggingface.co/moonshotai/Kimi-K3) and run your own evaluations. -{% data variables.copilot.copilot_kimi_k3 %} is hosted by {% data variables.product.github %} on Fireworks AI. +{% data variables.copilot.kimi_k3 %} is hosted by {% data variables.product.github %} on Fireworks AI. -{% data variables.copilot.copilot_kimi_k3 %} is covered by a zero data retention agreement with the hosting provider. Fireworks AI does not use prompts or completions to train models, and prompts and completions are not sent to Moonshot AI. +{% data variables.copilot.kimi_k3 %} is covered by a zero data retention agreement with the hosting provider. Fireworks AI does not use prompts or completions to train models, and prompts and completions are not sent to Moonshot AI. {% data variables.product.github %} does not use {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} customer data to train AI models. For individual subscribers—{% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %} users—{% data variables.product.github %} may use {% data variables.product.prodname_copilot_short %} interaction data, including prompts (inputs), suggestions (outputs), and code snippets generated during {% data variables.product.prodname_copilot_short %} sessions to train and improve AI models, in accordance with our [AUTOTITLE](/free-pro-team@latest/site-policy/privacy-policies/github-general-privacy-statement) and applicable user settings. Individual subscribers can opt out of having their data used for AI model training. To manage this setting, see [AUTOTITLE](/copilot/how-tos/manage-your-account/manage-policies#model-training-and-improvements). -When using {% data variables.copilot.copilot_kimi_k3 %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters. +When using {% data variables.copilot.kimi_k3 %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters. ## Inline suggestions diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index a31ebb1d5520..bb40b4be56f2 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -44,11 +44,11 @@ This table lists the AI models available in {% data variables.product.prodname_c {% endrowheaders %} -[^claude-fable-5]: When {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). +[^claude-fable-5]: When {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). ## Supported AI models in {% data variables.copilot.copilot_auto_model_selection_short_cap_a %} -This table lists the supported AI models for {% data variables.copilot.copilot_auto_model_selection_short_cap_a %}. Available models may be limited by model policies. For example, as the long term support model, {% data variables.copilot.copilot_gpt_53_codex %} will be used in the event no other models are available. See [AUTOTITLE](/copilot/concepts/models/fallback-and-lts-models) and [AUTOTITLE](/copilot/concepts/models/auto-model-selection). +This table lists the supported AI models for {% data variables.copilot.copilot_auto_model_selection_short_cap_a %}. Available models may be limited by model policies. For example, as the long term support model, {% data variables.copilot.gpt_53_codex %} will be used in the event no other models are available. See [AUTOTITLE](/copilot/concepts/models/fallback-and-lts-models) and [AUTOTITLE](/copilot/concepts/models/auto-model-selection). {% rowheaders %} @@ -78,25 +78,25 @@ Choosing a larger context window or higher reasoning will impact {% data variabl | Model | 1 million token context window | Configurable reasoning | | --- | --- | --- | -| {% data variables.copilot.copilot_claude_sonnet_46 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_48 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_sonnet_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_sonnet_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_haiku_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_48_fast %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_fable_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_fable_51 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_53_codex %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_terra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_astra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_61_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_kimi_k3 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_46 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_48 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_haiku_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_48_fast %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_fable_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_fable_51 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_53_codex %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_terra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_astra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_61_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.kimi_k3 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | {% endrowheaders %} @@ -125,35 +125,35 @@ Some {% data variables.product.prodname_copilot_short %} models require minimum | Model | {% data variables.product.prodname_vscode %} | {% data variables.product.prodname_vs %} | JetBrains IDEs | Xcode | Eclipse | |----------------------------------------------------------|----------------------------------------------|------------------------------------------|----------------|-------|---------| -| {% data variables.copilot.copilot_gemini_38_flash %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gemini_4_argon %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_53_codex %} | `v1.104.1` | `17.14.19` | `1.5.61` | `0.45.0` | `0.13.0` | -| {% data variables.copilot.copilot_gpt_56_luna %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_56_sol %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_56_terra %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_astra %} | `1.136.1` | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_luna %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_61_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_48 %} | `v1.118` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_5 %} | `v1.128.0` | `17.14.22` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_sonnet_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_sonnet_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_haiku_55 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_fable_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_fable_51 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_kimi_k3 %} | `v1.131` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_mai_code_1_1_flash %} | `v1.121` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_45 %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_46 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_47 %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gemini_38_flash %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | +| {% data variables.copilot.gemini_4_argon %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_53_codex %} | `v1.104.1` | `17.14.19` | `1.5.61` | `0.45.0` | `0.13.0` | +| {% data variables.copilot.gpt_56_luna %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_56_sol %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_56_terra %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_astra %} | `1.136.1` | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_luna %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_61_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_48 %} | `v1.118` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_5 %} | `v1.128.0` | `17.14.22` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_sonnet_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_sonnet_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_haiku_55 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.claude_fable_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_fable_51 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.kimi_k3 %} | `v1.131` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.mai_code_1_1_flash %} | `v1.121` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.grok_45 %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.grok_46 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.grok_47 %} | TBD | `17.14.19` | TBD | TBD | TBD | {% endrowheaders %} > [!NOTE] > -> * For {% data variables.copilot.copilot_gpt_53_codex %} in {% data variables.product.prodname_vscode %}, `v1.108` and later provide improved prompting and response quality. +> * For {% data variables.copilot.gpt_53_codex %} in {% data variables.product.prodname_vscode %}, `v1.108` and later provide improved prompting and response quality. > * "No minimum listed" means the reviewed changelog or release guidance did not specify a minimum version, not that all older versions are supported. > * Even when a model appears in the model picker on older supported versions, prompting and model behavior may work best with the latest IDE and {% data variables.product.prodname_copilot_short %} extension or plugin versions. @@ -218,7 +218,7 @@ The following table lists AI models that are retired or scheduled for retirement {% endrowheaders %} -[^claude-sonnet-46-annual]: {% data variables.copilot.copilot_claude_sonnet_46 %} remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual plans. The retirement of {% data variables.copilot.copilot_claude_sonnet_46 %} does not apply to these subscribers. +[^claude-sonnet-46-annual]: {% data variables.copilot.claude_sonnet_46 %} remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual plans. The retirement of {% data variables.copilot.claude_sonnet_46 %} does not apply to these subscribers. ## Next steps diff --git a/content/copilot/reference/copilot-billing/models-and-pricing.md b/content/copilot/reference/copilot-billing/models-and-pricing.md index 0d193afb7f01..c35f81c2b76f 100644 --- a/content/copilot/reference/copilot-billing/models-and-pricing.md +++ b/content/copilot/reference/copilot-billing/models-and-pricing.md @@ -33,7 +33,7 @@ All prices are **per 1 million tokens**. {% data reusables.copilot.extended-context-pricing %} -{% data variables.copilot.copilot_gpt_56_sol %}, {% data variables.copilot.copilot_gpt_56_terra %}, {% data variables.copilot.copilot_gpt_56_luna %}, {% data variables.copilot.copilot_gpt_6_astra %}, {% data variables.copilot.copilot_gpt_6_luna %}, {% data variables.copilot.copilot_gpt_6_sol %}, and {% data variables.copilot.copilot_gpt_61_sol %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. +{% data variables.copilot.gpt_56_sol %}, {% data variables.copilot.gpt_56_terra %}, {% data variables.copilot.gpt_56_luna %}, {% data variables.copilot.gpt_6_astra %}, {% data variables.copilot.gpt_6_luna %}, {% data variables.copilot.gpt_6_sol %}, and {% data variables.copilot.gpt_61_sol %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. | Model | Release status | Category | Tier | Threshold (input tokens) | Input | Cached input | Cache write | Output | | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | @@ -120,10 +120,10 @@ You can view your current {% data variables.product.prodname_actions %} usage fo {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} subscribers on **existing annual billing plans** using the **request-based billing** model have different model multipliers. See [AUTOTITLE](/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans). -[^gemini-flash-promo]: {% data variables.copilot.copilot_gemini_38_flash %} is available at the promotional pricing of $0.75 per 1M input tokens, $0.075 per 1M cached input tokens, and $3.75 per 1M output tokens through December 31, 2026. +[^gemini-flash-promo]: {% data variables.copilot.gemini_38_flash %} is available at the promotional pricing of $0.75 per 1M input tokens, $0.075 per 1M cached input tokens, and $3.75 per 1M output tokens through December 31, 2026. -[^gemini-4-argon-promo]: {% data variables.copilot.copilot_gemini_4_argon %} is available at the promotional pricing of $2.00 per 1M input tokens, $0.10 per 1M cached input tokens, and $10.00 per 1M output tokens until December 1, 2026. +[^gemini-4-argon-promo]: {% data variables.copilot.gemini_4_argon %} is available at the promotional pricing of $2.00 per 1M input tokens, $0.10 per 1M cached input tokens, and $10.00 per 1M output tokens until December 1, 2026. diff --git a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md index 81098d27e996..20e4e5a183d2 100644 --- a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md +++ b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md @@ -33,7 +33,7 @@ The following table shows the model multipliers per supported model. > [!NOTE] > The multiplier for these models are subject to change. > -> * {% data variables.copilot.copilot_claude_sonnet_46 %} +> * {% data variables.copilot.claude_sonnet_46 %} > > If you use {% data variables.copilot.copilot_auto_model_selection_short %} in {% data variables.copilot.copilot_chat_short %}, {% data variables.copilot.copilot_cli_short %}, the {% data variables.copilot.github_copilot_app %}, or {% data variables.copilot.copilot_cloud_agent %}, you qualify for a 10% discount. For example, if a model has a multiplier of 1x you'll be billed at 0.9x instead. diff --git a/content/copilot/tutorials/vibe-coding.md b/content/copilot/tutorials/vibe-coding.md index 210331f0ae21..b422e4a32714 100644 --- a/content/copilot/tutorials/vibe-coding.md +++ b/content/copilot/tutorials/vibe-coding.md @@ -71,7 +71,7 @@ We'll create a time-tracking app in a new {% data variables.product.github %} re 1. Choose a model from the models dropdown. > [!NOTE] - > The responses referred to in this tutorial were received while using {% data variables.copilot.copilot_claude_sonnet_45 %}, which has since been retired. Other models will respond differently, but you should get roughly similar results. + > The responses referred to in this tutorial were received while using {% data variables.copilot.claude_sonnet_45 %}, which has since been retired. Other models will respond differently, but you should get roughly similar results. > > Set the model to **Auto**, or select a model of your choice. diff --git a/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md b/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md index f33a0a931eda..0345bca19b50 100644 --- a/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md +++ b/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md @@ -1 +1 @@ -Claude Code, {% data variables.product.prodname_openai_codex %}, or Google {% data variables.copilot.copilot_gemini %} CLI +Claude Code, {% data variables.product.prodname_openai_codex %}, or Google {% data variables.copilot.gemini %} CLI diff --git a/data/reusables/copilot/anthropic-claude-agent-models.md b/data/reusables/copilot/anthropic-claude-agent-models.md index 132cd68a834c..e52cccd31b2a 100644 --- a/data/reusables/copilot/anthropic-claude-agent-models.md +++ b/data/reusables/copilot/anthropic-claude-agent-models.md @@ -1,6 +1,6 @@ -* {% data variables.copilot.copilot_claude_sonnet_46 %} -* {% data variables.copilot.copilot_claude_sonnet_5 %} -* {% data variables.copilot.copilot_claude_opus_48 %} -* {% data variables.copilot.copilot_claude_opus_48_fast %} -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_fable_51 %} +* {% data variables.copilot.claude_sonnet_46 %} +* {% data variables.copilot.claude_sonnet_5 %} +* {% data variables.copilot.claude_opus_48 %} +* {% data variables.copilot.claude_opus_48_fast %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_fable_51 %} diff --git a/data/reusables/copilot/available-models-per-plan.md b/data/reusables/copilot/available-models-per-plan.md index 6efe53aa7921..323d33a090c9 100644 --- a/data/reusables/copilot/available-models-per-plan.md +++ b/data/reusables/copilot/available-models-per-plan.md @@ -12,4 +12,4 @@ [^gpt54nano]: GPT-5.4 nano is currently only available in the Codex {% data variables.product.prodname_vscode %} extension ({% data variables.copilot.copilot_pro_plus_short %} only) and is not available in {% data variables.copilot.copilot_chat_short %}. -[^claude-sonnet-46-plans]: {% data variables.copilot.copilot_claude_sonnet_46 %} was retired on September 1, 2026, but remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} plans. It is not available to subscribers on monthly plans. +[^claude-sonnet-46-plans]: {% data variables.copilot.claude_sonnet_46 %} was retired on September 1, 2026, but remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} plans. It is not available to subscribers on monthly plans. diff --git a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md index 6da4ff46e703..df58864e20f6 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -1,18 +1,18 @@ -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_opus_55 %} -* {% data variables.copilot.copilot_claude_sonnet_55 %} -* {% data variables.copilot.copilot_claude_haiku_45 %} -* {% data variables.copilot.copilot_claude_haiku_55 %} -* {% data variables.copilot.copilot_gemini_38_flash %} -* {% data variables.copilot.copilot_gemini_4_argon %} -* {% data variables.copilot.copilot_gpt_56_luna %} -* {% data variables.copilot.copilot_gpt_56_sol %} -* {% data variables.copilot.copilot_gpt_56_terra %} -* {% data variables.copilot.copilot_gpt_6_astra %} -* {% data variables.copilot.copilot_gpt_6_luna %} -* {% data variables.copilot.copilot_gpt_6_sol %} -* {% data variables.copilot.copilot_gpt_61_sol %} -* {% data variables.copilot.copilot_grok_45 %} -* {% data variables.copilot.copilot_grok_46 %} -* {% data variables.copilot.copilot_grok_47 %} -* {% data variables.copilot.copilot_mai_code_1_1_flash %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_opus_55 %} +* {% data variables.copilot.claude_sonnet_55 %} +* {% data variables.copilot.claude_haiku_45 %} +* {% data variables.copilot.claude_haiku_55 %} +* {% data variables.copilot.gemini_38_flash %} +* {% data variables.copilot.gemini_4_argon %} +* {% data variables.copilot.gpt_56_luna %} +* {% data variables.copilot.gpt_56_sol %} +* {% data variables.copilot.gpt_56_terra %} +* {% data variables.copilot.gpt_6_astra %} +* {% data variables.copilot.gpt_6_luna %} +* {% data variables.copilot.gpt_6_sol %} +* {% data variables.copilot.gpt_61_sol %} +* {% data variables.copilot.grok_45 %} +* {% data variables.copilot.grok_46 %} +* {% data variables.copilot.grok_47 %} +* {% data variables.copilot.mai_code_1_1_flash %} diff --git a/data/reusables/copilot/model-autoenablement-excluded-models.md b/data/reusables/copilot/model-autoenablement-excluded-models.md index 37fe8dd99409..3ae1f43176f3 100644 --- a/data/reusables/copilot/model-autoenablement-excluded-models.md +++ b/data/reusables/copilot/model-autoenablement-excluded-models.md @@ -3,6 +3,6 @@ The default policy applies to models that you have not explicitly configured. Th The following models are **not** in scope. They are disabled by default, regardless of your "Default availability" policy setting. * Pre-GA models -* Open weight models (DeepSeek, {% data variables.copilot.copilot_kimi_k3 %}) -* Models that are not covered by {% data variables.product.company_short %}'s data retention agreement ({% data variables.copilot.copilot_claude_fable_5 %}, {% data variables.copilot.copilot_claude_fable_51 %}) +* Open weight models (DeepSeek, {% data variables.copilot.kimi_k3 %}) +* Models that are not covered by {% data variables.product.company_short %}'s data retention agreement ({% data variables.copilot.claude_fable_5 %}, {% data variables.copilot.claude_fable_51 %}) * For enterprises that have restricted models to data-resident or FedRAMP-compliant models, any models that do not respect these policies diff --git a/data/reusables/copilot/openai-codex-agent-models.md b/data/reusables/copilot/openai-codex-agent-models.md index 3ff97c9ed39a..b42391ed1fb1 100644 --- a/data/reusables/copilot/openai-codex-agent-models.md +++ b/data/reusables/copilot/openai-codex-agent-models.md @@ -1,2 +1,2 @@ -* {% data variables.copilot.copilot_gpt_53_codex %} -* {% data variables.copilot.copilot_gpt_54_nano %} +* {% data variables.copilot.gpt_53_codex %} +* {% data variables.copilot.gpt_54_nano %} diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index 49704a34dc1d..67221b29b1e7 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -182,74 +182,74 @@ max-chat-indexed-repos: '50' ## LLM models for Copilot # Claude: -copilot_claude: 'Claude' -copilot_claude_haiku_45: 'Claude Haiku 4.5' -copilot_claude_haiku_55: 'Claude Haiku 5.5' -copilot_claude_opus: 'Claude Opus 4' -copilot_claude_opus_41: 'Claude Opus 4.1' -copilot_claude_opus_45: 'Claude Opus 4.5' -copilot_claude_opus_46: 'Claude Opus 4.6' -copilot_claude_opus_46_fast: 'Claude Opus 4.6 (fast mode) (preview)' -copilot_claude_opus_48: 'Claude Opus 4.8' -copilot_claude_opus_48_fast: 'Claude Opus 4.8 (fast mode) (preview)' -copilot_claude_opus_5: 'Claude Opus 5' -copilot_claude_opus_55: 'Claude Opus 5.5' -copilot_claude_fable_5: 'Claude Fable 5' -copilot_claude_fable_51: 'Claude Fable 5.1' -copilot_claude_sonnet: 'Claude Sonnet' -copilot_claude_sonnet_35: 'Claude Sonnet 3.5' -copilot_claude_sonnet_37: 'Claude Sonnet 3.7' -copilot_claude_sonnet_40: 'Claude Sonnet 4' -copilot_claude_sonnet_45: 'Claude Sonnet 4.5' -copilot_claude_sonnet_46: 'Claude Sonnet 4.6' -copilot_claude_sonnet_5: 'Claude Sonnet 5' -copilot_claude_sonnet_55: 'Claude Sonnet 5.5' +claude: 'Claude' +claude_haiku_45: 'Claude Haiku 4.5' +claude_haiku_55: 'Claude Haiku 5.5' +claude_opus: 'Claude Opus 4' +claude_opus_41: 'Claude Opus 4.1' +claude_opus_45: 'Claude Opus 4.5' +claude_opus_46: 'Claude Opus 4.6' +claude_opus_46_fast: 'Claude Opus 4.6 (fast mode) (preview)' +claude_opus_48: 'Claude Opus 4.8' +claude_opus_48_fast: 'Claude Opus 4.8 (fast mode) (preview)' +claude_opus_5: 'Claude Opus 5' +claude_opus_55: 'Claude Opus 5.5' +claude_fable_5: 'Claude Fable 5' +claude_fable_51: 'Claude Fable 5.1' +claude_sonnet: 'Claude Sonnet' +claude_sonnet_35: 'Claude Sonnet 3.5' +claude_sonnet_37: 'Claude Sonnet 3.7' +claude_sonnet_40: 'Claude Sonnet 4' +claude_sonnet_45: 'Claude Sonnet 4.5' +claude_sonnet_46: 'Claude Sonnet 4.6' +claude_sonnet_5: 'Claude Sonnet 5' +claude_sonnet_55: 'Claude Sonnet 5.5' # Gemini: -copilot_gemini: 'Gemini' -copilot_gemini_flash: 'Gemini 2.0 Flash' -copilot_gemini_3_flash: 'Gemini 3 Flash' -copilot_gemini_25_pro: 'Gemini 2.5 Pro' -copilot_gemini_3_pro: 'Gemini 3 Pro' -copilot_gemini_31_pro: 'Gemini 3.1 Pro' -copilot_gemini_38_flash: 'Gemini 3.8 Flash' -copilot_gemini_4_argon: 'Gemini 4 Argon' +gemini: 'Gemini' +gemini_flash: 'Gemini 2.0 Flash' +gemini_3_flash: 'Gemini 3 Flash' +gemini_25_pro: 'Gemini 2.5 Pro' +gemini_3_pro: 'Gemini 3 Pro' +gemini_31_pro: 'Gemini 3.1 Pro' +gemini_38_flash: 'Gemini 3.8 Flash' +gemini_4_argon: 'Gemini 4 Argon' # OpenAI GPT series: -copilot_gpt_4o: 'GPT-4o' -copilot_gpt_4o_mini: 'GPT-4o mini' -copilot_gpt_41: 'GPT-4.1' -copilot_gpt_5: 'GPT-5' -copilot_gpt_5_codex: 'GPT-5-Codex' -copilot_gpt_51: 'GPT-5.1' -copilot_gpt_51_codex: 'GPT-5.1-Codex' -copilot_gpt_51_codex_mini: 'GPT-5.1-Codex-Mini' -copilot_gpt_51_codex_max: 'GPT-5.1-Codex-Max' -copilot_gpt_52: 'GPT-5.2' -copilot_gpt_52_codex: 'GPT-5.2-Codex' -copilot_gpt_53_codex: 'GPT-5.3-Codex' -copilot_gpt_54_nano: 'GPT-5.4 nano' -copilot_gpt_56_luna: 'GPT-5.6 Luna' -copilot_gpt_56_sol: 'GPT-5.6 Sol' -copilot_gpt_56_terra: 'GPT-5.6 Terra' -copilot_gpt_6_astra: 'GPT-6 Astra' -copilot_gpt_6_luna: 'GPT-6 Luna' -copilot_gpt_6_sol: 'GPT-6 Sol' -copilot_gpt_61_sol: 'GPT-6.1 Sol' +gpt_4o: 'GPT-4o' +gpt_4o_mini: 'GPT-4o mini' +gpt_41: 'GPT-4.1' +gpt_5: 'GPT-5' +gpt_5_codex: 'GPT-5-Codex' +gpt_51: 'GPT-5.1' +gpt_51_codex: 'GPT-5.1-Codex' +gpt_51_codex_mini: 'GPT-5.1-Codex-Mini' +gpt_51_codex_max: 'GPT-5.1-Codex-Max' +gpt_52: 'GPT-5.2' +gpt_52_codex: 'GPT-5.2-Codex' +gpt_53_codex: 'GPT-5.3-Codex' +gpt_54_nano: 'GPT-5.4 nano' +gpt_56_luna: 'GPT-5.6 Luna' +gpt_56_sol: 'GPT-5.6 Sol' +gpt_56_terra: 'GPT-5.6 Terra' +gpt_6_astra: 'GPT-6 Astra' +gpt_6_luna: 'GPT-6 Luna' +gpt_6_sol: 'GPT-6 Sol' +gpt_61_sol: 'GPT-6.1 Sol' # OpenAI 'o' series: -copilot_o3: 'o3' -copilot_o4_mini: 'o4-mini' +o3: 'o3' +o4_mini: 'o4-mini' ## xAI: -copilot_grok_code: 'Grok Code Fast 1' -copilot_grok_45: 'Grok 4.5' -copilot_grok_46: 'Grok 4.6' -copilot_grok_47: 'Grok 4.7' +grok_code: 'Grok Code Fast 1' +grok_45: 'Grok 4.5' +grok_46: 'Grok 4.6' +grok_47: 'Grok 4.7' # Qwen: -copilot_qwen_25: 'Qwen2.5' +qwen_25: 'Qwen2.5' # Microsoft: -copilot_mai_code_1_1_flash: 'MAI-Code-1.1-Flash' +mai_code_1_1_flash: 'MAI-Code-1.1-Flash' # Microsoft fine-tuned: -copilot_raptor_mini: 'Raptor mini' +raptor_mini: 'Raptor mini' # Open-weight models: -copilot_kimi_k3: 'Kimi K3' +kimi_k3: 'Kimi K3' ## Next edit suggestions next_edit_suggestions: 'next edit suggestions' From 527f71da381c72fb4d4f0fe64c4eae928d2edaa5 Mon Sep 17 00:00:00 2001 From: Pantelis <85220850+Pantelis-Santorinios@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:27:03 +0000 Subject: [PATCH 61/71] Update supported processor architectures section for self-hosted runners (#64317) --- content/actions/reference/runners/self-hosted-runners.md | 1 - 1 file changed, 1 deletion(-) diff --git a/content/actions/reference/runners/self-hosted-runners.md b/content/actions/reference/runners/self-hosted-runners.md index 66f804626618..a82b03bde608 100644 --- a/content/actions/reference/runners/self-hosted-runners.md +++ b/content/actions/reference/runners/self-hosted-runners.md @@ -59,7 +59,6 @@ You can use a machine as a self-hosted runner as long as it meets these requirem * `x64` - Linux, macOS, Windows. * `ARM64` - Linux, macOS{% ifversion actions-windows-arm %}, Windows (currently in {% data variables.release-phases.public_preview %}){% endif %}. -* `ARM32` - Linux. ## Routing precedence for self-hosted runners From bca24fc390b0c8d7109a08f9d7b5c9f62318e529 Mon Sep 17 00:00:00 2001 From: Carlin Cherry <61124041+carlincherry@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:33:08 +0000 Subject: [PATCH 62/71] Document the Transitive label in Dependabot alert filters (#64309) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ec3aa8f-d7ea-435a-95da-fb99d518dac6 --- data/reusables/dependabot/dependabot-alerts-filters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/reusables/dependabot/dependabot-alerts-filters.md b/data/reusables/dependabot/dependabot-alerts-filters.md index 064a1b09dd7f..fb44f5029225 100644 --- a/data/reusables/dependabot/dependabot-alerts-filters.md +++ b/data/reusables/dependabot/dependabot-alerts-filters.md @@ -17,7 +17,7 @@ You can sort and filter {% data variables.product.prodname_dependabot_alerts %} | `manifest` | Displays alerts for the selected manifest | Use `manifest:webwolf/pom.xml` to show alerts on the pom.xml file of the webwolf application | | `package` | Displays alerts for the selected package | Use `package:django` to show alerts for django | | {% ifversion transitive-dependency-labeling-npm %} | -| `relationship` | Displays alerts of the selected relationship status
Note that this filter is only available for ecosystems with transitive support. | Use `relationship:direct` to show alerts for direct dependencies (marked with the `Direct` label). | +| `relationship` | Displays alerts of the selected relationship status
Note that this filter is only available for ecosystems with transitive support. | Use `relationship:direct` to show alerts for direct dependencies (marked with the `Direct` label), or `relationship:transitive` to show alerts for transitive dependencies (marked with the `Transitive` label). | | {% endif %} | | `resolution` | Displays alerts of the selected resolution status | Use `resolution:no-bandwidth` to show alerts previously parked due to lack of resources or time to fix them | | `repo` | Displays alerts based on the repository they relate to
Note that this filter is only available for security overview. For more information, see [AUTOTITLE](/code-security/concepts/security-at-scale/security-overview) | Use `repo:octocat-repo` to show alerts in the repository called `octocat-repo` | From 0501c1f24604948e69084f1c1681e9977b57a041 Mon Sep 17 00:00:00 2001 From: pararohan Date: Wed, 21 Oct 2026 13:39:04 +0000 Subject: [PATCH 63/71] Rule insights dashboard: org-level is cloud only, repo-level available on GHES 3.23+ (#64300) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 562249f9-a044-4d1e-aaa4-ebcb04d23f72 --- .../managing-rulesets-for-a-repository.md | 8 ++++++++ data/features/rule-insights-dashboard-org-level.yml | 1 - data/features/rule-insights-dashboard.yml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md index 80ab6a4de568..b6180455cc92 100644 --- a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md +++ b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md @@ -115,9 +115,13 @@ You can view insights for rulesets to see how rulesets are affecting a repositor ### Viewing the rule insights dashboard +{% ifversion fpt or ghec %} + > [!NOTE] > The rule insights dashboard is available for {% data variables.product.prodname_team %} and {% data variables.product.prodname_ghe_cloud %} plans. +{% endif %} + The rule insights dashboard gives you a visual, high-level summary of rule evaluation activity for your repository, including: * **Successes, failures, and bypasses over time**: A chart showing trends in rule evaluation results, helping you spot spikes in blocked pushes or unusual patterns. @@ -132,12 +136,16 @@ To view the dashboard: 1. In the left sidebar, under "Code and automation," click **Rules**, then click **Insights**. 1. At the top of the "Rule Insights" page, view the dashboard charts for an overview of rule evaluation activity. +{% ifversion rule-insights-dashboard-org-level %} + At the organization level, you can view the rule insights dashboard to review the evaluation activity of rulesets across your organization. See [AUTOTITLE](/organizations/managing-organization-settings/managing-rulesets-for-repositories-in-your-organization#viewing-the-rule-insights-dashboard). {% endif %} {% endif %} +{% endif %} + {% ifversion push-rule-delegated-bypass %} {% data reusables.repositories.managing-delegated-bypass %} diff --git a/data/features/rule-insights-dashboard-org-level.yml b/data/features/rule-insights-dashboard-org-level.yml index 33e16d808761..3a4030c563e1 100644 --- a/data/features/rule-insights-dashboard-org-level.yml +++ b/data/features/rule-insights-dashboard-org-level.yml @@ -3,4 +3,3 @@ versions: fpt: '*' ghec: '*' - ghes: '>=3.23' diff --git a/data/features/rule-insights-dashboard.yml b/data/features/rule-insights-dashboard.yml index 14ba8f5e2d5b..d14197926f73 100644 --- a/data/features/rule-insights-dashboard.yml +++ b/data/features/rule-insights-dashboard.yml @@ -1,3 +1,4 @@ versions: fpt: '*' ghec: '*' + ghes: '>=3.24' From 86e45eebccc0be5e2c760cf3ded666ec6fc99c53 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Oct 2026 14:02:44 +0000 Subject: [PATCH 64/71] Stop GHD022 from deleting content behind ifversion != (#63936) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../liquid-ifversion-versions.ts | 69 ++++++++-- .../tests/unit/liquid-ifversion-versions.ts | 128 +++++++++++++++++- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts index 30b176e2b028..9abc73c5820d 100644 --- a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts +++ b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts @@ -59,6 +59,8 @@ type CondTagItem = { versionsObj: VersionsObject featureVersionsObj?: VersionsObject versionsObjAll: VersionsObject + // A bare ghes in an and chain, such as ghes and ghes != 3.20, keeps fpt and ghec excluded. + ghesGuard: boolean versions: string[] action: CondTagAction // addError accepts this cached range, but this rule never sets it. @@ -314,6 +316,7 @@ async function initTagObject( versionsObj: {}, featureVersionsObj: undefined, versionsObjAll: {}, + ghesGuard: false, versions: [], action: { type: 'none' }, } @@ -326,14 +329,57 @@ async function initTagObject( ...condTagItem.versionsObj, ...condTagItem.featureVersionsObj, } - condTagItem.versions = getApplicableVersions(condTagItem.versionsObj, '', { - doNotThrow: true, - includeNextVersion: true, - }) + condTagItem.ghesGuard = condTagItem.cond + .split(' or ') + .some( + (part) => + part.includes(' and ') && part.split(' and ').some((and) => and.trim() === 'ghes'), + ) + condTagItem.versions = getApplicableVersions( + toSemverVersionsObj(condTagItem.versionsObj, condTagItem.ghesGuard), + '', + { doNotThrow: true, includeNextVersion: true }, + ) } return condTagItem } +// semver has no != comparator, so split each != term into < and > branches. +// The ifversion tag renders a version comparison as false on other plans, except !=, which +// renders as true. So a GHES range made only of != terms also applies to fpt and ghec. +function toSemverVersionsObj(versionsObj: VersionsObject, ghesGuard: boolean): VersionsObject { + const ghes = versionsObj.ghes + if (!ghes || !ghes.includes('!=')) return versionsObj + const terms = rangeTerms(ghes) + const notEqualReleases = terms + .filter(([operator]) => operator === '!=') + .map(([, release]) => release) + const otherTerms = terms + .filter(([operator]) => operator !== '!=') + .map(([operator, release]) => `${operator} ${release}`) + let branches: string[][] = [otherTerms] + for (const release of notEqualReleases) { + branches = branches.flatMap((branch) => [ + [...branch, `< ${release}`], + [...branch, `> ${release}`], + ]) + } + const semverRange = branches.map((branch) => branch.join(' ')).join(' || ') + return otherTerms.length || ghesGuard + ? { ...versionsObj, ghes: semverRange } + : { ...versionsObj, fpt: '*', ghec: '*', ghes: semverRange } +} + +// Dropping the last bound beside != terms would make the condition render true on fpt and ghec. +function widensToOtherPlans(range: string, simplifiedRange: string): boolean { + return !isNotEqualOnly(range) && isNotEqualOnly(simplifiedRange) +} + +function isNotEqualOnly(range: string): boolean { + const terms = rangeTerms(range) + return terms.length > 0 && terms.every(([operator]) => operator === '!=') +} + // Resolve each feature through getFeatureVersionsObject, which reads ROOT at call time. // Frontmatter can combine plans with a feature, list several features, or use full plan names. function getResolvedVersions(versionsObj: FileVersionsFm): string[] { @@ -497,13 +543,16 @@ function updateConditionals(condTagItems: CondTagItem[]) { } // Also drop deprecated lower bounds so one fix pass leaves no GHD022 error. if (item.versionsObj.ghes && item.versionsObj.ghes !== '*') { - item.versionsObj.ghes = getSimplifiedSemverRange( + const simplifiedGhes = getSimplifiedSemverRange( rangeTerms(item.versionsObj.ghes) .map((term) => term.join(' ')) .join(' '), ) + if (!widensToOtherPlans(item.versionsObj.ghes, simplifiedGhes)) { + item.versionsObj.ghes = simplifiedGhes + } } - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) item.action.type = 'change' continue } @@ -512,11 +561,12 @@ function updateConditionals(condTagItems: CondTagItem[]) { if (!item.versionsObjAll.ghes || item.versionsObjAll.ghes === '*') continue const simplifiedSemver = getSimplifiedSemverRange(item.versionsObjAll.ghes) + if (widensToOtherPlans(item.versionsObjAll.ghes, simplifiedSemver)) continue // Drop GHES from the conditional when other products still apply. if (simplifiedSemver === '' && Object.keys(item.versionsObj).length > 1) { item.action.type = 'change' delete item.versionsObj.ghes - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) continue } @@ -524,7 +574,7 @@ function updateConditionals(condTagItems: CondTagItem[]) { if (item.versionsObjAll.ghes !== simplifiedSemver && !item.versionsObjAll.feature) { item.action.type = 'change' item.versionsObj.ghes = simplifiedSemver - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) } } @@ -562,11 +612,12 @@ function updateConditionals(condTagItems: CondTagItem[]) { // Turn a versions object back into Liquid, such as { ghes: '> 3.18 < 3.22', fpt: '*' } // into 'ghes > 3.18 and ghes < 3.22 or fpt'. -function toLiquidCondition(versionsObj: VersionsObject): string { +function toLiquidCondition(versionsObj: VersionsObject, ghesGuard = false): string { return Object.entries(versionsObj) .map(([key, value]) => { if (key === 'feature') return value const terms = rangeTerms(value).map(([operator, release]) => `${key} ${operator} ${release}`) + if (key === 'ghes' && ghesGuard && isNotEqualOnly(value)) terms.unshift(key) return terms.length ? terms.join(' and ') : key }) .join(' or ') diff --git a/src/content-linter/tests/unit/liquid-ifversion-versions.ts b/src/content-linter/tests/unit/liquid-ifversion-versions.ts index 4802a6786b28..cc47a4f5d497 100644 --- a/src/content-linter/tests/unit/liquid-ifversion-versions.ts +++ b/src/content-linter/tests/unit/liquid-ifversion-versions.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { liquidIfversionVersions } from '../../lib/linting-rules/liquid-ifversion-versions' -import { supported } from '@/versions/lib/enterprise-server-releases' +import { deprecated, supported } from '@/versions/lib/enterprise-server-releases' describe(liquidIfversionVersions.names.join(' - '), () => { const envVarValueBefore: string | undefined = process.env.ROOT @@ -385,6 +385,132 @@ describe(liquidIfversionVersions.names.join(' - '), () => { expect(errors[0].fixInfo?.insertText).toBe('ifversion ghec') }) + test.each([ + `{% ifversion ghes != ${supported[2]} %}A{% endif %}`, + `{% ifversion ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes > ${supported[4]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes != ${supported[1]} %}A{% else %}B{% endif %}`, + ])('ifversion using != keeps the content: %s', async (liquid) => { + const markdown = [...placeholderAllVersionsFm, liquid].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != on a GHES-only page keeps the content', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' ghes: "*"', + '---', + `{% ifversion ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != with a deprecated release applies to all versions', async () => { + const markdown = [ + ...placeholderAllVersionsFm, + `{% ifversion ghes != ${deprecated[0]} %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(2) + expect(errors[0].errorDetail).toContain('applies to all versions') + }) + + test.each([ + `{% ifversion ghes and ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes %}A{% else %}B{% endif %}`, + ])('ifversion using != with a ghes guard applies to no cloud versions: %s', async (liquid) => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghec: "*"', + '---', + liquid, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors[0].errorDetail).toContain('applies to no versions') + expect(errors.some((error) => error.errorDetail.includes('applies to all versions'))).toBe(true) + }) + + test.each([ + `{% ifversion ghes and ghes != ${deprecated[0]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${deprecated[0]} and ghes %}A{% else %}B{% endif %}`, + ])('ifversion using != with a ghes guard keeps the else content: %s', async (liquid) => { + const markdown = [...placeholderAllVersionsFm, liquid].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != keeps the ghes guard when removing a product', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghes: "*"', + '---', + `{% ifversion ghes and ghes != ${supported[0]} or ghec %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].fixInfo?.insertText).toBe(`ifversion ghes and ghes != ${supported[0]}`) + }) + + test('ifversion using != keeps a deprecated lower bound so fpt and ghec stay excluded', async () => { + const markdown = [ + ...placeholderAllVersionsFm, + `{% ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]} %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.map((error) => error.fixInfo?.insertText)).not.toContain( + `ifversion ghes != ${supported[0]}`, + ) + expect(errors).toEqual([]) + }) + + test('ifversion using != keeps a deprecated lower bound when removing a product', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghes: "*"', + '---', + `{% ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]} or ghec %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].fixInfo?.insertText).toBe( + `ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]}`, + ) + }) + test('does not crash with nested if blocks inside ifversion', async () => { const markdown = [ ...placeholderAllVersionsFm, From 961986067876ea6eb91286c35c2238fda519836d Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Oct 2026 14:02:52 +0000 Subject: [PATCH 65/71] Keep locale and version in landing page hero links (#63944) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf5940de-658e-4a1a-bffa-3018fb78b138 --- .../fixtures/article-with-introLinks.md | 2 + src/frame/tests/page.ts | 28 +++++++++ .../components/shared/LandingHero.tsx | 13 +++- src/landings/lib/intro-links.ts | 20 ++++++ src/landings/pages/product.tsx | 7 +++ src/landings/tests/intro-links.ts | 56 +++++++++++++++++ src/landings/tests/landing-hero.ts | 63 +++++++++++++++++++ 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/landings/lib/intro-links.ts create mode 100644 src/landings/tests/intro-links.ts create mode 100644 src/landings/tests/landing-hero.ts diff --git a/src/fixtures/fixtures/article-with-introLinks.md b/src/fixtures/fixtures/article-with-introLinks.md index bfff0ffec3d2..5f7491a83e10 100644 --- a/src/fixtures/fixtures/article-with-introLinks.md +++ b/src/fixtures/fixtures/article-with-introLinks.md @@ -6,4 +6,6 @@ versions: introLinks: overview: 'https://github.com' custom link!: 'https://github.com/features' + quickstart: /get-started/quickstart + reference: /get-started/reference --- diff --git a/src/frame/tests/page.ts b/src/frame/tests/page.ts index 7e14641ed98d..7242c55800fc 100644 --- a/src/frame/tests/page.ts +++ b/src/frame/tests/page.ts @@ -270,8 +270,36 @@ describe('Page class', () => { expect(page!.introLinks).toStrictEqual({ overview: 'https://github.com', 'custom link!': 'https://github.com/features', + quickstart: '/get-started/quickstart', + reference: '/get-started/reference', }) }) + + // Markdown and article API requests read introLinks from the shared Page without rendering it. + test('keeps every link after rendering a version that lacks a target page', async () => { + const page = await Page.init({ + relativePath: 'article-with-introLinks.md', + basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), + languageCode: 'en', + }) + const pages = { + '/en/get-started/quickstart': { applicableVersions: [nonEnterpriseDefaultVersion] }, + '/en/get-started/reference': { applicableVersions: Object.keys(allVersions) }, + } as unknown as Context['pages'] + + await page!.render({ + currentLanguage: 'en', + currentVersion: `enterprise-server@${latest}`, + pages, + redirects: {}, + }) + expect(Object.keys(page!.introLinks!)).toEqual([ + 'overview', + 'custom link!', + 'quickstart', + 'reference', + ]) + }) }) describe('Page.parseFrontmatter()', () => { diff --git a/src/landings/components/shared/LandingHero.tsx b/src/landings/components/shared/LandingHero.tsx index 4870022da520..6eb7da6e8aea 100644 --- a/src/landings/components/shared/LandingHero.tsx +++ b/src/landings/components/shared/LandingHero.tsx @@ -1,8 +1,10 @@ +import { useRouter } from 'next/router' import { LinkExternalIcon } from '@primer/octicons-react' import { Button, ButtonGroup, Heading, Text } from '@primer/react-brand' import styles from './LandingHero.module.scss' import { LandingSection } from './LandingSection' import { useTranslation } from '@/languages/components/useTranslation' +import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' type LandingHeroProps = { @@ -24,6 +26,15 @@ function heroBackgroundCss(heroImage: string | undefined) { export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHeroProps) => { const { t } = useTranslation(['product_landing']) + const router = useRouter() + const { currentVersion } = useVersion() + + // Frontmatter paths lack locale and version, and redirects for bare paths take both from cookies or headers, not this page. + const localizeHref = (href: string) => { + if (!href.startsWith('/') || href.startsWith('//')) return href + const versionPrefix = currentVersion !== DEFAULT_VERSION ? `/${currentVersion}` : '' + return `/${router.locale}${versionPrefix}${href}` + } const linkEntries = introLinks ? Object.entries(introLinks) : [] @@ -51,7 +62,7 @@ export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHero