From b228c11fb417662ac7a8d3db9ed578e3bbb79313 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:19:39 +0000 Subject: [PATCH 01/28] Clarify GHES organization membership 2FA requirements for UI and API (#63593) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kyanny <10515+kyanny@users.noreply.github.com> Co-authored-by: Kensuke Nagae Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Laura Coursen --- .../adding-people-to-your-organization.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/content/organizations/managing-membership-in-your-organization/adding-people-to-your-organization.md b/content/organizations/managing-membership-in-your-organization/adding-people-to-your-organization.md index c6e63f5719b9..e89b52ca5adc 100644 --- a/content/organizations/managing-membership-in-your-organization/adding-people-to-your-organization.md +++ b/content/organizations/managing-membership-in-your-organization/adding-people-to-your-organization.md @@ -17,7 +17,10 @@ category: {% endif %} -If your organization [requires members to use two-factor authentication](/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization), users must [enable two-factor authentication](/authentication/securing-your-account-with-two-factor-authentication-2fa) before you can add them to the organization. +If your organization requires members to use two-factor authentication (2FA), the requirements for adding a user depend on how you add them: + +* **Web UI**: The user must enable 2FA before you can add them to the organization. +* **REST API**: You can use `PUT /orgs/{org}/memberships/{username}` to add a user who has not enabled 2FA. The user cannot access organization resources until they enable 2FA. See [AUTOTITLE](/rest/orgs/members#set-organization-membership-for-a-user). {% data reusables.profile.access_org %} {% data reusables.user-settings.access_org %} From 5df4e11c5606473616af124d83a0858941c8c41d Mon Sep 17 00:00:00 2001 From: Isaac Brown <101839405+isaacmbrown@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:01:48 +0000 Subject: [PATCH 02/28] External custom properties public preview (#63531) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Sophie <29382425+sophietheking@users.noreply.github.com> Co-authored-by: Brad Willis --- .../custom-properties.md | 23 ++-- ...ies-for-repositories-in-your-enterprise.md | 14 ++- .../edit-default-setup.md | 2 +- .../managing-organization-settings/index.md | 1 + ...s-for-repositories-in-your-organization.md | 14 ++- .../sync-external-custom-properties.md | 115 ++++++++++++++++++ .../enterprise-admin/custom-properties.md | 2 +- data/features/external-custom-properties.yml | 6 + .../external-properties-intro.md | 1 + .../external-properties-preview.md | 1 + 10 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 content/organizations/managing-organization-settings/sync-external-custom-properties.md create mode 100644 data/features/external-custom-properties.yml create mode 100644 data/reusables/organizations/external-properties-intro.md create mode 100644 data/reusables/organizations/external-properties-preview.md diff --git a/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/custom-properties.md b/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/custom-properties.md index 83ee9974b14e..a006f017664f 100644 --- a/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/custom-properties.md +++ b/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/custom-properties.md @@ -1,7 +1,6 @@ --- title: Custom properties intro: 'Custom properties allow you to add structured metadata to repositories and organizations, enabling better organization, governance, and automation across your {% data variables.product.github %} environment.' -permissions: 'Repository custom properties can be managed by organization owners and users with admin permissions to the repository. Organization custom properties can be managed by enterprise owners and users with the "Manage the Enterprise''s custom properties definitions" permission.' versions: ghec: '*' ghes: '>= 3.21' @@ -15,15 +14,11 @@ category: Custom properties are structured metadata fields that you can attach to repositories or organizations in {% data variables.location.product_location %}. They allow you to decorate your repositories or organizations with information such as compliance frameworks, data sensitivity, or project details. -An enterprise can have up to 100 property definitions. An allowed value list can have up to 200 items. - There are two types of custom properties: * **Repository custom properties**: Metadata attached to individual repositories. * **Organization custom properties**: Metadata attached to organizations within an enterprise. -{% data reusables.enterprise-accounts.org-custom-properties-public-preview %} - ## What are the benefits of using custom properties? As well as providing improved discovery, automated workflows, compliance tracking, targeted policy enforcement, and better reporting capabilities, custom properties enable powerful governance through **ruleset integration**. @@ -35,10 +30,20 @@ Both repository and organization custom properties can be used as targeting crit ## How do I add and manage custom properties? -{% ifversion ghec %} +There are multiple ways to manage custom properties. To manage properties within {% data variables.product.github %}, you can use: -Custom properties are fully supported through {% data variables.product.github %}'s REST API, enabling programmatic management and integration with external systems. See [AUTOTITLE](/rest/enterprise-admin/custom-properties). +* Your organization or enterprise settings. See [AUTOTITLE](/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization) and [AUTOTITLE](/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/managing-custom-properties-for-organizations). +* {% data variables.product.github %}'s [AUTOTITLE](/rest/enterprise-admin/custom-properties). -{% endif %} +{% ifversion external-custom-properties %} + +You can also set up an integration to automatically update custom properties with metadata from an external system, such as a software catalog or internal developer portal. External properties can be used in the same places as standard repository custom properties. See [AUTOTITLE](/organizations/managing-organization-settings/sync-external-custom-properties) -You can add custom properties through {% data variables.product.github %}'s UI. See [AUTOTITLE](/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization) and [AUTOTITLE](/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/managing-custom-properties-for-organizations). +Both standard custom properties and external properties can be managed at scale with the REST API and {% data variables.product.prodname_github_apps %}. External properties are more suitable when the external system should be the source of truth, because they are: + +* Namespaced (`external_system.property_name`), so their provenance is clear and they don't conflict with other custom properties in the organization. +* Read-only on {% data variables.product.github %}, so users cannot edit them and bring them out of line with the external system. + +External properties are **not** available for organization custom properties (metadata attached to organizations). + +{% endif %} diff --git a/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/managing-custom-properties-for-repositories-in-your-enterprise.md b/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/managing-custom-properties-for-repositories-in-your-enterprise.md index b3b8d557c030..5d58c3ea2459 100644 --- a/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/managing-custom-properties-for-repositories-in-your-enterprise.md +++ b/content/admin/managing-accounts-and-repositories/managing-repositories-in-your-enterprise/managing-custom-properties-for-repositories-in-your-enterprise.md @@ -42,7 +42,19 @@ When you create a single-select or multi-select property, {% data variables.prod This feature is available with {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %}. By default, suggestions are enabled for enterprise-level properties and each organization can decide whether to enable suggestions. Enterprise owners can instead enable or disable suggestions everywhere with the **Repository custom property suggestions** policy. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-enterprise-policies). {% endif %} -## Adding custom properties +{% ifversion external-custom-properties %} + +## Syncing custom properties with an external system + +> [!NOTE] {% data reusables.organizations.external-properties-preview %} + +{% data reusables.organizations.external-properties-intro %} + +External custom properties are configured separately for each organization. For setup instructions, see [AUTOTITLE](/organizations/managing-organization-settings/sync-external-custom-properties). + +{% endif %} + +## Adding custom properties on {% data variables.product.github %} You can add custom properties to your enterprise to make those properties available in all of your organizations. diff --git a/content/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/edit-default-setup.md b/content/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/edit-default-setup.md index dcc556aa5f64..fd4a21911d3f 100644 --- a/content/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/edit-default-setup.md +++ b/content/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration/edit-default-setup.md @@ -101,7 +101,7 @@ The recommended way to customize default setup at scale is to set an organizatio We recommend testing the configuration file on a single repository before setting the organization-wide default. See [AUTOTITLE](/code-security/concepts/code-scanning/repository-properties#testing-changes-before-applying-them). -1. The configuration file will be automatically detected and merged with the configuration default setup generates the next time {% data variables.product.prodname_code_scanning %} runs on each repository in the organization. Repositories that already have an explicit value set for the `github-codeql-config-file` property continue to use that value instead of the organization-wide default. For more information about how default and explicit repository property values interact, see [AUTOTITLE](/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization#adding-custom-properties). +1. The configuration file will be automatically detected and merged with the configuration default setup generates the next time {% data variables.product.prodname_code_scanning %} runs on each repository in the organization. Repositories that already have an explicit value set for the `github-codeql-config-file` property continue to use that value instead of the organization-wide default. For more information about how default and explicit repository property values interact, see [AUTOTITLE](/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization#adding-custom-properties-on-github). ### Applying a configuration file to a repository diff --git a/content/organizations/managing-organization-settings/index.md b/content/organizations/managing-organization-settings/index.md index c23ba2088199..2f21fb4b1593 100644 --- a/content/organizations/managing-organization-settings/index.md +++ b/content/organizations/managing-organization-settings/index.md @@ -52,6 +52,7 @@ children: - /creating-rulesets-for-repositories-in-your-organization - /managing-rulesets-for-repositories-in-your-organization - /managing-custom-properties-for-repositories-in-your-organization + - /sync-external-custom-properties shortTitle: Manage organization settings --- diff --git a/content/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization.md b/content/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization.md index 069ab5416257..5ae5f487e5c1 100644 --- a/content/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization.md +++ b/content/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization.md @@ -37,7 +37,19 @@ This feature is available with {% data variables.copilot.copilot_business_short {% data reusables.repositories.custom-property-allowed-characters %} -## Adding custom properties +{% ifversion external-custom-properties %} + +## Syncing custom properties with an external system + +> [!NOTE] {% data reusables.organizations.external-properties-preview %} + +{% data reusables.organizations.external-properties-intro %} + +For setup instructions, see [AUTOTITLE](/organizations/managing-organization-settings/sync-external-custom-properties). + +{% endif %} + +## Adding custom properties on {% data variables.product.github %} You can add custom properties to your organization and set values for those properties for repositories in your organization. diff --git a/content/organizations/managing-organization-settings/sync-external-custom-properties.md b/content/organizations/managing-organization-settings/sync-external-custom-properties.md new file mode 100644 index 000000000000..d58ed55af394 --- /dev/null +++ b/content/organizations/managing-organization-settings/sync-external-custom-properties.md @@ -0,0 +1,115 @@ +--- +title: Integrating custom properties with an external system +intro: Use a {% data variables.product.prodname_github_app %} to write external metadata to custom properties in an organization's repositories. +versions: + feature: external-custom-properties +shortTitle: Sync external custom properties +contentType: how-tos +category: + - Set up your organization +--- + +> [!NOTE] {% data reusables.organizations.external-properties-preview %} + +{% data reusables.organizations.external-properties-intro %} + +To set up this automation, you'll install a {% data variables.product.prodname_github_app %} that calls {% data variables.product.github %}'s API endpoints for external properties with data from the external system. + +* Our integration partner [Port](https://www.port.io/) has developed an integration for external custom properties. For all required steps to sync metadata from Port, see [Sync Port properties to {% data variables.product.github %} external custom properties](https://docs.port.io/guides/all/sync-port-properties-to-github-external-custom-properties/) in the Port documentation. {% data variables.product.github %} will work to add more providers in the future. +* If your organization uses **another external system**, or if you're a representative of an external system who wants to create an integration with {% data variables.product.github %}, you will need to create your own {% data variables.product.prodname_github_app %} and automation. Continue reading this guide. + +## Prerequisites + +This process may require multiple different people. You will need: + +* Someone to configure the {% data variables.product.prodname_github_app %}, under either their personal account or an organization or enterprise account where they are an owner +* One or more organization owners on {% data variables.product.github %} to install the app in each organization where it's required, and possibly to register a display name for the app + +Outside the scope of this guide, you will also need someone who can create and run the automation, with appropriate access to the external system and the server where the automation will run. + +## 1. Choose a display name + +Every external custom property key in your organization will be prefixed by a display name. For example: `port.environment`. This acts as a namespace and helps avoid conflicts with custom properties managed on {% data variables.product.github %} or other external providers. + +Each display name is scoped to a single {% data variables.product.prodname_github_app %} installation in the organization. Before an app can write custom properties to {% data variables.product.github %}, you must register the app installation with a display name. This is a one-time process that can be performed by the app itself or by an organization administrator. An app installation can only be registered once, and its display name can't be changed later. + +Choose a name that will avoid conflicts and will help users identify custom properties from the external system. If you're publishing an app on behalf of a third-party system, you may want to respond to conflicts or allow users to choose their own display name as part of the setup flow on your system. + +The display name must between 1 and 15 characters and contain only letters and numbers. For all requirements, see the [Register an app installation for external properties](/rest/orgs/custom-properties#register-an-app-installation-for-external-custom-properties) endpoint of the REST API. + +## 2. Register a {% data variables.product.prodname_github_app %} + +The {% data variables.product.prodname_github_app %} is the identity that will call the APIs to manage external custom properties. It can also listen for webhooks for events on {% data variables.product.github %}. + +If you're creating an app for an internal process, we recommend creating the app under an organization or enterprise account. Then, you'll be able to install the app in as many organizations as you require. If you're a representative from a third-party system, you will likely publish the app to {% data variables.product.prodname_marketplace %} so that other companies can install it. + +For instructions, see [AUTOTITLE](/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). + +### Selecting permissions + +Under **Organization permissions**, enable the **External custom properties for repositories** permission so that the app can write data to the external properties API. The level of access required depends on what the app needs to do: + +* Choose **Admin** access if the app will register its own display name using its installation access token. This is a good model for a self-service app that will be installed on many organizations. +* Choose **Read and write** access if the app only needs to write custom properties to {% data variables.product.github %}. An organization administrator will need to register the display name for their installation. + +**Read-only** access is not an option for this task. An app with this level of access will only be able to read its own external custom property definitions. + +If you want to subscribe to webhook events, you may need to enable additional permissions. + +For more information, see [AUTOTITLE](/rest/authentication/permissions-required-for-github-apps#organization-permissions-for-external-custom-properties-for-repositories). + +### Selecting webhooks + +You can enable webhooks to subscribe to events on {% data variables.product.github %} that should trigger data transfer from your external system. + +For example: + +* When an app is installed on an organization (the `installation` event with the `created` action), this can trigger the first sync from the external system to the organization's repositories. This event is sent to all {% data variables.product.prodname_github_apps %} by default. +* When a new repository is created in the organization (the `repository` event with the `created` action), the repository can automatically be populated with metadata. This event requires read access to the **Metadata** repository permission. + +Webhooks are not required if you prefer the automation to simply run on a schedule. + +For more information, see [AUTOTITLE](/apps/creating-github-apps/registering-a-github-app/using-webhooks-with-github-apps). + +### Selecting the installation scope + +Under **Where can this GitHub App be installed?**, make sure your app can be installed on all the organizations where it is required. + +## 3. Create the automation + +> [!TIP] For an example implementation, see the [external-custom-properties-sample](https://github.com/github/external-custom-properties-sample) repository. + +The automation can run on a schedule or listen for events. The webhook you selected for the app determines which {% data variables.product.github %} events are forwarded to your webhook URL. You may also want to respond to events on the third-party system, such as changes to metadata values. + +In the automation, the {% data variables.product.prodname_github_app %} must obtain an installation access token and use the token to send data from the external system to {% data variables.product.github %}'s external properties API endpoints. See [AUTOTITLE](/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation). + +See the following endpoints of the REST API. You will find information on request size limits and error codes that your automation should account for. + +* [Register an app installation for external custom properties](/rest/orgs/custom-properties#register-an-app-installation-for-external-custom-properties) (the app must register its display name before it can update properties, unless an organization administrator is expected to do this) +* [Get registered app installations for external custom properties](/rest/orgs/custom-properties#get-registered-app-installations-for-external-custom-properties) +* [Get all external custom properties for a {% data variables.product.prodname_github_app %} installation in an organization](/rest/orgs/custom-properties#get-all-external-custom-properties-for-a-github-app-installation-in-an-organization) +* [Create or update external custom property values for organization repositories](/rest/orgs/custom-properties#create-or-update-external-custom-property-values-for-organization-repositories) +* [Create or update external custom property values for a property across organization repositories](/rest/orgs/custom-properties#create-or-update-external-custom-property-values-for-a-property-across-organization-repositories) +* [Remove all external custom property values for a property across all organization repositories](/rest/orgs/custom-properties#remove-all-external-custom-property-values-for-a-property-across-all-organization-repositories) + +## 4. Install the app + +Install the {% data variables.product.prodname_github_app %} on the organizations where it's required, authorizing the permissions it needs. See [AUTOTITLE](/apps/using-github-apps/installing-your-own-github-app). + +Because the external custom properties permission is organization-scoped, the app will be installed with access to all repositories by default. You won't see an option to select individual repositories unless the app also has repository-level permissions. + +If the app does not automatically register a display name or you cannot authorize **Admin** access, an organization administrator must register the display name for the installation. This can be an organization owner or someone with the `organization_external_properties_for_repos:admin` fine-grained permission. See [Register an app installation for external custom properties](/rest/orgs/custom-properties#register-an-app-installation-for-external-custom-properties). + +## 5. Validate the data transfer + +Once the automation has run, validate that external properties are being synced with the organization's repositories. You should be able to see these in the custom property settings for your organization or its repositories. The property keys will be prefixed with the external display name, and the values will be indicated with a {% octicon "plug" aria-label="External custom property value" %} icon. See [AUTOTITLE](/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization#viewing-values-for-repositories-in-your-organization). + +External property **values** are also returned alongside traditional custom properties in the [Get all custom property values for a repository](/rest/repos/custom-properties#get-all-custom-property-values-for-a-repository) REST API endpoint. However, `/schema` endpoints for custom properties, such as "Get all custom properties for an organization," do **not** return external properties. + +Users will not be able to edit these properties on {% data variables.product.github %}, but they will be able to use them anywhere they use traditional custom properties. + +## 6. Maintain the integration + +Keep the automation running and the app installed to keep syncing data from the external system. If you uninstall the {% data variables.product.prodname_github_app %} from an organization, the installation and display name will be deregistered, and all external properties that the app created will be **removed**. + +Pay attention to the number of properties defined in the organization. Each organization can have up to 100 property definitions. Both external and standard custom properties count toward this limit. diff --git a/content/rest/enterprise-admin/custom-properties.md b/content/rest/enterprise-admin/custom-properties.md index 6246187ea99b..5d06da7d328d 100644 --- a/content/rest/enterprise-admin/custom-properties.md +++ b/content/rest/enterprise-admin/custom-properties.md @@ -1,5 +1,5 @@ --- -title: Custom properties +title: REST API endpoints for custom properties shortTitle: Custom properties intro: Use the REST API to manage custom properties for your enterprise. versions: # DO NOT MANUALLY EDIT. CHANGES WILL BE OVERWRITTEN BY A πŸ€– diff --git a/data/features/external-custom-properties.yml b/data/features/external-custom-properties.yml new file mode 100644 index 000000000000..b48d78c01940 --- /dev/null +++ b/data/features/external-custom-properties.yml @@ -0,0 +1,6 @@ +# Issue 23277 +# API endpoints and GitHub App permission for the external custom properties public preview +# Note: If this ships to GHES, there may be some follow-up work in the docs +versions: + fpt: '*' + ghec: '*' diff --git a/data/reusables/organizations/external-properties-intro.md b/data/reusables/organizations/external-properties-intro.md new file mode 100644 index 000000000000..67871677006d --- /dev/null +++ b/data/reusables/organizations/external-properties-intro.md @@ -0,0 +1 @@ +You can automatically write metadata from an external system, such as a software catalog or internal developer portal, to repository custom properties on {% data variables.product.github %}. This makes the external system the source of truth for these properties, and helps you keep business context such as ownership, service tier, or compliance status up to date in your repositories. External properties can be used in the same places as custom properties that are managed on {% data variables.product.github %}. diff --git a/data/reusables/organizations/external-properties-preview.md b/data/reusables/organizations/external-properties-preview.md new file mode 100644 index 000000000000..7c33263b0aa5 --- /dev/null +++ b/data/reusables/organizations/external-properties-preview.md @@ -0,0 +1 @@ +External custom properties are in {% data variables.release-phases.public_preview %} and subject to change. From 30e9daf92a29812ec0aceb746c9abbd4932d8183 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:36:22 +0000 Subject: [PATCH 03/28] Fix Next.js cache restore-keys prefix (#63477) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .github/actions/cache-nextjs/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/cache-nextjs/action.yml b/.github/actions/cache-nextjs/action.yml index c0251afbaff3..12281457f28e 100644 --- a/.github/actions/cache-nextjs/action.yml +++ b/.github/actions/cache-nextjs/action.yml @@ -15,4 +15,4 @@ runs: key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }} # If source files changed but packages didn't, rebuild from a prior cache. restore-keys: | - ${{ runner.os }}-nextjs-v13-${{ hashFiles('**/package-lock.json') }}- + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- From e25cc2fa5dfb55963daeb1b44e73b47002b5a1ad Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:37:03 +0000 Subject: [PATCH 04/28] Tighten code comments in src/workflows/tests (#63447) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/workflows/tests/actions-workflows.ts | 57 +++++++--------- src/workflows/tests/find-past-built-pr.ts | 6 +- src/workflows/tests/github.ts | 11 ++- src/workflows/tests/projects.ts | 6 +- .../tests/purge-fastly-changed-content.ts | 22 +++--- src/workflows/tests/strip-hidden-blocks.ts | 5 +- .../tests/sync-sdk-docs-preserve-redirects.ts | 67 ++++++------------- src/workflows/tests/wait-for-build.ts | 2 +- 8 files changed, 63 insertions(+), 113 deletions(-) diff --git a/src/workflows/tests/actions-workflows.ts b/src/workflows/tests/actions-workflows.ts index 339f7ce336b7..34e9394fb913 100644 --- a/src/workflows/tests/actions-workflows.ts +++ b/src/workflows/tests/actions-workflows.ts @@ -47,8 +47,9 @@ const workflowsDir = path.join(__dirname, '../../../.github/workflows') const workflows: WorkflowMeta[] = fs .readdirSync(workflowsDir) .filter((filename) => filename.endsWith('.yml') || filename.endsWith('.yaml')) - .filter((filename) => filename !== 'moda-ci.yaml') // Skip moda-ci - .filter((filename) => !filename.endsWith('.lock.yml')) // Skip auto-generated agentic workflow lock files + .filter((filename) => filename !== 'moda-ci.yaml') + // Agentic workflow lock files are auto-generated. + .filter((filename) => !filename.endsWith('.lock.yml')) .map((filename) => { const fullpath = path.join(workflowsDir, filename) const data = load(fs.readFileSync(fullpath, 'utf8')) as WorkflowMeta['data'] @@ -71,18 +72,12 @@ const allUsedActions = chain(workflows) const scheduledWorkflows = workflows.filter(({ data }) => data.on.schedule) -// Triggers where a workflow runs without a human actively watching and -// therefore needs explicit failure reporting (Slack + issue). Attended -// triggers (pull_request*, workflow_dispatch, workflow_call, merge_group) -// are intentionally excluded: the person who triggered the run sees the -// result directly. +// Unattended triggers need explicit Slack and issue alerts because no human watches the run. +// Pull request, workflow dispatch, workflow call, and merge group triggers are attended. // -// `issues` and `issue_comment` are only considered unattended for jobs -// running in docs-internal itself. When a job is scoped to the public -// github/docs fork via `if: github.repository == 'github/docs'`, those -// triggers fire from external reporters/commenters, and the issue or -// comment itself is the natural failure surface. Piling on automated -// alert-issues there is duplicative and noisy. +// Treat issues and issue_comment as unattended only for docs-internal jobs. +// Jobs gated by if: github.repository == 'github/docs' surface failures on the public issue +// or comment, so another alert issue would duplicate that signal. const ALWAYS_UNATTENDED_TRIGGERS = ['schedule', 'workflow_run', 'repository_dispatch', 'push'] const DOCS_INTERNAL_ONLY_UNATTENDED_TRIGGERS = ['issues', 'issue_comment'] @@ -104,21 +99,19 @@ function jobRequiresFailureAlerts(workflow: WorkflowMeta, job: WorkflowJob): boo return false } -// Workflows where at least one job requires failure alerts. Used to drive -// the parameterised tests below. Per-job filtering happens inside each test. +// Workflows with steps are the candidate set. Each alert test filters jobs by trigger. const alertWorkflows = workflows.filter(({ data }) => Object.values(data.jobs).some((job) => job.steps), ) -// to generate list, console.log(new Set(workflows.map(({ data }) => Object.keys(data.on)).flat())) const dailyWorkflows = scheduledWorkflows - // purge-fastly's daily soft purge runs every day off-peak (02:20 UTC) + // purge-fastly.yml soft-purges daily at 02:20 UTC, outside the standard 16:20 slot. .filter(({ filename }) => filename !== 'purge-fastly.yml') .filter(({ data }) => data.on.schedule!.find(({ cron }: { cron: string }) => /^20 \d{1,2} /.test(cron)), ) -// Weekly workflows have a single day-of-week digit (e.g. "20 16 * * 1") +// Weekly workflows use one day-of-week digit, such as "20 16 * * 1". const weeklyWorkflows = dailyWorkflows.filter(({ data }) => data.on.schedule!.find(({ cron }: { cron: string }) => /^20 16 \* \* \d$/.test(cron)), ) @@ -156,7 +149,7 @@ describe('GitHub Actions workflows', () => { for (const { cron } of data.on.schedule!) { const fields = cron.trim().split(/\s+/) const dayOfWeek = fields[4] - // Day-of-week must be 1-5 (Mon-Fri) or a range within 1-5 + // Day-of-week must be a weekday digit, 1 through 5, or a range within it. expect(dayOfWeek).toMatch(/^[1-5](-[1-5])?$/) } }) @@ -165,7 +158,7 @@ describe('GitHub Actions workflows', () => { for (const { cron } of data.on.schedule!) { const fields = cron.trim().split(/\s+/) const dayOfWeek = fields[4] - // Day-of-week must be 1 (Monday) + // Day-of-week must be 1 for Monday. expect(dayOfWeek).toBe('1') } }) @@ -227,23 +220,19 @@ describe('GitHub Actions workflows', () => { }, ) - // A long-lived shared PAT (DOCS_BOT_PAT_BASE) must never be handed to a - // local composite action (`uses: ./...`) inside a `pull_request_target` - // workflow. That trigger runs with full repository secrets even for PRs - // opened from forks by anonymous outside contributors, and the local action - // lives in the checked-out PR workspace, so a malicious fork PR could rewrite - // it to exfiltrate the token. Such jobs should generate a short-lived, scoped - // GitHub App token instead. + // A long-lived shared personal access token, DOCS_BOT_PAT_BASE, must never pass to a + // local composite action, uses: ./..., inside a pull_request_target workflow. That + // trigger exposes repository secrets even for fork PRs from anonymous outside + // contributors, and the local action lives in the checked-out PR workspace, so a + // malicious fork PR could rewrite it to exfiltrate the token. + // Use a short-lived, scoped GitHub App token instead. // - // NOTE: this intentionally does NOT cover plain `pull_request`. That trigger - // does not expose secrets to fork PRs, only to same-repo branch PRs from - // contributors who already have write access, and passing the PAT to local - // actions there (e.g. get-docs-early-access) is a longstanding, accepted - // pattern across many workflows. See #62343. + // Plain pull_request workflows stay out of scope because they do not expose secrets to fork PRs. + // Workflows such as get-docs-early-access intentionally pass DOCS_BOT_PAT_BASE to local actions + // on same-repo pull requests, where contributors already have write access. const pullRequestTargetWorkflows = workflows.filter(({ data }) => { const on = (data.on || {}) as Record - // Use key presence, not truthiness: a trigger with no nested value parses - // to null, which a truthy check would skip. + // Check key presence because YAML parses a trigger with no nested value as null. return 'pull_request_target' in on }) diff --git a/src/workflows/tests/find-past-built-pr.ts b/src/workflows/tests/find-past-built-pr.ts index eee60c2312ce..af53a92b30e4 100644 --- a/src/workflows/tests/find-past-built-pr.ts +++ b/src/workflows/tests/find-past-built-pr.ts @@ -18,7 +18,6 @@ interface FakeOptions { failCreateOn?: number[] } -// Builds a minimal Octokit stand-in exposing only the methods the script uses. function makeFakeOctokit(options: FakeOptions = {}) { const { commitMessages = [], comments = {}, locked = {}, failCreateOn = [] } = options @@ -46,8 +45,7 @@ function makeFakeOctokit(options: FakeOptions = {}) { createComment, }, }, - // The real octokit.paginate pulls every page; our fakes are single-page, so - // resolve straight to the configured comment list. + // The real Octokit paginate reads every page, but these fakes return one configured page. paginate: vi.fn(async (_method: unknown, params: { issue_number: number }) => { return comments[params.issue_number] || [] }), @@ -152,7 +150,7 @@ describe('commentOnDeployBatch', () => { await expect( commentOnDeployBatch(octokit, 'github', 'docs-internal', [5, 4, 3]), ).rejects.toThrow(/Failed to comment on 1 PR/) - // #5 and #3 still get their comments despite #4 failing. + // Successful PRs still get comments even when another PR comment fails. expect(createComment).toHaveBeenCalledTimes(3) }) }) diff --git a/src/workflows/tests/github.ts b/src/workflows/tests/github.ts index 2a1f8310416d..7395c17e84ae 100644 --- a/src/workflows/tests/github.ts +++ b/src/workflows/tests/github.ts @@ -5,11 +5,9 @@ import { RequestError } from '@octokit/request-error' import { isRequestError } from '@/workflows/github' -// `@octokit/request` throws errors built from whichever copy of -// `@octokit/request-error` resolves from its own location, which npm may or may -// not hoist to the top level. Resolve it the same way Node would so this test -// keeps working either way. When it is a separate copy, `instanceof` against the -// top-level class fails, and that is the whole reason `isRequestError` exists. +// @octokit/request throws errors from the @octokit/request-error copy that resolves from +// its package path. Resolve that copy the way Node does, because instanceof against the +// top-level class fails when npm installs a separate nested copy. const requireFromRequest = createRequire(createRequire(import.meta.url).resolve('@octokit/request')) const nested = await import(requireFromRequest.resolve('@octokit/request-error')) @@ -23,8 +21,7 @@ function makeError(RequestErrorClass: typeof RequestError, status: number, messa describe('isRequestError', () => { test('matches an error thrown from the copy @octokit/request uses', () => { const err = makeError(nested.RequestError, 404, 'Not Found') - // When npm does not hoist to a single copy, this is the case a plain - // `instanceof RequestError` gets wrong. + // This nested-copy case is where plain instanceof RequestError fails. if (nested.RequestError !== RequestError) { expect(err instanceof RequestError).toBe(false) } diff --git a/src/workflows/tests/projects.ts b/src/workflows/tests/projects.ts index 716019ae0f93..c49ca4e3e02d 100644 --- a/src/workflows/tests/projects.ts +++ b/src/workflows/tests/projects.ts @@ -26,8 +26,7 @@ describe('isDocsTeamMember', () => { await isDocsTeamMember('heiskr') - // The team was renamed from `docs` to `technical-content`. GraphQL returns null rather - // than erroring for an unknown slug, so a stale value here fails silently in prod. + // GraphQL returns null for an unknown slug, so a stale value makes every author a non-member. const [, variables] = graphql.mock.calls[0] expect(variables.slug).toBe('technical-content') }) @@ -48,8 +47,7 @@ describe('isDocsTeamMember', () => { test('degrades instead of throwing when the slug no longer resolves', async () => { graphql.mockResolvedValue({ organization: { team: null } }) - // Dereferencing the null used to throw and kill the whole job, but only *after* the PR - // had been added to the board, leaving an item with none of its fields populated. + // Return false so the job keeps populating fields instead of throwing after adding the PR. await expect(isDocsTeamMember('heiskr')).resolves.toBe(false) }) diff --git a/src/workflows/tests/purge-fastly-changed-content.ts b/src/workflows/tests/purge-fastly-changed-content.ts index 5198938e7492..7ea7c244a6cb 100644 --- a/src/workflows/tests/purge-fastly-changed-content.ts +++ b/src/workflows/tests/purge-fastly-changed-content.ts @@ -125,7 +125,7 @@ describe('contentFilesToPageKeys', () => { { filename: 'content/get-started/bar.md', status: 'added' }, { filename: 'content/get-started/foo.md', status: 'modified' }, ]) - // One key per source page, deduped, covering every version-URL of the page. + // One surrogate key covers every version URL for a source page. expect(keys).toEqual([ 'language:en,path:get-started/foo.md', 'language:en,path:get-started/bar.md', @@ -154,10 +154,9 @@ describe('chunk', () => { }) describe('hardPurgeSurrogateKeys', () => { - // Skips the between-pass delay so tests don't wait 20 real seconds. + // Tests skip the 20-second between-pass delay. const noSleep = async () => {} - // A minimal stand-in for a fetch Response, with a case-insensitive headers.get. function fakeResponse( status: number, { headers = {}, ok = false }: { headers?: Record; ok?: boolean } = {}, @@ -191,7 +190,6 @@ describe('hardPurgeSurrogateKeys', () => { expect(JSON.parse(init.body)).toEqual({ surrogate_keys: ['language:en,path:a.md', 'language:en,path:b.md'], }) - // The second pass repeats the identical batch. expect(fetchWithRetry.mock.calls[1][1].body).toBe(init.body) }) @@ -257,7 +255,7 @@ describe('hardPurgeSurrogateKeys', () => { .mockResolvedValueOnce(fakeResponse(429, { headers: { 'retry-after': '0' } })) .mockResolvedValue(fakeResponse(200, { ok: true })) await hardPurgeSurrogateKeys(['language:en,path:a.md'], 'tok', 'svc', () => 0, noSleep) - // 429 + retry on the first pass, then one call for the second pass. + // The first pass gets a 429 and retries once; the second pass makes one call. expect(fetchWithRetry).toHaveBeenCalledTimes(3) }) @@ -266,7 +264,7 @@ describe('hardPurgeSurrogateKeys', () => { await expect( hardPurgeSurrogateKeys(['language:en,path:a.md'], 'tok', 'svc', () => 0, noSleep), ).rejects.toThrow(/2 of 2 batch purge\(s\) failed/) - // (Initial attempt + 5 retries) x 2 passes. + // Initial attempt plus 5 retries, times 2 passes. expect(fetchWithRetry).toHaveBeenCalledTimes(12) }) }) @@ -309,8 +307,7 @@ describe('rateLimitDelayMs', () => { }) test('adds jitter on top of a server hint to decorrelate workers', () => { - // Math.random -> 0.5 gives jitter = floor(0.5 * 150) = 75ms, added on top of - // the honored 5000ms hint so concurrent retries don't wake in lockstep. + // Math.random of 0.5 adds 75 ms to the 5000 ms hint, so retries do not wake together. vi.spyOn(Math, 'random').mockReturnValue(0.5) expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '5' }), 0)).toBe(5075) }) @@ -323,19 +320,18 @@ describe('rateLimitDelayMs', () => { test('clamps any delay to the maximum', () => { vi.spyOn(Math, 'random').mockReturnValue(0) - // 1000 * (40 + 1) would be 41,000ms; clamped to 30,000. + // 1000 * (40 + 1) would be 41,000 ms, so the 30,000 ms cap applies. expect(rateLimitDelayMs(fakeResponse({}), 40)).toBe(30_000) - // A far-future server hint is likewise capped. + // The 30,000 ms cap also applies to far-future server hints. expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '99999' }), 0)).toBe(30_000) }) test('floors a stale or zero hint at the backoff instead of retrying instantly', () => { vi.spyOn(Math, 'random').mockReturnValue(0) - // A negative Retry-After and an already-elapsed reset both compute to <= 0, - // but must not collapse the retry to 0ms; they floor at the backoff. + // Negative Retry-After and elapsed reset hints floor at backoff, so retries never hit 0 ms. expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '-5' }), 0)).toBe(1000) expect(rateLimitDelayMs(fakeResponse({ 'fastly-ratelimit-reset': '1' }), 0)).toBe(1000) - // The floor grows with the attempt count, same as a hintless backoff. + // Hint floors use the same attempt-based backoff as missing hints. expect(rateLimitDelayMs(fakeResponse({ 'retry-after': '0' }), 2)).toBe(3000) }) }) diff --git a/src/workflows/tests/strip-hidden-blocks.ts b/src/workflows/tests/strip-hidden-blocks.ts index 980ec0cf2735..de2be0f94494 100644 --- a/src/workflows/tests/strip-hidden-blocks.ts +++ b/src/workflows/tests/strip-hidden-blocks.ts @@ -273,7 +273,7 @@ describe('stripHiddenBlocks', () => { const { content, removed } = stripHiddenBlocks(input) expect(removed).toBe(1) - // A blank line is inserted so "before" and "after" stay separate paragraphs. + // Insert a blank line so "before" and "after" stay separate paragraphs. expect(content).toBe(['before', '', 'after'].join('\n')) }) @@ -281,8 +281,7 @@ describe('stripHiddenBlocks', () => { const outer = '````' const input = [ outer, - // These lines are sample text inside the four-backtick fence, so the - // markers and the inner fence must not be interpreted. + // The markers and inner fence are sample text inside the four-backtick fence. '', `${FENCE}go`, 'sample', diff --git a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts index 7e8f69ef5feb..68c26545aafe 100644 --- a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts +++ b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts @@ -17,14 +17,10 @@ const SCRIPT = path.join(process.cwd(), 'src/workflows/sync-sdk-docs/preserve-re const SDK_DIR = 'content/copilot/how-tos/copilot-sdk' const STEP_SUMMARY_FILE = 'step-summary.md' -/** - * Every invocation of the script must go through this helper. The script - * appends its unresolved-removal warning to whatever `GITHUB_STEP_SUMMARY` - * points at, so a child that inherited the real one would write this suite's - * synthetic warnings into the actual Actions job summary and raise a false - * operational alert. Pinning it to a per-fixture file both prevents that and - * makes the summary assertable via `readStepSummary`. - */ +// Every script invocation goes through this helper. The script appends unresolved-removal +// warnings to GITHUB_STEP_SUMMARY, so an inherited real path would write synthetic test +// warnings into the Actions job summary and raise a false operational alert. A per-fixture +// file prevents that and lets tests assert the warning through readStepSummary. const runScript = (cwd: string, args: string[] = []) => execFileSync('npx', ['tsx', SCRIPT, ...args], { cwd, @@ -106,8 +102,7 @@ describe('findSuccessor', () => { const currentPaths = [`${SDK_DIR}/features/mcp.md`] const removedPaths = [`${SDK_DIR}/old/mcp.md`, `${SDK_DIR}/legacy/mcp.md`] - // Neither removal may claim the single survivor: at most one of them is its - // real predecessor, so assigning both would invent a wrong redirect. + // A many-to-one match would assign the survivor to at least one wrong predecessor. for (const removed of removedPaths) { expect(findSuccessor(removed, currentPaths, removedPaths)).toBeNull() } @@ -142,8 +137,7 @@ describe('findSuccessor', () => { }) test('does not confuse an index.md with a same-named page', () => { - // `hooks/index.md` and `hooks.md` are different keys, so a removed - // directory index must not be matched to a page called hooks.md. + // Directory index keys such as hooks/index.md must not match pages such as hooks.md. expect( findSuccessor( `${SDK_DIR}/hooks/index.md`, @@ -252,10 +246,8 @@ describe('upsertRedirectBlock', () => { }) }) -/** - * End-to-end runs against a throwaway git repo. The script reconciles the - * working tree against a git ref, so a real commit is the only honest fixture. - */ +// End-to-end tests use a throwaway git repo because the script reconciles the working tree +// against a git ref, so real commits keep the fixture faithful. describe('preserve-redirects end to end', () => { let repo: string // Each test spawns npx tsx, and a cold start on a busy CI runner can exceed the 5s default. @@ -296,8 +288,7 @@ describe('preserve-redirects end to end', () => { git('config', 'user.email', 'test@example.com') git('config', 'user.name', 'Test') - // Pre-sync state: a page carrying a hand-added redirect, a page that will be - // moved, a directory index that will be renamed, and a page left untouched. + // Seed a hand-added redirect, a page that moves, a directory index, and an untouched page. write(`${SDK_DIR}/features/mcp.md`, page('MCP', ['/copilot/how-tos/copilot-sdk/old-mcp'])) write(`${SDK_DIR}/features/moving.md`, page('Moving', ['/copilot/how-tos/copilot-sdk/ancient'])) write(`${SDK_DIR}/use-hooks/index.md`, page('Hooks')) @@ -310,9 +301,9 @@ describe('preserve-redirects end to end', () => { if (repo) fs.rmSync(repo, { recursive: true, force: true }) }) + // Report moved pages for humans because matching filenames do not prove succession. + // Include the removed page URL and all inherited redirects, so humans can preserve the chain. test('restores redirects the sync would have dropped, and reports moves', () => { - // Simulate the sync: wipe the tree and rebuild it without any redirect_from, - // moving one page and renaming one directory along the way. fs.rmSync(path.join(repo, SDK_DIR), { recursive: true, force: true }) write(`${SDK_DIR}/features/mcp.md`, page('MCP')) write(`${SDK_DIR}/setup/moving.md`, page('Moving')) @@ -321,19 +312,13 @@ describe('preserve-redirects end to end', () => { const output = run() - // 1. A redirect on a page that kept its path is put back. expect(read(`${SDK_DIR}/features/mcp.md`)).toContain('/copilot/how-tos/copilot-sdk/old-mcp') - // 2. A moved page is reported for a human, never auto-redirected: a matching - // filename is not proof that one page replaced another. Both the page's - // own URL and the older redirect it had inherited must be listed, or a - // human fixing the obvious one would still strand the chain. expect(output).toContain('/copilot/how-tos/copilot-sdk/features/moving') expect(output).toContain('/copilot/how-tos/copilot-sdk/ancient') expect(output).toContain('possible replacement: /copilot/how-tos/copilot-sdk/setup/moving') expect(read(`${SDK_DIR}/setup/moving.md`)).not.toContain('redirect_from') - // 3. A page that never had redirects is left alone. expect(read(`${SDK_DIR}/features/stable.md`)).not.toContain('redirect_from') }) @@ -361,8 +346,7 @@ describe('preserve-redirects end to end', () => { }) test('does not reflow unrelated frontmatter', () => { - // A long `intro` is the field most likely to be rewrapped by a YAML - // round-trip, which would swamp the real change in every sync diff. + // A long intro exposes YAML reserialization, which would swamp redirect-only diffs. const longIntro = 'This intro is deliberately far longer than the eighty column default that ' + 'js-yaml wraps folded scalars at, so any re-serialization would be obvious.' @@ -396,7 +380,6 @@ describe('preserve-redirects end to end', () => { igit('commit', '-m', 'pre-sync state') const expected = fs.readFileSync(target, 'utf8') - // A sync rebuilds the frontmatter without the redirect. fs.writeFileSync(target, build(false), 'utf8') runScript(isolated, ['--sdk-docs-dir', path.join(isolated, SDK_DIR)]) @@ -412,7 +395,7 @@ describe('preserve-redirects end to end', () => { git('add', '-A') git('commit', '-m', 'sync result') - // `gone.md` disappears with no plausible successor. + // gone.md disappears with no plausible successor. write(`${SDK_DIR}/features/gone.md`, page('Gone')) git('add', '-A') git('commit', '-m', 'add page that will vanish') @@ -428,8 +411,7 @@ describe('preserve-redirects end to end', () => { }) test('writes the unresolved warning to the step summary it was given', () => { - // Self-contained: clear the file, trigger its own unresolved run, then read - // it back, rather than depending on a previous test having written it. + // This test triggers and reads its own warning instead of depending on a prior test. const file = path.join(repo, 'step-summary.md') fs.rmSync(file, { force: true }) @@ -440,20 +422,16 @@ describe('preserve-redirects end to end', () => { run() - // Guards the env redirect in `run`: without it these synthetic warnings - // would be appended to the real Actions job summary during CI. + // Without the env override, synthetic warnings would reach the real Actions job summary. const summary = stepSummary() expect(summary).toContain('need a redirect decision') expect(summary).toContain('/copilot/how-tos/copilot-sdk/features/vanishing') - // The inherited redirect is at risk too, so it must be reported, not just - // the removed page's own URL. + // Report the inherited redirect too, or the redirect chain can still strand users. expect(summary).toContain('/copilot/older-vanishing') }) test('fails loudly when the baseline ref cannot be read', () => { - // Previously a failed `git ls-tree` was indistinguishable from a first sync, - // so the run reported "nothing to preserve" and exited 0 β€” dropping every - // redirect in the tree without a single warning. + // A failed baseline read must fail loudly so the script never drops redirects silently. let message = '' try { run(['--git-ref', 'refs/heads/no-such-ref']) @@ -466,9 +444,7 @@ describe('preserve-redirects end to end', () => { }) test('carries inherited redirects when a page keeps its URL but changes file', () => { - // `guide.md` becoming `guide/index.md` keeps the URL live, so nothing 404s - // and no successor guess is needed β€” but the redirects the old file had - // inherited would be stranded unless they are moved by URL identity. + // guide.md and guide/index.md share a live URL, so match by URL to carry inherited redirects. const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-reshape-')) const igit = (...args: string[]) => execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) @@ -500,8 +476,7 @@ describe('preserve-redirects end to end', () => { }) test('exits cleanly when the ref is valid but the SDK directory is absent', () => { - // The first ever sync. This is the one empty baseline that is legitimate, - // and it must stay distinguishable from a baseline that could not be read. + // A missing baseline SDK directory is valid only when the ref exists. const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-first-')) const igit = (...args: string[]) => execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) @@ -514,7 +489,6 @@ describe('preserve-redirects end to end', () => { igit('add', '-A') igit('commit', '-m', 'repo without SDK docs') - // The sync has just created the tree for the first time. const target = path.join(isolated, SDK_DIR, 'features/new.md') fs.mkdirSync(path.dirname(target), { recursive: true }) fs.writeFileSync(target, page('New'), 'utf8') @@ -559,8 +533,7 @@ describe('preserve-redirects end to end', () => { igit('add', '-A') igit('commit', '-m', 'pre-sync state') - // The sync rewrites the page without any frontmatter at all, so there is - // nowhere to put the redirect back. + // Without frontmatter, the script has nowhere to restore the redirect. fs.writeFileSync(target, 'Body only, no frontmatter.\n', 'utf8') let message = '' diff --git a/src/workflows/tests/wait-for-build.ts b/src/workflows/tests/wait-for-build.ts index 283064b0f987..7dd0b15dfab9 100644 --- a/src/workflows/tests/wait-for-build.ts +++ b/src/workflows/tests/wait-for-build.ts @@ -80,7 +80,7 @@ describe('waitForBuild', () => { test('throws once the timeout elapses without enough matches', async () => { fetchWithRetry.mockResolvedValue(serves('older-sha')) - // startTime, then one in-budget poll, then over budget. + // Date.now returns start time, then one in-budget poll, then an over-budget poll. vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(60_000) await expect( From cf102cdc73e2dfc4286c9940d0932a80b00cdccb Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:37:44 +0000 Subject: [PATCH 05/28] Narrow moda-ci workflow permissions (#63508) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .github/workflows/moda-ci.yaml | 39 ++++++++++++++++++++++++---------- .github/zizmor.yml | 7 ------ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.github/workflows/moda-ci.yaml b/.github/workflows/moda-ci.yaml index 43240c6dcdb3..b168608203b8 100644 --- a/.github/workflows/moda-ci.yaml +++ b/.github/workflows/moda-ci.yaml @@ -11,25 +11,30 @@ on: merge_group: types: [checks_requested] +permissions: {} + jobs: ########################## # Generate Vault keys ########################## set-vault-keys: + permissions: {} runs-on: ubuntu-latest outputs: modified_vault_keys: ${{ steps.modify_vault_keys.outputs.modified }} steps: - name: Set vault-keys output id: modify_vault_keys + env: + VAULT_KEYS: ${{ vars.VAULT_KEYS }} run: | - if [ -z "${{ vars.VAULT_KEYS }}" ]; then + if [ -z "$VAULT_KEYS" ]; then # We want to add the DOCS_BOT_PAT_BASE to the list of keys # so that builds fetch the secret from the docs-internal vault # where --environment is "ci" - echo "modified=DOCS_BOT_PAT_BASE" >> $GITHUB_OUTPUT + echo "modified=DOCS_BOT_PAT_BASE" >> "$GITHUB_OUTPUT" else - echo "modified=${{ vars.VAULT_KEYS }},DOCS_BOT_PAT_BASE" >> $GITHUB_OUTPUT + echo "modified=${VAULT_KEYS},DOCS_BOT_PAT_BASE" >> "$GITHUB_OUTPUT" fi ############# @@ -39,6 +44,13 @@ jobs: if: ${{ github.repository == 'github/docs-internal' }} name: ${{ matrix.ci_job.job }} needs: set-vault-keys + permissions: + actions: read + attestations: write + checks: read + contents: read + id-token: write + statuses: read strategy: fail-fast: false matrix: @@ -58,6 +70,13 @@ jobs: if: ${{ github.repository == 'github/docs-internal' }} name: ${{ matrix.ci_job.job }} needs: set-vault-keys + permissions: + actions: read + attestations: write + checks: read + contents: read + id-token: write + statuses: read strategy: fail-fast: false matrix: @@ -80,6 +99,12 @@ jobs: if: ${{ github.repository == 'github/docs-internal' }} name: ${{ matrix.ci_job.job }} needs: set-vault-keys + permissions: + actions: read + checks: read + contents: read + id-token: write + statuses: read strategy: fail-fast: false matrix: @@ -93,11 +118,3 @@ jobs: secrets: dx-bot-token: ${{ secrets.INTERNAL_ACTIONS_DX_BOT_ACCOUNT_TOKEN }} datadog-api-key: ${{ secrets.DATADOG_API_KEY }} - -permissions: - actions: read - checks: read - contents: read - statuses: read - id-token: write - attestations: write diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 95639824c31c..9972e9f09bd7 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -4,13 +4,6 @@ rules: dangerous-triggers: disable: true - # moda-ci uses reusable workflows (uses:) which don't support job-level - # permissions. id-token:write and attestations:write are needed by docker-image - # for attestation but can't be scoped to that job alone. - excessive-permissions: - ignore: - - moda-ci.yaml - # actions/* has immutable tags, so ref-pinning is sufficient. # github/internal-actions is a private GitHub org repo, ref-pin is fine. # Everything else must be hash-pinned. From 524ed7a3b5836f82074dccaf96e0e3650361ca85 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:38:32 +0000 Subject: [PATCH 06/28] Make analyze-text honor --not-language (#63480) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 906e4c0b-953b-4672-99bb-65c53e08fb74 --- src/search/scripts/analyze-text.ts | 61 ++++++++++++++++++++---------- src/search/tests/analyze-text.ts | 26 +++++++++++++ 2 files changed, 68 insertions(+), 19 deletions(-) create mode 100644 src/search/tests/analyze-text.ts diff --git a/src/search/scripts/analyze-text.ts b/src/search/scripts/analyze-text.ts index 7897af2fc9c0..bf501b0b00f9 100755 --- a/src/search/scripts/analyze-text.ts +++ b/src/search/scripts/analyze-text.ts @@ -1,6 +1,8 @@ // Shows how different analyzers tokenize text. Requires an Elasticsearch index. // Usage: npm run analyze-text -- -V dotcom -l en "The name of the wind" +import { pathToFileURL } from 'url' + import { Client } from '@elastic/elasticsearch' import { Command, Option } from 'commander' import chalk from 'chalk' @@ -45,20 +47,24 @@ program .addOption( new Option('-l, --language ', 'Which language to focus on').choices(languageKeys), ) - .option('--not-language ', 'Exclude a specific language') + .addOption( + new Option('--not-language ', 'Exclude a specific language').choices(languageKeys), + ) .option('-u, --elasticsearch-url ', 'If different from $ELASTICSEARCH_URL') .option('--index-prefix ', 'Prefix for the index name') .argument('', 'text to tokenize') - .parse(process.argv) -const options = program.opts() -const args: string[] = program.args +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + program.parse(process.argv) + const options = program.opts() + const args: string[] = program.args -try { - await main(options, args) -} catch (err) { - console.error(chalk.red('Error:'), err) - process.exit(1) + try { + await main(options, args) + } catch (err) { + console.error(chalk.red('Error:'), err) + process.exit(1) + } } async function main(opts: Options, textArgs: string[]): Promise { @@ -84,11 +90,8 @@ async function main(opts: Options, textArgs: string[]): Promise { return } - const { verbose, language, notLanguage } = opts - - if (language && notLanguage) { - throw new Error("Can't combine --language and --not-language") - } + const { verbose } = opts + const languagesToAnalyze = getLanguagesToAnalyze(opts) if (verbose) { console.log(`Connecting to ${chalk.bold(safeUrlDisplay(node))}`) @@ -102,17 +105,37 @@ async function main(opts: Options, textArgs: string[]): Promise { if (verbose) { console.log(`Analyzing on version ${chalk.bold(versionKey)}`) } - const languageKey = opts.language || 'en' if (verbose) { - console.log(`Analyzing on language ${chalk.bold(languageKey)}`) + console.log(`Analyzing on languages ${chalk.bold(languagesToAnalyze.join(', '))}`) } const { indexPrefix } = opts const prefix = indexPrefix ? `${indexPrefix}_` : '' - const indexName = `${prefix}github-docs-${versionKey}-${languageKey}` - console.log(chalk.yellow(`Analyzing in ${chalk.bold(indexName)}`)) - await analyzeVersion(client, texts, indexName) + for (const languageKey of languagesToAnalyze) { + const indexName = `${prefix}github-docs-${versionKey}-${languageKey}` + console.log(chalk.yellow(`Analyzing in ${chalk.bold(indexName)}`)) + await analyzeVersion(client, texts, indexName) + } +} + +export function getLanguagesToAnalyze({ + language, + notLanguage, +}: Pick): string[] { + if (language && notLanguage) { + throw new Error("Can't combine --language and --not-language") + } + + if (language) { + return [language] + } + + if (notLanguage) { + return languageKeys.filter((languageKey) => languageKey !== notLanguage) + } + + return ['en'] } function safeUrlDisplay(url: string): string { diff --git a/src/search/tests/analyze-text.ts b/src/search/tests/analyze-text.ts new file mode 100644 index 000000000000..b187ad4e2434 --- /dev/null +++ b/src/search/tests/analyze-text.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' + +import { languageKeys } from '@/languages/lib/languages-server' +import { getLanguagesToAnalyze } from '@/search/scripts/analyze-text' + +describe('getLanguagesToAnalyze', () => { + test('defaults to English', () => { + expect(getLanguagesToAnalyze({})).toEqual(['en']) + }) + + test('uses the requested language', () => { + expect(getLanguagesToAnalyze({ language: 'ja' })).toEqual(['ja']) + }) + + test('excludes the requested language', () => { + expect(getLanguagesToAnalyze({ notLanguage: 'en' })).toEqual( + languageKeys.filter((languageKey) => languageKey !== 'en'), + ) + }) + + test('rejects language and notLanguage together', () => { + expect(() => getLanguagesToAnalyze({ language: 'en', notLanguage: 'ja' })).toThrow( + "Can't combine --language and --not-language", + ) + }) +}) From ad4064a77bcecd12ee5a5ae9c0280c060ef8a6af Mon Sep 17 00:00:00 2001 From: Greg Brunk Date: Tue, 29 Sep 2026 15:39:30 +0000 Subject: [PATCH 07/28] Clarify REST API rate limit status guidance (#63588) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: subatoi <32935794+subatoi@users.noreply.github.com> --- .../using-the-rest-api/rate-limits-for-the-rest-api.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/content/rest/using-the-rest-api/rate-limits-for-the-rest-api.md b/content/rest/using-the-rest-api/rate-limits-for-the-rest-api.md index c9d9f8a93a1a..092cd5610bc7 100644 --- a/content/rest/using-the-rest-api/rate-limits-for-the-rest-api.md +++ b/content/rest/using-the-rest-api/rate-limits-for-the-rest-api.md @@ -96,9 +96,12 @@ Header name | Description `x-ratelimit-reset` | The time at which the current rate limit window resets, in UTC epoch seconds `x-ratelimit-resource` | The rate limit resource that the request counted against. For more information about the different resources, see [AUTOTITLE](/rest/rate-limit/rate-limit#get-rate-limit-status-for-the-authenticated-user). -You can also call the `GET /rate_limit` endpoint to check your rate limit. Calling this endpoint does not count against your primary rate limit, but it can count against your secondary rate limit. See [AUTOTITLE](/rest/rate-limit/rate-limit). +You can call the `GET /rate_limit` endpoint for a periodic overview of all resource families for the authenticated user. Calling this endpoint does not count against your primary rate limit, but it can count against your secondary rate limit. See [AUTOTITLE](/rest/rate-limit/rate-limit). -The `x-ratelimit-*` response headers are the authoritative source for your current rate limit status. Use them to pace and back off your requests. Use `GET /rate_limit` for a periodic overview of all resource families for the authenticated user, and treat the response headers as authoritative if the two disagree. +> [!NOTE] +> Because {% data variables.product.company_short %} processes API requests in multiple regions, rate limit values can vary from one response to the next based on the location handling the request. For example, `x-ratelimit-remaining` may be higher on a later response than on an earlier one within the same rate limit window. The `x-ratelimit-*` headers are the authoritative source for your current rate limit status and may differ from values reported by the `GET /rate_limit` endpoint. If the two disagree, rely on the response headers. + +Use the `x-ratelimit-*` response headers to pace and back off your requests, but avoid logic that depends on an exact remaining count. Ensure your integration handles `403` and `429` responses as described in [Exceeding the rate limit](#exceeding-the-rate-limit) later in this article. There is not a way to check the status of your secondary rate limit. From a624e626095b1b5da64d8e6afaa7a80cc4bcfe3b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:39:45 +0000 Subject: [PATCH 08/28] Fix survey language test heading selector (#63509) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/languages/tests/frame.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/languages/tests/frame.ts b/src/languages/tests/frame.ts index 3960ad6b3592..b30d0a852db8 100644 --- a/src/languages/tests/frame.ts +++ b/src/languages/tests/frame.ts @@ -56,13 +56,14 @@ describe('frame', () => { ) }) - // Docs Engineering issue: 2637 - test.skip.each(langs)('loads the survey via site data in %s', async (lang) => { + test.each(langs)('loads the survey via site data in %s', async (lang) => { const $en = await getDOM(`/en`) const $ = await getDOM(`/${lang}`) - expect($('[data-testid="survey-form"] h2').text()).not.toEqual( - $en('[data-testid="survey-form"] h2').text(), - ) + const heading = $('[data-testid="survey-form"] h3').text() + const enHeading = $en('[data-testid="survey-form"] h3').text() + expect(heading).toBeTruthy() + expect(enHeading).toBeTruthy() + expect(heading).not.toEqual(enHeading) }) }) From b5f9b81b024249031b257a68d24f6dc1f591f12a Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:40:34 +0000 Subject: [PATCH 09/28] Clarify autocomplete fuzzy test coverage (#63507) Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../tests/api-ai-search-autocomplete.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/search/tests/api-ai-search-autocomplete.ts b/src/search/tests/api-ai-search-autocomplete.ts index f5d7832568b1..65735a476ca9 100644 --- a/src/search/tests/api-ai-search-autocomplete.ts +++ b/src/search/tests/api-ai-search-autocomplete.ts @@ -1,6 +1,8 @@ -// These tests need indexed fixtures and ELASTICSEARCH_URL. -// Run ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures. -// The command writes tests_-prefixed indexes and leaves regular indexes alone. +// These tests need indexed fixtures and an Elasticsearch URL for the server: +// +// ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures +// +// That writes `tests_`-prefixed indexes and leaves your regular ones alone. import { expect, test, vi } from 'vitest' @@ -25,7 +27,8 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => test('perform a basic ai autocomplete search', async () => { const sp = new URLSearchParams() - // Fixture queries under src/search/tests/fixtures/data/ai include "How do I clone a repository?". + // To see why this will work, + // see src/search/tests/fixtures/data/ai/* sp.set('query', 'how do I') const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) @@ -42,7 +45,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => expect(hit.highlights).toBeTruthy() expect(hit.highlights[0]).toBe('How do I clone a repository?') - // Search responses must be CDN-cacheable. + // Check that it can be cached at the CDN expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -103,19 +106,26 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => expect(JSON.parse(res.body).error).toBeTruthy() }) - test('fuzzy autocomplete search', async () => { + test('prefix autocomplete search for a two-character query', async () => { const sp = new URLSearchParams() - sp.set('query', 'cl') // Matches "clone". + sp.set('query', 'cl') const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) const results = JSON.parse(res.body) as AutocompleteSearchResponse - // cl matches "How do I clone a repository?". const hit = results.hits[0] expect(hit.term).toBe('How do I clone a repository?') - // Two-character queries use prefix matching, so cl highlights clone. expect(hit.highlights[0]).toBe('How do I clone a repository?') }) + test('fuzzy autocomplete search', async () => { + const sp = new URLSearchParams() + sp.set('query', 'clome') + const res = await get(getSearchEndpointWithParams(sp)) + expect(res.statusCode).toBe(200) + const results = JSON.parse(res.body) as AutocompleteSearchResponse + expect(results.hits.map((result) => result.term)).toContain('How do I clone a repository?') + }) + test('autocomplete term search', async () => { const sp = new URLSearchParams() sp.set('query', 'clone') @@ -130,12 +140,12 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => test('support empty query', async () => { const sp = new URLSearchParams() - // Omit query entirely. + // No query at all { const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) } - // Pass an empty query. + // Empty query { sp.set('query', '') const res = await get(getSearchEndpointWithParams(sp)) From a9e2ff1905701465abba3d8ed1568e0479ecc044 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:40:46 +0000 Subject: [PATCH 10/28] Exclude hidden products from search scraping (#63481) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 906e4c0b-953b-4672-99bb-65c53e08fb74 --- .../scrape/lib/find-indexable-pages.ts | 5 +- .../scrape/tests/find-indexable-pages.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/search/scripts/scrape/tests/find-indexable-pages.ts diff --git a/src/search/scripts/scrape/lib/find-indexable-pages.ts b/src/search/scripts/scrape/lib/find-indexable-pages.ts index 6b53de9976d1..1aff38b037bf 100644 --- a/src/search/scripts/scrape/lib/find-indexable-pages.ts +++ b/src/search/scripts/scrape/lib/find-indexable-pages.ts @@ -6,8 +6,9 @@ export default async function findIndexablePages(match = ''): Promise { const allPages: Page[] = await loadPages() const indexablePages = allPages .filter((page) => !page.hidden) - // Exclude visible WIP products. Hidden WIP products still pass through this filter. - .filter((page) => !page.parentProduct || !page.parentProduct.wip || page.parentProduct.hidden) + .filter( + (page) => !page.parentProduct || (!page.parentProduct.wip && !page.parentProduct.hidden), + ) // Exclude absolute home pages such as /en or /ja. .filter((page) => page.relativePath !== 'index.md') .filter((page) => !match || page.relativePath.includes(match)) diff --git a/src/search/scripts/scrape/tests/find-indexable-pages.ts b/src/search/scripts/scrape/tests/find-indexable-pages.ts new file mode 100644 index 000000000000..71b0048cc091 --- /dev/null +++ b/src/search/scripts/scrape/tests/find-indexable-pages.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import findIndexablePages from '@/search/scripts/scrape/lib/find-indexable-pages' +import type { Page } from '@/search/scripts/scrape/types' + +const { loadPagesMock } = vi.hoisted(() => ({ + loadPagesMock: vi.fn(), +})) + +vi.mock('@/frame/lib/page-data', () => ({ + loadPages: loadPagesMock, +})) + +function makePage(overrides: Partial = {}): Page { + return { + relativePath: 'visible/page.md', + languageCode: 'en', + permalinks: [], + ...overrides, + } +} + +describe('findIndexablePages', () => { + beforeEach(() => { + loadPagesMock.mockResolvedValue([ + makePage({ relativePath: 'index.md' }), + makePage({ relativePath: 'visible/page.md' }), + makePage({ relativePath: 'hidden/page.md', hidden: true }), + makePage({ + relativePath: 'hidden-product/page.md', + parentProduct: { hidden: true, wip: false }, + }), + makePage({ + relativePath: 'wip-product/page.md', + parentProduct: { hidden: false, wip: true }, + }), + makePage({ + relativePath: 'hidden-wip-product/page.md', + parentProduct: { hidden: true, wip: true }, + }), + ]) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('excludes hidden pages, home pages, and pages in hidden or WIP products', async () => { + await expect(findIndexablePages()).resolves.toEqual([ + makePage({ relativePath: 'visible/page.md' }), + ]) + }) + + test('filters pages by relative path match', async () => { + await expect(findIndexablePages('missing')).resolves.toEqual([]) + await expect(findIndexablePages('visible')).resolves.toEqual([ + makePage({ relativePath: 'visible/page.md' }), + ]) + }) +}) From a81d4a15a8d2eb4884f4c34d14e0ef7343a1ff33 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 15:40:55 +0000 Subject: [PATCH 11/28] Block invalid Next data prefix lookalikes (#63505) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../middleware/handle-invalid-nextjs-paths.ts | 3 +- .../tests/handle-invalid-nextjs-paths.ts | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/shielding/tests/handle-invalid-nextjs-paths.ts diff --git a/src/shielding/middleware/handle-invalid-nextjs-paths.ts b/src/shielding/middleware/handle-invalid-nextjs-paths.ts index 8141544b5d7b..7a9e11f109ec 100644 --- a/src/shielding/middleware/handle-invalid-nextjs-paths.ts +++ b/src/shielding/middleware/handle-invalid-nextjs-paths.ts @@ -15,7 +15,8 @@ export default function handleInvalidNextPaths( ) { if ( process.env.NODE_ENV !== 'development' && - ((req.path.startsWith('/_next/') && !req.path.startsWith('/_next/data')) || + ((req.path.startsWith('/_next/') && + !(req.path.startsWith('/_next/data/') && req.path.endsWith('.json'))) || req.query?.['__nextFallback']) ) { defaultCacheControl(res) diff --git a/src/shielding/tests/handle-invalid-nextjs-paths.ts b/src/shielding/tests/handle-invalid-nextjs-paths.ts new file mode 100644 index 000000000000..dd4b3eb8de8c --- /dev/null +++ b/src/shielding/tests/handle-invalid-nextjs-paths.ts @@ -0,0 +1,91 @@ +import type { NextFunction, Response } from 'express' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import handleInvalidNextPaths from '@/shielding/middleware/handle-invalid-nextjs-paths' +import type { ExtendedRequest } from '@/types' + +vi.mock('@/observability/lib/statsd', () => ({ + default: { increment: vi.fn() }, +})) + +describe('handleInvalidNextPaths middleware', () => { + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production') + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + test.each([ + '/_next/database', + '/_next/datafoo', + '/_next/data', + '/_next/data/development/junk.css', + ])('blocks invalid production _next paths that resemble data routes: %s', (path) => { + const { next, res } = runMiddleware(path) + + expect(next).not.toHaveBeenCalled() + expect(res.statusCode).toBe(404) + expect(res.contentType).toBe('text') + expect(res.body).toBe('Not found') + expect(res.headers['cache-control']).toMatch('public') + }) + + test('allows real Next.js data routes through the scanner', () => { + const { next, res } = runMiddleware( + '/_next/data/development/en/free-pro-team%40latest/pages.json', + ) + + expect(next).toHaveBeenCalledOnce() + expect(res.statusCode).toBeUndefined() + }) + + test('allows invalid _next paths in development', () => { + vi.stubEnv('NODE_ENV', 'development') + + const { next, res } = runMiddleware('/_next/database') + + expect(next).toHaveBeenCalledOnce() + expect(res.statusCode).toBeUndefined() + }) +}) + +function runMiddleware(path: string) { + const req = { path, query: {} } as ExtendedRequest + const res = buildResponse() + const next = vi.fn() as NextFunction + + handleInvalidNextPaths(req, res as unknown as Response, next) + + return { next, res } +} + +function buildResponse() { + const res = { + headers: {} as Record, + statusCode: undefined as number | undefined, + contentType: undefined as string | undefined, + body: undefined as string | undefined, + hasHeader: vi.fn(() => false), + set(name: string, value: string) { + res.headers[name] = value + return res + }, + status(code: number) { + res.statusCode = code + return res + }, + type(value: string) { + res.contentType = value + return res + }, + send(body: string) { + res.body = body + return res + }, + } + + return res +} From 9703f5f21232f3197f68c3189ef4fc03b6391e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:42:52 +0000 Subject: [PATCH 12/28] Bump the npm_and_yarn group across 1 directory with 1 update (#63594) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 333b34ce3507..f292903e7289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6612,9 +6612,9 @@ } }, "node_modules/cheerio/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -15599,9 +15599,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index b03e0338d15d..78c392b4dd8d 100644 --- a/package.json +++ b/package.json @@ -351,7 +351,7 @@ "@opentelemetry/otlp-transformer": { "protobufjs": "^8.6.5" }, - "undici": "^6.28.0", + "undici": "^6.29.0", "cheerio": { "undici": "^7.29.0" }, From 4d328eea5151c8d5bed31216981253db4f231e0c Mon Sep 17 00:00:00 2001 From: "Hector A." Date: Tue, 29 Sep 2026 16:02:03 +0000 Subject: [PATCH 13/28] Transition to primer/brand components on discovery pages (#63558) --- .../tests/playwright-rendering.spec.ts | 138 +++++++++++++++++- .../LandingArticleGridWithFilter.module.scss | 45 +++--- .../shared/LandingArticleGridWithFilter.tsx | 57 +++++--- 3 files changed, 193 insertions(+), 47 deletions(-) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index e27c4cd06cc2..c08a17841a48 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1622,6 +1622,141 @@ test.describe('Docs 2026 in-article navigation', () => { }) test.describe('LandingArticleGridWithFilter component', () => { + test('category menu selects once via keyboard and preserves URL state', async ({ page }) => { + await page.goto('/get-started/article-grid-discovery?articles-filter=Grid&articles-page=2') + const trigger = page.getByTestId('filter-header').getByRole('button') + await trigger.focus() + await page.keyboard.press('Enter') + const menu = page.getByRole('menu', { name: 'Category', exact: true }) + const allCategories = menu.getByRole('menuitemradio', { name: 'All categories' }) + await expect(allCategories).toBeFocused() + await expect(allCategories).toHaveAttribute('aria-checked', 'true') + await page.keyboard.press('ArrowUp') + await expect(menu.getByRole('menuitemradio').last()).toBeFocused() + await page.keyboard.press('ArrowDown') + await expect(allCategories).toBeFocused() + + // Brand wires both Enter and Space through the overlay's and the item's own + // handlers; instrument both so a regression firing either raw handler shows up, + // even though only Enter is exercised below (Space runs through the same + // onKeyDownCapture guard in the component). + await menu.evaluate((element) => { + element.addEventListener('keydown', (event) => { + if (event instanceof KeyboardEvent && (event.key === 'Enter' || event.key === ' ')) { + document.body.dataset.categoryKeydowns = String( + Number(document.body.dataset.categoryKeydowns || 0) + 1, + ) + } + }) + element.addEventListener('click', () => { + document.body.dataset.categoryClicks = String( + Number(document.body.dataset.categoryClicks || 0) + 1, + ) + }) + }) + await menu.getByRole('menuitemradio', { name: 'Testing', exact: true }).focus() + await page.keyboard.press('Enter') + await expect(menu).toHaveCount(0) + await expect(trigger).toBeFocused() + await expect(page.locator('body')).toHaveAttribute('data-category-clicks', '1') + await expect(page.locator('body')).not.toHaveAttribute('data-category-keydowns') + await expect(page).toHaveURL(/articles-category=Testing/) + expect(new URL(page.url()).searchParams.get('articles-filter')).toBe('Grid') + expect(new URL(page.url()).searchParams.has('articles-page')).toBe(false) + await expect(page.getByTestId('article-grid').getByTestId('article-card')).toHaveCount(1) + + await trigger.click() + await expect(menu.getByRole('menuitemradio', { checked: true })).toHaveText('Testing') + await allCategories.click() + await expect(page).not.toHaveURL(/articles-category=/) + await expect(page.getByTestId('article-grid').getByTestId('article-card')).toHaveCount(4) + }) + + test('category menu aligns with its inline label on desktop', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await page.goto('/get-started/article-grid-discovery?articles-category=Testing') + const trigger = page.getByTestId('filter-header').getByRole('button') + const menu = page.getByRole('menu', { name: 'Category', exact: true }) + await expect(trigger).toContainText('Testing') + const label = trigger.getByText('Category:', { exact: true }) + const value = trigger.getByText('Testing', { exact: true }) + await expect(async () => { + const labelBounds = await label.boundingBox() + const valueBounds = await value.boundingBox() + expect(labelBounds).not.toBeNull() + expect(valueBounds).not.toBeNull() + expect(valueBounds!.y).toBeCloseTo(labelBounds!.y, 0) + expect(valueBounds!.x).toBeGreaterThan(labelBounds!.x + labelBounds!.width) + }).toPass() + await trigger.click() + const selected = menu.getByRole('menuitemradio', { checked: true }) + await expect(selected).toHaveText('Testing') + await expect(selected).toBeInViewport() + const bounds = await menu.boundingBox() + const triggerBounds = await trigger.boundingBox() + expect(bounds).not.toBeNull() + expect(triggerBounds).not.toBeNull() + expect(bounds!.x).toBeCloseTo(triggerBounds!.x, 0) + await page.keyboard.press('Escape') + await expect(menu).toHaveCount(0) + await expect(trigger).toBeFocused() + await trigger.click() + await page.getByRole('heading', { level: 1 }).click() + await expect(menu).toHaveCount(0) + await expect(trigger).toContainText('Testing') + }) + + test('category menu keeps its label inline and fits within the mobile viewport', async ({ + page, + }) => { + await page.setViewportSize({ width: 375, height: 900 }) + await page.goto('/get-started/article-grid-discovery?articles-category=Testing') + const trigger = page.getByTestId('filter-header').getByRole('button') + const menu = page.getByRole('menu', { name: 'Category', exact: true }) + await expect(trigger).toContainText('Testing') + const label = trigger.getByText('Category:', { exact: true }) + const value = trigger.getByText('Testing', { exact: true }) + await expect(async () => { + const labelBounds = await label.boundingBox() + const valueBounds = await value.boundingBox() + expect(labelBounds).not.toBeNull() + expect(valueBounds).not.toBeNull() + expect(valueBounds!.y).toBeCloseTo(labelBounds!.y, 0) + expect(valueBounds!.x).toBeGreaterThan(labelBounds!.x + labelBounds!.width) + }).toPass() + await trigger.click() + const selected = menu.getByRole('menuitemradio', { checked: true }) + await expect(selected).toHaveText('Testing') + await expect(selected).toBeInViewport() + const bounds = await menu.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(375) + }) + + for (const colorScheme of ['light', 'dark'] as const) { + test(`category menu border and item padding use brand tokens in ${colorScheme} mode`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme }) + await page.goto('/get-started/article-grid-discovery?articles-category=Testing') + const trigger = page.getByTestId('filter-header').getByRole('button') + await trigger.click() + const menu = page.getByRole('menu', { name: 'Category', exact: true }) + const selected = menu.getByRole('menuitemradio', { checked: true }) + const borderColor = await menu.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--brand-color-border-default)' + element.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + }) + await expect(menu).toHaveCSS('border-top-color', borderColor) + await expect(selected).toHaveCSS('padding-inline-end', '8px') + }) + } + test('displays article grid with filter controls', async ({ page }) => { await page.goto('/get-started/article-grid-discovery') @@ -1678,7 +1813,8 @@ test.describe('LandingArticleGridWithFilter component', () => { await expect(allArticleCards).toHaveCount(4) await categoryDropdown.click() - const testingOption = page.getByText('Testing', { exact: true }).last() + const menu = page.getByRole('menu', { name: 'Category', exact: true }) + const testingOption = menu.getByRole('menuitemradio', { name: 'Testing', exact: true }) await expect(testingOption).toBeVisible() await testingOption.click() diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss index 96e639132bd6..2730f669f86d 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss +++ b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss @@ -134,11 +134,12 @@ // The category dropdown follows the Sort by pattern: muted label, // bold value, Action/Small type, and transparent background. .categoryDropdown { - // Mobile clips the control inside row 1 so long category values do not widen the row. + // Mobile: sit at the right end of row 1 (next to the title) and allow the + // control to shrink so a long category value ellipsises rather than pushing + // the row wider. Keep overflow visible for Brand's non-portaled overlay. justify-self: end; min-width: 0; max-width: 100%; - overflow: hidden; button { max-width: 100%; @@ -149,31 +150,29 @@ box-shadow: none !important; text-align: left !important; - // Primer ButtonBase sets min-width: max-content on the button and its - // inner content/label spans, preventing the label from shrinking. Force the - // whole track to shrink, and make the label a flex row so the value can - // take the remaining space and ellipsise. - span { - min-width: 0 !important; - max-width: 100%; - justify-content: start !important; + :global([class*="Button__text"]), + :global([class*="Button--label"]) { + min-width: 0; } - // The label span holds Category: plus the value, so flex lets the value ellipsise. - // Target by class substring because Primer's hashed class is version-specific. - :global([class*="Button-Label"]) { - display: flex; - align-items: baseline; - gap: 0.25rem; - overflow: hidden; + :global([class*="Button__trailing-visual"]) { + flex-shrink: 0; } + } - // Primer Button-Content uses label and visual tracks; shrink the label - // to keep the caret inside. - :global([class*="Button-Content"]) { - grid-template-columns: minmax(0, auto) auto; - overflow: hidden; - } + .categoryButtonLabel { + display: flex; + align-items: baseline; + gap: 0.25rem; + overflow: hidden; + } + + [role="menu"] { + border-color: var(--brand-color-border-default); + } + + [role="menuitemradio"] { + padding-inline-end: var(--base-size-8); } @include breakpoint(md) { diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx index dd69d0f57a02..e6fddbc8142a 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.tsx +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react' import { useRouter } from 'next/router' -import { ActionMenu, ActionList } from '@primer/react' -import { Card, Pagination, TextInput, Token } from '@primer/react-brand' +import { ActionMenu, Card, Pagination, TextInput, Token } from '@primer/react-brand' import { SearchIcon } from '@primer/octicons-react' import { announce } from '@primer/live-region-element' import cx from 'clsx' @@ -263,29 +262,41 @@ export const ArticleGrid = ({
{/* Text-style control matches the Sort by pattern. */}
- - - - {t('article_grid.filter_by_category')}: - {' '} - - {categories[selectedCategoryIndex] === ALL_CATEGORIES - ? t('article_grid.all_categories') - : categories[selectedCategoryIndex]} + + + + + {t('article_grid.filter_by_category')}: + {' '} + + {categories[selectedCategoryIndex] === ALL_CATEGORIES + ? t('article_grid.all_categories') + : categories[selectedCategoryIndex]} + - - - {categories.map((category, index) => ( - handleFilter(category)} - > - {category === ALL_CATEGORIES ? t('article_grid.all_categories') : category} - - ))} - + + {categories.map((category, index) => ( + ) => { + if (event.key !== 'Enter' && event.key !== ' ') return + // Brand handles Enter in both the overlay and item; use one click instead. + event.preventDefault() + event.stopPropagation() + event.currentTarget.click() + }} + > + {category === ALL_CATEGORIES ? t('article_grid.all_categories') : category} + + ))}
From 70d210fa01080197f3875f29bb1a746779b0e094 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:02:54 +0000 Subject: [PATCH 14/28] Remove unused dotcom path script (#63501) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/redirects/scripts/get-new-dotcom-path.ts | 37 -------------------- 1 file changed, 37 deletions(-) delete mode 100644 src/redirects/scripts/get-new-dotcom-path.ts diff --git a/src/redirects/scripts/get-new-dotcom-path.ts b/src/redirects/scripts/get-new-dotcom-path.ts deleted file mode 100644 index 89ce2a21c208..000000000000 --- a/src/redirects/scripts/get-new-dotcom-path.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Finds the content/github path for an old dotcom path. -// content/github no longer exists, so this script currently fails. - -import assert from 'assert' -import { last } from 'lodash-es' -import fs from 'fs' -import { execSync } from 'child_process' - -const markdownExtension = '.md' -const markdownRegex = new RegExp(`${markdownExtension}$`, 'm') - -const newDotcomDir = 'content/github' - -const oldPath: string = process.argv.slice(2)[0] -assert(oldPath, 'must provide old dotcom path like "foo" or "articles/foo"') - -let filename: string = oldPath - -if (filename.includes('/')) filename = last(filename.split('/')) as string - -const categoryDir = `${newDotcomDir}/${filename.replace(markdownRegex, '')}` - -if (fs.existsSync(categoryDir)) { - console.log(`New path:\n${categoryDir}/`) - process.exit(0) -} - -if (!filename.endsWith(markdownExtension)) filename = filename + markdownExtension - -const newPath: string = execSync(`find ${newDotcomDir} -name ${filename}`).toString() - -if (!newPath) { - console.log(`Cannot find new path for "${oldPath}". Check the name and try again.\n`) - process.exit(0) -} - -console.log(`New path:\n${newPath}`) From 771cb11fa31fd9a6e8ec32c5ebafd021f8136130 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:06 +0000 Subject: [PATCH 15/28] Make the enterprise-cloud search filter test prove filtering (#63482) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/fixtures/tests/playwright-rendering.spec.ts | 13 +++++++++++++ ..._github-docs_general-search_ghec_en-records.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index c08a17841a48..fa0e20479ab3 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -408,7 +408,20 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag await page.waitForTimeout(1000) await page.getByText('View more results').click() + const matchingResult = page + .getByTestId('search-result') + .filter({ has: page.getByRole('link', { name: 'Foo', exact: true }) }) + const nonMatchingResult = page + .getByTestId('search-result') + .filter({ has: page.getByRole('link', { name: 'Bar', exact: true }) }) + await expect(matchingResult).toBeVisible() + await expect(nonMatchingResult).toBeVisible() + await expect(nonMatchingResult.getByTestId('search-result-toplevel')).toHaveText('Baring') + await page.getByText('Fooing (1)').click() + await expect(page).toHaveURL(/toplevel=Fooing/) + await expect(matchingResult).toBeVisible() + await expect(nonMatchingResult).toHaveCount(0) await page.getByRole('link', { name: 'Clear' }).click() }) diff --git a/src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_ghec_en-records.json b/src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_ghec_en-records.json index 0042222995b6..2ae121aaf70f 100644 --- a/src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_ghec_en-records.json +++ b/src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_ghec_en-records.json @@ -11,5 +11,18 @@ ], "intro": "Sample intro", "toplevel": "Fooing" + }, + "/en/enterprise-cloud@latest/bar": { + "objectID": "/en/enterprise-cloud@latest/bar", + "breadcrumbs": "bar", + "title": "Bar", + "headings": "", + "content": "This is another fixture hit for GHEC search filtering.", + "topics": [ + "Test", + "Fixture" + ], + "intro": "Sample intro", + "toplevel": "Baring" } } From 6411bfccbeb2422e8db3ffb7f38e305f73a223a6 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:10 +0000 Subject: [PATCH 16/28] Fix false layout handling (#63500) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/frame/middleware/context/layout.ts | 4 +-- src/frame/tests/layout.ts | 41 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/frame/tests/layout.ts diff --git a/src/frame/middleware/context/layout.ts b/src/frame/middleware/context/layout.ts index 45f3a5342c84..e8cd6c596355 100644 --- a/src/frame/middleware/context/layout.ts +++ b/src/frame/middleware/context/layout.ts @@ -7,9 +7,9 @@ export default function layoutContext(req: ExtendedRequest, res: Response, next: if (!req.context.page) return next() let layoutName = 'default' - if (req.context.page.layout) { + if (req.context.page.layout !== undefined) { if (typeof req.context.page.layout === 'boolean') { - // Only layout: true reaches here and clears the layout name. layout: false gets the default. + // `layout: false` means use no layout. The schema rejects `true`. layoutName = '' } else if (typeof req.context.page.layout === 'string') { layoutName = req.context.page.layout diff --git a/src/frame/tests/layout.ts b/src/frame/tests/layout.ts new file mode 100644 index 000000000000..6b1df1992ab8 --- /dev/null +++ b/src/frame/tests/layout.ts @@ -0,0 +1,41 @@ +import { describe, expect, test, vi } from 'vitest' +import type { Response } from 'express' + +import layoutContext from '@/frame/middleware/context/layout' +import type { ExtendedRequest } from '@/types' + +function runLayoutContext(layout?: string | boolean) { + const req = { + context: { + page: layout === undefined ? {} : { layout }, + }, + } as unknown as ExtendedRequest + const next = vi.fn() + + layoutContext(req, {} as Response, next) + + return { req, next } +} + +describe('layout context middleware', () => { + test('uses the default layout when layout frontmatter is missing', () => { + const { req, next } = runLayoutContext() + + expect(req.context?.currentLayoutName).toBe('default') + expect(next).toHaveBeenCalledOnce() + }) + + test('uses named layouts from layout frontmatter', () => { + const { req, next } = runLayoutContext('inline') + + expect(req.context?.currentLayoutName).toBe('inline') + expect(next).toHaveBeenCalledOnce() + }) + + test('uses no layout when layout frontmatter is false', () => { + const { req, next } = runLayoutContext(false) + + expect(req.context?.currentLayoutName).toBe('') + expect(next).toHaveBeenCalledOnce() + }) +}) From 0e48ff33b3e7d3b9d0ae6ffc2ee935250772197c Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:37 +0000 Subject: [PATCH 17/28] Fix top-level oneOf required body params (#63510) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/rest/scripts/utils/get-body-params.ts | 13 ++++--- .../utils/tests/get-body-params.test.ts | 39 +++++++++++++++++-- src/rest/tests/merge-all-of.ts | 3 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/rest/scripts/utils/get-body-params.ts b/src/rest/scripts/utils/get-body-params.ts index 7dd90544c57b..e0061f7cf31c 100644 --- a/src/rest/scripts/utils/get-body-params.ts +++ b/src/rest/scripts/utils/get-body-params.ts @@ -62,15 +62,16 @@ async function getTopLevelOneOfProperty( // need to display all of the parameters. // This merges all of the properties and required values. if (allOneOfAreObjects) { - for (const each of schema.oneOf.slice(1)) { - if (firstOneOfObject.properties && each.properties) { - Object.assign(firstOneOfObject.properties, each.properties) + required = [] + properties = {} + for (const each of schema.oneOf) { + if (each.properties) { + Object.assign(properties, each.properties) } - if (firstOneOfObject.required && each.required) { - required = firstOneOfObject.required.concat(each.required) + if (each.required) { + required = required.concat(each.required) } } - properties = firstOneOfObject.properties || {} } return { properties, required } } diff --git a/src/rest/scripts/utils/tests/get-body-params.test.ts b/src/rest/scripts/utils/tests/get-body-params.test.ts index 77383456daaa..826ad950bbc9 100644 --- a/src/rest/scripts/utils/tests/get-body-params.test.ts +++ b/src/rest/scripts/utils/tests/get-body-params.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { getBodyParams } from '../get-body-params' +import { getBodyParams, type Schema } from '@/rest/scripts/utils/get-body-params' // Mock render-content so tests don't require the full content-render pipeline vi.mock('../render-content', () => ({ @@ -58,9 +58,8 @@ describe('getBodyParams β€” OAS 3.1 nullable handling', () => { expect(params[0].type).toBe('object or null') }) - it('renders anyOf [{type:"null"},{type:"string"}] as "string" (no object found, falls back to first non-null via existing path)', async () => { + it('renders anyOf [{type:"string"},{type:"null"}] as "string" using the first option fallback', async () => { // When anyOf has no object, it uses the existing fallback: param.anyOf[0].type - // The null entry is at index 0, so this tests the non-null fallback path const schema = { type: 'object', properties: { @@ -76,6 +75,40 @@ describe('getBodyParams β€” OAS 3.1 nullable handling', () => { expect(params[0].type).toBe('string') }) + it('preserves required fields from all object-only top-level oneOf alternatives', async () => { + const schema: Schema = { + oneOf: [ + { + type: 'object', + properties: { + first: { type: 'string', description: 'First value' }, + }, + required: ['first'], + }, + { + type: 'object', + properties: { + middle: { type: 'string', description: 'Middle value' }, + }, + required: ['middle'], + }, + { + type: 'object', + properties: { + last: { type: 'string', description: 'Last value' }, + }, + required: ['last'], + }, + ], + } + const params = await getBodyParams(schema, true) + expect(params.map(({ name, isRequired }) => [name, isRequired])).toEqual([ + ['first', true], + ['middle', true], + ['last', true], + ]) + }) + // ── OAS 3.1 type: ["string", "null"] scalar ───────────────────────────── // This is already handled by existing code (paramType array normalization). // These tests verify the existing OAS 3.1 scalar nullable path still works. diff --git a/src/rest/tests/merge-all-of.ts b/src/rest/tests/merge-all-of.ts index 45285155abb2..b68ba6c45cd9 100644 --- a/src/rest/tests/merge-all-of.ts +++ b/src/rest/tests/merge-all-of.ts @@ -209,7 +209,8 @@ describe('mergeAllOf', () => { const before = JSON.stringify(schema) const merged = mergeAllOf(schema) as { oneOf: { properties: Record }[] } - // Mutating merged oneOf members must not change the source OpenAPI operation schema. + // Consumers can mutate merged schemas, so this must not reach back into + // the OpenAPI operation the schema came from. Object.assign(merged.oneOf[0].properties, merged.oneOf[1].properties) merged.oneOf[0].properties.injected = true From bc67798774191dd28650f9f7c973f1480e28ea56 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:47 +0000 Subject: [PATCH 18/28] Remove dead REST auth version guard (#63502) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/rest/components/RestAuth.tsx | 5 ----- src/rest/docs.ts | 4 ++-- src/rest/scripts/utils/update-markdown.ts | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/rest/components/RestAuth.tsx b/src/rest/components/RestAuth.tsx index 6cc865ee484b..ddd06445ae89 100644 --- a/src/rest/components/RestAuth.tsx +++ b/src/rest/components/RestAuth.tsx @@ -21,13 +21,8 @@ type Props = { } export function RestAuth({ progAccess, slug, operationTitle }: Props) { - const { currentVersion } = useVersion() const { t } = useTranslation('rest_reference') - // GHES 3.8 and 3.9 lacked fine-grained tokens; both are deprecated, so this never matches. - if (currentVersion === 'enterprise-server@3.9' || currentVersion === 'enterprise-server@3.8') - return null - // Some operations omit progAccess. if (!progAccess) return null const { diff --git a/src/rest/docs.ts b/src/rest/docs.ts index 4a4d5e3653b0..7407f39ad1c2 100755 --- a/src/rest/docs.ts +++ b/src/rest/docs.ts @@ -40,7 +40,7 @@ log( ) log( `${chalk.cyan.bold(' - REST Two versions:')} ${chalk.magenta( - 'npm run sync-rest -- --versions ghes-3.7 ghes-3.8 && npm run dev', + 'npm run sync-rest -- --versions ghes-3.21 ghes-3.22 && npm run dev', )}`, ) log( @@ -67,7 +67,7 @@ log( ) log( `${chalk.cyan.bold(' - Webhooks Two versions:')} ${chalk.magenta( - 'npm run sync-webhooks -- --versions ghes-3.7 ghes-3.8 && npm run dev', + 'npm run sync-webhooks -- --versions ghes-3.21 ghes-3.22 && npm run dev', )}`, ) log(chalk.green.bold('\nFor more info and additional options, run:\n')) diff --git a/src/rest/scripts/utils/update-markdown.ts b/src/rest/scripts/utils/update-markdown.ts index 64162259248d..5d94e90d8047 100644 --- a/src/rest/scripts/utils/update-markdown.ts +++ b/src/rest/scripts/utils/update-markdown.ts @@ -115,7 +115,7 @@ async function getDataFrontmatter(dataDirectory: string): Promise // { // "actions": { // "artifacts": { -// "versions": ["free-pro-team@latest", "enterprise-server@3.8", ...] +// "versions": ["free-pro-team@latest", "enterprise-server@3.22", ...] // } // } // } From 39cdf469a9f1111d9f2516e7c8972259122db4bf Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:51 +0000 Subject: [PATCH 19/28] Remove unreachable URL decode fallback (#63478) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/frame/middleware/url-decode.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/frame/middleware/url-decode.ts b/src/frame/middleware/url-decode.ts index ae76a8774b6d..3ea3e33021ce 100644 --- a/src/frame/middleware/url-decode.ts +++ b/src/frame/middleware/url-decode.ts @@ -11,11 +11,7 @@ export default function urlDecode(req: ExtendedRequest, res: Response, next: Nex return next() } - try { - const decodedUrl = originalUrl.replace(/%40/g, '@') - req.url = decodedUrl - return next() - } catch { - return next() - } + const decodedUrl = originalUrl.replace(/%40/g, '@') + req.url = decodedUrl + return next() } From 2406e0d02d32b4a162c3c74e4a2000710eee8284 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:03:59 +0000 Subject: [PATCH 20/28] Unskip invalid path server test (#63506) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/frame/tests/server.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frame/tests/server.ts b/src/frame/tests/server.ts index 011911e5c17a..89866ccfd162 100644 --- a/src/frame/tests/server.ts +++ b/src/frame/tests/server.ts @@ -133,10 +133,11 @@ describe('server', () => { expect(res.statusCode).toBe(404) }) - // The skip predates the native-fetch helper, which sends this malformed path unchanged. - test.skip('renders a 400 for invalid paths', async () => { - const $ = await getDOM('/en/%7B%') - expect($.res.statusCode).toBe(400) + test('renders a 400 for invalid paths', async () => { + const res = await get('/en/%7B%') + expect(res.statusCode).toBe(400) + expect(res.headers['content-type']).toMatch('text/plain') + expect(res.body).toBe('Bad Request: Malformed URL') }) test('renders a 500 page when errors are thrown', async () => { From bde6fb58f019c94a1d44fffb69132dd6da402586 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:04:08 +0000 Subject: [PATCH 21/28] Remove dead GHES release notes fallback (#63503) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- .../middleware/ghes-release-notes.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/release-notes/middleware/ghes-release-notes.ts b/src/release-notes/middleware/ghes-release-notes.ts index 6ce4b006be76..26f14747e788 100644 --- a/src/release-notes/middleware/ghes-release-notes.ts +++ b/src/release-notes/middleware/ghes-release-notes.ts @@ -2,9 +2,8 @@ import type { NextFunction, Response } from 'express' import { formatReleases, renderPatchNotes } from '@/release-notes/lib/release-notes-utils' import { all, latestStable } from '@/versions/lib/enterprise-server-releases' -import { executeWithFallback } from '@/languages/lib/render-with-fallback' import { getReleaseNotes } from './get-release-notes' -import type { Context, ExtendedRequest } from '@/types' +import type { ExtendedRequest } from '@/types' export default async function ghesReleaseNotesContext( req: ExtendedRequest, @@ -42,19 +41,7 @@ export default async function ghesReleaseNotesContext( req.context.currentLanguage = 'en' try { - req.context.ghesReleaseNotes = await executeWithFallback( - req.context, - () => renderPatchNotes(currentReleaseNotes, req.context!), - (enContext: Context) => { - // Unreachable while currentLanguage is forced to en; rebuild props if that changes. - enContext.ghesReleases = formatReleases(ghesReleaseNotes) - - const enMatchedNotes = enContext.ghesReleases!.find((r) => r.version === requestedRelease) - if (!enMatchedNotes) throw new Error('Release notes not found') - const enCurrentNotes = enMatchedNotes.patches - return renderPatchNotes(enCurrentNotes, enContext) - }, - ) + req.context.ghesReleaseNotes = await renderPatchNotes(currentReleaseNotes, req.context) } finally { req.context.currentLanguage = originalLanguage } From 2d325fc0423404485bec4914317ef00c2e3b7e0b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:04:09 +0000 Subject: [PATCH 22/28] Remove translation .git directories from the Docker image (#63491) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/deployments/production/build-scripts/fetch-repos.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/deployments/production/build-scripts/fetch-repos.sh b/src/deployments/production/build-scripts/fetch-repos.sh index 9f24050f0013..ae10318e7cde 100644 --- a/src/deployments/production/build-scripts/fetch-repos.sh +++ b/src/deployments/production/build-scripts/fetch-repos.sh @@ -44,6 +44,10 @@ else echo "βœ… All translations fetched." fi +# The Dockerfile copies translations/ into the image, and each .git/config keeps +# the token-bearing clone URL. Nothing reads translation git metadata at runtime. +rm -rf ./*/.git + # Return to the docs-internal root after cloning translations. cd .. From 55b5291223117247e6a7a3d45860d16bac0677d7 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:04:38 +0000 Subject: [PATCH 23/28] Fix pages content validation tests (#63513) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/frame/tests/pages.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/frame/tests/pages.ts b/src/frame/tests/pages.ts index 7827708918a3..1344cf945142 100644 --- a/src/frame/tests/pages.ts +++ b/src/frame/tests/pages.ts @@ -124,30 +124,11 @@ describe('pages module', () => { expect(nonMatches.length, message).toBe(0) }) - test('every page has valid frontmatter', async () => { - const frontmatterErrors = chain(pages) - // Loaded pages cannot expose frontmatterErrors because Page throws before construction. - .map((page) => (page as Record).frontmatterErrors) - .filter(Boolean) - .flatten() - .value() - - const failureMessage = `${JSON.stringify(frontmatterErrors, null, 2)}\n\n${chain( - frontmatterErrors, - ) - .map('filepath') - .join('\n') - .value()}` - - expect(frontmatterErrors.length, failureMessage).toBe(0) - }) - test('every page has valid Liquid templating', async () => { const liquidErrors: Array<{ filename: string; error: string }> = [] for (const page of pages) { - // raw is not a Page property here, so this loop does not parse page markdown. - const markdown = (page as Record).raw as string + const markdown = page.markdown if (!patterns.hasLiquid.test(markdown)) continue try { await liquid.parse(markdown) From ca47eaaac391f106aa55403cf78792e80610a316 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:04:41 +0000 Subject: [PATCH 24/28] Unskip sidebar custom link aria-current test (#63479) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/landings/tests/sidebar-custom-links.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/landings/tests/sidebar-custom-links.ts b/src/landings/tests/sidebar-custom-links.ts index d170c3e354ae..f2d631dc78a4 100644 --- a/src/landings/tests/sidebar-custom-links.ts +++ b/src/landings/tests/sidebar-custom-links.ts @@ -39,13 +39,13 @@ describe('sidebar custom links', () => { expect(customLinkIndex).toBe(0) // Custom sidebar links appear first in their subnav. }) - test.skip('sidebar custom link has correct aria attributes', async () => { + test('sidebar custom link has correct aria-current attribute', async () => { const $ = await getDOM('/get-started/sidebar-test') const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') expect(customLink.length).toBe(1) - expect(customLink.attr('href')).toBeDefined() + expect(customLink.attr('aria-current')).toBe('page') expect(customLink.text().trim()).toBe('All sidebar test items') }) From e9bd383233b46cc6c7579bfb5d61b6af7d94e0a6 Mon Sep 17 00:00:00 2001 From: v-kbukum1 Date: Tue, 29 Sep 2026 16:30:50 +0000 Subject: [PATCH 25/28] Document repository-level Dependabot runner settings (#63563) Copilot-Session: 1cc10ece-0db2-4032-bddf-ea7a183aa5b5 --- .../dependabot-on-actions.md | 18 ++++++++++++++- .../configure-global-settings.md | 6 ++--- .../configure-on-github-hosted-runners.md | 6 +++++ .../configure-on-self-hosted-runners.md | 23 +++++++++++++++---- .../dependabot-repository-runner-settings.yml | 4 ++++ 5 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 data/features/dependabot-repository-runner-settings.yml diff --git a/content/code-security/concepts/supply-chain-security/dependabot-on-actions.md b/content/code-security/concepts/supply-chain-security/dependabot-on-actions.md index 0b7093d81470..d214c6dcb11c 100644 --- a/content/code-security/concepts/supply-chain-security/dependabot-on-actions.md +++ b/content/code-security/concepts/supply-chain-security/dependabot-on-actions.md @@ -39,7 +39,7 @@ You may see workflow runs named `dynamic/dependabot/dependabot-updates` or check You can run {% data variables.product.prodname_dependabot %} on {% data variables.product.prodname_actions %} using: * **Standard {% data variables.product.prodname_dotcom %}-hosted runners.** These are the default runners used by {% data variables.product.github %} to execute {% data variables.product.prodname_actions %} jobs. * **{% data variables.actions.hosted_runners_caps %}.** These are {% data variables.product.prodname_dotcom %}-hosted runners with advanced features like more RAM, CPU, and disk space. For more information, see [AUTOTITLE](/actions/how-tos/manage-runners/larger-runners). -* **Self-hosted runners.** These runners grant you greater control over {% data variables.product.prodname_dependabot %} access to your private registries and internal network resources. Be aware that for security reasons, {% data variables.product.prodname_dependabot_updates %} on self-hosted runners will not run on public repositories. For more information on assigning a `dependabot` label on self-hosted runners, see [AUTOTITLE](/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners). +* **Self-hosted runners.** These runners grant you greater control over {% data variables.product.prodname_dependabot %} access to your private registries and internal network resources. Be aware that for security reasons, {% data variables.product.prodname_dependabot_updates %} on self-hosted runners will not run on public repositories. For more information on assigning labels to self-hosted runners, see [AUTOTITLE](/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners). Running {% data variables.product.prodname_dependabot %} on standard {% data variables.product.prodname_dotcom %}-hosted or self-hosted runners **does not** count towards your included {% data variables.product.prodname_actions %} minutes. For {% data variables.product.prodname_dependabot %} on {% data variables.actions.hosted_runners %}, {% data variables.product.prodname_dotcom %} will bill your organization at the regular rate. See [AUTOTITLE](/billing/reference/actions-runner-pricing). @@ -47,6 +47,20 @@ Running {% data variables.product.prodname_dependabot %} on standard {% data var ## How runner settings interact +{% ifversion dependabot-repository-runner-settings %} + +You can select a runner type for {% data variables.product.prodname_dependabot %} at the organization or repository level: + +* **Standard {% data variables.product.company_short %} runner** uses the default {% data variables.product.company_short %}-hosted environment. +* **Labeled runner** sends jobs to self-hosted or {% data variables.actions.hosted_runners %} that match the configured label. If you do not specify a label, {% data variables.product.prodname_dependabot %} uses the `dependabot` label. You can also specify a runner group to limit jobs to matching runners in that group. + +> [!WARNING] +> If the specified runner group does not exist, {% data variables.product.prodname_dependabot %} reports an error immediately. If the group exists but no online runner in the group matches the configured label, the job remains queued until a matching runner is available. Make sure the repository can access the specified runner group. + +Labeled runners are not available for public repositories. These repositories use standard {% data variables.product.company_short %}-hosted runners. + +{% else %} + The {% data variables.product.prodname_dependabot %} on {% data variables.product.prodname_actions %} runners and {% data variables.product.prodname_dependabot %} on self-hosted runners settings are interdependent: * Enabling "{% data variables.product.prodname_dependabot %} on self-hosted runners" automatically enables "{% data variables.product.prodname_dependabot %} on {% data variables.product.prodname_actions %} runners". Disabling "{% data variables.product.prodname_dependabot %} on {% data variables.product.prodname_actions %} runners" automatically disables "{% data variables.product.prodname_dependabot %} on self-hosted runners". @@ -55,6 +69,8 @@ The {% data variables.product.prodname_dependabot %} on {% data variables.produc > [!WARNING] > If both settings are enabled but no self-hosted runners or {% data variables.actions.hosted_runners %} with a `dependabot` label are available, {% data variables.product.prodname_dependabot %} jobs will remain queued indefinitely. Ensure runners with this label are configured before enabling "{% data variables.product.prodname_dependabot %} on self-hosted runners". +{% endif %} + ## Access and permissions If you are transitioning to using {% data variables.product.prodname_dependabot %} on {% data variables.product.prodname_actions %} runners and you restrict access to your organization's or repository's private resources, you may need to update your list of allowed IP addresses. For example, if you currently limit access to your private resources to the IP addresses that {% data variables.product.prodname_dependabot %} uses, you should update your allowlist to use the {% data variables.product.prodname_dotcom %}-hosted runners IP addresses sourced from the meta API endpoint. For more information, see [AUTOTITLE](/rest/meta). diff --git a/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/configure-global-settings.md b/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/configure-global-settings.md index dc5c95320a66..dca955a03a32 100644 --- a/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/configure-global-settings.md +++ b/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/configure-global-settings.md @@ -60,7 +60,7 @@ For more information, see [AUTOTITLE](/code-security/concepts/supply-chain-secur ### Configuring the runner type for {% data variables.product.prodname_dependabot %} -You can configure which type of runner {% data variables.product.prodname_dependabot %} uses to scan for version and security updates. By default, {% data variables.product.prodname_dependabot %} uses standard **{% data variables.product.company_short %}-hosted runners**. You can configure {% data variables.product.prodname_dependabot %} to use **self-hosted runners** with custom labels, which allows you to integrate with existing runner infrastructure such as {% data variables.product.prodname_actions_runner_controller %} (ARC). +You can configure which type of runner {% data variables.product.prodname_dependabot %} uses to scan for version and security updates. By default, {% data variables.product.prodname_dependabot %} uses standard **{% data variables.product.company_short %}-hosted runners**. You can configure {% data variables.product.prodname_dependabot %} to use **labeled runners**, which allows you to integrate with existing runner infrastructure such as {% data variables.product.prodname_actions_runner_controller %} (ARC). > [!NOTE] > * For security reasons, {% data variables.product.prodname_dependabot %} uses {% data variables.product.company_short %}-hosted runners for public repositories, even when you configure labeled runners. @@ -71,9 +71,9 @@ To configure the runner type: 1. Under "{% data variables.product.prodname_dependabot %}", next to "Runner type", select {% octicon "pencil" aria-label="Edit runner type" %}. 1. In the "Edit runner type for {% data variables.product.prodname_dependabot %}" dialog, select the runner type you want {% data variables.product.prodname_dependabot %} to use: * **Standard {% data variables.product.company_short %} runner**. - * **Labeled runner**: If you select this option, {% data variables.product.prodname_dependabot %} will use self-hosted runners that match the label you specify. + * **Labeled runner**: If you select this option, {% data variables.product.prodname_dependabot %} will use {% ifversion fpt or ghec %}self-hosted or {% data variables.actions.hosted_runners %}{% else %}self-hosted runners{% endif %} that match the label you specify. 1. If you selected **Labeled runner**: - * In "Runner label", enter the label assigned to your self-hosted runners. {% data variables.product.prodname_dependabot %} will use runners with this label. By default, the `dependabot` label is used, but you can specify a custom label to match your existing runner infrastructure. + * In "Runner label", enter the label assigned to your runners. {% data variables.product.prodname_dependabot %} will use runners with this label. By default, the `dependabot` label is used, but you can specify a custom label to match your existing runner infrastructure. * Optionally, in "Runner group name", enter the name of a runner group if you want to target a specific group of runners. 1. Click **Save runner selection**. diff --git a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-github-hosted-runners.md b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-github-hosted-runners.md index 232ff6837c44..b0b0606b9174 100644 --- a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-github-hosted-runners.md +++ b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-github-hosted-runners.md @@ -25,7 +25,13 @@ If you restrict access to your organization's or repository's private resources, {% data reusables.repositories.navigate-to-repo %} {% data reusables.repositories.sidebar-settings %} {% data reusables.repositories.navigate-to-code-security-and-analysis %} +{% ifversion dependabot-repository-runner-settings %} +1. Under "Dependency scanning", in the "{% data variables.product.prodname_dependabot %} version updates" section, next to "Runner type", click {% octicon "pencil" aria-label="Edit runner type" %}. +1. From the "Runner type" dropdown menu, select **Standard {% data variables.product.github %} runner**. +1. Click **Save runner selection**. +{% else %} 1. Under "Dependabot", to the right of "{% data variables.product.prodname_dependabot %} on Actions runners", click **Enable** to enable the feature or **Disable** to disable it. +{% endif %} {% data reusables.dependabot.no-ubuntu-latest-label-self-hosted %} diff --git a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners.md b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners.md index d54ef609fbee..9f8cdde9b297 100644 --- a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners.md +++ b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/configure-on-self-hosted-runners.md @@ -24,29 +24,44 @@ category: ## Adding self-hosted runners for {% data variables.product.prodname_dependabot %} updates 1. Provision self-hosted runners, at the repository or organization level. For more information, see [AUTOTITLE](/actions/concepts/runners/self-hosted-runners) and [AUTOTITLE](/actions/how-tos/manage-runners/self-hosted-runners/add-runners). -1. Configure your environment and runners to meet the requirements for {% data variables.product.prodname_dependabot %}. See [Requirements for using {% data variables.product.prodname_dependabot %} with self-hosted runners](/code-security/reference/supply-chain-security/dependabot-on-actions#requirements-for-using-dependabot-with-self-hosted-runners).{% ifversion dependabot-self-hosted-labels %} +1. Configure your environment and runners to meet the requirements for {% data variables.product.prodname_dependabot %}. See [Requirements for using {% data variables.product.prodname_dependabot %} with self-hosted runners](/code-security/reference/supply-chain-security/dependabot-on-actions#requirements-for-using-dependabot-with-self-hosted-runners).{% ifversion dependabot-repository-runner-settings %} +1. Assign the default `dependabot` label or a custom label to each runner you want {% data variables.product.prodname_dependabot %} to use. See [AUTOTITLE](/actions/how-tos/manage-runners/self-hosted-runners/apply-labels).{% elsif dependabot-self-hosted-labels %} 1. If you are configuring self-hosted runners for your organization, you can create and assign a custom label for your runners. Otherwise, if you are configuring self-hosted runners for a standalone repository, you need to apply the `dependabot` label. See [AUTOTITLE](/actions/how-tos/manage-runners/self-hosted-runners/apply-labels).{% else %} 1. Assign a `dependabot` label to each runner you want {% data variables.product.prodname_dependabot %} to use. For more information, see [AUTOTITLE](/actions/how-tos/manage-runners/self-hosted-runners/apply-labels#assigning-a-label-to-a-self-hosted-runner).{% endif %} 1. Optionally, enable workflows triggered by {% data variables.product.prodname_dependabot %} to use more than read-only permissions and to have access to any secrets that are normally available. For more information, see [AUTOTITLE](/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions). -## Enabling self-hosted runners for {% data variables.product.prodname_dependabot_updates %} +## Configuring self-hosted runners for {% data variables.product.prodname_dependabot_updates %} +{% ifversion dependabot-repository-runner-settings %} +> [!WARNING] +> Before selecting **Labeled runner**, make sure a runner has the label you plan to use. If you specify a runner group, make sure the group exists and the repository can access it. See [AUTOTITLE](/code-security/concepts/supply-chain-security/dependabot-on-actions#how-runner-settings-interact). + +Once you have configured self-hosted runners for {% data variables.product.prodname_dependabot_updates %}, you can select them at the organization or repository level. +{% else %} > [!WARNING] > Before enabling "{% data variables.product.prodname_dependabot %} on self-hosted runners", ensure that your self-hosted runners or {% data variables.actions.hosted_runners %} are configured with the runner label used by {% data variables.product.prodname_dependabot %} (by default, `dependabot`). When this setting is enabled, {% data variables.product.prodname_dependabot %} jobs will only run on runners with this label. If no runners with this label are available, jobs will remain queued indefinitely. See [AUTOTITLE](/code-security/concepts/supply-chain-security/dependabot-on-actions#how-runner-settings-interact). Once you have configured self-hosted runners for {% data variables.product.prodname_dependabot_updates %}, you can enable or disable {% data variables.product.prodname_dependabot_updates %} on self-hosted runners at the organization or repository level. +{% endif %} > [!NOTE] -> Disabling and re-enabling the "{% data variables.product.prodname_dependabot %} on self-hosted runners" setting does not trigger a new {% data variables.product.prodname_dependabot %} run. +> Changing the runner setting does not trigger a new {% data variables.product.prodname_dependabot %} run. ### For your private{% ifversion ghec %} or internal{% endif %} repository {% data reusables.repositories.navigate-to-repo %} {% data reusables.repositories.sidebar-settings %} {% data reusables.repositories.navigate-to-code-security-and-analysis %} +{% ifversion dependabot-repository-runner-settings %} +1. Under "Dependency scanning", in the "{% data variables.product.prodname_dependabot %} version updates" section, next to "Runner type", click {% octicon "pencil" aria-label="Edit runner type" %}. +1. From the "Runner type" dropdown menu, select **Labeled runner**. +1. Optionally, enter a runner group name and a custom runner label. If you do not enter a label, {% data variables.product.prodname_dependabot %} uses the `dependabot` label. +1. Click **Save runner selection**. +{% else %} 1. Under "Dependabot", to the right of "{% data variables.product.prodname_dependabot %} on self-hosted runners", click **Enable** to enable the feature or **Disable** to disable it. +{% endif %} - > [!NOTE] If you do not see the option to enable {% data variables.product.prodname_dependabot %} on self-hosted runners, your organization may have configured a policy to restrict actions and self-hosted runners from running in specific repositories. Contact your organization owner for more information. + > [!NOTE] If you cannot change the runner setting, your organization may restrict actions and self-hosted runners for the repository. Contact your organization owner for more information. ### For your organization diff --git a/data/features/dependabot-repository-runner-settings.yml b/data/features/dependabot-repository-runner-settings.yml new file mode 100644 index 000000000000..9d03840e444f --- /dev/null +++ b/data/features/dependabot-repository-runner-settings.yml @@ -0,0 +1,4 @@ +# Repository-level custom runner settings for Dependabot +versions: + fpt: '*' + ghec: '*' From 6cc6985ceb4c1baa5d98b9c32f775d1f7e446f0c Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:33:23 +0000 Subject: [PATCH 26/28] Tighten code comments in src/rest/scripts (#63456) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: 38d28cc1-1877-4d86-9dd3-888adb11de0d Copilot-Session: 03e47b55-eabf-42a3-86cc-8d79e869cb1f --- src/rest/scripts/openapi-check.ts | 6 +-- src/rest/scripts/test-open-api-schema.ts | 14 ++--- src/rest/scripts/update-files.ts | 42 ++++----------- src/rest/scripts/utils/get-openapi-schemas.ts | 14 ++--- src/rest/scripts/utils/get-operations.ts | 2 - src/rest/scripts/utils/get-redirects.ts | 4 +- src/rest/scripts/utils/merge-all-of.ts | 53 ++++++------------- src/rest/scripts/utils/normalize-docs-urls.ts | 5 +- src/rest/scripts/utils/openapi-types.ts | 12 ++--- src/rest/scripts/utils/operation-schema.ts | 3 -- src/rest/scripts/utils/operation.ts | 33 ++++-------- src/rest/scripts/utils/render-content.ts | 3 +- src/rest/scripts/utils/sync-changelogs.ts | 45 +++++----------- src/rest/scripts/utils/sync.ts | 51 +++++------------- src/rest/scripts/utils/update-markdown.ts | 46 +++++----------- 15 files changed, 96 insertions(+), 237 deletions(-) diff --git a/src/rest/scripts/openapi-check.ts b/src/rest/scripts/openapi-check.ts index 61a12aa24e6b..ae83b102de5f 100755 --- a/src/rest/scripts/openapi-check.ts +++ b/src/rest/scripts/openapi-check.ts @@ -1,8 +1,4 @@ -// [start-readme] -// -// Run this script to check if OpenAPI files can be decorated successfully. -// -// [end-readme] +// Verifies that OpenAPI files can be decorated successfully. import fs from 'fs' import path from 'path' diff --git a/src/rest/scripts/test-open-api-schema.ts b/src/rest/scripts/test-open-api-schema.ts index e1e586755a75..98aaeb225111 100755 --- a/src/rest/scripts/test-open-api-schema.ts +++ b/src/rest/scripts/test-open-api-schema.ts @@ -1,8 +1,4 @@ -// [start-readme] -// -// Run this script to check if OpenAPI operations match versions in content/rest operations -// -// [end-readme] +// Checks whether OpenAPI operations match content/rest version frontmatter. import fs from 'fs' import path from 'path' import { isEqual } from 'lodash-es' @@ -27,8 +23,6 @@ export async function getDiffOpenAPIContentRest(): Promise { const openAPISchemaCheck = await createOpenAPISchemasCheck() - // Compare the categories and subcategories in the dereferenced schemas - // against the versions in the content/rest frontmatter. const differences = getDifferences(openAPISchemaCheck, checkContentDir) const errorMessages: ErrorMessages = {} @@ -53,7 +47,7 @@ async function createOpenAPISchemasCheck(): Promise { const restDirectory = fs .readdirSync(REST_DATA_DIR) .filter((dir) => !dir.endsWith('.json')) - // Allow the most recent deprecation to exist on disk until fully deprecated + // Skip the most recently deprecated GitHub Enterprise Server data, which stays on disk until deprecation finishes. .filter((dir) => !dir.includes(deprecated[0])) for (const dir of restDirectory) { @@ -65,7 +59,7 @@ async function createOpenAPISchemasCheck(): Promise { for (const categoryFile of categoryFiles) { const category = categoryFile.replace('.json', '') - const categoryData = JSON.parse(fs.readFileSync(path.join(dirPath, categoryFile), 'utf8')) // categoryData is { [subcategory]: Operation[] } + const categoryData = JSON.parse(fs.readFileSync(path.join(dirPath, categoryFile), 'utf8')) const subcategories = Object.keys(categoryData) as string[] if (isApiVersioned(version)) { @@ -91,7 +85,7 @@ async function createCheckContentDirectory(contentFiles: string[]): Promise { return isApiVersioned(version) ? allVersions[version].apiVersions.map( diff --git a/src/rest/scripts/update-files.ts b/src/rest/scripts/update-files.ts index 5b4256945ec2..054da36d4362 100755 --- a/src/rest/scripts/update-files.ts +++ b/src/rest/scripts/update-files.ts @@ -1,9 +1,4 @@ -// [start-readme] -// -// Run this script to generate the updated data files for the rest, -// github-apps, and webhooks automated pipelines. -// -// [end-readme] +// Generates data files for the REST, GitHub Apps, and webhooks automated pipelines. import { mkdir, rm, readdir, copyFile, readFile, writeFile, rename } from 'fs/promises' import path from 'path' @@ -77,8 +72,7 @@ async function main() { await rm(TEMP_OPENAPI_DIR, { recursive: true, force: true }) await mkdir(TEMP_OPENAPI_DIR, { recursive: true }) - // If the source repo is github, this is the local development workflow - // and the files in github must be bundled and dereferenced first. + // Bundle and dereference github/github schemas for the local development workflow. if (sourceRepos.includes('github')) { await getBundledFiles() } @@ -86,10 +80,6 @@ async function main() { ? GITHUB_REP_DIR : REST_API_DESCRIPTION_ROOT - // When we get the dereferenced OpenAPI files from the open-source - // rest description repo (REST_API_DESCRIPTION_ROOT), we need to - // remove any versions that are deprecated because that repo contains - // all past versions. const sourceDirectory = sourceRepos.includes('github') ? TEMP_BUNDLED_OPENAPI_DIR : REST_DESCRIPTION_DIR @@ -107,15 +97,12 @@ async function main() { await rm(TEMP_BUNDLED_OPENAPI_DIR, { recursive: true, force: true }) await normalizeDataVersionNames(TEMP_OPENAPI_DIR) - // The REST_API_DESCRIPTION_ROOT repo contains all current and - // deprecated versions. We need to remove the deprecated versions - // so that we don't spend time generating data files for them. + // rest-api-description includes deprecated versions, so remove them before generating data files. if (sourceRepos.includes(REST_API_DESCRIPTION_ROOT)) { const derefDir = await readdir(TEMP_OPENAPI_DIR) const currentOpenApiVersions = Object.values(allVersions).map((elem) => elem.openApiVersionName) for (const schema of derefDir) { - // if the schema does not start with a current version name, delete it if (!currentOpenApiVersions.find((version) => schema.startsWith(version))) { await rm(path.join(TEMP_OPENAPI_DIR, schema), { recursive: true, force: true }) } @@ -146,8 +133,7 @@ async function main() { await syncRestRedirects() } - // If the source repo is REST_API_DESCRIPTION_ROOT, we want to update - // the pipeline config files with the SHA of the synced commit. + // When syncing from rest-api-description, store the synced commit SHA in each pipeline config. if (sourceRepos.includes(REST_API_DESCRIPTION_ROOT)) { const syncedSha = execSync('git rev-parse HEAD', { cwd: REST_API_DESCRIPTION_ROOT, @@ -172,13 +158,11 @@ async function main() { } async function getBundledFiles(): Promise { - // Get the github/github repo branch name and pull latest const githubBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: GITHUB_REP_DIR }) .toString() .trim() - // Only pull master branch because development mode branches are assumed - // to be up-to-date during active work. + // Pull only master; development branches are assumed current during active work. if (githubBranch === 'master') { execSync('git pull', { cwd: GITHUB_REP_DIR }) } @@ -189,7 +173,6 @@ async function getBundledFiles(): Promise { console.log( `\nπŸƒβ€β™€οΈπŸƒπŸƒβ€β™€οΈRunning \`bin/openapi bundle\` in branch '${githubBranch}' of your github/github checkout to generate the dereferenced OpenAPI schema files.\n`, ) - // Build the command for the bundle script in `github/github`. const bundlerOptions = await getBundlerOptions() const bundleCommand = `bundle -v -w${ next ? ' -n' : '' @@ -222,15 +205,13 @@ async function getBundlerOptions(): Promise { } async function validateInputParameters(): Promise { - // The `--versions` option cannot be used - // with the `--include-deprecated` option + // The bundler cannot combine --versions with --include-deprecated. if (includeDeprecated && versions) { const errorMsg = `πŸ›‘ You cannot use the versions option with the include-deprecated option. This is not currently supported in the bundler.\nPlease reach out to #technical-content if a new use case should be supported.` throw new Error(errorMsg) } - // The `--decorate-only` option cannot be used - // with the `--include-deprecated` or `--include-unpublished` options + // --include-deprecated and --include-unpublished need the github/github bundler. if ((includeDeprecated || includeUnpublished) && !sourceRepos.includes('github')) { const errorMsg = `πŸ›‘ You cannot use the decorate-only option with include-unpublished or include-deprecated because the include-unpublished and include-deprecated options are only available when running the bundler. The decorate-only option skips running the bundler.\nPlease reach out to #technical-content if a new use case should be supported.` throw new Error(errorMsg) @@ -251,9 +232,8 @@ async function validateInputParameters(): Promise { } } -// Version names in the incoming data vary by the team that owns it. This -// renames the files using the versionMapping in src/rest/lib/config.json, and -// rewrites a calendar date suffix from .2022-11-28 to -2022-11-28. +// Version names vary by owning team. normalizeDataVersionNames reads versionMapping +// in src/rest/lib/config.json and rewrites .YYYY-MM-DD to -YYYY-MM-DD. export async function normalizeDataVersionNames(sourceDirectory: string): Promise { const schemas = await readdir(sourceDirectory) @@ -262,13 +242,11 @@ export async function normalizeDataVersionNames(sourceDirectory: string): Promis const matchingSourceVersion = Object.keys(VERSION_NAMES).find((version) => baseName.startsWith(version), ) - // Update the version name to use docs convention, e.g., - // api.github.com.2022-11-28 -> fpt.2022-11-28 + // Convert api.github.com.YYYY-MM-DD to fpt.YYYY-MM-DD. const docsBaseName = baseName.replace( matchingSourceVersion!, VERSION_NAMES[matchingSourceVersion!], ) - // Match a calendar version if it exists, e.g., .2022-11-28 const regex = /.\d{4}-\d{2}-\d{2}/ const matches = baseName.match(regex) const versionName = matches ? docsBaseName.replace(matches[0], '') : docsBaseName diff --git a/src/rest/scripts/utils/get-openapi-schemas.ts b/src/rest/scripts/utils/get-openapi-schemas.ts index 39cc53fe2e4c..07ff6570b901 100644 --- a/src/rest/scripts/utils/get-openapi-schemas.ts +++ b/src/rest/scripts/utils/get-openapi-schemas.ts @@ -8,10 +8,8 @@ const OPEN_API_RELEASES_DIR = '../github/app/api/description/config/releases' const configData: { versionMapping: Record } = JSON.parse( await readFile('src/rest/lib/config.json', 'utf8'), ) -// Reads the release YAML files in `directory`, which points at -// app/api/description/config/releases in github/github, and returns the -// generated schema filenames split into currentReleases, unpublished and -// deprecated. +// Release YAML files in github/github map to generated schema filenames grouped +// as current, unpublished, and deprecated. export async function getSchemas( directory: string = OPEN_API_RELEASES_DIR, ): Promise<{ currentReleases: string[]; unpublished: string[]; deprecated: string[] }> { @@ -20,8 +18,7 @@ export async function getSchemas( const deprecated: string[] = [] const currentReleases: string[] = [] - // The file content in the `github/github` repo is YAML before it is - // bundled into JSON. + // github/github stores release configs as YAML; bundled files are JSON. for (const file of openAPIConfigs) { const fileBaseName = path.basename(file, '.yaml') const newFileName = `${fileBaseName}.deref.json` @@ -47,10 +44,7 @@ export async function getSchemas( if (!yamlContent.published) { unpublished.push(newFileName) } - // If it's deprecated, it must have been published at some point in the past - // This checks if the schema is deprecated in github/github and - // github/docs-internal. Sometimes deprecating in github/github lags - // behind deprecating in github/docs-internal a few days + // Either repo can deprecate a published schema; github/github sometimes lags docs-internal. if ( (yamlContent.deprecated && yamlContent.published) || (isDeprecatedInDocs && yamlContent.published) diff --git a/src/rest/scripts/utils/get-operations.ts b/src/rest/scripts/utils/get-operations.ts index 3449e72d29d1..f24933ad4106 100644 --- a/src/rest/scripts/utils/get-operations.ts +++ b/src/rest/scripts/utils/get-operations.ts @@ -7,8 +7,6 @@ interface ProgAccessData { export type SchemaInput = OpenApiSchema -// Runs `process` on every operation with the programmatic access data, then -// returns the same array. export async function processOperations( operations: Operation[], progAccessData: ProgAccessData, diff --git a/src/rest/scripts/utils/get-redirects.ts b/src/rest/scripts/utils/get-redirects.ts index b70039aa4591..af85c21e5b5c 100644 --- a/src/rest/scripts/utils/get-redirects.ts +++ b/src/rest/scripts/utils/get-redirects.ts @@ -18,7 +18,7 @@ interface RedirectMap { [oldUrl: string]: string } -// Adds redirects from one URL fragment to another, applied in the browser. +// Client-side redirects preserve legacy REST URL fragments in the browser. export async function syncRestRedirects(): Promise { const clientSideRedirects = await getClientSideRedirects() @@ -26,8 +26,6 @@ export async function syncRestRedirects(): Promise { console.log(`βœ… Wrote ${STATIC_REDIRECTS}`) } -// Reads in src/rest/lib/rest-api-overrides.json and generates the -// redirect file src/rest/data/client-side-rest-api-redirects.json async function getClientSideRedirects(): Promise { const { operationUrls, sectionUrls }: RestApiOverrides = JSON.parse( await readFile(REST_API_OVERRIDES, 'utf8'), diff --git a/src/rest/scripts/utils/merge-all-of.ts b/src/rest/scripts/utils/merge-all-of.ts index 0c3133fddf2e..4813fbd62400 100644 --- a/src/rest/scripts/utils/merge-all-of.ts +++ b/src/rest/scripts/utils/merge-all-of.ts @@ -1,6 +1,5 @@ type Schema = Record -// Keywords whose value is a map of name to schema. const SCHEMA_MAP_KEYWORDS = new Set([ 'properties', 'patternProperties', @@ -9,7 +8,6 @@ const SCHEMA_MAP_KEYWORDS = new Set([ 'dependentSchemas', ]) -// Keywords whose value is a single schema. const SINGLE_SCHEMA_KEYWORDS = new Set([ 'additionalProperties', 'additionalItems', @@ -23,12 +21,9 @@ const SINGLE_SCHEMA_KEYWORDS = new Set([ 'else', ]) -// Keywords whose value is an array of schemas. const SCHEMA_ARRAY_KEYWORDS = new Set(['anyOf', 'oneOf', 'prefixItems']) -// Keywords that only describe a schema. When two `allOf` members disagree on -// one of these, the first definition wins instead of being treated as a -// conflict, because the choice cannot make the rendered docs wrong. +// Annotation keywords do not constrain instances, so the first allOf definition wins. const ANNOTATION_KEYWORDS = new Set([ 'title', 'description', @@ -45,8 +40,8 @@ function isSchemaObject(value: unknown): value is Schema { return typeof value === 'object' && value !== null && !Array.isArray(value) } -// Plain assignment would treat a key like `__proto__` as the prototype rather -// than a property, so keys that come from the schema are defined explicitly. +// Define schema keys explicitly because plain assignment treats __proto__ as the +// prototype instead of a property. function setOwn(target: Schema, key: string, value: unknown): void { Object.defineProperty(target, key, { value, @@ -72,18 +67,13 @@ function isDeepEqual(a: unknown, b: unknown): boolean { return false } -/** - * Combines `source` into `target`, treating the two as an intersection of - * constraints. Keywords already on `target` win, so the schema that owns the - * `allOf` takes precedence over its members and earlier members take - * precedence over later ones. - * - * Only the cases the GitHub OpenAPI descriptions actually use are merged: - * identical values, `properties`, `required`, `type`, and annotations. - * Anything else throws rather than guessing, so a future description that - * needs real conflict resolution fails the build loudly instead of quietly - * publishing the wrong request body parameters. - */ +// mergeInto treats source and target as an intersection of constraints. Existing +// target keywords win, so the schema that owns allOf takes precedence over its +// members and earlier members beat later ones. +// Only the GitHub OpenAPI cases are merged: identical values, properties, +// required, type, and annotations. +// Unexpected conflicts throw so future descriptions do not publish wrong request +// body parameters. function mergeInto(target: Schema, source: Schema, path: string): void { for (const [key, value] of Object.entries(source)) { if (!Object.hasOwn(target, key)) { @@ -117,11 +107,7 @@ function mergeInto(target: Schema, source: Schema, path: string): void { continue } - // `type` may be a single type name or an array of allowed type names, and - // different `allOf` members can spell the same constraint differently - // (e.g. `"object"` vs `["object", "null"]`, or the same array in a - // different order). Per JSON Schema, `allOf` members combine as an - // intersection, so the merged type is whichever names both sides allow. + // allOf intersects its members, so keep only the type names both sides allow. if (key === 'type') { const existingTypes = Array.isArray(existing) ? existing : [existing] const valueTypes = Array.isArray(value) ? value : [value] @@ -155,7 +141,7 @@ function resolveKeyword(key: string, value: unknown, path: string): unknown { return value.map((item, index) => resolveSchema(item, `${path}/${index}`)) } - // `items` is a single schema in current drafts and an array in draft-04. + // JSON Schema items is a single schema in current drafts and an array in draft-04. if (key === 'items') { if (Array.isArray(value)) { return value.map((item, index) => resolveSchema(item, `${path}/${index}`)) @@ -165,9 +151,7 @@ function resolveKeyword(key: string, value: unknown, path: string): unknown { if (SINGLE_SCHEMA_KEYWORDS.has(key)) return resolveSchema(value, path) - // Anything else holds instance data rather than a schema, such as `enum`, - // `const`, or `default`. It is copied through untouched so that a value or a - // property that happens to be named `allOf` survives. + // Instance data such as enum, const, and default passes through; a property named allOf survives. return value } @@ -197,13 +181,10 @@ function resolveSchema(schema: unknown, path: string): unknown { return resolved } -/** - * Flattens every `allOf` in a JSON schema so that consumers only have to walk - * `properties`. Replaces the unmaintained `json-schema-merge-allof` package. - * - * The returned schema is a deep copy, so callers are free to mutate it without - * touching the OpenAPI operation it came from. - */ +// mergeAllOf flattens JSON Schema allOf so consumers only walk properties. +// It replaces the unmaintained json-schema-merge-allof package. +// The returned schema is a deep copy so callers can mutate it without changing +// the source operation. export function mergeAllOf(schema: unknown): unknown { return resolveSchema(structuredClone(schema), '#') } diff --git a/src/rest/scripts/utils/normalize-docs-urls.ts b/src/rest/scripts/utils/normalize-docs-urls.ts index 0274ab661ca4..621b52eaa72c 100644 --- a/src/rest/scripts/utils/normalize-docs-urls.ts +++ b/src/rest/scripts/utils/normalize-docs-urls.ts @@ -1,6 +1,5 @@ -// Normalizes the double slashes in GHEC docs URLs. The upstream OpenAPI spec in -// github/github contains URLs like "enterprise-cloud@latest//rest/...". The -// extra slash is harmless in browsers but trips CCR lint errors. +// github/github emits GitHub Enterprise Cloud docs URLs like "enterprise-cloud@latest//rest/...". +// Browsers allow the double slash, but published docs should use clean URLs. const DOUBLE_SLASH_RE = /(docs\.github\.com\/[^/]+@[^/]+)\/\//g export function normalizeDocsUrls(html: string): string { diff --git a/src/rest/scripts/utils/openapi-types.ts b/src/rest/scripts/utils/openapi-types.ts index 128d9d3509c7..eae87967497a 100644 --- a/src/rest/scripts/utils/openapi-types.ts +++ b/src/rest/scripts/utils/openapi-types.ts @@ -1,10 +1,6 @@ -// Loose-but-typed OpenAPI shapes shared across the REST sync pipeline -// (get-operations, operation, create-rest-examples, sync). -// -// The upstream OpenAPI descriptions are dynamic and vary by endpoint, so each -// interface keeps an index signature escape hatch (`[key: string]: unknown`) -// for properties we don't model explicitly. This replaces the `any` types these -// modules previously used while still describing the fields the code reads. +// OpenAPI descriptions vary by endpoint, so these shared REST sync types keep +// index signatures for unmodeled fields while declaring the fields this pipeline +// reads. export interface OpenApiMediaType { example?: unknown @@ -66,7 +62,7 @@ export interface OpenApiOperation { responses: Record previews?: unknown[] 'x-github': OpenApiGitHubExtension - // Attached during processing by the Operation class + // Operation adds these fields during processing. serverUrl?: string requestPath?: string verb?: string diff --git a/src/rest/scripts/utils/operation-schema.ts b/src/rest/scripts/utils/operation-schema.ts index 2a5ae57170d9..5d9eb153e459 100644 --- a/src/rest/scripts/utils/operation-schema.ts +++ b/src/rest/scripts/utils/operation-schema.ts @@ -1,5 +1,3 @@ -// This schema is used to validate each generated operation object at build time - export default { type: 'object', required: [ @@ -12,7 +10,6 @@ export default { 'codeExamples', ], properties: { - // Properties from the source OpenAPI schema that this module depends on title: { description: 'The title of the operation', type: 'string', diff --git a/src/rest/scripts/utils/operation.ts b/src/rest/scripts/utils/operation.ts index d3a0595bbed4..0b56297e87d7 100644 --- a/src/rest/scripts/utils/operation.ts +++ b/src/rest/scripts/utils/operation.ts @@ -21,7 +21,6 @@ export default class Operation { category: string subcategory: string parameters: OpenApiParameter[] - // Body parameters are dynamically generated from OpenAPI schema bodyParameters: TransformedParam[] descriptionHTML?: string codeExamples?: MergedExample[] @@ -29,6 +28,9 @@ export default class Operation { previews?: string[] progAccess?: Record + // The constructor clones parameters so renderParameterDescriptions can delete + // deprecated, example, and examples without mutating this.#operation.parameters, + // which renderCodeExamples reads through getParameterExamples. constructor( verb: string, requestPath: string, @@ -36,9 +38,7 @@ export default class Operation { globalServers?: OpenApiServer[], ) { this.#operation = operation - // The global server object sets metadata including the base url for - // all operations in a version. Individual operations can override - // the global server url at the operation level. + // Operation-level servers override global version servers. this.serverUrl = ( operation.servers ? operation.servers[0].url : globalServers?.[0]?.url ) as string @@ -56,8 +56,6 @@ export default class Operation { this.serverUrl = this.serverUrl.replace('http:', 'http(s):') - // Attach some global properties to the operation object to use - // during processing this.#operation.serverUrl = this.serverUrl this.#operation.requestPath = requestPath this.#operation.verb = verb @@ -67,10 +65,6 @@ export default class Operation { this.title = operation.summary as string this.category = operation['x-github'].category this.subcategory = operation['x-github'].subcategory - // Shallow-clone each parameter so that renderParameterDescriptions() can - // safely delete fields (e.g. deprecated, example, examples) without - // mutating this.#operation.parameters, which renderCodeExamples() reads - // concurrently via getParameterExamples(). this.parameters = (operation.parameters || []).map((p) => ({ ...p })) this.bodyParameters = [] return this @@ -133,9 +127,7 @@ export default class Operation { const response = responses[responseCode] const httpStatusCode = responseCode const httpStatusMessage = STATUS_CODES[Number(responseCode)] || 'Unknown' - // The OpenAPI should be updated to provide better descriptions, but - // until then, we can catch some known generic descriptions and replace - // them with the default http status message. + // Use default HTTP messages when OpenAPI omits a description or sets it to "response". const responseDescription = !response.description || response.description?.toLowerCase() === 'response' ? await renderContent(httpStatusMessage) @@ -158,13 +150,12 @@ export default class Operation { return Promise.all( this.parameters.map(async (param) => { param.description = await renderContent(param.description ?? '') - // Remove fields that are not used at runtime to keep schema.json lean + // Drop fields the runtime does not read to keep schema.json lean. delete param.deprecated delete param.example delete param.examples delete param['x-multi-segment'] - // Strip unused parameter schema sub-fields; only type, default, and - // enum are consumed by renderers + // Keep only parameter schema subfields that renderers consume: type, default, and enum. if (param.schema && typeof param.schema === 'object') { const { type, default: defaultVal, enum: enumVal } = param.schema param.schema = { type } @@ -180,12 +171,12 @@ export default class Operation { } } + // renderBodyParameterDescriptions uses the first content type because + // markdown/render-raw is the only operation with multiple content types, and + // its request body parameter types match. async renderBodyParameterDescriptions(): Promise { if (!this.#operation.requestBody) return - // There is currently only one operation with more than one content type - // and the request body parameter types are the same for both. - // Operation Id: markdown/render-raw const contentType = Object.keys(this.#operation.requestBody.content)[0] const schema = get(this.#operation, `requestBody.content.${contentType}.schema`, {}) const mergedAllofSchema = mergeAllOf(schema) @@ -207,14 +198,12 @@ export default class Operation { this.previews = await Promise.all( previews.map(async (preview) => { const note = preview.note - // remove extra leading and trailing newlines .replace(/```\n\n\n/gm, '```\n') .replace(/```\n\n/gm, '```\n') .replace(/\n\n\n```/gm, '\n```') .replace(/\n\n```/gm, '\n```') - // convert single-backtick code snippets to fully fenced triple-backtick blocks - // example: This is the description.\n\n`application/vnd.github.machine-man-preview+json` + // Fence preview MIME snippets such as application/vnd.github.machine-man-preview+json. .replace(/\n`application/, '\n```\napplication') .replace(/json`$/, 'json\n```') return await renderContent(note) diff --git a/src/rest/scripts/utils/render-content.ts b/src/rest/scripts/utils/render-content.ts index 0fa74d19aab6..4dcb474ddeb0 100644 --- a/src/rest/scripts/utils/render-content.ts +++ b/src/rest/scripts/utils/render-content.ts @@ -2,8 +2,7 @@ import { renderContent as _renderContent } from '@/content-render/index' import { getAlertTitles } from '@/languages/lib/get-alert-titles' import { normalizeDocsUrls } from './normalize-docs-urls' -// Wrap the renderContent function and provide the alertTitles -// so they aren't blank +// Provide English alert titles because renderContent leaves alert boxes blank without them. export async function renderContent(template: string) { const context = { alertTitles: await getAlertTitles({ languageCode: 'en' }), diff --git a/src/rest/scripts/utils/sync-changelogs.ts b/src/rest/scripts/utils/sync-changelogs.ts index c07b17f809da..c92372cb2417 100644 --- a/src/rest/scripts/utils/sync-changelogs.ts +++ b/src/rest/scripts/utils/sync-changelogs.ts @@ -17,10 +17,8 @@ interface VersionSection { content: string } -// The initial REST API version (2022-11-28) predates the changelog system -// in rest-api-description, so it has no CHANGELOG.md entry. We hardcode -// the "no breaking changes" description here so it always appears in the -// generated output. Keyed by ifversion expression. +// The initial REST API version predates rest-api-description changelogs, so +// this fallback keeps the generated output complete. Keyed by ifversion expression. const INITIAL_VERSION = '2022-11-28' const INITIAL_VERSION_SECTIONS: Record = { fpt: { @@ -33,14 +31,10 @@ const INITIAL_VERSION_SECTIONS: Record = { }, } -// Build a list of { sourceDir, ifversionExpr } tuples from allVersions. -// For example: -// fpt β†’ source dir "api.github.com", ifversion "fpt" -// ghec β†’ source dir "ghec", ifversion "ghec" -// ghes-3.14 β†’ source dir "ghes-3.14", ifversion "ghes = 3.14" +// buildVersionMappings maps docs short names to rest-api-description directories +// and Liquid ifversion expressions. Examples: fpt -> api.github.com and fpt; +// ghec -> ghec and ghec; ghes- -> ghes- and ghes = . function buildVersionMappings(versionNames: Record): 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 @@ -58,11 +52,10 @@ function buildVersionMappings(versionNames: Record): VersionMapp let ifversionExpr: string if (versionObj.shortName === 'ghes') { - // GHES versions: source dir is like "ghes-3.14", ifversion is "ghes = 3.14" + // GitHub Enterprise Server source directories include the release number. 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 } @@ -73,13 +66,10 @@ function buildVersionMappings(versionNames: Record): VersionMapp 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', @@ -91,9 +81,8 @@ export function getChangelogPath(sourceRepoDir: string, releaseDir: string): str ) } -// 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. +// parseVersionSections drops the title and intro, then splits at headings for +// versions with YYYY-MM-DD dates. export function parseVersionSections(markdown: string): VersionSection[] { const lines = markdown.split('\n') const sections: VersionSection[] = [] @@ -102,13 +91,11 @@ export function parseVersionSections(markdown: string): VersionSection[] { 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) { if (currentVersion) { @@ -138,9 +125,11 @@ export function parseVersionSections(markdown: string): VersionSection[] { 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. +// syncChangelogs disables liquid-quoted-conditional-arg because generated Liquid +// compares quoted date strings such as "YYYY-MM-DD" <= query.apiVersion, which +// Liquid accepts. +// It also disables search-replace and GHD046 because upstream changelogs can +// contain docs.github.com URLs and "deprecated" terms. export async function syncChangelogs( sourceRepoDir: string, versionNames: Record, @@ -164,8 +153,7 @@ export async function syncChangelogs( sections = parseVersionSections(markdown) } - // Inject the hardcoded initial version section if the changelog - // doesn't already include it and we have one for this product. + // Inject the hardcoded initial section when the source changelog lacks it for this product. const hasInitialVersion = sections.some((s) => s.version === INITIAL_VERSION) if (!hasInitialVersion && ifversionExpr in INITIAL_VERSION_SECTIONS) { sections.push(INITIAL_VERSION_SECTIONS[ifversionExpr]) @@ -200,11 +188,6 @@ export async function syncChangelogs( 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` diff --git a/src/rest/scripts/utils/sync.ts b/src/rest/scripts/utils/sync.ts index 9e8ceb073207..f64994c202b3 100644 --- a/src/rest/scripts/utils/sync.ts +++ b/src/rest/scripts/utils/sync.ts @@ -12,9 +12,6 @@ import type Operation from './operation' type OperationsByCategory = Record> -// All of the schema releases that we store in allVersions -// Ex: 'api.github.com', 'ghec', 'ghes-3.6', 'ghes-3.5', -// 'ghes-3.4', 'ghes-3.3', 'ghes-3.2', 'github.ae' const OPENAPI_VERSION_NAMES = Object.keys(allVersions).map( (elem) => allVersions[elem].openApiVersionName, ) @@ -29,8 +26,7 @@ export async function syncRestData( ) => OpenApiSchema | Promise, ): Promise { const writeTasks: Promise[] = [] - // Track which category files were written per version directory so we - // can remove stale files that no longer appear in the upstream schema. + // Track written category files so stale upstream removals delete matching data files after sync. const writtenFilesByVersion = new Map>() await Promise.all( @@ -40,7 +36,7 @@ export async function syncRestData( if (injectIntoSchema) { const injectedSchema = await injectIntoSchema(schema, schemaName) - schema = injectedSchema || schema // Fallback to original if injection returns null + schema = injectedSchema || schema } const operations: Operation[] = [] @@ -129,8 +125,7 @@ async function formatRestData(operations: Operation[]): Promise operation.subcategory)), ].sort() - // the first item should be the item that has no subcategory - // e.g., when the subcategory = category + // Put the category-level subcategory first so it renders before nested subcategories. const firstItemIndex = subcategories.indexOf(category) if (firstItemIndex > -1) { const firstItem = subcategories.splice(firstItemIndex, 1)[0] @@ -150,12 +145,11 @@ async function formatRestData(operations: Operation[]): Promise. async function updateRestConfigData(schemas: string[]): Promise { const restConfigFilename = 'src/rest/lib/config.json' const restConfigData = JSON.parse(await readFile(restConfigFilename, 'utf8')) as Record< @@ -164,9 +158,7 @@ async function updateRestConfigData(schemas: string[]): Promise { > const restApiVersionData = (restConfigData['api-versions'] as Record) || {} - // Phase 1: collect the dates in the incoming schemas, keyed by OpenAPI - // version name. Only calendar-date schemas count, meaning the ones that start - // with an OPENAPI_VERSION_NAMES entry without exactly matching it. + // Calendar-date schemas start with an OPENAPI_VERSION_NAMES entry without exactly matching it. const incomingDates: Record> = {} for (const schema of schemas) { @@ -182,9 +174,7 @@ async function updateRestConfigData(schemas: string[]): Promise { } } - // Phase 2: For each version key that appeared in this sync run, replace its - // date array with exactly what was synced. This removes any deprecated dates - // that are no longer present in the upstream schemas. + // Replacing each touched date array removes deprecated dates missing from upstream schemas. for (const [openApiVer, dates] of Object.entries(incomingDates)) { restApiVersionData[openApiVer] = [...dates].sort() } @@ -198,35 +188,22 @@ export async function getOpenApiSchemaFiles( ): Promise<{ restSchemas: string[]; webhookSchemas: string[] }> { const restSchemas: string[] = [] const webhookSchemas: string[] = [] - // The full list of dereferened OpenAPI schemas received from - // bundling the OpenAPI in github/github const schemaNames = schemas.map((schema) => path.basename(schema, '.json')) const versionNames = Object.keys(allVersions).map((elem) => allVersions[elem].openApiVersionName) for (const schema of schemaNames) { const schemaBasename = `${schema}.json` - // If the version doesn't have calendar date versioning - // it should have an exact match with one of the versions defined - // in the allVersions object. + // Non-calendar schemas must exactly match an allVersions OpenAPI version name. if (versionNames.includes(schema)) { webhookSchemas.push(schemaBasename) } - // If the schema version has calendar date versioning, then one of - // the versions defined in allVersions should be a substring of the - // schema version. This means the schema version is a supported version + // Supported schemas start with an allVersions OpenAPI version name. if (versionNames.some((elem) => schema.startsWith(elem))) { - // If the schema being evaluated is a calendar-date version, then - // there would only be one exact match in the list of schema names. - // If the schema being evaluated is a non-calendar-date version, then - // there will be two matches. - // Ex: api.github.com would match api.github.com and - // api.github.com.2022-09-09 + // Base names match themselves and dated schemas, such as api.github.com.YYYY-MM-DD. const filteredMatches = schemaNames.filter((elem) => elem.includes(schema)) - // If there is only one match then it's either a calendar-date version - // or the version doesn't support calendar dates yet. We favor calendar-date - // versions but default to non calendar-date versions. + // One match means a dated schema or a version without dates; REST data favors dated schemas. if (filteredMatches.length === 1) { restSchemas.push(schemaBasename) } diff --git a/src/rest/scripts/utils/update-markdown.ts b/src/rest/scripts/utils/update-markdown.ts index 5d94e90d8047..f908d58f31cf 100644 --- a/src/rest/scripts/utils/update-markdown.ts +++ b/src/rest/scripts/utils/update-markdown.ts @@ -48,9 +48,9 @@ export async function updateRestFiles() { }) } -// The GHES version in a file path, or null if the path isn't a GHES one. +// GitHub Enterprise Server paths include ghes-; other paths return null. export function getGHESVersionFromFilepath(filePath: string): string | null { - // Normalize path separators to handle both Unix and Windows paths + // Normalize Windows separators before splitting paths. const normalizedPath = filePath.replace(/\\/g, '/') const pathParts = normalizedPath.split('/') const ghesDir = pathParts.find((part) => part.startsWith('ghes-')) @@ -63,40 +63,33 @@ export function getGHESVersionFromFilepath(filePath: string): string | null { return versionMatch ? versionMatch[1] : null } -// The data files are split up by version, so all files must be -// read to get a complete list of versions. +// Read every version directory because REST data files split versions across +// per-category JSON files. async function getDataFrontmatter(dataDirectory: string): Promise { const fileList = walk(dataDirectory, { includeBasePath: true }) .filter((file) => file.endsWith('.json')) - // Exclude non-category JSON files that live alongside per-category data. - // If new non-category files are added to version directories, update this filter. + // Keep non-category JSON files out of category data; add future sidecar files to this filter. .filter((file) => !file.endsWith('client-side-rest-api-redirects.json')) - // Exclude any legacy monolithic schema files. Their top-level keys are - // category names, not subcategories, so they would be misread here as a - // bogus "schema" category. + // Legacy monolithic schema files use category keys; without this filter, schema becomes a fake category. .filter((file) => !file.endsWith('schema.json')) - // Ignore any deprecated versions. This allows us to stop supporting - // the most recent deprecated version but still allow data to exist. - // This makes the deprecation steps easier. + // Ignore deprecated GitHub Enterprise Server versions so data stays on disk after support ends. .filter((file) => { const ghesVersion = getGHESVersionFromFilepath(file) - // If it's not a GHES file, include it (e.g., ghae, fpt, ghec) if (!ghesVersion) { return true } - // If it's a GHES file, exclude it only if the version is deprecated return !deprecated.includes(ghesVersion) }) const restVersions: RestVersions = {} for (const file of fileList) { - const data = JSON.parse(await readFile(file, 'utf-8')) // data is RestOperationCategory + const data = JSON.parse(await readFile(file, 'utf-8')) const docsVersionName = getDocsVersion(path.basename(path.dirname(file))) - const category = path.basename(file, '.json') // filename IS the category - const subcategories = Object.keys(data) // data keys are subcategories directly + const category = path.basename(file, '.json') + const subcategories = Object.keys(data) for (const subcategory of subcategories) { if (!restVersions[category]) restVersions[category] = {} if (!restVersions[category][subcategory]) { @@ -109,29 +102,16 @@ async function getDataFrontmatter(dataDirectory: string): Promise return restVersions } -// Takes the version frontmatter to apply to the Markdown page for each category -// and subcategory, in the shape: -// -// { -// "actions": { -// "artifacts": { -// "versions": ["free-pro-team@latest", "enterprise-server@3.22", ...] -// } -// } -// } +// For existing files, updateContentDirectory keeps everything but versions. +// TODOCS defaults apply only to new files, so the content linter blocks +// merging until a docs reviewer fills them in. async function getMarkdownContent(versions: RestVersions): Promise { const markdownUpdates: MarkdownUpdates = {} for (const [category, subcategoryObject] of Object.entries(versions)) { const subcategories = Object.keys(subcategoryObject) - // The file path will be content/rest//.md for (const subcategory of subcategories) { const filepath = path.join('content/rest', category, `${subcategory}.md`) - // If the file already exists on disk, only the `versions` frontmatter - // property is updated. So the TODOCS placeholder values are only used - // when the file is newly created, which is the intention. When the TODOCS - // placeholder is added, it will fail the content linter CI test alerting - // the docs content reviewer to update the file before merging. markdownUpdates[filepath] = { data: { title: 'TODOCS', From f1d0f90b2510dbba58589f13b1aec935c943b1f4 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 29 Sep 2026 16:44:28 +0000 Subject: [PATCH 27/28] Fix generic TOC faux subcategory detection (#63484) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 Copilot-Session: a5dcec1f-8c80-4765-b1ae-b50654664575 --- src/frame/middleware/context/generic-toc.ts | 7 +- src/frame/middleware/render-page.ts | 5 +- src/frame/tests/generic-toc.ts | 124 ++++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/frame/tests/generic-toc.ts diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index 67012fe6c363..8cc3cf4ecf13 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -50,10 +50,13 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne req.pagePath, ) - // fauxSubcategory is meant to flatten categories without grandchildren, but [] is truthy. + // Categories whose children are all leaf pages render a flat TOC, like a subcategory. + // Use childPages, not children: leaf nodes carry `children: []` and children ignores versioning. let fauxSubcategory = false if (req.context.page.documentType === 'category' && req.context.page.autogenerated !== 'rest') { - const hasGrandchildren = (treePage.childPages || []).some((child) => child.children) + const hasGrandchildren = (treePage.childPages || []).some( + (child) => (child.childPages?.length || 0) > 0, + ) fauxSubcategory = !hasGrandchildren } diff --git a/src/frame/middleware/render-page.ts b/src/frame/middleware/render-page.ts index 1d3e0b4b7d2a..c89eb8cdcc2d 100644 --- a/src/frame/middleware/render-page.ts +++ b/src/frame/middleware/render-page.ts @@ -52,8 +52,9 @@ async function buildRenderedPageHast(req: ExtendedRequest) { try { const hastContext = { ...context, collectMiniToc: undefined } - const { hast } = await renderContentToHast(page.markdown, hastContext) - return hast || undefined + const { hast, html } = await renderContentToHast(page.markdown, hastContext) + // A whitespace-only body yields an empty root, which consumers would treat as content. + return hast && html.trim() ? hast : undefined } catch (error) { logger.error( 'buildRenderedPageHast failed; falling back to string path', diff --git a/src/frame/tests/generic-toc.ts b/src/frame/tests/generic-toc.ts new file mode 100644 index 000000000000..18923004e0e9 --- /dev/null +++ b/src/frame/tests/generic-toc.ts @@ -0,0 +1,124 @@ +import { describe, expect, test, vi } from 'vitest' +import type { NextFunction, Response } from 'express' + +import genericToc from '@/frame/middleware/context/generic-toc' +import type { ExtendedRequest, Page, Tree } from '@/types' + +type TestPage = Pick & + Partial> + +function createPage(overrides: Partial): Page { + return { + documentType: 'article', + relativePath: 'product/category/article.md', + hidden: false, + async renderProp(prop: string) { + return prop === 'rawIntro' ? 'Intro' : 'Title' + }, + ...overrides, + } as Page +} + +function createTree(page: Page, children: Tree[] = []): Tree { + return { + page, + children: children.map((child) => child.page.relativePath), + href: `/en/${page.relativePath.replace(/\/index\.md$|\.md$/, '')}`, + childPages: children, + } +} + +function createRequest(treePage: Tree, page: Page): ExtendedRequest { + return { + pagePath: treePage.href, + context: { + page, + currentLayoutName: 'default', + currentProductTree: treePage, + currentEnglishTree: treePage, + currentPath: treePage.href, + }, + } as ExtendedRequest +} + +describe('genericToc middleware', () => { + const response = {} as Response + + test('uses a flat TOC for categories whose child pages have no children', async () => { + const category = createPage({ + documentType: 'category', + relativePath: 'product/category/index.md', + }) + const child = createPage({ relativePath: 'product/category/article.md' }) + const treePage = createTree(category, [createTree(child)]) + const req = createRequest(treePage, category) + const next = vi.fn() as NextFunction + + await genericToc(req, response, next) + + expect(req.context?.genericTocFlat).toEqual([ + expect.objectContaining({ + fullPath: '/en/product/category/article', + title: 'Title', + childTocItems: [], + }), + ]) + expect(req.context?.genericTocNested).toBeUndefined() + expect(next).toHaveBeenCalledOnce() + }) + + test('uses a nested TOC for categories whose child pages have children', async () => { + const category = createPage({ + documentType: 'category', + relativePath: 'product/category/index.md', + }) + const child = createPage({ + documentType: 'subcategory', + relativePath: 'product/category/subcategory/index.md', + }) + const grandchild = createPage({ + relativePath: 'product/category/subcategory/article.md', + }) + const treePage = createTree(category, [createTree(child, [createTree(grandchild)])]) + const req = createRequest(treePage, category) + const next = vi.fn() as NextFunction + + await genericToc(req, response, next) + + expect(req.context?.genericTocFlat).toBeUndefined() + expect(req.context?.genericTocNested).toEqual([ + expect.objectContaining({ + fullPath: '/en/product/category/subcategory', + title: 'Title', + intro: null, + childTocItems: [ + expect.objectContaining({ + fullPath: '/en/product/category/subcategory/article', + title: 'Title', + }), + ], + }), + ]) + expect(next).toHaveBeenCalledOnce() + }) + + test('uses a flat TOC when child pages declare children but none apply to this version', async () => { + const category = createPage({ + documentType: 'category', + relativePath: 'product/category/index.md', + }) + const child = createPage({ + documentType: 'subcategory', + relativePath: 'product/category/subcategory/index.md', + }) + const childTree = { ...createTree(child), children: ['/other-version-only'] } + const treePage = createTree(category, [childTree]) + const req = createRequest(treePage, category) + const next = vi.fn() as NextFunction + + await genericToc(req, response, next) + + expect(req.context?.genericTocFlat).toHaveLength(1) + expect(req.context?.genericTocNested).toBeUndefined() + }) +}) From f187b6c1617910ea799e8f84ad7e2b4c8697672c Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:52:12 +0000 Subject: [PATCH 28/28] Sync secret scanning data (#63600) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../data/pattern-docs/fpt/public-docs.yml | 46 +++++++++++++++++-- .../data/pattern-docs/ghec/public-docs.yml | 46 +++++++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) 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 f040b29c87d6..98aa48edf13b 100644 --- a/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml @@ -200,6 +200,16 @@ hasExtendedMetadata: '{% ifversion ghes %}false{% else %}true{% endif %}' base64Supported: true isduplicate: true +- provider: Anthropic + supportedSecret: Anthropic Service Account API Key + secretType: anthropic_service_api_key + isPublic: true + isPrivateWithGhas: false + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: Anthropic supportedSecret: Anthropic Session ID secretType: anthropic_session_id @@ -210,6 +220,16 @@ hasExtendedMetadata: false base64Supported: false isduplicate: false +- provider: Anthropic + supportedSecret: Anthropic User API Key + secretType: anthropic_user_api_key + isPublic: true + isPrivateWithGhas: false + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: APIclub supportedSecret: APIclub API Key secretType: apiclub_api_key @@ -3078,7 +3098,7 @@ supportedSecret: Lovable API Key secretType: lovable_api_key isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: false @@ -4064,6 +4084,26 @@ hasExtendedMetadata: '{% ifversion ghes %}false{% else %}true{% endif %}' base64Supported: false isduplicate: false +- provider: Pydantic Services Inc. + supportedSecret: Logfire Token + secretType: logfire_token + isPublic: false + isPrivateWithGhas: true + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false +- provider: Pydantic Services Inc. + supportedSecret: Pydantic AI Gateway API Key + secretType: pydantic_ai_gateway_api_key + isPublic: false + isPrivateWithGhas: true + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: PyPI supportedSecret: PyPI API Token secretType: pypi_api_token @@ -4718,7 +4758,7 @@ supportedSecret: Supabase OAuth Access Token secretType: supabase_oauth_access_token isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: false @@ -4738,7 +4778,7 @@ supportedSecret: Supabase Personal Access Token (scoped) secretType: supabase_scoped_personal_access_token isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: 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 f040b29c87d6..98aa48edf13b 100644 --- a/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml @@ -200,6 +200,16 @@ hasExtendedMetadata: '{% ifversion ghes %}false{% else %}true{% endif %}' base64Supported: true isduplicate: true +- provider: Anthropic + supportedSecret: Anthropic Service Account API Key + secretType: anthropic_service_api_key + isPublic: true + isPrivateWithGhas: false + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: Anthropic supportedSecret: Anthropic Session ID secretType: anthropic_session_id @@ -210,6 +220,16 @@ hasExtendedMetadata: false base64Supported: false isduplicate: false +- provider: Anthropic + supportedSecret: Anthropic User API Key + secretType: anthropic_user_api_key + isPublic: true + isPrivateWithGhas: false + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: APIclub supportedSecret: APIclub API Key secretType: apiclub_api_key @@ -3078,7 +3098,7 @@ supportedSecret: Lovable API Key secretType: lovable_api_key isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: false @@ -4064,6 +4084,26 @@ hasExtendedMetadata: '{% ifversion ghes %}false{% else %}true{% endif %}' base64Supported: false isduplicate: false +- provider: Pydantic Services Inc. + supportedSecret: Logfire Token + secretType: logfire_token + isPublic: false + isPrivateWithGhas: true + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false +- provider: Pydantic Services Inc. + supportedSecret: Pydantic AI Gateway API Key + secretType: pydantic_ai_gateway_api_key + isPublic: false + isPrivateWithGhas: true + hasPushProtection: false + hasValidityCheck: false + hasExtendedMetadata: false + base64Supported: false + isduplicate: false - provider: PyPI supportedSecret: PyPI API Token secretType: pypi_api_token @@ -4718,7 +4758,7 @@ supportedSecret: Supabase OAuth Access Token secretType: supabase_oauth_access_token isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: false @@ -4738,7 +4778,7 @@ supportedSecret: Supabase Personal Access Token (scoped) secretType: supabase_scoped_personal_access_token isPublic: true - isPrivateWithGhas: false + isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false hasExtendedMetadata: false