From a2a50f0fcf6e0122c0c371c1d957044d11d4345d Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Mon, 10 Aug 2026 20:36:51 +0000 Subject: [PATCH 1/5] Fix mobile nav drawer + REST sidebar hook crash (#62603) --- .../components/AutomatedPageContext.tsx | 7 +++++ .../tests/playwright-rendering.spec.ts | 29 +++++++++++++++++++ src/frame/components/DefaultLayout.tsx | 7 ++++- src/landings/components/SidebarProduct.tsx | 8 +++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/automated-pipelines/components/AutomatedPageContext.tsx b/src/automated-pipelines/components/AutomatedPageContext.tsx index 7a5e5c9d189e..e5dd1ef44b31 100644 --- a/src/automated-pipelines/components/AutomatedPageContext.tsx +++ b/src/automated-pipelines/components/AutomatedPageContext.tsx @@ -31,6 +31,13 @@ export const useAutomatedPageContext = (): AutomatedPageContextT => { return context } +// Non-throwing variant: returns null when there is no provider. For components that render +// both inside and outside an AutomatedPageContext.Provider (e.g. the product sidebar, shared +// across automated REST reference pages and conceptual REST pages). Call it unconditionally. +export const useAutomatedPageContextOptional = (): AutomatedPageContextT | null => { + return useContext(AutomatedPageContext) +} + type AutomatedPageContextRequest = { context?: Partial } | IncomingMessage type AutomatedPage = { diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index a45c9e2c610a..56a0719bbe1c 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -609,6 +609,35 @@ test.describe('test nav at different viewports', () => { await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() }) + test('mobile nav opens even when the desktop rail was collapsed', async ({ page }) => { + // Collapse the desktop rail at the xxl breakpoint so the persisted + // `collapsed` state is set (the collapse toggle only exists at 1400px+). + page.setViewportSize({ + width: 1400, + height: 700, + }) + await page.goto('/get-started/foo/bar') + await page.getByTestId('sidebar-collapse-toggle').click() + // With the rail collapsed the sidebar is not rendered on desktop. + await expect(page.getByTestId('sidebar')).toHaveCount(0) + + // Drop below xxl where the inline mobile nav lives. `collapsed` persists. + page.setViewportSize({ + width: 1013, + height: 700, + }) + + // Opening the mobile nav must still render the doc-tree drawer -- before the + // fix, `collapsed` short-circuited the sidebar to null while the open state + // hid the content column, leaving a blank area with no drawer. + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() + + // Closing it restores the content column (main content visible again). + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.locator('#main-content')).toBeVisible() + }) + test('large -> x-large viewports - 1012+', async ({ page }) => { page.setViewportSize({ width: 1013, diff --git a/src/frame/components/DefaultLayout.tsx b/src/frame/components/DefaultLayout.tsx index 64bc41cc6744..68e014aa4261 100644 --- a/src/frame/components/DefaultLayout.tsx +++ b/src/frame/components/DefaultLayout.tsx @@ -265,7 +265,12 @@ const LayoutBody = ({ children, scrollToTopLabel }: LayoutBodyProps) => { const { collapsed, mobileNavOpen } = useSidebarCollapsed() return (
- {collapsed ? null : } + {/* `collapsed` is the desktop rail-collapse state (persisted). The inline + mobile nav is independent, so still render the sidebar when it's open — + otherwise opening the mobile nav while the desktop rail is collapsed + hides the content column (contentHiddenForNav) with no drawer to show, + so the open nav displays a blank area instead of the doc tree. */} + {collapsed && !mobileNavOpen ? null : } {/* Need to set an explicit height for sticky elements since we also set overflow to auto */}
asPath.includes(item)) ? [] - : useAutomatedPageContext().miniTocItems + : (automatedPage?.miniTocItems ?? []) useEffect(() => { if (nonAutomatedRestPaths.every((item: string) => !asPath.includes(item))) { From 3e55da1f64942e79fb2b10c0be4e6f376fdeb28a Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Mon, 10 Aug 2026 21:31:28 +0000 Subject: [PATCH 2/5] Top-align homepage product sub-items (#62695) --- src/landings/components/ProductSelectionCard.module.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/landings/components/ProductSelectionCard.module.scss b/src/landings/components/ProductSelectionCard.module.scss index 1bfbcc9b9903..ccd73bf99076 100644 --- a/src/landings/components/ProductSelectionCard.module.scss +++ b/src/landings/components/ProductSelectionCard.module.scss @@ -1,6 +1,6 @@ // A single "All Docs" grid cell: category heading at the top, product links -// bottom-aligned (space-between) so every cell in a row shares the tallest -// height — matching the Docs 2026 design. Internal dividers are the cell's left +// top-aligned directly beneath it. Cells in a row still stretch to the tallest +// cell's height via the grid. Internal dividers are the cell's left // border (skipped on the first column of each row, per breakpoint, so they don't // double the container rail) plus a bottom border for row dividers. The column // count steps 1 -> 2 -> 3 -> 4, so each breakpoint re-applies the left border to @@ -8,7 +8,7 @@ .cell { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: flex-start; gap: 2.25rem; padding: 2rem; border-bottom: var(--brand-borderWidth-thin, 1px) solid From 3dfceca928b064cafadf5901d5c9dfe58082dd6a Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 10 Aug 2026 21:34:26 +0000 Subject: [PATCH 3/5] Honor the version cookie on article URLs, not just the homepage (#62647) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d026affa-e332-421f-9b6e-26cfe1530138 Copilot-Session: b342f68c-4c62-4a0b-b2c2-50eccf0ddb75 Copilot-Session: b0ca110c-e800-4e51-8a72-a038eabd3c47 --- src/redirects/lib/version-preference.ts | 102 ++++++++++++ src/redirects/middleware/handle-redirects.ts | 43 ++++- src/redirects/tests/version-preference.ts | 156 +++++++++++++++++++ src/versions/tests/version-cookie.ts | 100 ++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 src/redirects/lib/version-preference.ts create mode 100644 src/redirects/tests/version-preference.ts diff --git a/src/redirects/lib/version-preference.ts b/src/redirects/lib/version-preference.ts new file mode 100644 index 000000000000..3c279b365047 --- /dev/null +++ b/src/redirects/lib/version-preference.ts @@ -0,0 +1,102 @@ +import { allVersions, allVersionKeys } from '@/versions/lib/all-versions' +import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' +import { getPathWithoutLanguage } from '@/frame/lib/path-utils' + +// Every version the reader can actually prefer over the unversioned form. +// `detect-version.ts` already refuses cookie values outside `allVersionKeys`, +// so anything reaching here is a real version. +const alternateVersions = allVersionKeys.filter((v) => v !== nonEnterpriseDefaultVersion) + +// Segments that name a version rather than a product, in any form we have ever +// served. `allVersions` is not enough on its own: it has no key for +// `enterprise-server@latest`, for deprecated releases like `enterprise-server@3.0`, +// or for the older `/enterprise/3.3/` and `/enterprise-server/3.9/` shapes. Those +// still have to count as an explicit request, because a URL naming a version must +// beat the cookie even when we no longer publish that version. +const VERSION_PLANS = new Set([ + ...Object.values(allVersions).map((v) => v.plan), + 'github-ae', + 'enterprise', +]) + +/** + * Does this path name a version itself, rather than leaving it implied? + * + * Anything with an `@` is a version segment; no article slug contains one. The plan + * names cover the legacy unsuffixed shapes. + */ +export function pathNamesAVersion(path: string): boolean { + const firstSegment = getPathWithoutLanguage(path).split('/')[1] + if (!firstSegment) return false + return firstSegment.includes('@') || VERSION_PLANS.has(firstSegment) +} + +export type VersionPreference = { + /** + * True when some version cookie value would have changed the response, so the + * response has to vary on `x-user-version` even when this particular reader has + * no cookie. Varying only for cookie holders would let a no-cookie reader's + * cached page be served to someone who should have been redirected. + */ + vary: boolean + /** Where to send this reader, if their preference applies and the article exists there. */ + redirectTo?: string +} + +const NOTHING: VersionPreference = { vary: false } + +/** + * Work out whether a reader's version preference applies to a request. + * + * The rule, decided in github/docs-team#7227, is that version behaves exactly like + * language: the cookie is only a default, a version named in the URL always wins, and + * an article that does not exist in the preferred version silently stays where it is. + * + * `requestPath` is the URL as asked for, and is what decides whether a version was named. + * It has to be, because `getRedirect` strips an explicit `/free-pro-team@latest` prefix + * before we get here. Reading the resolved path instead would make an explicit request + * for Free/Pro/Team indistinguishable from no request at all, and a reader who has set a + * cookie could never deliberately look at the Free/Pro/Team article again. + * + * `resolvedPath` is where the ordinary redirect logic decided to send them, and is what + * the versioned candidate is built from, so a renamed article resolves in one hop instead + * of two. + */ +export function getVersionPreference( + requestPath: string, + resolvedPath: string, + userVersion: string | undefined, + pages: Record, +): VersionPreference { + // External redirects are not ours to version. + if (resolvedPath.includes('://')) return NOTHING + + if (pathNamesAVersion(requestPath) || pathNamesAVersion(resolvedPath)) return NOTHING + + // Always a `URL.pathname` from the caller, so it always starts with `/` and this is + // always the first segment. On a path that never got a language prefix this reads some + // article slug as the language, and the candidate lookup below simply misses, because + // every key in `pages` is language-prefixed. + const language = resolvedPath.split('/')[1] + const withoutLanguage = getPathWithoutLanguage(resolvedPath) + + // `pages` is keyed by permalink, which carries no `.md` extension. Keep the extension + // aside so a `.md` request redirects to the `.md` form of the versioned article. + const extension = withoutLanguage.endsWith('.md') ? '.md' : '' + const lookupSuffix = extension ? withoutLanguage.slice(0, -extension.length) : withoutLanguage + + let vary = false + let redirectTo: string | undefined + + for (const version of alternateVersions) { + if (!(`/${language}/${version}${lookupSuffix}` in pages)) continue + // At least one version of this article exists that the cookie could select, so the + // response depends on the cookie whether or not this reader has one. + vary = true + if (version === userVersion) { + redirectTo = `/${language}/${version}${lookupSuffix}${extension}` + } + } + + return { vary, redirectTo } +} diff --git a/src/redirects/middleware/handle-redirects.ts b/src/redirects/middleware/handle-redirects.ts index 76d4540c1d4a..740aa832d2b7 100644 --- a/src/redirects/middleware/handle-redirects.ts +++ b/src/redirects/middleware/handle-redirects.ts @@ -4,6 +4,7 @@ import patterns from '@/frame/lib/patterns' import { pathLanguagePrefixed } from '@/languages/lib/languages-server' import { deprecatedWithFunctionalRedirects } from '@/versions/lib/enterprise-server-releases' import getRedirect from '../lib/get-redirect' +import { getVersionPreference } from '../lib/version-preference' import { applyGraphqlCategoryRedirect } from '../lib/graphql-category-redirect' import { defaultCacheControl, @@ -134,13 +135,51 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex } } + if (!req.context.pages) throw new Error('req.context.pages not yet set') + + // Honor the reader's version preference on a URL that does not name a version. + // + // Without this, the cookie is only ever consulted on the bare homepage, so a deep link + // from search, the product UI, or a bookmark silently serves Free/Pro/Team. See + // github/docs-team#7227 for the measurements. + // + // This is deliberately its own branch rather than a tweak to `redirect` below, because + // the ordinary path would emit a 301 for a language-prefixed URL. A redirect that + // depends on a cookie has to stay a 302, or a browser caches one reader's preference + // forever. + if (!redirect.includes('://')) { + const preference = getVersionPreference( + req.path, + removeQueryParams(redirect), + req.userVersion, + req.context.pages, + ) + if (preference.vary && !preference.redirectTo) { + // Only needed when we do not redirect. The redirect below calls + // `languageAndVersionCacheControl`, which already lists `x-user-version`. + // + // We set it even though this response is not a redirect, because it still depends + // on the cookie: a cached copy without this header would be served to readers whose + // preference we should have honored. + // + // `append`, not `set`, so this survives the cache-control call that whatever + // handles the request downstream makes. Those all append too, so nothing clobbers + // it. The `varies on the cookie even for readers who have not set one` test in + // `src/versions/tests/version-cookie.ts` asserts the served 200 really does carry + // the header, so this holds even if that stops being true. + res.append('vary', 'x-user-version') + } + if (preference.redirectTo) { + languageAndVersionCacheControl(res) + return res.safeRedirect(302, preference.redirectTo + (queryParams || '')) + } + } + // do not redirect a path to itself if (redirect === req.originalUrl) { return next() } - if (!req.context.pages) throw new Error('req.context.pages not yet set') - // do not redirect if the redirected page can't be found if ( !( diff --git a/src/redirects/tests/version-preference.ts b/src/redirects/tests/version-preference.ts new file mode 100644 index 000000000000..f6a9e6a53bfa --- /dev/null +++ b/src/redirects/tests/version-preference.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'vitest' + +import { getVersionPreference, pathNamesAVersion } from '@/redirects/lib/version-preference' +import { latest } from '@/versions/lib/enterprise-server-releases' + +const GHEC = 'enterprise-cloud@latest' +const GHES = `enterprise-server@${latest}` + +// A stand-in for `req.context.pages`, which is keyed by full versioned permalink. +// `/actions/versioned` exists in all three versions, `/actions/fpt-only` only in the +// unversioned one. +const pages = Object.fromEntries( + [ + '/en/actions/versioned', + `/en/${GHEC}/actions/versioned`, + `/en/${GHES}/actions/versioned`, + '/en/actions/fpt-only', + '/en/actions/ghec-only', + `/en/${GHEC}/actions/ghec-only`, + '/ja/actions/versioned', + `/ja/${GHEC}/actions/versioned`, + ].map((permalink) => [permalink, {}]), +) + +describe('pathNamesAVersion', () => { + test.each([ + ['/en/enterprise-cloud@latest/actions/foo', true], + ['/en/free-pro-team@latest/actions/foo', true], + ['/en/enterprise-server@latest/actions/foo', true], + // Deprecated releases are not keys of `allVersions`, but naming one is still + // an explicit request and has to beat the cookie. + ['/en/enterprise-server@3.0/actions/foo', true], + ['/en/github-ae@latest/actions/foo', true], + // Legacy shapes that carry no `@`. + ['/en/enterprise-server/3.9/actions/foo', true], + ['/en/enterprise/3.3/actions/foo', true], + // No version named. + ['/en/actions/foo', false], + ['/actions/foo', false], + ['/en', false], + ['/', false], + ])('%s -> %s', (path, expected) => { + expect(pathNamesAVersion(path)).toBe(expected) + }) +}) + +describe('getVersionPreference', () => { + test('redirects an unversioned article to the preferred version', () => { + expect( + getVersionPreference('/en/actions/versioned', '/en/actions/versioned', GHEC, pages), + ).toEqual({ vary: true, redirectTo: `/en/${GHEC}/actions/versioned` }) + }) + + test('leaves an explicitly versioned URL alone', () => { + const path = `/en/${GHES}/actions/versioned` + expect(getVersionPreference(path, path, GHEC, pages)).toEqual({ vary: false }) + }) + + // The escape hatch. `getRedirect` strips `/free-pro-team@latest` before the middleware + // gets here, so the resolved path looks unversioned. Only the request path still shows + // that the reader asked for Free/Pro/Team on purpose. + test('leaves an explicit free-pro-team URL alone even after the prefix is stripped', () => { + expect( + getVersionPreference( + '/en/free-pro-team@latest/actions/versioned', + '/en/actions/versioned', + GHEC, + pages, + ), + ).toEqual({ vary: false }) + }) + + test('falls back silently when the article has no such version', () => { + expect( + getVersionPreference('/en/actions/fpt-only', '/en/actions/fpt-only', GHEC, pages), + ).toEqual({ vary: false }) + }) + + test('does nothing without a cookie, but still varies', () => { + expect( + getVersionPreference('/en/actions/versioned', '/en/actions/versioned', undefined, pages), + ).toEqual({ vary: true }) + }) + + test('redirects to an enterprise-server preference too', () => { + expect( + getVersionPreference('/en/actions/versioned', '/en/actions/versioned', GHES, pages), + ).toEqual({ vary: true, redirectTo: `/en/${GHES}/actions/versioned` }) + }) + + // The partial case: this article has a version the cookie could have selected, just not + // the one this reader asked for. No redirect, but the response still depends on the + // cookie, so it must not be cached as though it were the same for everyone. + test('varies without redirecting when the cookie names a version this article lacks', () => { + expect( + getVersionPreference('/en/actions/ghec-only', '/en/actions/ghec-only', GHES, pages), + ).toEqual({ vary: true }) + }) + + test('treats free-pro-team in the cookie as no preference', () => { + expect( + getVersionPreference( + '/en/actions/versioned', + '/en/actions/versioned', + 'free-pro-team@latest', + pages, + ), + ).toEqual({ vary: true }) + }) + + test('keeps the reader in their language', () => { + expect( + getVersionPreference('/ja/actions/versioned', '/ja/actions/versioned', GHEC, pages), + ).toEqual({ vary: true, redirectTo: `/ja/${GHEC}/actions/versioned` }) + }) + + test('resolves a renamed article in one hop', () => { + expect( + getVersionPreference('/en/actions/old-name', '/en/actions/versioned', GHEC, pages), + ).toEqual({ vary: true, redirectTo: `/en/${GHEC}/actions/versioned` }) + }) + + test('keeps the .md extension', () => { + expect( + getVersionPreference('/en/actions/versioned.md', '/en/actions/versioned.md', GHEC, pages), + ).toEqual({ vary: true, redirectTo: `/en/${GHEC}/actions/versioned.md` }) + }) + + test('ignores external redirects', () => { + expect( + getVersionPreference('/en/actions/versioned', 'https://github.com/foo', GHEC, pages), + ).toEqual({ vary: false }) + }) + + test('ignores paths that are not articles', () => { + expect(getVersionPreference('/en/search', '/en/search', GHEC, pages)).toEqual({ vary: false }) + expect(getVersionPreference('/healthcheck', '/healthcheck', GHEC, pages)).toEqual({ + vary: false, + }) + }) + + // The redirect target names a version, so the next request short-circuits on + // `pathNamesAVersion` and cannot bounce back. + test('cannot loop', () => { + const first = getVersionPreference( + '/en/actions/versioned', + '/en/actions/versioned', + GHEC, + pages, + ) + expect(first.redirectTo).toBe(`/en/${GHEC}/actions/versioned`) + expect(getVersionPreference(first.redirectTo!, first.redirectTo!, GHEC, pages)).toEqual({ + vary: false, + }) + }) +}) diff --git a/src/versions/tests/version-cookie.ts b/src/versions/tests/version-cookie.ts index e42dfd140a47..edaedcd31aaf 100644 --- a/src/versions/tests/version-cookie.ts +++ b/src/versions/tests/version-cookie.ts @@ -57,3 +57,103 @@ describe('version cookie redirects', () => { expect(res.headers.location).toBe(`/en/enterprise-server@${latest}`) }) }) + +// See github/docs-team#7227. Before this, the cookie was only ever consulted on the bare +// homepage, so every deep link served Free/Pro/Team no matter what the reader preferred. +describe('version cookie on article URLs', () => { + // Exists in Free/Pro/Team and in Enterprise Cloud. + const versioned = '/en/get-started/start-your-journey/what-is-github' + // Exists only in Free/Pro/Team. + const fptOnly = '/en/get-started/learning-to-code/getting-started-with-git' + + test('unversioned article redirects to the preferred version', async () => { + const res = await get(versioned, { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe( + '/en/enterprise-cloud@latest/get-started/start-your-journey/what-is-github', + ) + expect(res.headers.vary).toContain('x-user-version') + // Listed once, not twice. The manual append is skipped on the redirect path because + // `languageAndVersionCacheControl` already names it. + expect(res.headers.vary!.match(/x-user-version/g)).toHaveLength(1) + }) + + // A 301 would let a browser cache one reader's preference for that URL forever. + test('the redirect is never permanent', async () => { + const res = await get(versioned, { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }) + expect(res.statusCode).not.toBe(301) + }) + + test('the preferred version is served without redirecting again', async () => { + const res = await get( + '/en/enterprise-cloud@latest/get-started/start-your-journey/what-is-github', + { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }, + ) + expect(res.statusCode).toBe(200) + }) + + // The escape hatch. `getRedirect` strips the `/free-pro-team@latest` prefix, so without + // reading the request path we would bounce this reader straight back to Enterprise Cloud + // and they could never look at the Free/Pro/Team article on purpose. + test('an explicit free-pro-team URL beats the cookie', async () => { + const res = await get( + '/en/free-pro-team@latest/get-started/start-your-journey/what-is-github', + { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }, + ) + expect(res.headers.location).toBe(versioned) + }) + + test('an explicit enterprise-server URL beats the cookie', async () => { + const res = await get( + `/en/enterprise-server@${latest}/get-started/start-your-journey/what-is-github`, + { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }, + ) + expect(res.statusCode).toBe(200) + }) + + test('an article with no such version stays put', async () => { + const res = await get(fptOnly, { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }) + expect(res.statusCode).toBe(200) + }) + + // Varying only for cookie holders would let this cached response be handed to a reader + // who should have been redirected. + test('varies on the cookie even for readers who have not set one', async () => { + const res = await get(versioned, { followRedirects: false }) + expect(res.statusCode).toBe(200) + expect(res.headers.vary).toContain('x-user-version') + }) + + test('query params survive the redirect', async () => { + const res = await get(`${versioned}?json=breadcrumbs`, { + headers: { Cookie: `${USER_VERSION_COOKIE_NAME}=enterprise-cloud@latest` }, + followRedirects: false, + }) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe( + '/en/enterprise-cloud@latest/get-started/start-your-journey/what-is-github?json=breadcrumbs', + ) + }) + + // Staying in the reader's language is covered by the unit tests in + // src/redirects/tests/version-preference.ts. It cannot be covered here because this + // suite runs against real content, and only English is loaded. +}) From 81deeedcf9ab3bf0632c017bbd32ef8a8516338f Mon Sep 17 00:00:00 2001 From: Pallavi <96553709+pallsama@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:56:42 +0000 Subject: [PATCH 4/5] Copilot CLI using models configured in GHES (#62460) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Benedict Ng Co-authored-by: Anne-Marie <102995847+am-stead@users.noreply.github.com> Copilot-Session: fb171c62-5146-460c-9a7d-01eb132c6df5 --- .../customize-copilot/use-byok-models.md | 159 +++++++++++++++++- 1 file changed, 156 insertions(+), 3 deletions(-) diff --git a/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md b/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md index 92c9e7883d30..f7f3e13fda19 100644 --- a/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md +++ b/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md @@ -4,7 +4,9 @@ shortTitle: Use your own model provider intro: 'Use a model from an external provider of your choice in {% data variables.product.prodname_copilot_short %} by supplying your own API key.' allowTitleToDifferFromFilename: true versions: - feature: copilot + fpt: '*' + ghec: '*' + ghes: '>=3.22' contentType: how-tos category: - Configure Copilot @@ -16,7 +18,9 @@ docsTeamMetrics: You can configure {% data variables.copilot.copilot_cli_short %} to use your own LLM provider, also called BYOK (Bring Your Own Key), instead of {% data variables.product.github %}-hosted models. This lets you connect to OpenAI-compatible endpoints, Azure OpenAI, or Anthropic, including locally running models such as Ollama. > [!NOTE] -> This article is for users who want to configure their own LLM provider API key on their local machine. To set up custom models for users in an enterprise, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/use-your-own-api-keys). +> This article is for users who want to configure their own LLM provider API key on their local machine. To set up custom models for users in an enterprise, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/use-your-own-api-keys).{% ifversion ghes %} +> +> This article is also for administrators who want to configure their own LLM provider API key in GHES and for users who want to use those models in {% data variables.copilot.copilot_cli_short %}.{% endif %} ## Prerequisites @@ -123,5 +127,154 @@ You can run {% data variables.copilot.copilot_cli_short %} in offline mode to pr ```shell export COPILOT_OFFLINE=true ``` + +1. {% data reusables.copilot.copilot-cli.start-cli %} -{% data reusables.copilot.copilot-cli.start-cli %} +{% ifversion ghes %} + +## Using Copilot CLI with GitHub Enterprise Server + +> [!NOTE] +> This feature is in {% data variables.release-phases.technical_preview %} and subject to change. Additionally, GHES 3.22, the first version to support this functionality, is in the release candidate phase. We recommend waiting until GHES 3.22 reaches GA before validating and using this capability. We are publishing these docs early to provide visibility into what is coming. + +{% data variables.copilot.copilot_cli_short %} can be configured to work with {% data variables.product.prodname_ghe_server %} for enterprises that operate in disconnected or air-gapped environments without connectivity to {% data variables.product.github %} Cloud. Your {% data variables.product.prodname_ghe_server %} administrator configures a model provider once, and users across the enterprise can use {% data variables.copilot.copilot_cli_short %} with their {% data variables.product.prodname_ghe_server %} credentials. + +Setting up this feature involves two roles: + +* **Administrator**: Configures the model provider on the {% data variables.product.prodname_ghe_server %} instance using `ghe-config`. This is a one-time setup that requires administrative SSH access. +* **End user**: Sets environment variables on their local machine to connect {% data variables.copilot.copilot_cli_short %} to the instance. + +### Prerequisites + +* Your {% data variables.product.prodname_ghe_server %} administrator has configured a model provider on the instance. See [Configuring your {% data variables.product.prodname_ghe_server %} instance](#configuring-your-github-enterprise-server-instance). +* {% data variables.copilot.copilot_cli_short %} is installed on client machines. See [AUTOTITLE](/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli). +* {% data variables.product.company_short %} CLI (`gh`) is installed on client machines. See [Installing gh](https://cli.github.com/manual/installation). + +The same [supported providers](#supported-providers) and [model requirements](#model-requirements) apply. + +### Configuring your {% data variables.product.prodname_ghe_server %} instance + +This step is for the operator or administrator of the {% data variables.product.prodname_ghe_server %} instance. + +With administrative SSH access to the {% data variables.product.prodname_ghe_server %} instance, configure the model provider using the following `ghe-config` values. After configuring, run `ghe-config-apply` to apply the changes. + +| Variable name | Required | Options | Description | +|---|---|---|---| +| `app.copilot-proxy.enabled` | Yes | `true`, `false` | Enables or disables the feature. | +| `app.copilot-proxy.endpoint-url` | Yes | URI | The full upstream base URL including any version prefix (for example, `https://api.openai.com/v1`). | +| `secrets.copilot-proxy.endpoint-key` | Yes | String | The API key for the upstream provider. | +| `app.copilot-proxy.provider-model-id` | Yes | String | The provider model ID that {% data variables.copilot.copilot_cli_short %} uses to look up the model internally. | +| `app.copilot-proxy.provider-type` | Yes | `openai`, `azure`, `anthropic` | The provider type. OpenAI includes OpenAI, Ollama, vLLM, Foundry Local, and any other OpenAI Chat Completions API-compatible endpoint. | +| `app.copilot-proxy.upstream-timeout` | No | Integer (seconds) | Read/send timeout in seconds for upstream requests. If not set, falls back to the default timeout. | +| `app.copilot-proxy.provider-wire-api` | No | `completions`, `responses` | The wire API format for the provider. | +| `app.copilot-proxy.provider-wire-model` | No | String | Overrides the model identifier sent to the upstream provider if it differs from the internal model ID. | +| `app.copilot-proxy.enable-upstream-probe` | No | `true`, `false` | Enables or disables the startup upstream probe. Defaults to enabled. When disabled, the startup probe is skipped. | + +### Examples + +```shell +ghe-config app.copilot-proxy.enabled true +ghe-config app.copilot-proxy.endpoint-url 'https://api.openai.com/v1' +ghe-config secrets.copilot-proxy.endpoint-key 'YOUR-API-KEY' +ghe-config app.copilot-proxy.provider-model-id 'gpt-5.5' +ghe-config app.copilot-proxy.provider-wire-model 'gpt-5.5' +ghe-config app.copilot-proxy.provider-type openai +ghe-config app.copilot-proxy.upstream-timeout 300 +ghe-config app.copilot-proxy.enable-upstream-probe false +ghe-config-apply +``` + +Replace `YOUR-API-KEY` with the real API key before applying. + +### Configuring your {% data variables.copilot.copilot_cli_short %} client (end user) + +Configure {% data variables.copilot.copilot_cli_short %} to connect to your {% data variables.product.prodname_ghe_server %} instance by setting the following environment variables before starting {% data variables.copilot.copilot_cli_short %}. + +| Environment variable | Required | Description | +|---|---|---| +| `COPILOT_PROVIDER_GHES_HOST` | Yes | The hostname of your {% data variables.product.prodname_ghe_server %} instance. | +| `COPILOT_PROVIDER_GHES_TOKEN` | Yes | A {% data variables.product.pat_generic %} for the {% data variables.product.prodname_ghe_server %} instance. This token authenticates requests to the instance. | +| `COPILOT_OFFLINE=true` | Yes | Enables offline mode. The {% data variables.product.prodname_ghe_server %} provider is only active when offline mode is enabled. | + +### Understanding client (end user) tokens + +{% data variables.copilot.copilot_cli_short %} needs access to LLM inference, so `COPILOT_PROVIDER_GHES_TOKEN` is always required. You will also very likely want {% data variables.copilot.copilot_cli_short %} to perform {% data variables.product.github %} operations such as create issues, pull requests, and search repositories. Such operations can be done via the {% data variables.product.github %} CLI. + +It is recommended and preferred that you run `gh auth login --hostname YOUR-GHES-HOSTNAME`. After it succeeds, next step is to set COPILOT_PROVIDER_GHES_TOKEN to the token generated in `gh auth login --hostname YOUR-GHES-HOSTNAME`. It is more secure to retrieve the token dynamically rather than copying it from `~/.config/gh/hosts.yml`. You can do so by using `COPILOT_PROVIDER_GHES_TOKEN="$(gh auth token --hostname YOUR-GHES-HOSTNAME)"`. + +Alternatively, you can generate a {% data variables.product.pat_generic %} on your {% data variables.product.prodname_ghe_server %} instance, set that token as `COPILOT_PROVIDER_GHES_TOKEN`, and use the same token when running `gh auth login --hostname YOUR-GHES-HOSTNAME`. + +The above approach works when you are using {% data variables.copilot.copilot_cli_short %} interactively. For automation, you need to do a few things differently: +* Set `GH_ENTERPRISE_TOKEN` (or `GITHUB_ENTERPRISE_TOKEN`) to the {% data variables.product.pat_generic %}. +* Set `GH_HOST` to your server's hostname. +* When both `GH_ENTERPRISE_TOKEN` and `gh auth login` credentials exist for the same host, the environment variable takes precedence. + +### Recommended end user setup + +1. Authenticate the {% data variables.product.company_short %} CLI. + + ```shell + gh auth login --hostname YOUR-GHES-HOSTNAME + ``` + +1. Set the following environment variables: + + ```shell + export COPILOT_PROVIDER_GHES_HOST=YOUR-GHES-HOSTNAME + export COPILOT_PROVIDER_GHES_TOKEN="$(gh auth token --hostname YOUR-GHES-HOSTNAME)" + export COPILOT_OFFLINE=true + ``` + + If you are authenticated with `gh auth login` to multiple accounts, you can set `GH_HOST` to your server's hostname and set `GH_ENTERPRISE_TOKEN` (or `GITHUB_ENTERPRISE_TOKEN`) to `"$(gh auth token --hostname YOUR-GHES-HOSTNAME)"`. This ensures {% data variables.product.prodname_cli %} targets your {% data variables.product.prodname_ghe_server %} instance. + + ```shell + export GH_HOST=YOUR-GHES-HOSTNAME + export GH_ENTERPRISE_TOKEN="$(gh auth token --hostname YOUR-GHES-HOSTNAME)" + ``` + +1. {% data reusables.copilot.copilot-cli.start-cli %} + +You can run this entire set-up as a script. + +### Examples + +If both {% data variables.product.prodname_ghe_server %} and your {% data variables.copilot.copilot_cli_short %} configurations are correct, then you should see responses like the following in your {% data reusables.copilot.copilot-cli.start-cli %} session. + +```shell + • fabric-core-mcp — disabled + • powerbi-mcp — disabled + • slack — connected + + ● Current model: gpt-5.5 + + ❯ Hello 13:31 + + ● Hello! + + ❯ what is going on in github/codeql-action repo? 13:33 + + ● I’ll check recent repository activity on the GHES host: repo metadata, open + PRs/issues, and latest commits. + + $ Shell Fetch repo metadata 2 lines… 5s + gh api --hostname "$GH_HOST" repos/github/codeql-action --jq '{name_with_own… +``` + +### Supported capabilities on {% data variables.product.prodname_ghe_server %} + +For the most up-to-date information on {% data variables.copilot.copilot_cli_short %} features, refer to the [AUTOTITLE](/copilot/how-tos/copilot-cli) as the primary source of truth. In general, any capability that relies on connectivity to {% data variables.product.github %} cloud services is not available in the {% data variables.product.prodname_ghe_server %} offline configuration. + +The following table provides a directional overview of what is available in {% data variables.product.prodname_ghe_server %} offering. + +| Capability | {% data variables.product.prodname_dotcom %} / {% data variables.product.prodname_ghe_cloud %} | {% data variables.product.prodname_ghe_server %} | +|---|---|---| +| AI-assisted coding (prompts, code generation, debugging) | {% octicon "check-circle" aria-label="Available" %} | {% octicon "check-circle" aria-label="Available" %} | +| Shell commands and file operations | {% octicon "check-circle" aria-label="Available" %} | {% octicon "check-circle" aria-label="Available" %} | +| {% data variables.product.github %} operations (issues, PRs, repos) via `gh` CLI | {% octicon "check-circle" aria-label="Available" %} | {% octicon "check-circle" aria-label="Available" %} (requires `gh` CLI authenticated to the instance) | +| {% data variables.product.github %} MCP server tools | {% octicon "check-circle" aria-label="Available" %} | {% octicon "x-circle" aria-label="Not available" %} | +| Web search and web fetch | {% octicon "check-circle" aria-label="Available" %} | {% octicon "x-circle" aria-label="Not available" %} | +| {% data variables.product.prodname_copilot_short %} model selection ({% data variables.product.github %}-hosted models) | {% octicon "check-circle" aria-label="Available" %} | {% octicon "x-circle" aria-label="Not available" %} | +| Telemetry and usage reporting | {% octicon "check-circle" aria-label="Available" %} | {% octicon "x-circle" aria-label="Not available" %} | +| Auto-update | {% octicon "check-circle" aria-label="Available" %} | {% octicon "x-circle" aria-label="Not available" %} | + +{% endif %} From 63307fa193c6f293c468160cc868d0dd022e2e1f Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:55:48 +0000 Subject: [PATCH 5/5] Delete orphaned features (2026-08-10-16-38) (#62687) Co-authored-by: Vanessa --- data/features/security-delegated-alert-dismissal.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 data/features/security-delegated-alert-dismissal.yml diff --git a/data/features/security-delegated-alert-dismissal.yml b/data/features/security-delegated-alert-dismissal.yml deleted file mode 100644 index 049aaa6ec14a..000000000000 --- a/data/features/security-delegated-alert-dismissal.yml +++ /dev/null @@ -1,7 +0,0 @@ -# Reference: #16384 and #16319 -# Documentation for Delegated alert dismissal for code scanning and Delegated alert closures for secret scanning, both Public Preview -# Ref 17108 Advanced Security available to Team plans -versions: - fpt: '*' - ghec: '*' - ghes: '>3.16'