diff --git a/config/moda/secrets/production/secrets.yml b/config/moda/secrets/production/secrets.yml index ce9ebe2c6975..ccdb3af5732d 100644 --- a/config/moda/secrets/production/secrets.yml +++ b/config/moda/secrets/production/secrets.yml @@ -4,3 +4,5 @@ secrets: kind: latest_at_deployment_start key: COOKIE_SECRET type: salt + owner: '@github/docs-engineering' + externally_usable: true diff --git a/config/moda/secrets/staging/secrets.yml b/config/moda/secrets/staging/secrets.yml index ce9ebe2c6975..ccdb3af5732d 100644 --- a/config/moda/secrets/staging/secrets.yml +++ b/config/moda/secrets/staging/secrets.yml @@ -4,3 +4,5 @@ secrets: kind: latest_at_deployment_start key: COOKIE_SECRET type: salt + owner: '@github/docs-engineering' + externally_usable: true diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md index 11010f9ce8e8..1e7c8e0515f0 100644 --- a/data/reusables/contributing/content-linter-rules.md +++ b/data/reusables/contributing/content-linter-rules.md @@ -64,6 +64,7 @@ | GHD060 | journey-tracks-unique-ids | Journey track IDs must be unique within a page | error | frontmatter, journey-tracks, unique-ids | | GHD061 | frontmatter-hero-image | Hero image paths must be absolute, extensionless, and point to valid images in /assets/images/banner-images/ | error | frontmatter, images | | GHD062 | frontmatter-intro-links | introLinks keys must be valid keys defined in data/ui.yml under product_landing | error | frontmatter, single-source | +| GHD063 | frontmatter-children | Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion. | error | frontmatter, children | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon- | The octicon liquid syntax used is deprecated. Use this format instead `octicon "" aria-label=""` | error | | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | | diff --git a/src/content-linter/lib/linting-rules/frontmatter-children.ts b/src/content-linter/lib/linting-rules/frontmatter-children.ts new file mode 100644 index 000000000000..1c4d0a5b1ce2 --- /dev/null +++ b/src/content-linter/lib/linting-rules/frontmatter-children.ts @@ -0,0 +1,100 @@ +import fs from 'fs' +import path from 'path' +import { addError } from 'markdownlint-rule-helpers' + +import { getFrontmatter } from '../helpers/utils' +import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' + +interface Frontmatter { + children?: string[] + [key: string]: unknown +} + +/** + * Check if a child path is valid. + * Supports both: + * - Relative paths (e.g., /local-child) resolved from current directory + * - Absolute /content/ paths (e.g., /content/actions/workflows) resolved from content root + */ +function isValidChildPath(childPath: string, currentFilePath: string): boolean { + const ROOT = process.env.ROOT || '.' + const contentDir = path.resolve(ROOT, 'content') + + let resolvedPath: string + + if (childPath.startsWith('/content/')) { + // Absolute path from content root - strip /content/ prefix + const absoluteChildPath = childPath.slice('/content/'.length) + resolvedPath = path.resolve(contentDir, absoluteChildPath) + } else { + // Relative path from current file's directory + const currentDir: string = path.dirname(currentFilePath) + const normalizedPath = childPath.startsWith('/') ? childPath.substring(1) : childPath + resolvedPath = path.resolve(currentDir, normalizedPath) + } + + // Security check: ensure resolved path stays within content directory + // This prevents path traversal attacks using sequences like '../' + if (!resolvedPath.startsWith(contentDir + path.sep) && resolvedPath !== contentDir) { + return false + } + + // Check for direct .md file + const mdPath = `${resolvedPath}.md` + if (fs.existsSync(mdPath) && fs.statSync(mdPath).isFile()) { + return true + } + + // Check for index.md file in directory + const indexPath = path.join(resolvedPath, 'index.md') + if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { + return true + } + + // Check if the path exists as a directory (may have children) + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + return true + } + + return false +} + +export const frontmatterChildren = { + names: ['GHD063', 'frontmatter-children'], + description: + 'Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion.', + tags: ['frontmatter', 'children'], + function: (params: RuleParams, onError: RuleErrorCallback) => { + const fm = getFrontmatter(params.lines) as Frontmatter | null + if (!fm || !fm.children) return + + const childrenLine: string | undefined = params.lines.find((line) => + line.startsWith('children:'), + ) + + if (!childrenLine) return + + const lineNumber: number = params.lines.indexOf(childrenLine) + 1 + + if (Array.isArray(fm.children)) { + const invalidPaths: string[] = [] + + for (const child of fm.children) { + if (!isValidChildPath(child, params.name)) { + invalidPaths.push(child) + } + } + + if (invalidPaths.length > 0) { + addError( + onError, + lineNumber, + `Found invalid children paths: ${invalidPaths.join(', ')}. For cross-product paths, use /content/ prefix (e.g., /content/actions/workflows).`, + childrenLine, + [1, childrenLine.length], + null, + ) + } + } + }, +} diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index 3afc9fa151bd..e9afc32987f8 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -53,6 +53,7 @@ import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists import { journeyTracksUniqueIds } from './journey-tracks-unique-ids' import { frontmatterHeroImage } from './frontmatter-hero-image' import { frontmatterIntroLinks } from './frontmatter-intro-links' +import { frontmatterChildren } from './frontmatter-children' // Using any type because @github/markdownlint-github doesn't provide TypeScript declarations // The elements in the array have a 'names' property that contains rule identifiers @@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = { journeyTracksUniqueIds, // GHD060 frontmatterHeroImage, // GHD061 frontmatterIntroLinks, // GHD062 + frontmatterChildren, // GHD063 // Search-replace rules searchReplace, // Open-source plugin diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index e70be6fbf19b..f1f1f9cf7461 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -284,6 +284,12 @@ export const githubDocsFrontmatterConfig = { 'partial-markdown-files': false, 'yml-files': false, }, + 'frontmatter-children': { + // GHD063 + severity: 'error', + 'partial-markdown-files': false, + 'yml-files': false, + }, } // Configures rules from the `github/markdownlint-github` repo diff --git a/src/content-linter/tests/category-pages.ts b/src/content-linter/tests/category-pages.ts index 5519287ee36f..2c31c6ef04ca 100644 --- a/src/content-linter/tests/category-pages.ts +++ b/src/content-linter/tests/category-pages.ts @@ -239,6 +239,15 @@ describe.skip('category pages', () => { }) function getPath(productDir: string, link: string, filename: string) { + // Handle absolute /content/ paths for cross-product children + // The link parameter contains the child path from frontmatter + if (link.startsWith('/content/')) { + const absolutePath = link.slice('/content/'.length) + if (filename === 'index') { + return path.join(contentDir, absolutePath, 'index.md') + } + return path.join(contentDir, absolutePath, `${filename}.md`) + } return path.join(productDir, link, `${filename}.md`) } diff --git a/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md b/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md new file mode 100644 index 000000000000..e2bd12cbd3cb --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md @@ -0,0 +1,8 @@ +--- +title: Invalid children paths +children: + - /content/nonexistent/product + - /another/invalid/path +--- + +This page has invalid children paths. diff --git a/src/content-linter/tests/fixtures/frontmatter-children/no-children.md b/src/content-linter/tests/fixtures/frontmatter-children/no-children.md new file mode 100644 index 000000000000..256cd22d03d7 --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/no-children.md @@ -0,0 +1,5 @@ +--- +title: No children +--- + +This page has no children frontmatter. diff --git a/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md b/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md new file mode 100644 index 000000000000..85fe35cc0ec1 --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md @@ -0,0 +1,8 @@ +--- +title: Valid children with content prefix +children: + - /content/get-started/foo + - /content/get-started/learning-about-github +--- + +This page has valid /content/ prefixed children paths. diff --git a/src/content-linter/tests/unit/frontmatter-children.ts b/src/content-linter/tests/unit/frontmatter-children.ts new file mode 100644 index 000000000000..388550747f31 --- /dev/null +++ b/src/content-linter/tests/unit/frontmatter-children.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, beforeAll, afterAll } from 'vitest' + +import { runRule } from '@/content-linter/lib/init-test' +import { frontmatterChildren } from '@/content-linter/lib/linting-rules/frontmatter-children' + +const VALID_CONTENT_PREFIX = + 'src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md' +const INVALID_PATHS = 'src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md' +const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-children.md' + +const ruleName = frontmatterChildren.names[1] + +// Configure the test fixture to not split frontmatter and content +const fmOptions = { markdownlintOptions: { frontMatter: null } } + +describe(ruleName, () => { + const envVarValueBefore = process.env.ROOT + + beforeAll(() => { + process.env.ROOT = 'src/fixtures/fixtures' + }) + + afterAll(() => { + process.env.ROOT = envVarValueBefore + }) + + test('page with valid /content/ prefixed children paths passes', async () => { + const result = await runRule(frontmatterChildren, { + files: [VALID_CONTENT_PREFIX], + ...fmOptions, + }) + expect(result[VALID_CONTENT_PREFIX]).toEqual([]) + }) + + test('page without children property passes', async () => { + const result = await runRule(frontmatterChildren, { + files: [NO_CHILDREN], + ...fmOptions, + }) + expect(result[NO_CHILDREN]).toEqual([]) + }) + + test('page with invalid children paths fails', async () => { + const result = await runRule(frontmatterChildren, { + files: [INVALID_PATHS], + ...fmOptions, + }) + expect(result[INVALID_PATHS]).toHaveLength(1) + expect(result[INVALID_PATHS][0].errorDetail).toContain('Found invalid children paths:') + expect(result[INVALID_PATHS][0].errorDetail).toContain('/content/nonexistent/product') + expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path') + }) +}) diff --git a/src/fixtures/fixtures/content/get-started/index.md b/src/fixtures/fixtures/content/get-started/index.md index 45693f0c7003..742039a9df50 100644 --- a/src/fixtures/fixtures/content/get-started/index.md +++ b/src/fixtures/fixtures/content/get-started/index.md @@ -45,6 +45,7 @@ children: - /carousel - /article-grid-discovery - /article-grid-bespoke + - /non-child-resolution communityRedirect: name: Provide HubGit Feedback href: 'https://hubgit.com/orgs/community/discussions/categories/get-started' diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md new file mode 100644 index 000000000000..bc99bb47d3be --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md @@ -0,0 +1,10 @@ +--- +title: Cross-product children test +intro: Testing cross-product children resolution using /content/ prefix +versions: + fpt: '*' +children: + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category uses /content/ prefix for cross-product article inclusion. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md new file mode 100644 index 000000000000..30d82dcb912e --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md @@ -0,0 +1,10 @@ +--- +title: Children only test +intro: Testing children-only page resolution +versions: + fpt: '*' +children: + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category uses traditional children-only approach but with a cross-product path. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md new file mode 100644 index 000000000000..1ddd29f7ffa6 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md @@ -0,0 +1,17 @@ +--- +title: Non-child resolution test +intro: Testing non-child page resolution from frontmatter +versions: + fpt: '*' + ghec: '*' + ghes: '*' +children: + - /children-only + - /articles-only + - /local-category + - /standalone-article + - /versioned-cross-product + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This is the non-child resolution test page. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md new file mode 100644 index 000000000000..1f36ea49b409 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md @@ -0,0 +1,12 @@ +--- +title: Local category test +intro: Testing local children combined with cross-product children +versions: + fpt: '*' +children: + - /local-article-one + - /local-article-two + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category has both local children and cross-product children. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md new file mode 100644 index 000000000000..18ee8fca82cd --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md @@ -0,0 +1,8 @@ +--- +title: Local article one +intro: A local article in the local category +versions: + fpt: '*' +--- + +This is local article one. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md new file mode 100644 index 000000000000..14cf59975403 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md @@ -0,0 +1,8 @@ +--- +title: Local article two +intro: Another local article in the local category +versions: + fpt: '*' +--- + +This is local article two. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md new file mode 100644 index 000000000000..07d23dc68547 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md @@ -0,0 +1,8 @@ +--- +title: Standalone article +intro: A standalone article for testing +versions: + fpt: '*' +--- + +This is a standalone article. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md new file mode 100644 index 000000000000..ed09f293cac6 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md @@ -0,0 +1,15 @@ +--- +title: Versioned cross-product test +intro: Testing cross-product children with version constraints +versions: + fpt: '*' + ghec: '*' + ghes: '*' +children: + - /content/get-started/versioning/only-fpt + - /content/get-started/versioning/only-ghec + - /content/get-started/versioning/only-ghec-and-ghes +--- + +This category includes cross-product children from articles with different version constraints. +The children should only appear in versions where the referenced article is available. diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index d1055d12a3f7..1b1ab529427e 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1345,3 +1345,80 @@ test.describe('LandingArticleGridWithFilter component', () => { await expect(articleGrid).toBeVisible() }) }) + +test.describe('Non-child page resolution', () => { + test('category page with local children renders properly', async ({ page }) => { + // The local-category has local children (local-article-one, local-article-two) + // and an external article reference via children frontmatter + await page.goto('/get-started/non-child-resolution/local-category') + + // Should have a title + await expect(page).toHaveTitle(/Local category test/) + + // The page should load without errors and have main content + await expect(page.locator('main')).toBeVisible() + }) + + test('cross-product children page loads correctly', async ({ page }) => { + // The articles-only fixture now uses /content/ prefix in children for cross-product paths + await page.goto('/get-started/non-child-resolution/articles-only') + + await expect(page).toHaveTitle(/Cross-product children test/) + await expect(page.locator('main')).toBeVisible() + }) + + test('children-only page with /content/ path loads correctly', async ({ page }) => { + // The children-only fixture uses /content/ prefix for cross-product paths + await page.goto('/get-started/non-child-resolution/children-only') + + await expect(page).toHaveTitle(/Children only test/) + await expect(page.locator('main')).toBeVisible() + }) + + test('standalone article is accessible', async ({ page }) => { + await page.goto('/get-started/non-child-resolution/standalone-article') + + await expect(page).toHaveTitle(/Standalone article/) + await expect(page.locator('main')).toBeVisible() + }) + + test('versioned cross-product children - fpt shows only fpt article', async ({ page }) => { + // In fpt version, only the only-fpt article should be available + await page.goto('/get-started/non-child-resolution/versioned-cross-product') + + await expect(page).toHaveTitle(/Versioned cross-product test/) + await expect(page.locator('main')).toBeVisible() + + // Check TOC has the fpt-only article + const tocLinks = page.locator('[data-testid="table-of-contents"] a') + await expect(tocLinks).toHaveCount(1) + await expect(tocLinks.first()).toHaveAttribute('href', /only-fpt/) + }) + + test('versioned cross-product children - ghec shows ghec articles', async ({ page }) => { + // In ghec version, only-ghec and only-ghec-and-ghes should be available + await page.goto( + '/enterprise-cloud@latest/get-started/non-child-resolution/versioned-cross-product', + ) + + await expect(page).toHaveTitle(/Versioned cross-product test/) + await expect(page.locator('main')).toBeVisible() + + // Check TOC has ghec articles (only-ghec and only-ghec-and-ghes) + const tocLinks = page.locator('[data-testid="table-of-contents"] a') + await expect(tocLinks).toHaveCount(2) + }) + + test('cross-product children excluded from sidebar in Japanese translation', async ({ page }) => { + // The Japanese translation should work with cross-product children + await page.goto('/ja/get-started/non-child-resolution') + + // Verify page loads correctly with Japanese site context + // Note: The title may not be fully translated in test fixtures, but the page should render + await expect(page).toHaveTitle(/GitHub Docs/) + await expect(page.locator('main')).toBeVisible() + + // Verify page loads correctly - the cross-product children don't prevent the page from working + // The detailed sidebar filtering is tested by the survey test which verifies no duplicate entries + }) +}) diff --git a/src/frame/lib/create-tree.ts b/src/frame/lib/create-tree.ts index 7b112a65b93b..1cb18aa80d7d 100644 --- a/src/frame/lib/create-tree.ts +++ b/src/frame/lib/create-tree.ts @@ -119,11 +119,36 @@ export default async function createTree( childPreviousTree = previousTree.childPages[i] } } - const subTree = await createTree( - path.posix.join(originalPath, child), - basePath, - childPreviousTree, - ) + + // Handle absolute /content/ paths - allows cross-product directory inclusion + // e.g., /content/actions/workflows will include the entire actions/workflows tree + let childPath: string + if (child.startsWith('/content/')) { + // Absolute content path - resolve from the content root + // Strip '/content/' prefix and join with the base content directory + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + + // Security check: ensure the resolved path stays within the content directory + // This prevents path traversal attacks using sequences like '../' + const resolvedPath = path.resolve(childPath) + const resolvedBasePath = path.resolve(basePath) + if (!resolvedPath.startsWith(resolvedBasePath + path.sep)) { + throw new Error( + `Invalid child path "${child}" in ${originalPath}/index.md - path traversal detected. ` + + `Resolved path "${resolvedPath}" escapes content directory "${resolvedBasePath}".`, + ) + } + } else { + // Traditional relative path + childPath = path.posix.join(originalPath, child) + } + + const subTree = await createTree(childPath, basePath, childPreviousTree) + if (subTree && child.startsWith('/content/')) { + // Mark this subtree as a cross-product child so it can be excluded from the sidebar + subTree.crossProductChild = true + } if (!subTree) { // Remove that children. // For example, the 'early-access' might have been in the diff --git a/src/frame/lib/page-data.ts b/src/frame/lib/page-data.ts index e301bd223e31..5424d7f362d2 100644 --- a/src/frame/lib/page-data.ts +++ b/src/frame/lib/page-data.ts @@ -259,6 +259,12 @@ async function translateTree( translatedData, ) as any, ) as any + + // Preserve the crossProductChild flag from the English tree + if (enTree.crossProductChild) { + ;(item as UnversionedTree).crossProductChild = true + } + if ( ((item as UnversionedTree).page as any).children && ((item as UnversionedTree).page as any).children.length > 0 diff --git a/src/frame/middleware/context/current-product-tree.ts b/src/frame/middleware/context/current-product-tree.ts index 130e8504e380..80b6fa2ab816 100644 --- a/src/frame/middleware/context/current-product-tree.ts +++ b/src/frame/middleware/context/current-product-tree.ts @@ -127,6 +127,7 @@ async function getCurrentProductTreeTitles(input: Tree, context: Context): Promi if (page.hidden) node.hidden = true if (page.sidebarLink) node.sidebarLink = page.sidebarLink if (page.layout && typeof page.layout === 'string') node.layout = page.layout + if (input.crossProductChild) node.crossProductChild = true return node } @@ -146,7 +147,9 @@ function excludeHidden(tree: TitlesTree) { function sidebarTree(tree: TitlesTree) { const { href, title, shortTitle, childPages, sidebarLink } = tree - const childChildPages = childPages.map(sidebarTree) + // Filter out cross-product children from the sidebar + const filteredChildPages = childPages.filter((child) => !child.crossProductChild) + const childChildPages = filteredChildPages.map(sidebarTree) const newTree: TitlesTree = { href, title: shortTitle || title, diff --git a/src/frame/tests/non-child-pages-resolution.test.ts b/src/frame/tests/non-child-pages-resolution.test.ts new file mode 100644 index 000000000000..39ae9f4a46b9 --- /dev/null +++ b/src/frame/tests/non-child-pages-resolution.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'vitest' +import path from 'path' +import fs from 'fs' + +// ROOT is the project root directory +// From src/frame/tests/ -> ../../.. gets to project root +const ROOT = path.resolve(__dirname, '../../..') + +/** + * Tests for non-child page resolution: + * `/content/` prefix in children frontmatter resolves to absolute content paths, + * allowing cross-product directory and article inclusion. + */ + +describe('Non-child page resolution', () => { + describe('/content/ prefix in children frontmatter', () => { + test('detects /content/ prefix in children', () => { + const child = '/content/actions/workflows' + expect(child.startsWith('/content/')).toBe(true) + }) + + test('strips /content/ prefix correctly', () => { + const child = '/content/actions/workflows' + const strippedPath = child.slice('/content/'.length) + expect(strippedPath).toBe('actions/workflows') + }) + + test('relative children do not have /content/ prefix', () => { + const relativeChild = '/local-child' + expect(relativeChild.startsWith('/content/')).toBe(false) + }) + + test('/content/ prefix handling resolves absolute paths for directories', () => { + const basePath = '/Users/test/docs-internal/content' + const child = '/content/actions/workflows' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + } else { + childPath = path.posix.join('/Users/test/docs-internal/content/get-started', child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/actions/workflows') + }) + + test('/content/ prefix handling resolves absolute paths for articles', () => { + const basePath = '/Users/test/docs-internal/content' + const child = '/content/get-started/foo/bar' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + } else { + childPath = path.posix.join('/Users/test/docs-internal/content/get-started', child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/get-started/foo/bar') + }) + + test('relative children resolve relative to current directory', () => { + const originalPath = '/Users/test/docs-internal/content/get-started' + const child = '/local-child' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join('/Users/test/docs-internal/content', absoluteChildPath) + } else { + childPath = path.posix.join(originalPath, child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/get-started/local-child') + }) + }) + + describe('children path formats', () => { + test('children array can contain mixed path formats', () => { + const children = [ + '/local-category', // Local directory + '/standalone-article', // Local article + '/content/actions/workflows', // Cross-product directory + '/content/get-started/foo/bar', // Cross-product article + ] + expect(Array.isArray(children)).toBe(true) + expect(children.every((c) => typeof c === 'string')).toBe(true) + }) + + test('/content/ paths and relative paths are distinguishable', () => { + const children = ['/local-child', '/content/other-product/article'] + + const crossProductPaths = children.filter((c) => c.startsWith('/content/')) + const localPaths = children.filter((c) => !c.startsWith('/content/')) + + expect(crossProductPaths).toEqual(['/content/other-product/article']) + expect(localPaths).toEqual(['/local-child']) + }) + }) + + describe('test fixtures validation', () => { + const fixturesRoot = ROOT + const nonChildResolutionPath = path.join( + fixturesRoot, + 'src/fixtures/fixtures/content/get-started/non-child-resolution', + ) + + test('non-child-resolution fixture directory exists', () => { + expect(fs.existsSync(nonChildResolutionPath)).toBe(true) + }) + + test('non-child-resolution index.md exists', () => { + const indexPath = path.join(nonChildResolutionPath, 'index.md') + expect(fs.existsSync(indexPath)).toBe(true) + }) + + test('children-only fixture exists', () => { + const childrenOnlyPath = path.join(nonChildResolutionPath, 'children-only/index.md') + expect(fs.existsSync(childrenOnlyPath)).toBe(true) + }) + + test('cross-product children fixture exists (formerly articles-only)', () => { + const articlesOnlyPath = path.join(nonChildResolutionPath, 'articles-only/index.md') + expect(fs.existsSync(articlesOnlyPath)).toBe(true) + }) + + test('local-category fixture exists with local articles', () => { + const localCategoryPath = path.join(nonChildResolutionPath, 'local-category') + expect(fs.existsSync(path.join(localCategoryPath, 'index.md'))).toBe(true) + expect(fs.existsSync(path.join(localCategoryPath, 'local-article-one.md'))).toBe(true) + expect(fs.existsSync(path.join(localCategoryPath, 'local-article-two.md'))).toBe(true) + }) + + test('versioned-cross-product fixture exists', () => { + const versionedPath = path.join(nonChildResolutionPath, 'versioned-cross-product/index.md') + expect(fs.existsSync(versionedPath)).toBe(true) + }) + }) + + describe('translation behavior', () => { + test('cross-product children paths are language-agnostic', () => { + // The /content/ prefix paths should work regardless of the current language + // The actual translation is handled by the page loading system + const child = '/content/actions/using-workflows/storing-workflow-data-as-artifacts' + + // Path should not include language prefix + expect(child.startsWith('/content/')).toBe(true) + expect(child).not.toMatch(/\/content\/(en|ja|es|pt|zh|ru|ko|fr|de)\//) + }) + + test('resolved paths use content directory, not translations', () => { + // Cross-product children are resolved from the main content directory + // Translations are handled separately by the page rendering system + const basePath = '/Users/test/docs-internal/content' + const child = '/content/actions/workflows' + + const resolvedPath = path.posix.join(basePath, child.slice('/content/'.length)) + expect(resolvedPath).toBe('/Users/test/docs-internal/content/actions/workflows') + expect(resolvedPath).not.toContain('translations') + }) + }) + + describe('crossProductChild flag', () => { + test('flag is set for /content/ prefix paths', () => { + // Simulate the logic from create-tree.ts + const child = '/content/actions/workflows' + const isCrossProduct = child.startsWith('/content/') + expect(isCrossProduct).toBe(true) + }) + + test('flag is not set for relative paths', () => { + const child = '/local-child' + const isCrossProduct = child.startsWith('/content/') + expect(isCrossProduct).toBe(false) + }) + + test('crossProductChild flag excludes items from sidebar', () => { + // Simulate the sidebarTree filtering logic + const childPages = [ + { href: '/en/get-started/foo', title: 'Foo', crossProductChild: false }, + { href: '/en/actions/workflows', title: 'Workflows', crossProductChild: true }, + { href: '/en/get-started/bar', title: 'Bar', crossProductChild: false }, + ] + + const sidebarChildPages = childPages.filter((c) => !c.crossProductChild) + expect(sidebarChildPages).toHaveLength(2) + expect(sidebarChildPages.map((c) => c.title)).toEqual(['Foo', 'Bar']) + }) + }) +}) diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index 984f5b17c63f..3e3fb3489181 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -91,17 +91,28 @@ export async function updateAutomatedPipelines() { .map((version) => version.openApiVersionName) for (const pipeline of pipelines) { - if (!existsSync(`src/${pipeline}/data`)) continue + // secret-scanning has a different directory structure than the others + const directoryWithReleases = + pipeline === 'secret-scanning' + ? 'src/secret-scanning/data/pattern-docs' + : `src/${pipeline}/data` + if (!existsSync(directoryWithReleases)) continue + const isCalendarDateVersioned = JSON.parse( await readFile(`src/${pipeline}/lib/config.json`, 'utf-8'), )['api-versions'] - const directoryListing = await readdir(`src/${pipeline}/data`) + const directoryListing = await readdir(directoryWithReleases) // filter the directory list to only include directories that start with // basenames with numbered releases (e.g., ghes-). const existingDataDir = directoryListing.filter((directory) => numberedReleaseBaseNames.some((basename) => directory.startsWith(basename)), ) + + if (!existingDataDir.length) { + throw new Error(`Cannot find ghes- release directories in ${directoryWithReleases}.`) + } + const expectedDirectory = isCalendarDateVersioned ? versionNamesCalDate : versionNames // Get a list of data directories to remove (deprecate) and remove them diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index 7eed7172053e..25c3d9568543 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,17 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "

Argument query: String added to field PullRequest.suggestedReviewerActors

" + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-01-21" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index 7d6b56f005d7..14fffd984755 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -41363,6 +41363,11 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int + + """ + Search actors with query on user name and login. + """ + query: String ): SuggestedReviewerActorConnection! """ diff --git a/src/graphql/data/fpt/schema.json b/src/graphql/data/fpt/schema.json index 064eca54bcac..b7feb1ed8b77 100644 --- a/src/graphql/data/fpt/schema.json +++ b/src/graphql/data/fpt/schema.json @@ -55242,6 +55242,16 @@ "kind": "scalars", "href": "/graphql/reference/scalars#int" } + }, + { + "name": "query", + "description": "

Search actors with query on user name and login.

", + "type": { + "name": "String", + "id": "string", + "kind": "scalars", + "href": "/graphql/reference/scalars#string" + } } ] }, diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index 7d6b56f005d7..14fffd984755 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -41363,6 +41363,11 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int + + """ + Search actors with query on user name and login. + """ + query: String ): SuggestedReviewerActorConnection! """ diff --git a/src/graphql/data/ghec/schema.json b/src/graphql/data/ghec/schema.json index 064eca54bcac..b7feb1ed8b77 100644 --- a/src/graphql/data/ghec/schema.json +++ b/src/graphql/data/ghec/schema.json @@ -55242,6 +55242,16 @@ "kind": "scalars", "href": "/graphql/reference/scalars#int" } + }, + { + "name": "query", + "description": "

Search actors with query on user name and login.

", + "type": { + "name": "String", + "id": "string", + "kind": "scalars", + "href": "/graphql/reference/scalars#string" + } } ] }, diff --git a/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml b/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml index cbe6b33dbd58..13d6eec9b726 100644 --- a/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml @@ -2648,7 +2648,7 @@ secretType: mapbox_secret_access_token isPublic: false isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: true base64Supported: false isduplicate: false @@ -3648,7 +3648,7 @@ isPublic: true isPrivateWithGhas: true hasPushProtection: true - hasValidityCheck: false + hasValidityCheck: true base64Supported: false isduplicate: false - provider: Sentry @@ -3827,7 +3827,7 @@ secretType: snowflake_postgres_connection_string isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -3836,7 +3836,7 @@ secretType: snowflake_postgres_host,
snowflake_postgres_password isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4178,7 +4178,7 @@ secretType: vercel_api_key isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4205,7 +4205,7 @@ secretType: vercel_integration_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4214,7 +4214,7 @@ secretType: vercel_personal_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4223,7 +4223,7 @@ secretType: vercel_support_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false diff --git a/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml b/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml index cbe6b33dbd58..13d6eec9b726 100644 --- a/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml @@ -2648,7 +2648,7 @@ secretType: mapbox_secret_access_token isPublic: false isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: true base64Supported: false isduplicate: false @@ -3648,7 +3648,7 @@ isPublic: true isPrivateWithGhas: true hasPushProtection: true - hasValidityCheck: false + hasValidityCheck: true base64Supported: false isduplicate: false - provider: Sentry @@ -3827,7 +3827,7 @@ secretType: snowflake_postgres_connection_string isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -3836,7 +3836,7 @@ secretType: snowflake_postgres_host,
snowflake_postgres_password isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4178,7 +4178,7 @@ secretType: vercel_api_key isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4205,7 +4205,7 @@ secretType: vercel_integration_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4214,7 +4214,7 @@ secretType: vercel_personal_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4223,7 +4223,7 @@ secretType: vercel_support_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false diff --git a/src/types/types.ts b/src/types/types.ts index 74e2819256f6..e38defed7c5a 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -411,6 +411,7 @@ export type TitlesTree = { hidden?: boolean sidebarLink?: SidebarLink layout?: string + crossProductChild?: boolean } export type Tree = { @@ -418,6 +419,7 @@ export type Tree = { children: string[] | undefined href: string childPages: Tree[] + crossProductChild?: boolean } export type VersionedTree = { [version: string]: Tree @@ -431,6 +433,7 @@ export type UnversionedTree = { page: Page children: string[] childPages: UnversionedTree[] + crossProductChild?: boolean } export type UnversionLanguageTree = {