diff --git a/.github/actions/create-workflow-failure-issue/action.yml b/.github/actions/create-workflow-failure-issue/action.yml deleted file mode 100644 index 0e0176b89d36..000000000000 --- a/.github/actions/create-workflow-failure-issue/action.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Create workflow failure issue -description: Create or update a GitHub issue in docs-engineering when a workflow fails, for automated diagnosis by an agentic workflow. - -inputs: - token: - description: A token with issues write permission on the target repo - required: true - repo: - description: The repository to create the issue in - default: github/docs-engineering - required: false - -runs: - using: composite - steps: - - name: Check for existing open issue - id: check-existing - shell: bash - env: - GH_TOKEN: ${{ inputs.token }} - ISSUE_REPO: ${{ inputs.repo }} - WORKFLOW_NAME: ${{ github.workflow }} - run: | - existing=$(gh issue list \ - --repo "$ISSUE_REPO" \ - --label "workflow-failure" \ - --search "in:title [Workflow Failure] $WORKFLOW_NAME" \ - --state open \ - --json number \ - --jq '.[0].number // empty' 2>/dev/null || true) - echo "existing_issue=$existing" >> "$GITHUB_OUTPUT" - - - name: Comment on existing issue - if: steps.check-existing.outputs.existing_issue != '' - shell: bash - env: - GH_TOKEN: ${{ inputs.token }} - ISSUE_REPO: ${{ inputs.repo }} - ISSUE_NUMBER: ${{ steps.check-existing.outputs.existing_issue }} - WORKFLOW_NAME: ${{ github.workflow }} - SOURCE_REPO: ${{ github.repository }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - EVENT_NAME: ${{ github.event_name }} - GIT_REF: ${{ github.ref }} - run: | - body=$(cat < 0 && github.event_name != 'workflow_dispatch' needs: update_graphql_files @@ -101,8 +96,3 @@ jobs: These change types are not in CHANGES_TO_REPORT and were silently ignored. Consider reviewing if they should be added to the changelog. See workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - uses: ./.github/actions/create-workflow-failure-issue - if: ${{ failure() && github.event_name != 'workflow_dispatch' }} - with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} diff --git a/.github/workflows/sync-llms-txt-to-github.yml b/.github/workflows/sync-llms-txt-to-github.yml index 9de6fbb62ebd..cce898f3f529 100644 --- a/.github/workflows/sync-llms-txt-to-github.yml +++ b/.github/workflows/sync-llms-txt-to-github.yml @@ -9,7 +9,7 @@ on: - 'data/llms-txt-config.yml' - 'src/workflows/generate-llms-txt.ts' schedule: - - cron: '20 16 * * 1' # Run every Monday at 16:20 UTC / 8:20 PST + - cron: '20 16 * * 1-5' # Weekdays at ~9:20am Pacific permissions: contents: read @@ -75,7 +75,8 @@ jobs: REPO="github/github" echo "Checking if branch '$BRANCH' exists..." - if BRANCH_SHA=$(gh api "repos/$REPO/git/ref/heads/$BRANCH" --jq '.object.sha' 2>/dev/null); then + BRANCH_SHA=$(gh api "repos/$REPO/git/ref/heads/$BRANCH" --jq '.object.sha' 2>/dev/null || true) + if [ -n "$BRANCH_SHA" ]; then echo "Branch exists at $BRANCH_SHA" else echo "Branch does not exist, creating from default branch..." @@ -98,11 +99,11 @@ jobs: CONTENT=$(base64 -w 0 /tmp/llms.txt) echo "Checking for existing file SHA on branch..." - if EXISTING_SHA=$(gh api "repos/$REPO/contents/public/llms.txt?ref=$BRANCH" \ - --jq '.sha' 2>/dev/null); then + EXISTING_SHA=$(gh api "repos/$REPO/contents/public/llms.txt?ref=$BRANCH" \ + --jq '.sha' 2>/dev/null || true) + if [ -n "$EXISTING_SHA" ]; then echo "Existing file SHA: $EXISTING_SHA" else - EXISTING_SHA="" echo "No existing file on branch (new file)" fi @@ -127,8 +128,9 @@ jobs: REPO="github/github" echo "Checking for existing PR from '$BRANCH'..." - if EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" \ - --json number --jq '.[0].number' 2>/dev/null) && [ -n "$EXISTING_PR" ]; then + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" \ + --json number --jq '.[0].number' 2>/dev/null || true) + if [ -n "$EXISTING_PR" ]; then echo "PR #$EXISTING_PR already exists, updated with new commit" exit 0 fi @@ -167,8 +169,3 @@ jobs: with: slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - - - uses: ./.github/actions/create-workflow-failure-issue - if: ${{ failure() && github.event_name != 'workflow_dispatch' }} - with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} diff --git a/.github/workflows/sync-openapi.yml b/.github/workflows/sync-openapi.yml index 4d1762b6f651..f564aac181ee 100644 --- a/.github/workflows/sync-openapi.yml +++ b/.github/workflows/sync-openapi.yml @@ -123,8 +123,3 @@ jobs: with: slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - - - uses: ./.github/actions/create-workflow-failure-issue - if: ${{ failure() && github.event_name != 'workflow_dispatch' }} - with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} diff --git a/.github/workflows/sync-secret-scanning.yml b/.github/workflows/sync-secret-scanning.yml index a51bdfc881db..a0ec485db4d6 100644 --- a/.github/workflows/sync-secret-scanning.yml +++ b/.github/workflows/sync-secret-scanning.yml @@ -84,8 +84,3 @@ jobs: with: slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - - - uses: ./.github/actions/create-workflow-failure-issue - if: ${{ failure() && github.event_name != 'workflow_dispatch' }} - with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} diff --git a/.github/workflows/triage-stale-check.yml b/.github/workflows/triage-stale-check.yml index 63e081fe0c06..e154f9132bbe 100644 --- a/.github/workflows/triage-stale-check.yml +++ b/.github/workflows/triage-stale-check.yml @@ -6,7 +6,7 @@ name: Stale check for no activity on: schedule: - - cron: '20 16 * * 1' # Run every Monday at 16:20 UTC / 8:20 PST + - cron: '20 16 * * 2' # Run every Tuesday at 16:20 UTC / 8:20 PST — staleness & triage theme permissions: contents: read diff --git a/.github/workflows/validate-github-github-docs-urls.yml b/.github/workflows/validate-github-github-docs-urls.yml index db17a734203a..96afda1e0194 100644 --- a/.github/workflows/validate-github-github-docs-urls.yml +++ b/.github/workflows/validate-github-github-docs-urls.yml @@ -7,7 +7,7 @@ name: Validate github/github docs URLs on: workflow_dispatch: schedule: - - cron: '20 16 * * 1' # Run every Monday at 16:20 UTC / 8:20 PST + - cron: '20 16 * * 3' # Run every Wednesday at 16:20 UTC / 8:20 PST — orphan & hygiene cleanup theme # See https://gh.io/AAsyyao before uncommenting: # pull_request: # paths: @@ -129,8 +129,3 @@ jobs: with: slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} - - - uses: ./.github/actions/create-workflow-failure-issue - if: ${{ failure() && github.event_name == 'schedule' }} - with: - token: ${{ secrets.DOCS_BOT_PAT_BASE }} diff --git a/content/copilot/concepts/auto-model-selection.md b/content/copilot/concepts/auto-model-selection.md index b7dbce996bc8..431dab601326 100644 --- a/content/copilot/concepts/auto-model-selection.md +++ b/content/copilot/concepts/auto-model-selection.md @@ -62,6 +62,8 @@ During the {% data variables.release-phases.public_preview %}, if you're using a ## {% data variables.copilot.copilot_auto_model_selection_short_cap_a %} in {% data variables.copilot.copilot_coding_agent %} +> [!NOTE] {% data variables.copilot.copilot_auto_model_selection_short_cap_a %} for {% data variables.copilot.copilot_coding_agent %} is generally available and currently only available for {% data variables.copilot.copilot_pro %} and {% data variables.copilot.copilot_pro_plus %} plans. + When you select **Auto** in {% data variables.copilot.copilot_coding_agent %}, {% data variables.copilot.copilot_auto_model_selection_short_cap_a %} currently chooses from the following list of models, subject to your policies and subscription type: {% data reusables.copilot.copilot-coding-agent-auto-models %} diff --git a/content/copilot/how-tos/use-copilot-agents/coding-agent/changing-the-ai-model.md b/content/copilot/how-tos/use-copilot-agents/coding-agent/changing-the-ai-model.md index e8c700529621..a0fb945c5c41 100644 --- a/content/copilot/how-tos/use-copilot-agents/coding-agent/changing-the-ai-model.md +++ b/content/copilot/how-tos/use-copilot-agents/coding-agent/changing-the-ai-model.md @@ -20,7 +20,7 @@ In supported entrypoints, you can select the model used by {% data variables.cop You may find that different models perform better, or provide more useful responses, depending on the type of tasks you give {% data variables.product.prodname_copilot_short %}. > [!NOTE] -> Model selection for {% data variables.copilot.copilot_coding_agent %} is only supported when assigning an issue to {% data variables.product.prodname_copilot_short %} on {% data variables.product.prodname_dotcom_the_website %}, when mentioning `@copilot` in a pull request comment on {% data variables.product.prodname_dotcom_the_website %}, or when starting a task from the agents tab, agents panel, {% data variables.product.prodname_mobile %} or the Raycast launcher. Where a model picker is not available, Auto will be used automatically. See [AUTOTITLE](/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr). +> Model selection for {% data variables.copilot.copilot_coding_agent %} is only supported when assigning an issue to {% data variables.product.prodname_copilot_short %} on {% data variables.product.prodname_dotcom_the_website %}, or when starting a task from the agents tab, agents panel, {% data variables.product.prodname_mobile %} or the Raycast launcher. Where a model picker is not available, Auto will be used automatically. See [AUTOTITLE](/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr). ## Supported models diff --git a/content/copilot/how-tos/use-copilot-agents/coding-agent/make-changes-to-an-existing-pr.md b/content/copilot/how-tos/use-copilot-agents/coding-agent/make-changes-to-an-existing-pr.md index 4aa1192ce54f..aa59e6e3dddd 100644 --- a/content/copilot/how-tos/use-copilot-agents/coding-agent/make-changes-to-an-existing-pr.md +++ b/content/copilot/how-tos/use-copilot-agents/coding-agent/make-changes-to-an-existing-pr.md @@ -16,7 +16,7 @@ category: ## Introduction -You can ask {% data variables.product.prodname_copilot_short %} to make changes to an existing pull request created by a human developer by mentioning `@copilot` in a comment. +You can ask {% data variables.product.prodname_copilot_short %} to make changes to an existing pull request created by a human developer. {% data variables.product.prodname_copilot_short %} will create a child pull request, using the existing pull request's branch as the base branch. Once it has finished work on the changes you requested, it requests your review on the child pull request. @@ -27,9 +27,7 @@ You can ask {% data variables.product.prodname_copilot_short %} to make changes ## Asking {% data variables.product.prodname_copilot_short %} to make changes 1. Navigate to the pull request that you want {% data variables.product.prodname_copilot_short %} to make changes to. -1. Write a comment or review mentioning {% data variables.product.prodname_copilot_short %} with `@copilot`. -1. Optionally, when leaving a pull request comment (not a review or review comment) through the {% data variables.product.github %} web interface, select a model using the model picker. -1. Submit your comment or review. +1. Leave a comment or review mentioning {% data variables.product.prodname_copilot_short %} with `@copilot`. {% data variables.product.prodname_copilot_short %} will open a child pull request, using the existing pull request's branch as the base branch. diff --git a/content/copilot/how-tos/use-copilot-agents/coding-agent/review-copilot-prs.md b/content/copilot/how-tos/use-copilot-agents/coding-agent/review-copilot-prs.md index 59e515b74a96..45c94de8f06b 100644 --- a/content/copilot/how-tos/use-copilot-agents/coding-agent/review-copilot-prs.md +++ b/content/copilot/how-tos/use-copilot-agents/coding-agent/review-copilot-prs.md @@ -30,8 +30,6 @@ After {% data variables.product.prodname_copilot_short %} has finished working o You can ask {% data variables.product.prodname_copilot_short %} to make changes by mentioning `@copilot` in pull request comments, or you can check out {% data variables.product.prodname_copilot_short %}'s branch and make changes yourself. -Optionally, when submitting a pull request comment (not a review or review comment) through the {% data variables.product.github %} web interface, you can select a model using the model picker. By default, {% data variables.product.prodname_copilot_short %} will use the model originally used to create the pull request. - > [!TIP] > We recommend you batch your review comments instead of submitting them individually. diff --git a/content/copilot/responsible-use/copilot-in-github-desktop.md b/content/copilot/responsible-use/copilot-in-github-desktop.md index e8798a457434..04c56c3d5419 100644 --- a/content/copilot/responsible-use/copilot-in-github-desktop.md +++ b/content/copilot/responsible-use/copilot-in-github-desktop.md @@ -39,7 +39,7 @@ The feature is intended to supplement rather than replace a human's work to draf ### Provide feedback -If you encounter any issues or limitations with {% data variables.copilot.copilot_desktop_short %}, you can provide feedback by creating an issue in the [{% data variables.product.prodname_desktop %} open source repository](https://github.com/desktop/desktop/issues/new?template=bug_report.yaml). This can help the developers to improve the tool and address any concerns or limitations. +If you encounter any issues or limitations with {% data variables.copilot.copilot_desktop_short %}, you can provide feedback by creating an issue in the [{% data variables.product.prodname_desktop %} open source repository](https://github.com/desktop/desktop/issues/new?template=bug_report.yaml ). This can help the developers to improve the tool and address any concerns or limitations. ## Limitations of {% data variables.copilot.copilot_desktop_short %} diff --git a/content/rest/about-the-rest-api/breaking-changes.md b/content/rest/about-the-rest-api/breaking-changes.md index ec257cd46225..151b02462eac 100644 --- a/content/rest/about-the-rest-api/breaking-changes.md +++ b/content/rest/about-the-rest-api/breaking-changes.md @@ -24,4 +24,6 @@ When you update your integration to specify the new API version in the `X-GitHub Once your integration is updated, test your integration to verify that it works with the new API version. -{% data reusables.rest-api.breaking-changes-changelog %} +## Breaking changes for {{ initialRestVersioningReleaseDate }} + +Version `{{ initialRestVersioningReleaseDate }}` is the first version of the {% data variables.product.github %} REST API after date-based versioning was introduced. This version does not include any breaking changes. diff --git a/data/reusables/rest-api/breaking-changes-changelog.md b/data/reusables/rest-api/breaking-changes-changelog.md deleted file mode 100644 index fbaa9f0493fa..000000000000 --- a/data/reusables/rest-api/breaking-changes-changelog.md +++ /dev/null @@ -1,18 +0,0 @@ - -{% ifversion fpt %} -{% if query.apiVersion == nil or "2022-11-28" <= query.apiVersion %} -## Version 2022-11-28 - -Version `2022-11-28` is the first version of the GitHub Free, Pro & Team REST API after date-based versioning was introduced. This version does not include any breaking changes. - -{% endif %} -{% endif %} - -{% ifversion ghec %} -{% if query.apiVersion == nil or "2022-11-28" <= query.apiVersion %} -## Version 2022-11-28 - -Version `2022-11-28` is the first version of the GitHub Enterprise Cloud REST API after date-based versioning was introduced. This version does not include any breaking changes. - -{% endif %} -{% endif %} diff --git a/src/article-api/middleware/article.ts b/src/article-api/middleware/article.ts index b9eb40a97e55..713c5eb6790a 100644 --- a/src/article-api/middleware/article.ts +++ b/src/article-api/middleware/article.ts @@ -59,7 +59,6 @@ router.get( } incrementArticleLookup(req, 'full', cacheInfo) - recordBodySize(req, bodyContent) defaultCacheControl(res) return res.json({ @@ -101,7 +100,6 @@ router.get( } incrementArticleLookup(req, 'body') - recordBodySize(req, bodyContent) defaultCacheControl(res) return res.type('text/markdown').send(bodyContent) @@ -204,13 +202,4 @@ function incrementArticleLookup( statsd.increment('api.article.lookup', 1, tags) } -function recordBodySize(req: ExtendedRequestWithPageInfo, body: string) { - const sizeBytes = Buffer.byteLength(body, 'utf8') - const tags = [ - `pathname:${req.pageinfo.pathname}`.slice(0, 200), - `language:${req.pageinfo.page?.languageCode || 'en'}`, - ] - statsd.distribution('api.article.body_size_bytes', sizeBytes, tags) -} - export default router diff --git a/src/frame/lib/create-tree.ts b/src/frame/lib/create-tree.ts index 96227b716875..1cb18aa80d7d 100644 --- a/src/frame/lib/create-tree.ts +++ b/src/frame/lib/create-tree.ts @@ -40,18 +40,14 @@ export default async function createTree( } // Throw an error if we can't find a content file associated with the children: entry. // But don't throw an error if the user is running the site locally and hasn't cloned the Early Access repo. - // Also don't throw for missing children *within* early-access content — a broken - // early-access article should not block every docs-internal PR from merging. - const msg = `Cannot find a content file at ${originalPath}. Check the 'children' frontmatter in the parent index.md.` - - if ( - originalPath === 'content/early-access' || - originalPath.startsWith('content/early-access/') - ) { - console.warn(`Warning: ${msg}`) + if (originalPath === 'content/early-access') { return } - throw new Error(msg) + throw new Error( + `Cannot find a content file at ${originalPath}. Fix the children frontmatter entry "/${path.basename( + originalPath, + )}" in ${path.dirname(originalPath)}/index.md.\n`, + ) } } diff --git a/src/frame/middleware/cache-control.ts b/src/frame/middleware/cache-control.ts index fd4f79ecbf57..7132b94329d7 100644 --- a/src/frame/middleware/cache-control.ts +++ b/src/frame/middleware/cache-control.ts @@ -84,26 +84,20 @@ export function defaultCacheControl(res: Response): void { defaultBrowserCacheControl(res) } -// Vary on content type for pages that support content negotiation (HTML vs markdown) -export function contentTypeCacheControl(res: Response): void { - defaultCacheControl(res) - res.append('vary', 'accept') -} - // Vary on language when needed // x-user-language is a custom request header derived from req.cookie:user_language // accept-language is truncated to one of our available languages // https://bit.ly/3u5UeRN export function languageCacheControl(res: Response): void { defaultCacheControl(res) - res.append('vary', 'accept-language, x-user-language') + res.set('vary', 'accept-language, x-user-language') } // Vary on both language and version for homepage redirects // x-user-version is a custom request header derived from req.cookie:user_version export function languageAndVersionCacheControl(res: Response): void { defaultCacheControl(res) - res.append('vary', 'accept-language, x-user-language, x-user-version') + res.set('vary', 'accept-language, x-user-language, x-user-version') } // Long cache control for versioned assets: images, CSS, JS... diff --git a/src/frame/middleware/llms-txt.ts b/src/frame/middleware/llms-txt.ts index cca0299a180a..1ae9ea740d6b 100644 --- a/src/frame/middleware/llms-txt.ts +++ b/src/frame/middleware/llms-txt.ts @@ -53,17 +53,13 @@ function generateBasicLlmsTxt(): string { > Help for wherever you are on your GitHub journey. -## How to Use +## Docs Content -To find a specific article, use the **Search API** with a query. To browse all available pages, use the **Page List API** to get a list of paths, then fetch individual articles with the **Article API**. The \`/api/article/body\` endpoint returns clean markdown, ideal for LLM consumption. - -## APIs - -- [Page List API](${BASE_API_URL}/en/free-pro-team@latest): Returns all article paths for a given version. Use this to discover what content is available. -- [Article API](https://docs.github.com/api/article): Fetches a single article as JSON (metadata and markdown body). Use \`/api/article/body\` for markdown only. Example: \`/api/article/body?pathname=/en/get-started/start-your-journey/about-github-and-git\` -- [Search API](https://docs.github.com/api/search/v1): Full-text search across all articles. Returns matching pages with context. Example: \`/api/search/v1?query=actions&language=en&version=free-pro-team@latest&client_name=curl\` -- [Versions API](${BASE_API_URL}/versions): Lists all available documentation versions. -- [Languages API](${BASE_API_URL}/languages): Lists all available languages. +- [Page List API](${BASE_API_URL}/en/free-pro-team@latest) +- [Versions API](${BASE_API_URL}/versions): \`curl "https://docs.github.com/api/pagelist/versions"\` +- [Languages API](${BASE_API_URL}/languages): \`curl "https://docs.github.com/api/pagelist/languages"\` +- [Article API](https://docs.github.com/api/article): \`curl "https://docs.github.com/api/article?pathname=/en/get-started/start-your-journey/about-github-and-git"\` +- [Search API](https://docs.github.com/api/search): \`curl "https://docs.github.com/api/search?query=actions&language=en&version=free-pro-team@latest"\` ## Translations diff --git a/src/frame/middleware/render-page.ts b/src/frame/middleware/render-page.ts index 03902f45ec38..2dbc8b0415f1 100644 --- a/src/frame/middleware/render-page.ts +++ b/src/frame/middleware/render-page.ts @@ -10,7 +10,7 @@ import statsd from '@/observability/lib/statsd' import type { ExtendedRequest } from '@/types' import { allVersions } from '@/versions/lib/all-versions' import { minimumNotFoundHtml } from '../lib/constants' -import { contentTypeCacheControl, defaultCacheControl } from './cache-control' +import { defaultCacheControl } from './cache-control' import { isConnectionDropped } from './halt-on-dropped-connection' import { nextHandleRequest } from './next' @@ -90,12 +90,6 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { // Stop processing if the connection was already dropped if (isConnectionDropped(req, res)) return - // Content negotiation: serve markdown when the client prefers it over HTML. - // Agents like Claude Code send Accept headers that omit text/html. - if (req.accepts(['text/html', 'text/markdown']) === 'text/markdown') { - context.markdownRequested = true - } - if (!req.context) throw new Error('request not contextualized') req.context.renderedPage = await buildRenderedPage(req) req.context.miniTocItems = buildMiniTocItems(req) @@ -151,11 +145,15 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { } if (context.markdownRequested) { - contentTypeCacheControl(res) - return res.type('text/markdown').send(req.context.renderedPage) + if (!page.autogenerated && page.documentType === 'article') { + return res.type('text/markdown').send(req.context.renderedPage) + } else { + const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\.md$/, '')) + return res.redirect(newUrl) + } } - contentTypeCacheControl(res) + defaultCacheControl(res) return nextHandleRequest(req, res) } diff --git a/src/frame/tests/llms-txt.ts b/src/frame/tests/llms-txt.ts index d1111f430bd6..be882f2f4e41 100644 --- a/src/frame/tests/llms-txt.ts +++ b/src/frame/tests/llms-txt.ts @@ -20,17 +20,6 @@ describe('llms.txt endpoint', () => { expect(content).toMatch(/^# .*GitHub.*Docs/m) }) - test('includes how to use guidance', async () => { - const res = await get('/llms.txt') - const content = res.body - - // Should explain the workflow for LLMs - expect(content).toMatch(/Search API/) - expect(content).toMatch(/Page List API/) - expect(content).toMatch(/Article API/) - expect(content).toMatch(/markdown/i) - }) - test('includes programmatic access section', async () => { const res = await get('/llms.txt') const content = res.body @@ -38,7 +27,6 @@ describe('llms.txt endpoint', () => { // Should mention the existing APIs expect(content).toMatch(/Article API/i) expect(content).toMatch(/Page List API/i) - expect(content).toMatch(/Search API/i) expect(content).toMatch(/api\/article/i) expect(content).toMatch(/api\/pagelist\/en\/free-pro-team@latest/i) }) @@ -48,8 +36,7 @@ describe('llms.txt endpoint', () => { const content = res.body // Should have all the main sections we expect - expect(content).toMatch(/## How to Use/i) - expect(content).toMatch(/## APIs/i) + expect(content).toMatch(/## Docs Content/i) expect(content).toMatch(/## Translations/i) expect(content).toMatch(/## Versions/i) }) @@ -88,6 +75,7 @@ describe('llms.txt endpoint', () => { // Should prominently feature the pagelist API as the main content source expect(content).toMatch(/Page List API.*api\/pagelist\/en\/free-pro-team@latest/i) + expect(content).not.toMatch(/Machine-readable list/i) // Removed descriptions }) test.each(['free-pro-team@latest', 'enterprise-cloud@latest'])( @@ -112,7 +100,7 @@ describe('llms.txt endpoint', () => { expect(content).toMatch(/api\/pagelist\/en\/enterprise-server@\d+\.\d+/) }) - test('follows llms.txt specification structure', async () => { + test('follows llms.txt specification structure and has reasonable length', async () => { const res = await get('/llms.txt') const content = res.body @@ -128,6 +116,10 @@ describe('llms.txt endpoint', () => { // Check for markdown links expect(content).toMatch(/\[.+\]\(.+\)/m) + // Should include translations and versions but still be reasonable + expect(content.length).toBeGreaterThan(500) + expect(content.length).toBeLessThan(5000) + // Split into lines for structure analysis const lines = content.split('\n') @@ -139,8 +131,8 @@ describe('llms.txt endpoint', () => { const hasBlockquote = lines.some((line: string) => line.trim().startsWith('>')) expect(hasBlockquote).toBe(true) - // Should have multiple H2 sections (How to Use, APIs, Translations, Versions) + // Should have multiple H2 sections (Docs Content, Translations, Versions) const h2Sections = lines.filter((line: string) => line.trim().startsWith('## ')) - expect(h2Sections.length).toBeGreaterThanOrEqual(4) + expect(h2Sections.length).toBeGreaterThanOrEqual(3) }) }) diff --git a/src/frame/tests/server.ts b/src/frame/tests/server.ts index bb62313f5f40..6db4e28cc070 100644 --- a/src/frame/tests/server.ts +++ b/src/frame/tests/server.ts @@ -278,40 +278,6 @@ describe('server', () => { expect(res.headers['cache-control']).toMatch(/max-age=\d+/) }) }) - - describe('Accept: text/markdown content negotiation', () => { - test('returns markdown when Accept header prefers text/markdown', async () => { - const res = await get('/en', { - headers: { - accept: 'text/markdown', - }, - }) - expect(res.statusCode).toBe(200) - expect(res.headers['content-type']).toContain('text/markdown') - expect(res.headers.vary).toContain('accept') - }) - - test('returns HTML when Accept header prefers text/html', async () => { - const res = await get('/en', { - headers: { - accept: 'text/html,application/xhtml+xml', - }, - }) - expect(res.statusCode).toBe(200) - expect(res.headers['content-type']).toContain('text/html') - expect(res.headers.vary).toContain('accept') - }) - - test('returns HTML when Accept header is */*', async () => { - const res = await get('/en', { - headers: { - accept: '*/*', - }, - }) - expect(res.statusCode).toBe(200) - expect(res.headers['content-type']).toContain('text/html') - }) - }) }) describe('static routes', () => { diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index ca294175a8a9..3e3fb3489181 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -124,44 +124,28 @@ export async function updateAutomatedPipelines() { } // Get a list of data directories to create (release) and create them - // This should only happen if a release is being added. + // This should only happen if a relase is being added. const addFiles = difference(expectedDirectory, existingDataDir) - - // Verify all new directories belong to the current release - for (const dir of addFiles) { - if (!dir.includes(currentReleaseNumber)) { - throw new Error( - `Unexpected directory to add: ${dir}. Only directories for the current release ` + - `(${currentReleaseNumber}) should be added. Check that the lib/enterprise-server-releases.ts is correct.`, - ) - } + if (addFiles.length > numberedReleaseBaseNames.length) { + throw new Error( + 'Only one new release per numbered release version should be added at a time. Check that the lib/enterprise-server-releases.ts is correct.', + ) } for (const base of numberedReleaseBaseNames) { - // Find ALL directories to add for this base name (may be multiple - // when a release has more than one calendar-date version). - const dirsToAdd = addFiles.filter((item) => item.startsWith(base)) - for (const dirToAdd of dirsToAdd) { - // Derive the previous release's corresponding directory by replacing - // the current release number with the previous one. This correctly - // maps each calendar-date variant to its predecessor, e.g.: - // ghes-3.20-2022-11-28 → ghes-3.19-2022-11-28 - // ghes-3.20-2026-03-10 → ghes-3.19-2026-03-10 - const previousDirName = dirToAdd.replace(currentReleaseNumber, previousReleaseNumber) - if (!existingDataDir.includes(previousDirName)) { - throw new Error( - `Cannot find previous release directory '${previousDirName}' to copy from ` + - `when creating '${dirToAdd}' in src/${pipeline}/data/.`, - ) - } - - console.log( - `Copying src/${pipeline}/data/${previousDirName} to src/${pipeline}/data/${dirToAdd}`, - ) - await cp(`src/${pipeline}/data/${previousDirName}`, `src/${pipeline}/data/${dirToAdd}`, { - recursive: true, - }) - } + const dirToAdd = addFiles.find((item) => item.startsWith(base)) + if (!dirToAdd) continue + // The suppported array is ordered from most recent (index 0) to oldest + // Index 1 will be the release prior to the most recent release + const lastRelease = supported[1] + const previousDirName = existingDataDir.filter((directory) => directory.includes(lastRelease)) + + console.log( + `Copying src/${pipeline}/data/${previousDirName} to src/${pipeline}/data/${dirToAdd}`, + ) + await cp(`src/${pipeline}/data/${previousDirName}`, `src/${pipeline}/data/${dirToAdd}`, { + recursive: true, + }) } } diff --git a/src/links/lib/extract-links.ts b/src/links/lib/extract-links.ts index 328f3c4d9481..d6a5c79f32a1 100644 --- a/src/links/lib/extract-links.ts +++ b/src/links/lib/extract-links.ts @@ -16,9 +16,8 @@ import type { Context, Page } from '@/types' // Link patterns for Markdown const INTERNAL_LINK_PATTERN = /\]\(\/[^)]+\)/g const AUTOTITLE_LINK_PATTERN = /\[AUTOTITLE\]\(([^)]+)\)/g -// Handles one level of balanced parentheses in URLs (e.g., Wikipedia links). -// Uses an unrolled loop to avoid catastrophic backtracking on malformed URLs. -const EXTERNAL_LINK_PATTERN = /\]\((https?:\/\/[^()\s]*(?:\([^()]*\)[^()\s]*)*)\)/g +// Handles one level of balanced parentheses in URLs (e.g., Wikipedia links) +const EXTERNAL_LINK_PATTERN = /\]\((https?:\/\/(?:[^()\s]+|\([^()]*\))*)\)/g const IMAGE_LINK_PATTERN = /!\[[^\]]*\]\(([^)]+)\)/g // Anchor link patterns (for same-page links) diff --git a/src/links/scripts/check-links-external.ts b/src/links/scripts/check-links-external.ts index 526ff928d077..a5c5d89b87dc 100644 --- a/src/links/scripts/check-links-external.ts +++ b/src/links/scripts/check-links-external.ts @@ -155,20 +155,10 @@ async function extractAllExternalLinks(): Promise 1000) { - console.warn(` ⚠️ Slow extraction: ${file} took ${(fileMs / 1000).toFixed(1)}s`) - } for (const link of result.externalLinks) { // Only check HTTPS links @@ -183,16 +173,8 @@ async function extractAllExternalLinks(): Promise): VersionMapping[] { - // Build reverse lookup: docs short name → source directory name - // e.g. "fpt" → "api.github.com", "ghec" → "ghec" - const reverseMapping: Record = {} - for (const [sourceDir, docsName] of Object.entries(versionNames)) { - reverseMapping[docsName] = sourceDir - } - - const mappings: VersionMapping[] = [] - const seen = new Set() - - for (const versionObj of Object.values(allVersions)) { - const key = versionObj.openApiVersionName - if (seen.has(key)) continue - seen.add(key) - - let sourceDir: string - let ifversionExpr: string - - if (versionObj.shortName === 'ghes') { - // GHES versions: source dir is like "ghes-3.14", ifversion is "ghes = 3.14" - sourceDir = `ghes-${versionObj.currentRelease}` - ifversionExpr = `ghes = ${versionObj.currentRelease}` - } else { - // Non-GHES: look up source dir from reverse mapping - sourceDir = reverseMapping[versionObj.shortName] || versionObj.shortName - ifversionExpr = versionObj.shortName - } - - mappings.push({ sourceDir, ifversionExpr }) - } - - return mappings -} - -// Resolve the changelog file path based on whether we're using -// rest-api-description or the local github repo. -export function getChangelogPath(sourceRepoDir: string, releaseDir: string): string { - if (sourceRepoDir === REST_API_DESCRIPTION_ROOT) { - return path.join(REST_API_DESCRIPTION_ROOT, 'descriptions-next', releaseDir, 'CHANGELOG.md') - } - // Local github repo dev workflow - return path.join( - sourceRepoDir, - 'app', - 'api', - 'description', - 'changelogs', - releaseDir, - 'CHANGELOG.md', - ) -} - -// Parse a CHANGELOG.md into an array of { version, content } objects -// by splitting on `## Version YYYY-MM-DD` headings. -// Strips the top-level `# REST API Breaking Changes for ...` title and intro paragraph. -export function parseVersionSections(markdown: string): VersionSection[] { - const lines = markdown.split('\n') - const sections: VersionSection[] = [] - let currentVersion: string | null = null - let currentLines: string[] = [] - let pastHeader = false - - for (const line of lines) { - // Skip the top-level title (# REST API Breaking Changes ...) - if (!pastHeader && line.startsWith('# ')) { - pastHeader = true - continue - } - - // Skip intro paragraph lines before the first ## Version heading - const versionMatch = line.match(/^## Version (\d{4}-\d{2}-\d{2})/) - if (versionMatch) { - // Save previous section if any - if (currentVersion) { - sections.push({ - version: currentVersion, - content: currentLines.join('\n').trim(), - }) - } - currentVersion = versionMatch[1] - currentLines = [line] - pastHeader = true - continue - } - - if (currentVersion) { - currentLines.push(line) - } - } - - // Save last section - if (currentVersion) { - sections.push({ - version: currentVersion, - content: currentLines.join('\n').trim(), - }) - } - - return sections -} - -// Main function: reads changelogs from each release directory, wraps them -// in product-version gating ({% ifversion %}) and API-version filtering -// ({% if query.apiVersion %}), and writes a combined data file. -export async function syncChangelogs( - sourceRepoDir: string, - versionNames: Record, - outputPath: string = OUTPUT_PATH, -): Promise { - console.log(`\n▶️ Generating REST API breaking changes changelog...\n`) - - const mappings = buildVersionMappings(versionNames) - const outputBlocks: string[] = [] - - for (const { sourceDir, ifversionExpr } of mappings) { - const changelogPath = getChangelogPath(sourceRepoDir, sourceDir) - - if (!existsSync(changelogPath)) { - console.log(` ⏭️ No changelog found for ${sourceDir}, skipping.`) - continue - } - - const markdown = await readFile(changelogPath, 'utf-8') - const sections = parseVersionSections(markdown) - - if (sections.length === 0) { - console.log(` ⏭️ No version sections found in changelog for ${sourceDir}, skipping.`) - continue - } - - const sectionBlocks = sections.map(({ version, content }) => { - return [ - `{% if query.apiVersion == nil or "${version}" <= query.apiVersion %}`, - content, - '', - '{% endif %}', - ].join('\n') - }) - - const releaseBlock = [ - `{% ifversion ${ifversionExpr} %}`, - sectionBlocks.join('\n'), - '{% endif %}', - ].join('\n') - - outputBlocks.push(releaseBlock) - console.log(` ✅ Processed changelog for ${sourceDir} (${sections.length} version sections)`) - } - - if (outputBlocks.length === 0) { - console.log(' ⚠️ No changelogs found. Skipping changelog generation.') - return - } - - // The generated Liquid uses quoted date strings in comparisons - // (e.g., "2022-11-28" <= query.apiVersion) which is valid Liquid but - // triggers the GHD016 lint rule that flags quoted conditional args. - // The upstream changelogs may also contain docs.github.com URLs and - // "deprecated" terminology that trigger docs-domain and GHD046 rules. - const lintDisable = - '\n' - const output = `${lintDisable + outputBlocks.join('\n\n')}\n` - await writeFile(outputPath, output) - console.log(`\n✅ Wrote ${outputPath}`) -} diff --git a/src/rest/tests/sync-changelogs.ts b/src/rest/tests/sync-changelogs.ts deleted file mode 100644 index 904db6415476..000000000000 --- a/src/rest/tests/sync-changelogs.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'fs/promises' -import { existsSync } from 'fs' -import path from 'path' -import os from 'os' - -import { - parseVersionSections, - getChangelogPath, - syncChangelogs, -} from '../scripts/utils/sync-changelogs' - -// Suppress console.log output during tests -beforeEach(() => { - vi.spyOn(console, 'log').mockImplementation(() => {}) -}) -afterEach(() => { - vi.restoreAllMocks() -}) - -// --------------------------------------------------------------------------- -// parseVersionSections -// --------------------------------------------------------------------------- -describe('parseVersionSections', () => { - test('parses a changelog with multiple version sections', () => { - const markdown = `# REST API Breaking Changes for GitHub Free, Pro & Team - -For more information, see API Versions. - -## Version 2026-06-10 - -- **Removed topics property** - Use the dedicated endpoint instead. - -## Version 2026-03-10 - -- **Removed permission property** - Use notification_setting instead. - -## Version 2022-11-28 - -No breaking changes.` - - const sections = parseVersionSections(markdown) - expect(sections).toHaveLength(3) - expect(sections[0].version).toBe('2026-06-10') - expect(sections[0].content).toContain('Removed topics property') - expect(sections[1].version).toBe('2026-03-10') - expect(sections[1].content).toContain('Removed permission property') - expect(sections[2].version).toBe('2022-11-28') - expect(sections[2].content).toContain('No breaking changes') - }) - - test('parses a changelog with a single version section', () => { - const markdown = `# REST API Breaking Changes - -## Version 2022-11-28 - -This version has no breaking changes.` - - const sections = parseVersionSections(markdown) - expect(sections).toHaveLength(1) - expect(sections[0].version).toBe('2022-11-28') - expect(sections[0].content).toContain('no breaking changes') - }) - - test('strips the top-level title and intro paragraph', () => { - const markdown = `# REST API Breaking Changes for GitHub Enterprise Cloud - -For more information about versioning, see API Versions. - -## Version 2022-11-28 - -Content here.` - - const sections = parseVersionSections(markdown) - expect(sections).toHaveLength(1) - expect(sections[0].content).not.toContain('Breaking Changes for') - expect(sections[0].content).not.toContain('For more information') - expect(sections[0].content).toContain('Content here') - }) - - test('preserves the ## Version heading in section content', () => { - const markdown = `# Title - -## Version 2026-03-10 - -Some changes.` - - const sections = parseVersionSections(markdown) - expect(sections[0].content).toMatch(/^## Version 2026-03-10/) - }) - - test('returns empty array for markdown with no version sections', () => { - const markdown = `# REST API Breaking Changes - -Just some intro text with no version headings.` - - const sections = parseVersionSections(markdown) - expect(sections).toHaveLength(0) - }) - - test('returns empty array for empty string', () => { - expect(parseVersionSections('')).toHaveLength(0) - }) - - test('handles multi-line content within a version section', () => { - const markdown = `# Title - -## Version 2026-03-10 - -- **Change one** - Detailed description spanning - multiple lines. - - With a paragraph break. - -- **Change two** - Another description.` - - const sections = parseVersionSections(markdown) - expect(sections).toHaveLength(1) - expect(sections[0].content).toContain('Change one') - expect(sections[0].content).toContain('multiple lines') - expect(sections[0].content).toContain('paragraph break') - expect(sections[0].content).toContain('Change two') - }) -}) - -// --------------------------------------------------------------------------- -// getChangelogPath -// --------------------------------------------------------------------------- -describe('getChangelogPath', () => { - test('returns descriptions-next path for rest-api-description source', () => { - const result = getChangelogPath('rest-api-description', 'api.github.com') - expect(result).toBe( - path.join('rest-api-description', 'descriptions-next', 'api.github.com', 'CHANGELOG.md'), - ) - }) - - test('returns descriptions-next path for ghec', () => { - const result = getChangelogPath('rest-api-description', 'ghec') - expect(result).toBe( - path.join('rest-api-description', 'descriptions-next', 'ghec', 'CHANGELOG.md'), - ) - }) - - test('returns descriptions-next path for ghes release dir', () => { - const result = getChangelogPath('rest-api-description', 'ghes-3.19') - expect(result).toBe( - path.join('rest-api-description', 'descriptions-next', 'ghes-3.19', 'CHANGELOG.md'), - ) - }) - - test('returns local github repo path for github source', () => { - const result = getChangelogPath('../github', 'api.github.com') - expect(result).toBe( - path.join( - '..', - 'github', - 'app', - 'api', - 'description', - 'changelogs', - 'api.github.com', - 'CHANGELOG.md', - ), - ) - }) -}) - -// --------------------------------------------------------------------------- -// syncChangelogs (integration tests using temp directories) -// --------------------------------------------------------------------------- -describe('syncChangelogs', () => { - let tmpDir: string - let outputPath: string - - const versionNames: Record = { - 'api.github.com': 'fpt', - ghec: 'ghec', - ghes: 'ghes', - } - - beforeEach(async () => { - tmpDir = await mkdtemp(path.join(os.tmpdir(), 'sync-changelogs-test-')) - outputPath = path.join(tmpDir, 'breaking-changes-changelog.md') - }) - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }) - }) - - // Helper to create a changelog file in the github repo layout: - // /app/api/description/changelogs//CHANGELOG.md - async function createChangelog(githubDir: string, releaseDir: string, content: string) { - const changelogDir = path.join(githubDir, 'app', 'api', 'description', 'changelogs', releaseDir) - await mkdir(changelogDir, { recursive: true }) - await writeFile(path.join(changelogDir, 'CHANGELOG.md'), content) - } - - test('generates output with ifversion and apiVersion gating for fpt', async () => { - const githubDir = path.join(tmpDir, 'github') - await createChangelog( - githubDir, - 'api.github.com', - `# REST API Breaking Changes - -## Version 2026-03-10 - -- **Breaking change A** - -## Version 2022-11-28 - -No breaking changes.`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - - // Should have ifversion fpt wrapping - expect(output).toContain('{% ifversion fpt %}') - expect(output).toContain('{% endif %}') - - // Should have apiVersion filtering for each version section - expect(output).toContain('{% if query.apiVersion == nil or "2026-03-10" <= query.apiVersion %}') - expect(output).toContain('{% if query.apiVersion == nil or "2022-11-28" <= query.apiVersion %}') - - // Should include the actual content - expect(output).toContain('Breaking change A') - expect(output).toContain('No breaking changes') - }) - - test('generates GHES sections with ghes = X.Y ifversion syntax', async () => { - // Find a real GHES version from allVersions to use - const { allVersions } = await import('@/versions/lib/all-versions') - const ghesVersion = Object.values(allVersions).find((v) => v.shortName === 'ghes') - if (!ghesVersion) return - - const release = ghesVersion.currentRelease - const ghesSourceDir = `ghes-${release}` - - const githubDir = path.join(tmpDir, 'github') - await createChangelog( - githubDir, - ghesSourceDir, - `# REST API Breaking Changes for GHES ${release} - -## Version 2022-11-28 - -No breaking changes.`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - expect(output).toContain(`{% ifversion ghes = ${release} %}`) - }) - - test('skips release directories with no changelog file', async () => { - const githubDir = path.join(tmpDir, 'github') - // Only create a changelog for fpt, not ghec or ghes - await createChangelog( - githubDir, - 'api.github.com', - `# Breaking Changes - -## Version 2022-11-28 - -No breaking changes.`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - expect(output).toContain('{% ifversion fpt %}') - expect(output).not.toContain('{% ifversion ghec %}') - }) - - test('skips changelog files with no version sections', async () => { - const githubDir = path.join(tmpDir, 'github') - - // fpt has valid sections - await createChangelog( - githubDir, - 'api.github.com', - `# Breaking Changes - -## Version 2022-11-28 - -Content.`, - ) - - // ghec has a changelog but no version sections - await createChangelog( - githubDir, - 'ghec', - `# Breaking Changes - -This file has no version headings yet.`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - expect(output).toContain('{% ifversion fpt %}') - expect(output).not.toContain('{% ifversion ghec %}') - }) - - test('does not write output when no changelogs are found', async () => { - const githubDir = path.join(tmpDir, 'github') - await mkdir(githubDir, { recursive: true }) - - await syncChangelogs(githubDir, versionNames, outputPath) - - expect(existsSync(outputPath)).toBe(false) - }) - - test('combines multiple release changelogs into a single output', async () => { - const githubDir = path.join(tmpDir, 'github') - - await createChangelog( - githubDir, - 'api.github.com', - `# Breaking Changes for FPT - -## Version 2026-03-10 - -- FPT change - -## Version 2022-11-28 - -No breaking changes.`, - ) - - await createChangelog( - githubDir, - 'ghec', - `# Breaking Changes for GHEC - -## Version 2022-11-28 - -No breaking changes.`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - - // Both product versions should be present - expect(output).toContain('{% ifversion fpt %}') - expect(output).toContain('{% ifversion ghec %}') - - // FPT should have two apiVersion blocks, GHEC should have one. - // Extract the fpt block: everything between {% ifversion fpt %} and the - // next {% ifversion (which starts the ghec block). - const afterFpt = output.split('{% ifversion fpt %}')[1] - const fptBlock = afterFpt.split('{% ifversion ghec %}')[0] - expect(fptBlock).toContain('"2026-03-10"') - expect(fptBlock).toContain('"2022-11-28"') - expect(fptBlock).toContain('FPT change') - }) - - test('version sections are ordered as they appear in the changelog (newest first)', async () => { - const githubDir = path.join(tmpDir, 'github') - - await createChangelog( - githubDir, - 'api.github.com', - `# Breaking Changes - -## Version 2026-06-10 - -Change C - -## Version 2026-03-10 - -Change B - -## Version 2022-11-28 - -Change A`, - ) - - await syncChangelogs(githubDir, versionNames, outputPath) - - const output = await readFile(outputPath, 'utf-8') - - // Versions should appear in the same order as the changelog (newest first) - const idx2026_06 = output.indexOf('"2026-06-10"') - const idx2026_03 = output.indexOf('"2026-03-10"') - const idx2022 = output.indexOf('"2022-11-28"') - expect(idx2026_06).toBeLessThan(idx2026_03) - expect(idx2026_03).toBeLessThan(idx2022) - }) -}) diff --git a/src/shielding/middleware/handle-invalid-paths.ts b/src/shielding/middleware/handle-invalid-paths.ts index ccf9ace62ae5..f2def92c8879 100644 --- a/src/shielding/middleware/handle-invalid-paths.ts +++ b/src/shielding/middleware/handle-invalid-paths.ts @@ -85,12 +85,21 @@ export default function handleInvalidPaths( if (req.path.endsWith('/index.md')) { defaultCacheControl(res) + // The originalUrl is the full URL including query string. + // E.g. `/en/foo.md?bar=baz` const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, '')) return res.redirect(newUrl) } else if (req.path.endsWith('.md')) { - req.url = req.url.replace(/\.md($|\?)/, '$1') - req.headers.accept = 'text/markdown' - return next() + // encode the query params but also make them pretty so we can see + // them as `/` and `@` in the address bar + // e.g. /api/article/body?pathname=/en/enterprise-server@3.16/admin... + // NOT: /api/article/body?pathname=%2Fen%2Fenterprise-server%403.16%2Fadmin... + const encodedPath = encodeURIComponent(req.path.replace(/\.md$/, '')) + .replace(/%2F/g, '/') + .replace(/%40/g, '@') + const newUrl = `/api/article/body?pathname=${encodedPath}` + res.redirect(newUrl) + return } return next() } diff --git a/src/shielding/tests/shielding.ts b/src/shielding/tests/shielding.ts index 12208dfa7ccd..f7539951e1e3 100644 --- a/src/shielding/tests/shielding.ts +++ b/src/shielding/tests/shielding.ts @@ -72,12 +72,24 @@ describe('index.md and .md suffixes', () => { } }) - test('any URL that ends with .md serves markdown directly', async () => { - // .md is stripped and request flows through with Accept: text/markdown + test('any URL that ends with /.md redirects', async () => { + // With language prefix + { + const res = await get('/en/get-started/hello.md') + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/api/article/body?pathname=/en/get-started/hello') + } + // Without language prefix { - const res = await get('/en/get-started.md') - // Should not redirect — serves markdown directly (or 404 if page doesn't exist) - expect(res.statusCode).not.toBe(302) + const res = await get('/get-started/hello.md') + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/api/article/body?pathname=/get-started/hello') + } + // With query string + { + const res = await get('/get-started/hello.md?foo=bar') + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/api/article/body?pathname=/get-started/hello') } }) }) diff --git a/src/versions/lib/all-versions.ts b/src/versions/lib/all-versions.ts index 92e7e387a2e7..76fb644bc3aa 100644 --- a/src/versions/lib/all-versions.ts +++ b/src/versions/lib/all-versions.ts @@ -117,7 +117,7 @@ const apiVersions: RestApiConfig['api-versions'] = JSON.parse( for (const key of Object.keys(apiVersions)) { const docsVersion = getDocsVersion(key) - allVersions[docsVersion].apiVersions.push(...apiVersions[key].sort().reverse()) + allVersions[docsVersion].apiVersions.push(...apiVersions[key].sort()) // Create a copy of the array to avoid mutating the original when using pop() const sortedVersions = [...apiVersions[key].sort()] allVersions[docsVersion].latestApiVersion = sortedVersions.pop() || '' diff --git a/src/workflows/tests/actions-workflows.ts b/src/workflows/tests/actions-workflows.ts index c491ccc979ec..053884f0d4c1 100644 --- a/src/workflows/tests/actions-workflows.ts +++ b/src/workflows/tests/actions-workflows.ts @@ -104,21 +104,22 @@ describe('GitHub Actions workflows', () => { test.each(dailyWorkflows)('daily scheduled workflows only run Mon-Fri $filename', ({ data }) => { for (const { cron } of data.on.schedule) { - const fields = cron.trim().split(/\s+/) - const dayOfWeek = fields[4] + const dayOfWeek = cron.split(' ')[4] // Day-of-week must be 1-5 (Mon-Fri) or a range within 1-5 expect(dayOfWeek).toMatch(/^[1-5](-[1-5])?$/) } }) - test.each(weeklyWorkflows)('weekly scheduled workflows run on Monday $filename', ({ data }) => { - for (const { cron } of data.on.schedule) { - const fields = cron.trim().split(/\s+/) - const dayOfWeek = fields[4] - // Day-of-week must be 1 (Monday) - expect(dayOfWeek).toBe('1') - } - }) + test.each(weeklyWorkflows)( + 'weekly scheduled workflows only run Mon-Fri $filename', + ({ data }) => { + for (const { cron } of data.on.schedule) { + const dayOfWeek = cron.split(' ')[4] + // Day-of-week must be a single day 1 (Mon) through 5 (Fri) + expect(dayOfWeek).toMatch(/^[1-5]$/) + } + }, + ) test.each(workflows)( 'contains contents:read permissions when permissions are used $filename', @@ -150,22 +151,6 @@ describe('GitHub Actions workflows', () => { }, ) - test.each(alertWorkflows)( - 'scheduled workflows create failure issue on fail $filename', - ({ filename, data }) => { - for (const [name, job] of Object.entries(data.jobs)) { - if ( - !job.steps.find( - (step: Record) => - step.uses === './.github/actions/create-workflow-failure-issue', - ) - ) { - throw new Error(`Job ${filename} # ${name} missing create-workflow-failure-issue on fail`) - } - } - }, - ) - test.each(alertWorkflows)( 'performs a checkout before calling composite action $filename', ({ filename, data }) => {