diff --git a/.github/instructions/content-guidelines.instructions.md b/.github/instructions/content-guidelines.instructions.md index 4effecc5042a..984856faeef9 100644 --- a/.github/instructions/content-guidelines.instructions.md +++ b/.github/instructions/content-guidelines.instructions.md @@ -19,6 +19,8 @@ The strategic priority is simplification: create less content and remove content * Would a typical internet user figure this out on their own by exploring the UI? * Is the information presented at the moment the reader actually needs it? +Look for content to remove whether or not you were asked to, and don't add non-essential content in the first place. Before you cut, name the reader who would fail without it and how they would fail. If you cannot name a specific failure, cut it. + ## Give opinionated, actionable guidance This applies whenever you give the reader advice or present ways to accomplish a task. @@ -72,4 +74,5 @@ These apply to the prose in an article (intros and explanatory text), not to str * Write for the one reader scenario the article targets, for example a particular deployment configuration (GHEC with EMUs vs. Classic) or a particular type of reader (an open source maintainer vs. an enterprise developer). When the article has a content design plan, target the audience it identifies rather than inventing one; for small edits without a plan, follow the audience the existing article is clearly written for. Do not branch content to serve multiple audiences; readers in other scenarios can adapt the guidance. The exception is version differences: when in-article `{% ifversion %}` branching is genuinely required (see the versioning rules in `content.instructions.md`), it is not a scannability violation. * Ruthlessly minimize links. Only link when you actively want most readers to follow it in the ideal scenario. No "just in case" links. Links that build a logical user journey are exactly the kind to keep, for example a Prerequisites link that sends the reader to setup they need first, or a Next steps link that points them to the natural follow-on task. * Ruthlessly minimize alerts (notes, tips, warnings): more than one per article should be exceptional, and crowding several into one section is worse than spreading them out. Keep each to 1-2 sentences. Don't open an article or section with an alert unless the reader needs it before the surrounding content. Prefer folding a useful alert into the prose over deleting it, but first apply this test: if the reader must actually notice it to use the page correctly, keep it as an alert (don't fold or count it), since folding defeats its purpose. This covers, for example, critical warnings, plan or availability constraints, public preview notices, and cues that orient the reader to how the page works or which content applies to them. -* Prefer short sentences and paragraphs, generous white space, and formatting like bold and tables to highlight key information. Use a table only for genuinely complex data that belongs in a tabular format; do not add a table that repeats information already stated more clearly in prose. +* Ruthlessly minimize screenshots. Only add one, or ask a writer to add one, when a UI element is genuinely hard to find: small, hidden until something is opened, or ambiguous among competing options. A mention of screenshots in an issue or PR description is not a reason. +* Prefer short sentences and paragraphs, generous white space, and formatting like bold and tables to highlight key information. Keep each sentence to one idea: where a sentence carries two, split it into two sentences rather than trimming it. Use a table only for genuinely complex data that belongs in a tabular format; do not add a table that repeats information already stated more clearly in prose. diff --git a/.github/workflows/sync-sdk-docs.yml b/.github/workflows/sync-sdk-docs.yml index 82a1b2d3a1b4..92796217453e 100644 --- a/.github/workflows/sync-sdk-docs.yml +++ b/.github/workflows/sync-sdk-docs.yml @@ -50,6 +50,26 @@ jobs: - name: Checkout docs-internal uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # `preserve-redirects.ts` reads the pre-sync state from `git HEAD` to learn + # which URLs are currently live. That is only a valid baseline when HEAD is + # the published branch. `pull_request` runs are safe because they never + # publish, but a `workflow_dispatch` from another branch would publish while + # comparing against that branch's tree, writing stale redirects into a real + # PR. Fail early rather than let the run reach the push step. + - name: Verify publishing runs start from the default branch + if: github.event_name != 'pull_request' && inputs.dry_run != 'true' + run: | + if [ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "::error::This run would push and open a PR, but it started from" \ + "'$GITHUB_REF_NAME' rather than the default branch '$DEFAULT_BRANCH'." \ + "HEAD is the baseline for redirect preservation, so syncing from another" \ + "branch can drop or invent redirects. Re-run from '$DEFAULT_BRANCH', or" \ + "use the dry_run input to test from a branch." + exit 1 + fi + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + - name: Fetch SDK docs from copilot-sdk env: GH_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} @@ -91,6 +111,22 @@ jobs: --content-dir content \ --sdk-docs-dir "$SDK_DOCS_TARGET" + - name: Preserve redirects + run: | + # `--git-ref HEAD` is the record of which URLs are currently live. On + # publishing runs a preceding step has verified HEAD is the default + # branch, and the sync branch is only created later with `checkout -B`. + # On `pull_request` runs HEAD is the merge commit instead, which is fine + # because those runs are dry-run only. + # + # A removed page that needs a redirect decision should still produce a + # PR, because the PR is where that decision gets made and committed. The + # script still writes the at-risk URLs and a copy-pasteable + # `redirect_from` block to the run summary either way. + npx tsx src/workflows/sync-sdk-docs/preserve-redirects.ts \ + --sdk-docs-dir "$SDK_DOCS_TARGET" \ + --git-ref HEAD + - name: Convert Mermaid diagrams to PNG env: PUPPETEER_CHROMIUM_REVISION: '' @@ -165,7 +201,7 @@ jobs: ${{ env.ASSETS_TARGET }} retention-days: 7 - # --- Push and PR (only on schedule/dispatch, not dry-run, and changes exist) --- + # --- Push and PR (only on dispatch, not dry-run, and changes exist) --- - name: Commit and push if: >- env.has_changes == 'true' @@ -212,7 +248,7 @@ jobs: - Normalizes code fence languages and list formatting > [!NOTE] - > This PR is auto-generated. Do not edit it directly — make changes in the [copilot-sdk docs](https://github.com/github/copilot-sdk/tree/main/docs) instead. + > This PR is auto-generated. Do not edit it directly — make changes in the [copilot-sdk docs](https://github.com/github/copilot-sdk/tree/main/docs) instead. Exception: \`redirect_from\` cannot come from upstream, so add redirects here. --- _Generated by the [sync-sdk-docs workflow run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)._" diff --git a/assets/images/help/copilot/windows-terminal-dropdown.png b/assets/images/help/copilot/windows-terminal-dropdown.png deleted file mode 100644 index d05fc1634649..000000000000 Binary files a/assets/images/help/copilot/windows-terminal-dropdown.png and /dev/null differ diff --git a/assets/images/help/copilot/windows-terminal-settings.png b/assets/images/help/copilot/windows-terminal-settings.png deleted file mode 100644 index fcd4e7341e81..000000000000 Binary files a/assets/images/help/copilot/windows-terminal-settings.png and /dev/null differ diff --git a/assets/images/help/copilot/xcode-chat-about-file.png b/assets/images/help/copilot/xcode-chat-about-file.png deleted file mode 100644 index dc02b3ab5e58..000000000000 Binary files a/assets/images/help/copilot/xcode-chat-about-file.png and /dev/null differ diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index 763da511cea6..b8d8c3502c71 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -28,6 +28,8 @@ Used for: * {% data variables.copilot.copilot_gpt_56_sol %} * {% data variables.copilot.copilot_gpt_56_terra %} * {% data variables.copilot.copilot_gpt_6_astra %} +* {% data variables.copilot.copilot_gpt_6_luna %} +* {% data variables.copilot.copilot_gpt_6_sol %} These models are hosted by OpenAI and {% data variables.product.github %}'s Azure infrastructure. @@ -48,6 +50,7 @@ Used for: * {% data variables.copilot.copilot_claude_opus_48 %} * {% data variables.copilot.copilot_claude_opus_48_fast %} * {% data variables.copilot.copilot_claude_opus_5 %} +* {% data variables.copilot.copilot_claude_opus_55 %} * {% data variables.copilot.copilot_claude_fable_5 %} * {% data variables.copilot.copilot_claude_fable_51 %} diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index 92aace8cb465..e18f9c2166ef 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -82,6 +82,7 @@ Choosing a larger context window or higher reasoning will impact {% data variabl | {% data variables.copilot.copilot_claude_opus_47 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_claude_opus_48 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_claude_opus_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.copilot_claude_opus_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_claude_sonnet_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_claude_opus_48_fast %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_claude_fable_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | @@ -93,6 +94,8 @@ Choosing a larger context window or higher reasoning will impact {% data variabl | {% data variables.copilot.copilot_gpt_56_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_gpt_56_terra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_gpt_6_astra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.copilot_gpt_6_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.copilot_gpt_6_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | | {% data variables.copilot.copilot_kimi_k3 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | {% endrowheaders %} @@ -134,8 +137,11 @@ Some {% data variables.product.prodname_copilot_short %} models require minimum | {% data variables.copilot.copilot_gpt_56_sol %} | `1.128.0` | TBD | TBD | TBD | TBD | | {% data variables.copilot.copilot_gpt_56_terra %} | `1.128.0` | TBD | TBD | TBD | TBD | | {% data variables.copilot.copilot_gpt_6_astra %} | `1.136.1` | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.copilot_gpt_6_luna %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.copilot_gpt_6_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | | {% data variables.copilot.copilot_claude_opus_48 %} | `v1.118` | `17.14.6` | TBD | TBD | TBD | | {% data variables.copilot.copilot_claude_opus_5 %} | `v1.128.0` | `17.14.22` | TBD | TBD | TBD | +| {% data variables.copilot.copilot_claude_opus_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | | {% data variables.copilot.copilot_claude_sonnet_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | | {% data variables.copilot.copilot_claude_fable_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | | {% data variables.copilot.copilot_claude_fable_51 %} | TBD | TBD | TBD | TBD | TBD | diff --git a/content/copilot/reference/copilot-billing/models-and-pricing.md b/content/copilot/reference/copilot-billing/models-and-pricing.md index 835b6680274d..c316385ca524 100644 --- a/content/copilot/reference/copilot-billing/models-and-pricing.md +++ b/content/copilot/reference/copilot-billing/models-and-pricing.md @@ -33,7 +33,7 @@ All prices are **per 1 million tokens**. {% data reusables.copilot.extended-context-pricing %} -{% data variables.copilot.copilot_gpt_56_sol %}, {% data variables.copilot.copilot_gpt_56_terra %}, {% data variables.copilot.copilot_gpt_56_luna %}, and {% data variables.copilot.copilot_gpt_6_astra %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. +{% data variables.copilot.copilot_gpt_56_sol %}, {% data variables.copilot.copilot_gpt_56_terra %}, {% data variables.copilot.copilot_gpt_56_luna %}, {% data variables.copilot.copilot_gpt_6_astra %}, {% data variables.copilot.copilot_gpt_6_luna %}, and {% data variables.copilot.copilot_gpt_6_sol %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. | Model | Release status | Category | Tier | Threshold (input tokens) | Input | Cached input | Cache write | Output | | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | diff --git a/data/release-notes/enterprise-server/3-17/21.yml b/data/release-notes/enterprise-server/3-17/21.yml new file mode 100644 index 000000000000..2676a1edb045 --- /dev/null +++ b/data/release-notes/enterprise-server/3-17/21.yml @@ -0,0 +1,78 @@ +date: '2026-09-22' +sections: + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An unauthenticated attacker could cause a GitHub Enterprise Server instance to send crafted outbound requests to an attacker-controlled host via an unauthenticated Manage API endpoint that parsed an attacker-supplied cluster configuration. An attacker positioned to intercept the outbound request could capture a replayable management bearer token. High-availability deployments were not affected. GitHub has requested [CVE-2026-18730](https://www.cve.org/cverecord?id=CVE-2026-18730) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM**: An attacker could read the raw diff or patch of pull requests in private repositories they were not authorized to access, by creating a repository and pull request whose name and number matched the target. GitHub has requested CVE ID [CVE-2026-75101](https://www.cve.org/cverecord?id=CVE-2026-75101) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Dependabot alerts could remain open after the vulnerable dependency had been upgraded. Manifest files containing invalid UTF-8 byte sequences caused dependency graph parsing to fail, which prevented affected alerts from moving to a fixed state. + - | + During `ghe-config-apply`, a process that was intended to check that all pending jobs had passed health checks was silently skipped. + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + changes: + - | + During a live upgrade, the retained copy of a table left after an online schema migration is now removed in a single operation rather than by a gradual, row-by-row deletion, reducing database load and reclaiming disk space more quickly. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [AUTOTITLE](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + On an instance with the HTTP `X-Forwarded-For` header configured for use behind a load balancer, all client IP addresses in the instance's audit log erroneously appear as 127.0.0.1. + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + {% data reusables.release-notes.2024-06-possible-frontend-5-minute-outage-during-hotpatch-upgrade %} + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + An organization-level code scanning configuration page is displayed on instances that do not use GitHub Advanced Security or code scanning. + - | + When enabling automatic update checks for the first time in the Management Console, the status is not dynamically reflected until the "Updates" page is reloaded. + - | + When restoring from a backup snapshot, a large number of `mapper_parsing_exception` errors may be displayed. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + Unexpected elements may appear in the UI on the repository overview page for locked repositories. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. + - | + Git versions are mismatched between containers on the instance. diff --git a/data/release-notes/enterprise-server/3-18/15.yml b/data/release-notes/enterprise-server/3-18/15.yml new file mode 100644 index 000000000000..d193c45bc6b9 --- /dev/null +++ b/data/release-notes/enterprise-server/3-18/15.yml @@ -0,0 +1,106 @@ +date: '2026-09-22' +sections: + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An unauthenticated attacker could cause a GitHub Enterprise Server instance to send crafted outbound requests to an attacker-controlled host via an unauthenticated Manage API endpoint that parsed an attacker-supplied cluster configuration. An attacker positioned to intercept the outbound request could capture a replayable management bearer token. High-availability deployments were not affected. GitHub has requested [CVE-2026-18730](https://www.cve.org/cverecord?id=CVE-2026-18730) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM**: An attacker could read the raw diff or patch of pull requests in private repositories they were not authorized to access, by creating a repository and pull request whose name and number matched the target. GitHub has requested CVE ID [CVE-2026-75101](https://www.cve.org/cverecord?id=CVE-2026-75101) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + Server reports under `/stafftools/reports/` were accessible using a legacy non-expiring token. If you were accessing these reports programmatically using this legacy approach, you will need to switch to using a {% data variables.product.pat_v1 %} with the `site_admin` scope. + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Dependabot alerts could remain open after the vulnerable dependency had been upgraded. Manifest files containing invalid UTF-8 byte sequences caused dependency graph parsing to fail, which prevented affected alerts from moving to a fixed state. + - | + During `ghe-config-apply`, a process that was intended to check that all pending jobs had passed health checks was silently skipped. + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + Customers with the OpenTelemetry observability stack enabled experienced a loss of metrics after reboot because some exporter services failed to start due to a dependency on the data disk being mounted. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + Administrators experienced a broken SSH command when they ran `ghe-ssl-ca-certificate-install` on a high-availability instance with replication enabled. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with suspended users, organization member counts on the enterprise organizations page and the organization people page included suspended users. This caused the displayed count to be higher than the number of members visible in the list. + - | + The first user created via SAML-JIT during first-run bootstrap on a fresh GHES appliance was not added as an Enterprise owner. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + - | + Enterprise owners would sometimes experience timeouts when loading the "Notifications preferences" section on the "Verified & approved domains" page. + - | + Advanced security configuration pages would not load at all if there was a timeout affecting certain subsections. + - | + The User contributions backfill job would process the full repository set per user, and not just those with contributions, leading to an excessively high `user_contributions_backfill` queue backlog and contributing to high resource consumption when large batches of user changes occur. + - | + Administrators on instances with many organizations experienced security configurations failing to apply to repositories. Fanout processing repeatedly retried without applying repository updates. + - | + Administrators experienced errors when loading license and security configuration pages on instances with large GitHub Advanced Security usage datasets. + changes: + - | + Enterprise Live Migrations skip branch protection rules by default because only part of each rule can be migrated. Partial migration of branch protection rules is opt in with `ghe-config app.elm-exporter.migrate-protected-branches true`. + - | + During a live upgrade, the retained copy of a table left after an online schema migration is now removed in a single operation rather than by a gradual, row-by-row deletion, reducing database load and reclaiming disk space more quickly. + - | + To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [AUTOTITLE](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + On an instance with the HTTP `X-Forwarded-For` header configured for use behind a load balancer, all client IP addresses in the instance's audit log erroneously appear as 127.0.0.1. + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + {% data reusables.release-notes.2024-06-possible-frontend-5-minute-outage-during-hotpatch-upgrade %} + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + An organization-level code scanning configuration page is displayed on instances that do not use GitHub Advanced Security or code scanning. + - | + When enabling automatic update checks for the first time in the Management Console, the status is not dynamically reflected until the "Updates" page is reloaded. + - | + When restoring from a backup snapshot, a large number of `mapper_parsing_exception` errors may be displayed. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + Unexpected elements may appear in the UI on the repository overview page for locked repositories. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + The setting to define private registries at the organization level for code scanning is only available if Dependabot is also enabled for the instance. + - | + Custom NTP settings are removed during the upgrade process. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. diff --git a/data/release-notes/enterprise-server/3-19/12.yml b/data/release-notes/enterprise-server/3-19/12.yml new file mode 100644 index 000000000000..b17079f46fb9 --- /dev/null +++ b/data/release-notes/enterprise-server/3-19/12.yml @@ -0,0 +1,110 @@ +date: '2026-09-22' +sections: + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An unauthenticated attacker could cause a GitHub Enterprise Server instance to send crafted outbound requests to an attacker-controlled host via an unauthenticated Manage API endpoint that parsed an attacker-supplied cluster configuration. An attacker positioned to intercept the outbound request could capture a replayable management bearer token. High-availability deployments were not affected. GitHub has requested [CVE-2026-18730](https://www.cve.org/cverecord?id=CVE-2026-18730) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM**: An attacker could read the raw diff or patch of pull requests in private repositories they were not authorized to access, by creating a repository and pull request whose name and number matched the target. GitHub has requested CVE ID [CVE-2026-75101](https://www.cve.org/cverecord?id=CVE-2026-75101) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + Server reports under `/stafftools/reports/` were accessible using a legacy non-expiring token. If you were accessing these reports programmatically using this legacy approach, you will need to switch to using a {% data variables.product.pat_v1 %} with the `site_admin` scope. + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Dependabot alerts could remain open after the vulnerable dependency had been upgraded. Manifest files containing invalid UTF-8 byte sequences caused dependency graph parsing to fail, which prevented affected alerts from moving to a fixed state. + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + Customers with the OpenTelemetry observability stack enabled experienced a loss of metrics after reboot because some exporter services failed to start due to a dependency on the data disk being mounted. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + Kafka-lite sessions in Grafana dashboard showed empty panels. + - | + Users experienced slow Git LFS uploads over high-latency network connections, because the instances HAProxy frontend advertised a fixed HTTP/2 initial receive window that throttled each stream to roughly one 64 KiB window per round trip. Administrators can now tune this window with `ghe-config core.haproxy-h2-fe-initial-window-size `, using a value between 65536 and 67108864 (64 KiB-64 MiB) inclusive, to improve throughput for large uploads. The setting is opt-in and disabled by default. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + Administrators experienced a broken SSH command when they ran `ghe-ssl-ca-certificate-install` on a high-availability instance with replication enabled. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with suspended users, organization member counts on the enterprise organizations page and the organization people page included suspended users. This caused the displayed count to be higher than the number of members visible in the list. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + - | + The first user created via SAML-JIT during first-run bootstrap on a fresh GHES appliance was not added as an Enterprise owner. + - | + On the secret scanning metrics page for a large enough enterprise, the page could time out. + - | + Enterprise owners would sometimes experience timeouts when loading the "Notifications preferences" section on the "Verified & approved domains" page. + - | + Administrators who deleted a user that belonged to an organization received a 500 error, even though the user was successfully removed. Previously, the deletion attempted to enqueue a job to a Copilot Spaces queue that does not exist on GitHub Enterprise Server, causing the request to fail. + - | + Advanced security configuration pages would not load at all if there was a timeout affecting certain subsections. + - | + The User contributions backfill job would process the full repository set per user, and not just those with contributions, leading to an excessively high `user_contributions_backfill` queue backlog and contributing to high resource consumption when large batches of user changes occur. + - | + Administrators on instances with many organizations experienced security configurations failing to apply to repositories. Fanout processing repeatedly retried without applying repository updates. + - | + GitHub Advanced Security configuration pages would time out too quickly. + - | + On an instance using Redis 7, AOF (append-only file) recovery did not detect or repair corruption in Redis data volumes. This occurred because recovery only checked the legacy single-file AOF path and did not account for Redis 7s multi-part AOF format, which uses a manifest to track data across multiple files. + - | + Administrators experienced errors when loading license and security configuration pages on instances with large GitHub Advanced Security usage datasets. + changes: + - | + Enterprise Live Migrations skip branch protection rules by default because only part of each rule can be migrated. Partial migration of branch protection rules is opt in with `ghe-config app.elm-exporter.migrate-protected-branches true`. + - | + During a live upgrade, the retained copy of a table left after an online schema migration is now removed in a single operation rather than by a gradual, row-by-row deletion, reducing database load and reclaiming disk space more quickly. + - | + To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [AUTOTITLE](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + {% data reusables.release-notes.2024-06-possible-frontend-5-minute-outage-during-hotpatch-upgrade %} + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + When enabling automatic update checks for the first time in the Management Console, the status is not dynamically reflected until the "Updates" page is reloaded. + - | + When restoring from a backup snapshot, a large number of `mapper_parsing_exception` errors may be displayed. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + The setting to define private registries at the organization level for code scanning is only available if Dependabot is also enabled for the instance. + - | + An issue in the Management Console means the Backups (Preview) and Updates tabs may fail to open and instead return an Internal Server Error. We recommend using the command line interface (CLI) for backups and updates. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. diff --git a/data/release-notes/enterprise-server/3-20/8.yml b/data/release-notes/enterprise-server/3-20/8.yml new file mode 100644 index 000000000000..ec3d8f5c550f --- /dev/null +++ b/data/release-notes/enterprise-server/3-20/8.yml @@ -0,0 +1,113 @@ +date: '2026-09-22' +sections: + features: + - | + Site administrators can configure MySQL temporary table and heap table size limits using `ghe-config` to work around a known MySQL optimizer issue that can cause queries to use disk-based temporary tables unnecessarily. To adjust the temporary table size, run `ghe-config mysql.tmp-table-size ` followed by `ghe-config-apply`. + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An unauthenticated attacker could cause a GitHub Enterprise Server instance to send crafted outbound requests to an attacker-controlled host via an unauthenticated Manage API endpoint that parsed an attacker-supplied cluster configuration. An attacker positioned to intercept the outbound request could capture a replayable management bearer token. High-availability deployments were not affected. GitHub has requested [CVE-2026-18730](https://www.cve.org/cverecord?id=CVE-2026-18730) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM**: An attacker could read the raw diff or patch of pull requests in private repositories they were not authorized to access, by creating a repository and pull request whose name and number matched the target. GitHub has requested CVE ID [CVE-2026-75101](https://www.cve.org/cverecord?id=CVE-2026-75101) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + Server reports under `/stafftools/reports/` were accessible using a legacy non-expiring token. If you were accessing these reports programmatically using this legacy approach, you will need to switch to using a {% data variables.product.pat_v1 %} with the `site_admin` scope. + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Dependabot alerts could remain open after the vulnerable dependency had been upgraded. Manifest files containing invalid UTF-8 byte sequences caused dependency graph parsing to fail, which prevented affected alerts from moving to a fixed state. + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + Customers with the OpenTelemetry observability stack enabled experienced a loss of metrics after reboot because some exporter services failed to start due to a dependency on the data disk being mounted. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + Management Console users other than the root site administrator received an HTTP 401 error when they clicked "Backup Now" or attempted to delete (prune) a backup on the Backups page, even though the controls were displayed to them. These authenticated operators can now start and prune backups successfully. + - | + Users experienced slow Git LFS uploads over high-latency network connections, because the instances HAProxy frontend advertised a fixed HTTP/2 initial receive window that throttled each stream to roughly one 64 KiB window per round trip. Administrators can now tune this window with `ghe-config core.haproxy-h2-fe-initial-window-size `, using a value between 65536 and 67108864 (64 KiB–64 MiB) inclusive, to improve throughput for large uploads. The setting is opt-in and disabled by default. + - | + Kafka-lite sessions in Grafana dashboard showed empty panels. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + Administrators experienced a broken SSH command when they ran `ghe-ssl-ca-certificate-install` on a high-availability instance with replication enabled. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with suspended users, organization member counts on the enterprise organizations page and the organization people page included suspended users. This caused the displayed count to be higher than the number of members visible in the list. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + - | + The first user created via SAML-JIT during first-run bootstrap on a fresh GHES appliance was not added as an Enterprise owner. + - | + Enterprise Managed Users could remain absent from enterprise membership listings and the `enterprise.members` GraphQL response after being reinstated. + - | + On the secret scanning metrics page for a large enough enterprise, the page could time out. + - | + Enterprise owners would sometimes experience timeouts when loading the "Notifications preferences" section on the "Verified & approved domains" page. + - | + Administrators who deleted a user that belonged to an organization received a 500 error, even though the user was successfully removed. Previously, the deletion attempted to enqueue a job to a Copilot Spaces queue that does not exist on GitHub Enterprise Server, causing the request to fail. + - | + Advanced security configuration pages would not load at all if there was a timeout affecting certain subsections. + - | + The User contributions backfill job would process the full repository set per user, and not just those with contributions, leading to an excessively high `user_contributions_backfill` queue backlog and contributing to high resource consumption when large batches of user changes occur. + - | + Administrators on instances with many organizations experienced security configurations failing to apply to repositories. Fanout processing repeatedly retried without applying repository updates. + - | + GitHub Advanced Security configuration pages would time out too quickly. + - | + On an instance using Redis 7, AOF (append-only file) recovery did not detect or repair corruption in Redis data volumes. This occurred because recovery only checked the legacy single-file AOF path and did not account for Redis 7s multi-part AOF format, which uses a manifest to track data across multiple files. + - | + Administrators experienced errors when loading license and security configuration pages on instances with large GitHub Advanced Security usage datasets. + changes: + - | + Enterprise Live Migrations skip branch protection rules by default because only part of each rule can be migrated. Partial migration of branch protection rules is opt in with `ghe-config app.elm-exporter.migrate-protected-branches true`. + - | + Administrators can limit the CPU consumed by MySQL backup dumps by setting a CPU quota with `ghe-config backup.cpu-quota`. Provide a systemd `CPUQuota` value, such as `200%` to allow up to two cores. The setting is empty by default, which leaves backup behavior unchanged. The cap is applied on the node where each dump runs, including remote cluster and replica nodes. + - | + During a live upgrade, the retained copy of a table left after an online schema migration is now removed in a single operation rather than by a gradual, row-by-row deletion, reducing database load and reclaiming disk space more quickly. + - | + To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [AUTOTITLE](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. + - | + On instances with multiple Git storage nodes in a voting configuration, including cluster and geo-replication high availability topologies, upgrading may fail to correctly install Actions that ship with the new version. In some cases, previous versions of these Actions remain on the instance. To resolve this issue, run the following commands on the primary node: `ghe-config --unset 'app.actions.actions-repos-sha1sum'`, `ghe-config-apply`, and `/usr/local/share/enterprise/ghe-run-init-actions-graph`. + - | + _When restoring an instance with `ghe-restore` while the replication controller is enabled, the storage directory is not restored._ diff --git a/data/release-notes/enterprise-server/3-21/6.yml b/data/release-notes/enterprise-server/3-21/6.yml new file mode 100644 index 000000000000..a23950cbb99f --- /dev/null +++ b/data/release-notes/enterprise-server/3-21/6.yml @@ -0,0 +1,127 @@ +date: '2026-09-22' +sections: + features: + - | + Site administrators can configure MySQL temporary table and heap table size limits using `ghe-config` to work around a known MySQL optimizer issue that can cause queries to use disk-based temporary tables unnecessarily. To adjust the temporary table size, run `ghe-config mysql.tmp-table-size ` followed by `ghe-config-apply`. + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An unauthenticated attacker could cause a GitHub Enterprise Server instance to send crafted outbound requests to an attacker-controlled host via an unauthenticated Manage API endpoint that parsed an attacker-supplied cluster configuration. An attacker positioned to intercept the outbound request could capture a replayable management bearer token. High-availability deployments were not affected. GitHub has requested [CVE-2026-18730](https://www.cve.org/cverecord?id=CVE-2026-18730) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM**: An attacker could read the raw diff or patch of pull requests in private repositories they were not authorized to access, by creating a repository and pull request whose name and number matched the target. GitHub has requested CVE ID [CVE-2026-75101](https://www.cve.org/cverecord?id=CVE-2026-75101) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + Server reports under `/stafftools/reports/` were accessible using a legacy non-expiring token. If you were accessing these reports programmatically using this legacy approach, you will need to switch to using a {% data variables.product.pat_v1 %} with the `site_admin` scope. + - | + Upgraded the Notebooks container base image from Ubuntu Focal (20.04) to Noble (24.04) for continued security support. + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Dependabot could stop running scheduled version updates after upgrade. + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + Customers with the OpenTelemetry observability stack enabled experienced a loss of metrics after reboot because some exporter services failed to start due to a dependency on the data disk being mounted. + - | + Upgrade for HA with scale-out nodes could fail with HAProxy exceeding FD limit. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + Management Console users other than the root site administrator received an HTTP 401 error when they clicked "Backup Now" or attempted to delete (prune) a backup on the Backups page, even though the controls were displayed to them. These authenticated operators can now start and prune backups successfully. + - | + Users experienced slow Git LFS uploads over high-latency network connections, because the instances HAProxy frontend advertised a fixed HTTP/2 initial receive window that throttled each stream to roughly one 64 KiB window per round trip. Administrators can now tune this window with `ghe-config core.haproxy-h2-fe-initial-window-size `, using a value between 65536 and 67108864 (64 KiB-64 MiB) inclusive, to improve throughput for large uploads. The setting is opt-in and disabled by default. + - | + Kafka-lite sessions in Grafana dashboard showed empty panels. + - | + Configured standalone restores could omit storage objects when Replication Controller changed storage topology before the restore completed. Replication Controller is now paused during this restore window. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with suspended users, organization member counts on the enterprise organizations page and the organization people page included suspended users. This caused the displayed count to be higher than the number of members visible in the list. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + - | + The first user created via SAML-JIT during first-run bootstrap on a fresh GHES appliance was not added as an Enterprise owner. + - | + Enterprise Managed Users could remain absent from enterprise membership listings and the `enterprise.members` GraphQL response after being reinstated. + - | + On the secret scanning metrics page for a large enough enterprise, the page could time out. + - | + Enterprise owners would sometimes experience timeouts when loading the "Notifications preferences" section on the "Verified & approved domains" page. + - | + Administrators who deleted a user that belonged to an organization received a 500 error, even though the user was successfully removed. Previously, the deletion attempted to enqueue a job to a Copilot Spaces queue that does not exist on GitHub Enterprise Server, causing the request to fail. + - | + Advanced security configuration pages would not load at all if there was a timeout affecting certain subsections. + - | + The User contributions backfill job would process the full repository set per user, and not just those with contributions, leading to an excessively high `user_contributions_backfill` queue backlog and contributing to high resource consumption when large batches of user changes occur. + - | + Administrators on instances with many organizations experienced security configurations failing to apply to repositories. Fanout processing repeatedly retried without applying repository updates. + - | + GitHub Advanced Security configuration pages would time out too quickly. + - | + On instances using the Noble-based container image, users received errors when using auto-merge because GitHub Enterprise Server could not read the contents of the `git` and `gitro` home directories. + - | + On an instance using Redis 7, AOF (append-only file) recovery did not detect or repair corruption in Redis data volumes. This occurred because recovery only checked the legacy single-file AOF path and did not account for Redis 7s multi-part AOF format, which uses a manifest to track data across multiple files. + - | + When an administrator ran a configured restore that changed the appliances identity (UUID), the restore could skip the storage tier and still report success, resulting in an incomplete restore of repository data. + changes: + - | + Enterprise Live Migrations skip branch protection rules by default because only part of each rule can be migrated. Partial migration of branch protection rules is opt in with `ghe-config app.elm-exporter.migrate-protected-branches true`. + - | + Administrators can limit the CPU consumed by MySQL backup dumps by setting a CPU quota with `ghe-config backup.cpu-quota`. Provide a systemd `CPUQuota` value, such as `200%` to allow up to two cores. The setting is empty by default, which leaves backup behavior unchanged. The cap is applied on the node where each dump runs, including remote cluster and replica nodes. + - | + During a live upgrade, the retained copy of a table left after an online schema migration is now removed in a single operation rather than by a gradual, row-by-row deletion, reducing database load and reclaiming disk space more quickly. + - | + To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [Troubleshooting access to the Management Console](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. + - | + On instances with multiple Git storage nodes in a voting configuration, including cluster and geo-replication high availability topologies, upgrading may fail to correctly install Actions that ship with the new version. In some cases, previous versions of these Actions remain on the instance. To resolve this issue, run the following commands on the primary node: `ghe-config --unset 'app.actions.actions-repos-sha1sum'`, `ghe-config-apply`, and `/usr/local/share/enterprise/ghe-run-init-actions-graph`. + - | + In some cases, pull requests using auto-merge or merge queue may not merge automatically until mergeability is recalculated. + - | + After upgrading to GHES 3.21, scheduled Dependabot version updates may stop running for pre-existing configurations. If you have already upgraded and want to trigger scheduled version updates, save a change to each affected repository’s `.github/dependabot.yml` file. + - | + In clustered GitHub Enterprise Server environments, administrators may find that nodes that remain in the cluster but no longer have the `git-server`, `pages-server`, or `storage-server` role remain online and eligible to vote in the corresponding replication tables. Review role-specific replication state and contact GitHub Support if stale rows affect replication. + - | + _When restoring an instance with `ghe-restore` while the replication controller is enabled, the storage directory is not restored._ + - | + _On a newly booted {% data variables.product.prodname_ghe_server %} instance, the merge box on a newly created pull request can stay on "Checking for the ability to merge automatically" and not show the merge status. If encountered, refreshing the page shows the correct merge status._ diff --git a/data/release-notes/enterprise-server/3-22/1.yml b/data/release-notes/enterprise-server/3-22/1.yml new file mode 100644 index 000000000000..8ad970f32781 --- /dev/null +++ b/data/release-notes/enterprise-server/3-22/1.yml @@ -0,0 +1,125 @@ +date: '2026-09-22' +sections: + features: + - | + Site administrators can configure MySQL temporary table and heap table size limits using `ghe-config` to work around a known MySQL optimizer issue that can cause queries to use disk-based temporary tables unnecessarily. To adjust the temporary table size, run `ghe-config mysql.tmp-table-size ` followed by `ghe-config-apply`. + security_fixes: + - | + **CRITICAL**: An attacker with network access to a GitHub Enterprise Server instance could supply a notebook viewer URL specifying an explicit port to reach internal services co-located on the appliance. Although response bodies were not returned, response timing could act as an oracle that allowed instance secrets to be extracted. These secrets could be used to obtain remote code execution on the appliance. Exploitation was unauthenticated when private mode was disabled and required any authenticated user when private mode was enabled. GitHub Enterprise Server now rejects notebook viewer URLs that specify an explicit port. GitHub has requested CVE ID [CVE-2026-77987](https://www.cve.org/cverecord?id=CVE-2026-77987) for this vulnerability. This vulnerability was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **HIGH**: An authenticated attacker could execute malicious scripts in another user's browser by posting crafted Markdown content, such as an issue comment, that injected arbitrary HTML attributes into the rendered page. Successful exploitation could allow the attacker to read data visible to the victim, perform state-changing actions as the victim, exfiltrate data, and propagate the payload using the victim's write access. This was possible because the Markdown rendering pipeline rewrote quote characters in already-sanitized HTML without re-sanitizing the result. GitHub has requested CVE ID [CVE-2026-77912](https://www.cve.org/cverecord?id=CVE-2026-77912) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + Server reports under `/stafftools/reports/` were accessible using a legacy non-expiring token. If you were accessing these reports programmatically using this legacy approach, you will need to switch to using a {% data variables.product.pat_v1 %} with the `site_admin` scope. + - | + An attacker could corrupt an in-progress package upload in GitHub Packages by obtaining the identifiers for that upload, because the registry did not verify the users permissions before continuing or completing an upload. GitHub has addressed this by validating authorization before any changes are made to an upload. + bugs: + - | + Fixes a bug where Dependabot stopped running scheduled version updates after upgrade + - | + GHES administrators running instances with high-write workloads experienced increased Redis memory use and latency because Redis created periodic RDB snapshots in addition to its configured AOF persistence. + - | + Customers with the OpenTelemetry observability stack enabled experienced a loss of metrics after reboot because some exporter services failed to start due to a dependency on the data disk being mounted. + - | + Upgrade for HA with scale-out nodes could fail with HAProxy exceeding FD limit. + - | + On instances whose user data disk was initialized before the ownership fix in ghe-storage-init, `/var/log/github-backup` remained owned by root:root, so the admin user could not write to it and ghe-backup failed immediately with a permission-denied error. + - | + Management Console users other than the root site administrator received an HTTP 401 error when they clicked "Backup Now" or attempted to delete (prune) a backup on the Backups page, even though the controls were displayed to them. These authenticated operators can now start and prune backups successfully. + - | + Users experienced slow Git LFS uploads over high-latency network connections, because the instances HAProxy frontend advertised a fixed HTTP/2 initial receive window that throttled each stream to roughly one 64 KiB window per round trip. Administrators can now tune this window with `ghe-config core.haproxy-h2-fe-initial-window-size `, using a value between 65536 and 67108864 (64 KiB-64 MiB) inclusive, to improve throughput for large uploads. The setting is opt-in and disabled by default. + - | + Kafka-lite sessions in Grafana dashboard showed empty panels. + - | + Configured standalone restores could omit storage objects when Replication Controller changed storage topology before the restore completed. Replication Controller is now paused during this restore window. + - | + The `ghe-cluster-support-bundle` `-x` short option required an argument to run. + - | + On AWS and Azure, `ghe-setup-network` now applies explicitly configured IPv6 networking without enabling IPv6 during IPv4-only network updates. The `-4` and `-6` options now disable the selected address family, and explicit IPv4 and IPv6 network configuration is preserved across feature upgrades. + - | + Administrators running backups with the backup service could see a successful exit code even when rsync failed to back up repositories and gists. The backup service now reports these failures correctly. + - | + On an instance with suspended users, organization member counts on the enterprise organizations page and the organization people page included suspended users. This caused the displayed count to be higher than the number of members visible in the list. + - | + On an instance with security overview enabled, reconciliation could repeatedly retry when background job queues were busy, causing high CPU usage and delays across the appliance. + - | + The first user created via SAML-JIT during first-run bootstrap on a fresh GHES appliance was not added as an Enterprise owner. + - | + Enterprise Managed Users could remain absent from enterprise membership listings and the `enterprise.members` GraphQL response after being reinstated. + - | + On the secret scanning metrics page for a large enough enterprise, the page could time out. + - | + Enterprise owners would sometimes experience timeouts when loading the "Notifications preferences" section on the "Verified & approved domains" page. + - | + Administrators who deleted a user that belonged to an organization received a 500 error, even though the user was successfully removed. Previously, the deletion attempted to enqueue a job to a Copilot Spaces queue that does not exist on GitHub Enterprise Server, causing the request to fail. + - | + Advanced security configuration pages would not load at all if there was a timeout affecting certain subsections. + - | + The User contributions backfill job would process the full repository set per user, and not just those with contributions, leading to an excessively high `user_contributions_backfill` queue backlog and contributing to high resource consumption when large batches of user changes occur. + - | + Administrators on instances with many organizations experienced security configurations failing to apply to repositories. Fanout processing repeatedly retried without applying repository updates. + - | + GitHub Advanced Security configuration pages would time out too quickly. + - | + On instances using the Noble-based container image, users received errors when using auto-merge because GitHub Enterprise Server could not read the contents of the `git` and `gitro` home directories. + - | + On an instance using Redis 7, AOF (append-only file) recovery did not detect or repair corruption in Redis data volumes. This occurred because recovery only checked the legacy single-file AOF path and did not account for Redis 7s multi-part AOF format, which uses a manifest to track data across multiple files. + - | + When an administrator ran a configured restore that changed the appliances identity (UUID), the restore could skip the storage tier and still report success, resulting in an incomplete restore of repository data. + changes: + - | + To avoid data processing delays on instances with a large number of internal message topics, administrators can configure the network timeouts used during topic discovery via `app.github.topic-checker-connect-timeout-sec` and `app.github.topic-checker-socket-timeout-sec`. The default timeout has been increased from 10 to 30 seconds, and values between 1 and 300 seconds are accepted. + - | + Enterprise Live Migrations skip branch protection rules by default because only part of each rule can be migrated. Partial migration of branch protection rules is opt in with `ghe-config app.elm-exporter.migrate-protected-branches true`. + - | + Administrators can limit the CPU consumed by MySQL backup dumps by setting a CPU quota with `ghe-config backup.cpu-quota`. Provide a systemd `CPUQuota` value, such as `200%` to allow up to two cores. The setting is empty by default, which leaves backup behavior unchanged. The cap is applied on the node where each dump runs, including remote cluster and replica nodes. + - | + Upgrade (.pkg) and hotpatch (.hpkg) packages are smaller, reducing the time spent downloading a package and verifying its signature during an upgrade. + - | + Site administrators can ensure that secret scanning uses processor features available on their GHES instance because secret scanning compiles its pattern-matching databases on the instance instead of using prebuilt databases. The first scan of each type after an upgrade or restart can take a few seconds longer while the databases are prepared. + known_issues: + - | + During an upgrade of GitHub Enterprise Server, custom firewall rules are removed. If you use custom firewall rules, you must reapply them after upgrading. + - | + During the validation phase of a configuration run, a `No such object` error may occur for the Notebook and Viewscreen services. This error can be ignored as the services should still correctly start. + - | + If the root site administrator is locked out of the Management Console after failed login attempts, the account does not unlock automatically after the defined lockout time. Someone with administrative SSH access to the instance must unlock the account using the administrative shell. For more information, see [Troubleshooting access to the Management Console](/admin/administering-your-instance/administering-your-instance-from-the-web-ui/troubleshooting-access-to-the-management-console#unlocking-the-root-site-administrator-account). + - | + {% data reusables.release-notes.large-adoc-files-issue %} + - | + Admin stats REST API endpoints may timeout on appliances with many users or repositories. Retrying the request until data is returned is advised. + - | + When following the steps for [Replacing the primary MySQL node](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-the-primary-mysql-node), step 14 (running `ghe-cluster-config-apply`) might fail with errors. If this occurs, re-running `ghe-cluster-config-apply` is expected to succeed. + - | + Running a config apply as part of the steps for [Replacing a node in an emergency](/admin/monitoring-managing-and-updating-your-instance/configuring-clustering/replacing-a-cluster-node#replacing-a-node-in-an-emergency) may fail with errors if the node being replaced is still reachable. If this occurs, shutdown the node and repeat the steps. + - | + When restoring data originally backed up from a 3.13 or greater appliance version, the Elasticsearch indices need to be reindexed before some of the data will show up. This happens via a nightly scheduled job. It can also be forced by running `/usr/local/share/enterprise/ghe-es-search-repair`. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + When initializing a new GHES cluster, nodes with the `consul-server` role should be added to the cluster before adding additional nodes. Adding all nodes simultaneously creates a race condition between nomad server registration and nomad client registration. + - | + In a cluster, the host running restore requires access the storage nodes via their private IPs. + - | + On an instance hosted on Azure, commenting on an issue via email meant the comment was not added to the issue. + - | + After a restore, existing outside collaborators cannot be added to repositories in a new organization. This issue can be resolved by running `/usr/local/share/enterprise/ghe-es-search-repair` on the appliance. + - | + After a geo-replica is promoted to be a primary by running `ghe-repl-promote`, the actions workflow of a repository does not have any suggested workflows. + - | + When publishing npm packages in a workflow after restoring from a backup to GitHub Enterprise Server 3.13.5.gm4 or 3.14.2.gm3, you may encounter a `401 Unauthorized` error from the GitHub Packages service. This can happen if the restore is from an N-1 or N-2 version and the workflow targets the npm endpoint on the backup instance. To avoid this issue, ensure the access token is valid and includes the correct scopes for publishing to GitHub Packages. + - | + When applying an enterprise security configuration to all repositories (for example, enabling Secret Scanning or Code Scanning across all repositories), the system immediately enqueues enablement jobs for every organization in the enterprise simultaneously. For enterprises with a large number of repositories, this can result in significant system load and potential performance degradation. If you manage a large enterprise with many organizations and repositories, we recommend applying security configurations at the organization level rather than at the enterprise level in the UI. This allows you to enable security features incrementally and monitor system performance as you roll out changes. + - | + On instances with multiple Git storage nodes in a voting configuration, including cluster and geo-replication high availability topologies, upgrading may fail to correctly install Actions that ship with the new version. In some cases, previous versions of these Actions remain on the instance. To resolve this issue, run the following commands on the primary node: `ghe-config --unset 'app.actions.actions-repos-sha1sum'`, `ghe-config-apply`, and `/usr/local/share/enterprise/ghe-run-init-actions-graph`. + - | + After creating a new branch via the `New branch` button, the `/branches` page doesn't automatically show the new branch. The page requires a manual refresh before the new branch appears + - | + In some cases, pull requests using auto-merge or merge queue may not merge automatically until mergeability is recalculated. + - | + After upgrading to GHES 3.21, scheduled Dependabot version updates may stop running for pre-existing configurations. If you have already upgraded and want to trigger scheduled version updates, save a change to each affected repository’s `.github/dependabot.yml` file. + - | + In clustered GitHub Enterprise Server environments, administrators may find that nodes that remain in the cluster but no longer have the `git-server`, `pages-server`, or `storage-server` role remain online and eligible to vote in the corresponding replication tables. Review role-specific replication state and contact GitHub Support if stale rows affect replication. + - | + _When restoring an instance with `ghe-restore` while the replication controller is enabled, the storage directory is not restored._ + - | + _On a newly booted {% data variables.product.prodname_ghe_server %} instance, the merge box on a newly created pull request can stay on "Checking for the ability to merge automatically" and not show the merge status. If encountered, refreshing the page shows the correct merge status._ diff --git a/data/reusables/actions/actions-policies-preview-note.md b/data/reusables/actions/actions-policies-preview-note.md deleted file mode 100644 index c664959ab9f9..000000000000 --- a/data/reusables/actions/actions-policies-preview-note.md +++ /dev/null @@ -1,2 +0,0 @@ -> [!NOTE] -> Actions policies are in {% data variables.release-phases.public_preview %} and subject to change. diff --git a/data/reusables/actions/workflow-execution-protections-body.md b/data/reusables/actions/workflow-execution-protections-body.md deleted file mode 100644 index 4e401b368684..000000000000 --- a/data/reusables/actions/workflow-execution-protections-body.md +++ /dev/null @@ -1,29 +0,0 @@ -## About workflow execution protections - -Workflow execution protections let you define an allow list that controls who can trigger {% data variables.product.prodname_actions %} workflows and which events are permitted to run them. Previously, a workflow ran based on the workflow file in the commit that triggered it, and an attacker with repository access could modify that file to run malicious code. Workflow execution protections close that gap. Administrators define the rules, and {% data variables.product.prodname_actions %} evaluates them before a workflow runs, so an unauthorized actor or event never reaches execution. - -Workflow execution protections are available at the enterprise, organization, and repository levels. - -## Backed by rulesets - -Workflow execution protections are built on the {% data variables.product.github %} rulesets framework, so the targeting you already know from rulesets works here too. You can apply protections with rulesets and scope them to specific repositories using repository custom properties. This means you can enforce broad protections from one place rather than configuring each workflow file individually. For more information about rulesets, see [AUTOTITLE](/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets). - -You can also use evaluate mode to run your rules without enforcing them. Evaluate mode shows you exactly what a rule would block before you enforce it, so you can roll out policies without breaking existing workflows. - -## Available rules - -Event and actor are the first two rules, and {% data variables.product.github %} plans to add more rules over time. - -* **Actor rules** control who can trigger workflows, including individual users, repository roles such as Read, Maintain, and Admin, {% data variables.product.prodname_github_apps %}, {% data variables.product.prodname_copilot_short %}, and {% data variables.product.prodname_dependabot %}. -* **Event rules** control which events are permitted, such as `push`, `pull_request`, `pull_request_target`, and `workflow_dispatch`. - -By default, every user with write access to a repository can trigger workflows. Actor rules let you separate who contributes code from who runs your CI, so you can grant a contributor write access without granting them the ability to execute workflows. - -## Stop common attacker techniques - -Workflow execution protections disrupt several real-world attack patterns: - -* **Poisoned pipeline execution from pull requests.** Restrict or prohibit `pull_request_target`, including in public repositories where it is most often exploited. -* **Manual-trigger abuse.** Limit `workflow_dispatch` to maintainers so untrusted identities cannot start workflows. -* **Untrusted-actor execution.** Block low-trust identities from triggering workflows entirely. -* **Misconfiguration exploitation.** Apply central policy that overrides any single misconfigured workflow file. diff --git a/data/reusables/actions/workflow-execution-protections-preview-note.md b/data/reusables/actions/workflow-execution-protections-preview-note.md deleted file mode 100644 index 59ec2e5edf65..000000000000 --- a/data/reusables/actions/workflow-execution-protections-preview-note.md +++ /dev/null @@ -1,2 +0,0 @@ -> [!NOTE] -> Workflow execution protections are in {% data variables.release-phases.public_preview %} and subject to change. diff --git a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md index 4bfb531c1f27..be79f8ae57ec 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -1,5 +1,6 @@ * {% data variables.copilot.copilot_claude_opus_47 %} * {% data variables.copilot.copilot_claude_opus_5 %} +* {% data variables.copilot.copilot_claude_opus_55 %} * {% data variables.copilot.copilot_claude_haiku_45 %} * {% data variables.copilot.copilot_gemini_35_flash %} * {% data variables.copilot.copilot_gemini_36_flash %} @@ -10,6 +11,8 @@ * {% data variables.copilot.copilot_gpt_56_sol %} * {% data variables.copilot.copilot_gpt_56_terra %} * {% data variables.copilot.copilot_gpt_6_astra %} +* {% data variables.copilot.copilot_gpt_6_luna %} +* {% data variables.copilot.copilot_gpt_6_sol %} * {% data variables.copilot.copilot_grok_45 %} * {% data variables.copilot.copilot_grok_46 %} * {% data variables.copilot.copilot_grok_47 %} diff --git a/data/reusables/copilot/create-js-file.md b/data/reusables/copilot/create-js-file.md deleted file mode 100644 index 38946eaa5804..000000000000 --- a/data/reusables/copilot/create-js-file.md +++ /dev/null @@ -1 +0,0 @@ -1. In {% data variables.product.prodname_vscode %}, create a new JavaScript (_*.js_) file. diff --git a/data/reusables/copilot/quickstart-intro.md b/data/reusables/copilot/quickstart-intro.md deleted file mode 100644 index d22b7c59422a..000000000000 --- a/data/reusables/copilot/quickstart-intro.md +++ /dev/null @@ -1 +0,0 @@ -{% data variables.product.prodname_copilot %} provides coding suggestions as you type in your editor. You can also ask {% data variables.product.prodname_copilot_short %} coding-related questions, such as how best to code something, how to fix a bug, or how someone else's code works. For full details of what {% data variables.product.prodname_copilot_short %} can do, see [AUTOTITLE](/copilot/get-started/about-github-copilot). diff --git a/data/reusables/copilot/quickstart-nextsteps1.md b/data/reusables/copilot/quickstart-nextsteps1.md deleted file mode 100644 index 007bf85ca64a..000000000000 --- a/data/reusables/copilot/quickstart-nextsteps1.md +++ /dev/null @@ -1,3 +0,0 @@ -* **Find out more about {% data variables.product.prodname_copilot_short %} inline suggestions** - See [AUTOTITLE](/copilot/how-tos/get-code-suggestions/get-ide-code-suggestions). -* **Find out more about {% data variables.copilot.copilot_chat %}** - See [AUTOTITLE](/copilot/how-tos/chat-with-copilot/chat-in-ide){% ifversion fpt %}.{% endif %}{% ifversion ghec %} and [AUTOTITLE](/copilot/how-tos/copilot-on-github/chat-with-copilot/chat-in-github).{% endif %} -* **Learn how to write effective prompts** - See [AUTOTITLE](/copilot/concepts/prompting/prompt-engineering). diff --git a/data/reusables/copilot/quickstart-nextsteps2.md b/data/reusables/copilot/quickstart-nextsteps2.md deleted file mode 100644 index af96f2c440df..000000000000 --- a/data/reusables/copilot/quickstart-nextsteps2.md +++ /dev/null @@ -1,3 +0,0 @@ -* **Use {% data variables.product.prodname_copilot_short %} on your mobile device** - See [AUTOTITLE](/copilot/how-tos/copilot-on-github/chat-with-copilot/chat-in-mobile). -* **Use {% data variables.product.prodname_copilot_short %} on the command line** - See [AUTOTITLE](/copilot/concepts/agents/copilot-cli/about-copilot-cli). -* **Configure {% data variables.product.prodname_copilot_short %} in your editor** - You can enable or disable {% data variables.product.prodname_copilot %} from within your editor, and create your own preferred keyboard shortcuts for {% data variables.product.prodname_copilot_short %}. See [AUTOTITLE](/copilot/how-tos/configure-personal-settings/configure-in-ide). diff --git a/data/reusables/copilot/quickstart-signup.md b/data/reusables/copilot/quickstart-signup.md deleted file mode 100644 index 1b3eed5828d3..000000000000 --- a/data/reusables/copilot/quickstart-signup.md +++ /dev/null @@ -1,20 +0,0 @@ -{% ifversion fpt %} - -## Sign up for {% data variables.product.prodname_copilot %} - -Get started for free {% octicon "link-external" height:16 aria-label="link-external" %} - -To use {% data variables.product.prodname_copilot_short %}, you’ll need a personal {% data variables.product.github %} account with access to a {% data variables.product.prodname_copilot_short %} plan. You can: - -* Start with {% data variables.copilot.copilot_free_short %} to explore limited features without subscribing to a plan. -* Upgrade to {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, or {% data variables.copilot.copilot_max_short %} to unlock more features, models, and request limits. - -For more information about the different plans for {% data variables.product.prodname_copilot %}, see [AUTOTITLE](/copilot/get-started/plans). - -{% elsif ghec %} - -## Prerequisites - -To use {% data variables.product.prodname_copilot %} you must have been assigned a seat by your organization as part of a {% data variables.copilot.copilot_for_business %} or {% data variables.copilot.copilot_enterprise %} subscription, or you must have signed up for {% data variables.copilot.copilot_pro %} on your personal {% data variables.product.prodname_dotcom %} account. - -{% endif %} diff --git a/data/reusables/copilot/windows-terminal-access-denied.md b/data/reusables/copilot/windows-terminal-access-denied.md deleted file mode 100644 index 5e73a2b0bd7b..000000000000 --- a/data/reusables/copilot/windows-terminal-access-denied.md +++ /dev/null @@ -1 +0,0 @@ -If you have access to {% data variables.product.prodname_copilot %} via your organization{% ifversion ghec %} or enterprise{% endif %}, you won't be able to use {% data variables.product.prodname_copilot %} in {% data variables.product.prodname_windows_terminal %} if your organization owner{% ifversion ghec %} or enterprise administrator{% endif %} has disabled {% data variables.copilot.copilot_cli %}. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies). diff --git a/data/tables/copilot/model-comparison.yml b/data/tables/copilot/model-comparison.yml index 68941899fcbc..37d8fc3448e4 100644 --- a/data/tables/copilot/model-comparison.yml +++ b/data/tables/copilot/model-comparison.yml @@ -53,6 +53,16 @@ excels_at: Long-horizon coding tasks with continuous planning, batched diagnosis and verification, and independent result confirmation further_reading: '[GPT-6 Astra model card](https://deploymentsafety.openai.com/gpt-6-astra/gpt-6-astra.pdf)' +- name: GPT-6 Luna + task_area: Fast help with simple or repetitive tasks + excels_at: Quick, cost-efficient responses for smaller, faster tasks + further_reading: 'Coming soon' + +- name: GPT-6 Sol + task_area: Interactive and agentic coding + excels_at: All-round development tasks that benefit from careful, multistep validation + further_reading: 'Coming soon' + # Anthropic - name: Claude Fable 5 task_area: Long-horizon, autonomous coding and knowledge-work @@ -89,6 +99,11 @@ excels_at: Complex problem-solving challenges, sophisticated reasoning further_reading: '[Claude Opus 5 model card](https://www-cdn.anthropic.com/c5fbac3f0b1280a933ebd26d3cb8bb9f5bdeaf48/Claude%20Opus%205%20System%20Card.pdf)' +- name: Claude Opus 5.5 + task_area: Long-running agentic coding and knowledge work + excels_at: Efficient multistep tasks, error recovery, and collaboration + further_reading: '[Claude Opus 5.5 model card](https://www-cdn.anthropic.com/fc1b44717c85dc068bc6ba5024219938094694bd/Claude%20Opus%205.5%20System%20Card.pdf)' + - name: Claude Sonnet 4.6 task_area: General-purpose coding and agent tasks excels_at: Complex problem-solving challenges, sophisticated reasoning @@ -140,7 +155,7 @@ - name: Grok 4.7 task_area: Agentic coding and complex, multistep workflows excels_at: Complex problem-solving challenges, sophisticated reasoning - further_reading: 'Coming soon' + further_reading: '[Grok 4.7 model card](https://media.x.ai/v1/website/4p7card-5eccc980.pdf)' # Other providers (alphabetized by model name) - name: Qwen2.5 diff --git a/data/tables/copilot/model-release-status.yml b/data/tables/copilot/model-release-status.yml index 59ff828adc18..e83b05141cab 100644 --- a/data/tables/copilot/model-release-status.yml +++ b/data/tables/copilot/model-release-status.yml @@ -55,6 +55,14 @@ provider: 'OpenAI' release_status: 'GA' +- name: 'GPT-6 Luna' + provider: 'OpenAI' + release_status: 'GA' + +- name: 'GPT-6 Sol' + provider: 'OpenAI' + release_status: 'GA' + # Anthropic models - name: 'Claude Fable 5' @@ -85,6 +93,10 @@ provider: 'Anthropic' release_status: 'GA' +- name: 'Claude Opus 5.5' + provider: 'Anthropic' + release_status: 'GA' + - name: 'Claude Sonnet 4.6' provider: 'Anthropic' release_status: 'GA' diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 4080c6fb272e..2d2807e3a9fc 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -59,6 +59,15 @@ xcode: true jetbrains: true +- name: Claude Opus 5.5 + dotcom: true + cli: true + vscode: true + vs: true + eclipse: true + xcode: true + jetbrains: true + - name: Claude Fable 5 dotcom: true cli: true @@ -230,6 +239,24 @@ xcode: true jetbrains: true +- name: GPT-6 Luna + dotcom: true + cli: true + vscode: true + vs: true + eclipse: true + xcode: true + jetbrains: true + +- name: GPT-6 Sol + dotcom: true + cli: true + vscode: true + vs: true + eclipse: true + xcode: true + jetbrains: true + - name: Grok 4.5 dotcom: false cli: true diff --git a/data/tables/copilot/model-supported-plans.yml b/data/tables/copilot/model-supported-plans.yml index a5438a3d20c6..32887443a1a2 100644 --- a/data/tables/copilot/model-supported-plans.yml +++ b/data/tables/copilot/model-supported-plans.yml @@ -47,6 +47,13 @@ business: true enterprise: true +- name: Claude Opus 5.5 + pro: false + pro_plus: true + max: true + business: true + enterprise: true + - name: Claude Fable 5 pro: false pro_plus: true @@ -173,6 +180,20 @@ business: true enterprise: true +- name: GPT-6 Luna + pro: true + pro_plus: true + max: true + business: true + enterprise: true + +- name: GPT-6 Sol + pro: false + pro_plus: true + max: true + business: true + enterprise: true + - name: Grok 4.5 pro: true pro_plus: true diff --git a/data/tables/copilot/models-and-pricing.yml b/data/tables/copilot/models-and-pricing.yml index 37ec3e1c233c..b68d523a6e4e 100644 --- a/data/tables/copilot/models-and-pricing.yml +++ b/data/tables/copilot/models-and-pricing.yml @@ -196,6 +196,50 @@ output: $75.00 cache_write: $25.00 +- model: GPT-6 Luna + provider: openai + release_status: GA + category: Lightweight + threshold: '≤ 272K' + tier: Default + input: $0.10 + cached_input: $0.01 + output: $0.50 + cache_write: $0.125 + +- model: GPT-6 Luna + provider: openai + release_status: GA + category: Lightweight + threshold: '> 272K' + tier: 'Long context' + input: $0.20 + cached_input: $0.02 + output: $0.75 + cache_write: $0.25 + +- model: GPT-6 Sol + provider: openai + release_status: GA + category: Powerful + threshold: '≤ 272K' + tier: Default + input: $2.00 + cached_input: $0.20 + output: $10.00 + cache_write: $2.50 + +- model: GPT-6 Sol + provider: openai + release_status: GA + category: Powerful + threshold: '> 272K' + tier: 'Long context' + input: $4.00 + cached_input: $0.40 + output: $15.00 + cache_write: $5.00 + # Anthropic - model: Claude Haiku 4.5 provider: anthropic @@ -251,6 +295,15 @@ output: $25.00 cache_write: $6.25 +- model: Claude Opus 5.5 + provider: anthropic + release_status: GA + category: Powerful + input: $4.00 + cached_input: $0.20 + output: $20.00 + cache_write: $5.00 + - model: Claude Sonnet 5 provider: anthropic release_status: GA diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index 14c251abee68..996425dd1e0a 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -191,6 +191,7 @@ copilot_claude_opus_47: 'Claude Opus 4.7' copilot_claude_opus_48: 'Claude Opus 4.8' copilot_claude_opus_48_fast: 'Claude Opus 4.8 (fast mode) (preview)' copilot_claude_opus_5: 'Claude Opus 5' +copilot_claude_opus_55: 'Claude Opus 5.5' copilot_claude_fable_5: 'Claude Fable 5' copilot_claude_fable_51: 'Claude Fable 5.1' copilot_claude_sonnet: 'Claude Sonnet' @@ -233,6 +234,8 @@ copilot_gpt_56_luna: 'GPT-5.6 Luna' copilot_gpt_56_sol: 'GPT-5.6 Sol' copilot_gpt_56_terra: 'GPT-5.6 Terra' copilot_gpt_6_astra: 'GPT-6 Astra' +copilot_gpt_6_luna: 'GPT-6 Luna' +copilot_gpt_6_sol: 'GPT-6 Sol' # OpenAI 'o' series: copilot_o3: 'o3' copilot_o4_mini: 'o4-mini' diff --git a/src/content-render/tests/render-changed-and-deleted-files.ts b/src/content-render/tests/render-changed-and-deleted-files.ts index d856d92227cd..617089e59ea4 100644 --- a/src/content-render/tests/render-changed-and-deleted-files.ts +++ b/src/content-render/tests/render-changed-and-deleted-files.ts @@ -44,21 +44,12 @@ const EMPTY = Symbol('EMPTY') const pageList = await loadPages(undefined, ['en']) -const SDK_DOCS_PATH = 'content/copilot/how-tos/copilot-sdk/' - function getChangedContentFiles() { const deleted = new Set([...getDeletedContentFiles(), ...getRenamedOldContentFiles()]) - return getContentFiles(process.env.CHANGED_FILES).filter( - (f) => !deleted.has(f) && !f.startsWith(SDK_DOCS_PATH), - ) + return getContentFiles(process.env.CHANGED_FILES).filter((f) => !deleted.has(f)) } function getDeletedContentFiles() { - return getContentFiles(process.env.DELETED_FILES).filter((file) => { - // Auto-generated SDK docs are managed by the sync-sdk-docs pipeline, - // which deletes and recreates pages when the source repo restructures. - // These deletions are expected and don't need redirects. - return !file.startsWith(SDK_DOCS_PATH) - }) + return getContentFiles(process.env.DELETED_FILES) } // Parse `RENAMED_FILES` from tj-actions/changed-files `all_old_new_renamed_files` diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts index 4458abb464f1..bf13ef209f31 100644 --- a/src/languages/lib/correct-translation-content.ts +++ b/src/languages/lib/correct-translation-content.ts @@ -159,6 +159,48 @@ export function correctTranslatedContentStrings( ) } + // data/reusables/gated-features/dependabot-custom-auto-triage-rules.md (es): + // the translator duplicated the opening `{%- ifversion fpt %}` block in + // place of the `{%- elsif ghec %}` that should follow it, so the tag is + // never closed (`tag {%- ifversion fpt %} not closed`) and the `ghes` + // branch below is unreachable. English source has three branches: `fpt`, + // `elsif ghec`, `elsif ghes`. Restore the second branch's opener from + // `ifversion fpt` to `elsif ghec` — this string is unique to this file. + // Scoped path check supports both `dottedPath` (production reusable + // rendering via get-data.ts) and `relativePath` (count-translation- + // corruptions.ts validation path). + if ( + context.code === 'es' && + (context.dottedPath === 'reusables.gated-features.dependabot-custom-auto-triage-rules' || + context.relativePath?.endsWith( + 'data/reusables/gated-features/dependabot-custom-auto-triage-rules.md', + )) + ) { + content = content.replace( + /(están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en \{% data variables\.product\.prodname_team %\} con \[\{% data variables\.product\.prodname_GH_code_security %\}\]\(\/get-started\/learning-about-github\/about-github-advanced-security\) habilitado\.\n\n)\{%- ifversion fpt %\}( \{% data variables\.dependabot\.custom_rules_caps %\} para \{% data variables\.product\.prodname_dependabot_alerts %\} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en \{% data variables\.product\.prodname_team %\} o )/, + '$1{%- elsif ghec %}$2', + ) + } + + // data/reusables/actions/service-container-host-runner.md (es): the + // translator dropped the `{%- endif %}` that closes the + // `{% ifversion not ghes %}` conditional, leaving the tag never closed. + // English: `...the {% ifversion not ghes %} {% data + // variables.product.prodname_dotcom %}-hosted {%- endif %} runner...`. + // Every other translated language keeps the closing tag; only es dropped + // it. Restore it right before " runner" / "ejecutor", matching the + // English tag placement. + if ( + context.code === 'es' && + (context.dottedPath === 'reusables.actions.service-container-host-runner' || + context.relativePath?.endsWith('data/reusables/actions/service-container-host-runner.md')) + ) { + content = content.replace( + /(En el ejemplo se usa el ejecutor hospedado en \{% data variables\.product\.prodname_dotcom %\} `ubuntu-latest` \{% ifversion not ghes %\}) (como host de Docker\.)/, + '$1 {%- endif %} $2', + ) + } + // --- Per-language fixes (es, ja, pt, zh, ru, fr, ko, de) --- if (context.code === 'es') { diff --git a/src/languages/tests/correct-translation-content.ts b/src/languages/tests/correct-translation-content.ts index 58ea5c187610..d9ace41b121f 100644 --- a/src/languages/tests/correct-translation-content.ts +++ b/src/languages/tests/correct-translation-content.ts @@ -3465,4 +3465,63 @@ Para más información, consulta "[AUTOTITLE](/path)". expect(fix(fixed, 'ko')).toBe(fixed) }) }) + + // ─── dependabot-custom-auto-triage-rules.md per-file fix ──────────── + // The translator duplicated the opening `{%- ifversion fpt %}` block in + // place of the `{%- elsif ghec %}` that should follow it, leaving the + // tag never closed and the `ghes` branch unreachable. + describe('es: dependabot-custom-auto-triage-rules.md per-file fix', () => { + const context = { + relativePath: 'data/reusables/gated-features/dependabot-custom-auto-triage-rules.md', + code: 'es', + } + + test('restores the duplicated ifversion opener to elsif ghec', () => { + const broken = + '{%- ifversion fpt %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{%- ifversion fpt %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} o {% data variables.product.prodname_enterprise %} con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{%- elsif ghes %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles para los repositorios propiedad de la organización con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{% endif %}' + const fixed = + '{%- ifversion fpt %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{%- elsif ghec %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} o {% data variables.product.prodname_enterprise %} con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{%- elsif ghes %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles para los repositorios propiedad de la organización con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{% endif %}' + expect(correctTranslatedContentStrings(broken, '', context)).toBe(fixed) + // idempotent: the fix only matches the broken form + expect(correctTranslatedContentStrings(fixed, '', context)).toBe(fixed) + }) + + test('does not touch other languages or other files', () => { + const broken = + '{%- ifversion fpt %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} con [{% data variables.product.prodname_GH_code_security %}](/get-started/learning-about-github/about-github-advanced-security) habilitado.\n\n{%- ifversion fpt %} {% data variables.dependabot.custom_rules_caps %} para {% data variables.product.prodname_dependabot_alerts %} están disponibles en repositorios públicos y en cualquier repositorio propiedad de una organización en {% data variables.product.prodname_team %} o ' + expect(correctTranslatedContentStrings(broken, '', { ...context, code: 'pt' })).toBe(broken) + expect( + correctTranslatedContentStrings(broken, '', { ...context, relativePath: 'other.md' }), + ).toBe(broken) + }) + }) + + // ─── service-container-host-runner.md per-file fix ────────────────── + // The translator dropped the `{%- endif %}` that closes the + // `{% ifversion not ghes %}` conditional. + describe('es: service-container-host-runner.md per-file fix', () => { + const context = { + relativePath: 'data/reusables/actions/service-container-host-runner.md', + code: 'es', + } + + test('restores the dropped endif before the runner mention', () => { + const broken = + 'En el ejemplo se usa el ejecutor hospedado en {% data variables.product.prodname_dotcom %} `ubuntu-latest` {% ifversion not ghes %} como host de Docker.' + const fixed = + 'En el ejemplo se usa el ejecutor hospedado en {% data variables.product.prodname_dotcom %} `ubuntu-latest` {% ifversion not ghes %} {%- endif %} como host de Docker.' + expect(correctTranslatedContentStrings(broken, '', context)).toBe(fixed) + // idempotent: the fix only matches the broken form + expect(correctTranslatedContentStrings(fixed, '', context)).toBe(fixed) + }) + + test('does not touch other languages or other files', () => { + const broken = + 'En el ejemplo se usa el ejecutor hospedado en {% data variables.product.prodname_dotcom %} `ubuntu-latest` {% ifversion not ghes %} como host de Docker.' + expect(correctTranslatedContentStrings(broken, '', { ...context, code: 'pt' })).toBe(broken) + expect( + correctTranslatedContentStrings(broken, '', { ...context, relativePath: 'other.md' }), + ).toBe(broken) + }) + }) }) diff --git a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts index a4a1ec29566f..57901202dc25 100644 --- a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts +++ b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts @@ -30,7 +30,7 @@ import path from 'node:path' import { parseArgs } from 'node:util' import matter from '@gr2m/gray-matter' -import { stripHiddenBlocks } from './strip-hidden-blocks' +import { stripHiddenBlocks, nextFenceState, type OpenFence } from './strip-hidden-blocks' // Parse CLI arguments const { values: args } = parseArgs({ @@ -819,18 +819,19 @@ function convertDetailsToCodetabs(filePath: string): void { const lines = raw.split('\n') const result: string[] = [] let changed = false - let inCodeBlock = false + let openFence: OpenFence | null = null let i = 0 while (i < lines.length) { const line = lines[i] - // Track code fences to avoid matching
inside code blocks - if (/^\s*```/.test(line)) { - inCodeBlock = !inCodeBlock - } + // A bare toggle counts any ``` line as a delimiter, so a fenced content + // line such as ```
flips the state mid-block. That used to + // self-correct only because a stalled cursor re-toggled the same line. + // Now that every line is visited once, track fences the CommonMark way. + openFence = nextFenceState(line, openFence) - if (inCodeBlock || !/]/.test(line)) { + if (openFence || !/]/.test(line)) { result.push(line) i++ continue @@ -862,6 +863,14 @@ function convertDetailsToCodetabs(filePath: string): void { // Only convert groups of 2+ blocks if (group.length < 2) { + // When the first block fails to parse, `i` never moved — which happens + // for an inline `
` mention in prose, since fence tracking does + // not cover code spans. Step over the line so the loop can't stall. + if (i === groupStartLine) { + result.push(lines[i]) + i++ + continue + } // Emit original lines unchanged for (let j = groupStartLine; j < i; j++) { result.push(lines[j]) diff --git a/src/workflows/sync-sdk-docs/preserve-redirects.ts b/src/workflows/sync-sdk-docs/preserve-redirects.ts new file mode 100644 index 000000000000..1bf56ad9e983 --- /dev/null +++ b/src/workflows/sync-sdk-docs/preserve-redirects.ts @@ -0,0 +1,515 @@ +#!/usr/bin/env node + +/** + * Preserves and generates `redirect_from` frontmatter for synced Copilot SDK docs. + * + * The sync workflow deletes the SDK content directory and rebuilds it from the + * upstream repo on every run. Upstream markdown has no `redirect_from`, and the + * normalizer builds frontmatter from scratch, so every redirect previously added + * in docs-internal is silently dropped. Each sync since the May 2026 restructure + * has needed a manual "restore redirects" commit to avoid shipping live 404s. + * + * This script runs after normalization and reconciles the rebuilt tree against + * the pre-sync state recorded in git: + * + * - Preserve: redirects on a page that still exists are merged back in. + * - Generate: when a page disappears (renamed or moved upstream), its URL — + * plus any redirects it had accumulated — are transferred to its successor, + * so redirect chains are never broken. + * + * The script only ever adds redirects. It never removes one, so a redirect added + * by hand in docs-internal survives indefinitely. + * + * Usage: + * npx tsx preserve-redirects.ts --sdk-docs-dir [--git-ref HEAD] [--fail-on-unresolved] + */ + +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { parseArgs } from 'node:util' +import matter from '@gr2m/gray-matter' + +/** + * Convert a repo-relative content path to the URL docs.github.com serves it at. + * + * `content/copilot/how-tos/copilot-sdk/features/mcp.md` -> `/copilot/how-tos/copilot-sdk/features/mcp` + * `content/copilot/how-tos/copilot-sdk/auth/index.md` -> `/copilot/how-tos/copilot-sdk/auth` + */ +export function contentPathToUrl(repoRelativePath: string): string { + const withoutPrefix = repoRelativePath + .replace(/\\/g, '/') + .replace(/^content\//, '') + .replace(/\.md$/, '') + const withoutIndex = withoutPrefix.replace(/(^|\/)index$/, '') + return `/${withoutIndex}`.replace(/\/$/, '') || '/' +} + +/** Read `redirect_from` from a frontmatter blob, tolerating string or array form. */ +export function readRedirects(data: Record): string[] { + const raw = data.redirect_from + if (!raw) return [] + const list = Array.isArray(raw) ? raw : [raw] + return list.filter((entry): entry is string => typeof entry === 'string') +} + +/** + * Merge redirect lists, preserving first-seen order and dropping duplicates and + * trailing slashes. `redirect-orphans` fails the build on a trailing slash. + */ +export function mergeRedirects(...lists: string[][]): string[] { + const seen = new Set() + const merged: string[] = [] + for (const entry of lists.flat()) { + const normalized = entry.trim().replace(/\/+$/, '') + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + merged.push(normalized) + } + return merged +} + +/** + * The key a page is matched on when looking for its successor. + * + * An `index.md` identifies a directory rather than a page, so matching it on + * its basename would pair unrelated directories. Those match on the parent + * directory name instead. + */ +export function successorKey(repoPath: string): { key: string; reason: string } { + const basename = path.basename(repoPath) + return basename === 'index.md' + ? { key: `dir:${path.basename(path.dirname(repoPath))}`, reason: 'directory name' } + : { key: `file:${basename}`, reason: 'file name' } +} + +/** + * Suggest a candidate successor for a page that no longer exists. + * + * Upstream restructures move files between directories but rarely rename the + * file itself, so an unambiguous name match is a useful hint. It is only a + * hint: matching names are not evidence that one page replaced another, so the + * result is reported for a human to confirm and is never written automatically. + * + * The key must identify exactly one page on *both* sides. Requiring uniqueness + * among `removedPaths` as well as `currentPaths` stops two removed pages that + * share a basename from both being pointed at the same survivor. + */ +export function findSuccessor( + removedPath: string, + currentPaths: string[], + removedPaths: string[], +): { path: string; reason: string } | null { + const { key, reason } = successorKey(removedPath) + + // Ambiguous on the removed side: several pages disappeared under this name, + // so no single one of them can claim the survivor. + if (removedPaths.filter((p) => successorKey(p).key === key).length !== 1) return null + + const matches = currentPaths.filter((p) => successorKey(p).key === key) + return matches.length === 1 ? { path: matches[0], reason } : null +} + +/** + * List the .md files present under a directory at a given git ref. + * + * `git ls-tree` exits 0 with no output when the ref is valid but the path is + * absent, so an empty list genuinely means "nothing there yet" (the first sync). + * A throw therefore means the ref itself could not be read, which must fail the + * run rather than be mistaken for a first sync — silently treating a broken + * baseline as empty would drop every redirect in the tree. + */ +function listFilesAtRef(repoRoot: string, ref: string, dirRelativeToRoot: string): string[] { + let out: string + try { + out = execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', dirRelativeToRoot], { + encoding: 'utf8', + cwd: repoRoot, + }) + } catch (error) { + throw new Error( + `Could not read the baseline tree at ref '${ref}'. Refusing to continue, because ` + + `treating an unreadable baseline as empty would silently drop every redirect ` + + `under ${dirRelativeToRoot}.\n ${(error as Error).message}`, + ) + } + return out + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.endsWith('.md')) +} + +/** + * Read a file's contents at a given git ref. + * + * Callers only ask for paths that `listFilesAtRef` just reported at this same + * ref, so a failure here is a real error, not a missing file. + */ +function readFileAtRef(repoRoot: string, ref: string, repoRelativePath: string): string { + try { + return execFileSync('git', ['show', `${ref}:${repoRelativePath}`], { + encoding: 'utf8', + cwd: repoRoot, + maxBuffer: 20 * 1024 * 1024, + }) + } catch (error) { + throw new Error( + `Could not read '${repoRelativePath}' at ref '${ref}', although it is listed there. ` + + `Refusing to continue, because skipping it would silently drop its redirects.\n ` + + `${(error as Error).message}`, + ) + } +} + +/** Recursively collect .md files from the working tree. */ +function getAllMarkdownFiles(dir: string): string[] { + const results: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...getAllMarkdownFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(fullPath) + } + } + return results +} + +type PreSyncPage = { + url: string + redirects: string[] +} + +/** + * Insert or replace the `redirect_from` block in a raw frontmatter string. + * + * The block is edited as text rather than re-serialized from a parsed object. + * Round-tripping through YAML rewraps long values — the `intro` field in + * particular — which would bury the redirect change in unrelated reflow noise + * on every sync. Editing the lines directly leaves every other byte untouched. + * + * The block is placed just before `contentType` to match how these files are + * already written, falling back to the end of the frontmatter. + */ +export function upsertRedirectBlock(rawFrontmatter: string, redirects: string[]): string { + const lines = rawFrontmatter.split('\n') + const isListItem = (line: string | undefined) => line !== undefined && /^\s+-\s/.test(line) + const kept: string[] = [] + + // Drop any existing redirect_from, in block form or inline form. + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (/^redirect_from:\s*$/.test(line)) { + // Consume the indented list that follows. A blank line is only part of + // the block if another list item comes after it; otherwise it belongs to + // whatever follows and must be preserved. + let j = i + 1 + while (j < lines.length) { + if (isListItem(lines[j])) { + j++ + continue + } + if (lines[j].trim() === '') { + let k = j + while (k < lines.length && lines[k].trim() === '') k++ + if (isListItem(lines[k])) { + j = k + continue + } + } + break + } + i = j - 1 + continue + } + + if (/^redirect_from:\s*\S/.test(line)) continue + kept.push(line) + } + + if (redirects.length === 0) return kept.join('\n') + + const block = ['redirect_from:', ...redirects.map((url) => ` - ${url}`)] + const contentTypeIndex = kept.findIndex((line) => /^contentType:/.test(line)) + const insertAt = contentTypeIndex === -1 ? kept.length : contentTypeIndex + + kept.splice(insertAt, 0, ...block) + return kept.join('\n') +} + +/** + * Rewrite a file's `redirect_from` in place. Returns true if the file changed. + */ +function writeRedirects(absolutePath: string, redirects: string[]): boolean { + const raw = fs.readFileSync(absolutePath, 'utf8') + const match = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n)([\s\S]*)$/) + if (!match) { + throw new Error( + `Cannot add redirects to '${absolutePath}' because it has no frontmatter block. ` + + `Refusing to continue, because skipping the write would silently drop the ` + + `redirects meant for this page:\n ${redirects.join('\n ')}`, + ) + } + const [, open, frontmatterText, close, body] = match + const updated = upsertRedirectBlock(frontmatterText, redirects) + if (updated === frontmatterText) return false + fs.writeFileSync(absolutePath, `${open}${updated}${close}${body}`, 'utf8') + return true +} + +function main() { + const { values: args } = parseArgs({ + options: { + 'sdk-docs-dir': { type: 'string' }, + 'git-ref': { type: 'string', default: 'HEAD' }, + 'fail-on-unresolved': { type: 'boolean', default: false }, + }, + }) + + const sdkDocsDirArg = args['sdk-docs-dir'] + if (!sdkDocsDirArg) { + console.error('Missing required argument: --sdk-docs-dir') + process.exit(1) + } + + const resolvedArg = path.resolve(sdkDocsDirArg) + if (!fs.existsSync(resolvedArg)) { + console.error(`SDK docs directory not found: ${resolvedArg}`) + process.exit(1) + } + + const sdkDocsDir = fs.realpathSync(resolvedArg) + const gitRef = args['git-ref'] as string + const failOnUnresolved = args['fail-on-unresolved'] as boolean + + // Resolve the root from the docs directory so the script works against any + // checkout, not just the process's current working directory. Both sides are + // canonicalized so a symlinked path (macOS /var -> /private/var) still yields + // a correct relative path. + const repoRoot = fs.realpathSync( + path.resolve( + execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + cwd: sdkDocsDir, + }).trim(), + ), + ) + + const sdkDirRelative = path.relative(repoRoot, sdkDocsDir).replace(/\\/g, '/') + + // 1. Record the pre-sync state from git. + const preSyncPaths = listFilesAtRef(repoRoot, gitRef, sdkDirRelative) + const preSyncPages = new Map() + for (const repoPath of preSyncPaths) { + const raw = readFileAtRef(repoRoot, gitRef, repoPath) + let data: Record + try { + data = matter(raw).data as Record + } catch (error) { + throw new Error( + `Could not parse the frontmatter of '${repoPath}' at ref '${gitRef}'. Refusing to ` + + `continue, because treating it as empty would silently drop any redirects it ` + + `carries.\n ${(error as Error).message}`, + ) + } + preSyncPages.set(repoPath, { + url: contentPathToUrl(repoPath), + redirects: readRedirects(data), + }) + } + + if (preSyncPaths.length === 0) { + console.log(`No SDK docs exist at ${gitRef} yet — nothing to preserve.`) + return + } + + // 2. Read the post-sync working tree. + const currentRepoPaths = getAllMarkdownFiles(sdkDocsDir).map((p) => + path.relative(repoRoot, p).replace(/\\/g, '/'), + ) + const currentRepoPathSet = new Set(currentRepoPaths) + const currentUrls = new Set(currentRepoPaths.map(contentPathToUrl)) + + // Several files can resolve to one URL (`guide.md` and `guide/index.md` both + // serve `.../guide`), so the reverse mapping is one-to-many. + const currentPathsByUrl = new Map() + for (const repoPath of currentRepoPaths) { + const url = contentPathToUrl(repoPath) + currentPathsByUrl.set(url, [...(currentPathsByUrl.get(url) ?? []), repoPath]) + } + + // Redirects to add, keyed by the repo-relative path of the page receiving them. + const additions = new Map() + const addFor = (repoPath: string, urls: string[]) => { + additions.set(repoPath, mergeRedirects(additions.get(repoPath) ?? [], urls)) + } + + // 3. Preserve redirects for pages that survived the sync at the same path. + let preservedPages = 0 + for (const repoPath of currentRepoPaths) { + const before = preSyncPages.get(repoPath) + if (!before || before.redirects.length === 0) continue + addFor(repoPath, before.redirects) + preservedPages++ + } + + const allRemoved = [...preSyncPages.keys()].filter((p) => !currentRepoPathSet.has(p)) + + // 4. A page can lose its file while keeping its URL, because `guide.md` and + // `guide/index.md` serve the same URL. The URL itself stays live, so nothing + // 404s and no successor guess is needed — but the redirects it inherited are + // still stranded, since the file now serving that URL has never carried them. + // Transfer those by URL identity rather than by inference. + const needSuccessor: string[] = [] + let reshaped = 0 + for (const removedPath of allRemoved) { + const before = preSyncPages.get(removedPath)! + const servingPaths = currentPathsByUrl.get(before.url) + if (!servingPaths) { + needSuccessor.push(removedPath) + continue + } + // `before.url` is deliberately not carried over: it is the URL these files + // already serve, so adding it would create a self-redirect. + if (before.redirects.length === 0) continue + if (servingPaths.length > 1) { + throw new Error( + `The URL '${before.url}' is served by more than one file after the sync ` + + `(${servingPaths.join(', ')}), so there is no single place to move the ` + + `redirects that '${removedPath}' was carrying:\n ${before.redirects.join('\n ')}`, + ) + } + addFor(servingPaths[0], before.redirects) + reshaped++ + console.log(` RESHAPED: ${before.url} still served by ${servingPaths[0]}, redirects moved`) + } + + // 5. Pages that lost their URL outright need a human decision. + // + // A same-named page elsewhere in the tree is reported as a candidate but is + // never written. Matching names is not evidence of succession, and a redirect + // aimed at the wrong live page is worse than a 404 because nothing catches + // it: `render-changed-and-deleted-files` asserts the old URL resolves, but + // never checks where it lands. + const unresolved: { repoPath: string; urls: string[]; candidate: string | null }[] = [] + for (const removedPath of needSuccessor) { + const before = preSyncPages.get(removedPath)! + const successor = findSuccessor(removedPath, currentRepoPaths, needSuccessor) + unresolved.push({ + repoPath: removedPath, + // Every URL here 404s, not just the page's own: the redirects it carried + // have no other home either. + urls: [before.url, ...before.redirects], + candidate: successor ? contentPathToUrl(successor.path) : null, + }) + } + + // 6. Write the merged frontmatter back. + let written = 0 + let addedEntries = 0 + for (const [repoPath, incoming] of additions) { + const absolutePath = path.join(repoRoot, repoPath) + if (!fs.existsSync(absolutePath)) { + throw new Error( + `Expected to add redirects to '${repoPath}', but it is not on disk. Refusing to ` + + `continue, because skipping the write would silently drop these redirects:\n ` + + `${incoming.join('\n ')}`, + ) + } + + const existing = readRedirects(matter.read(absolutePath).data as Record) + const selfUrl = contentPathToUrl(repoPath) + + const merged = mergeRedirects(existing, incoming).filter((url) => { + // A page must never redirect to itself. + if (url === selfUrl) return false + // `redirect-orphans` fails if a live page's URL is another page's + // redirect_from. Keep entries we already had so this stays additive, and + // let that test flag any pre-existing conflict. + if (currentUrls.has(url) && !existing.includes(url)) { + console.log(` SKIP (live page): ${url} would shadow an existing page`) + return false + } + return true + }) + + if (merged.length === existing.length && merged.every((v, i) => v === existing[i])) continue + + if (writeRedirects(absolutePath, merged)) { + addedEntries += merged.length - existing.length + written++ + } + } + + const lostUrlCount = unresolved.reduce((n, entry) => n + entry.urls.length, 0) + + console.log('\n--- Redirect preservation summary ---') + console.log(` Pages kept in place with redirects: ${preservedPages}`) + console.log(` Pages reshaped but same URL: ${reshaped}`) + console.log(` Files rewritten: ${written}`) + console.log(` Redirect entries added: ${addedEntries}`) + + if (unresolved.length > 0) { + console.log( + `\n Removed pages needing a redirect decision (${unresolved.length} pages, ${lostUrlCount} URLs):`, + ) + for (const entry of unresolved) { + console.log(` was ${entry.repoPath}`) + for (const url of entry.urls) console.log(` ${url}`) + if (entry.candidate) console.log(` possible replacement: ${entry.candidate}`) + } + console.log( + '\n Add the URLs above to the `redirect_from` of whichever page replaced\n' + + ' them, or confirm they were never published. Any "possible replacement"\n' + + ' is a same-name match only and has not been verified.', + ) + + // Surface this in the Actions run summary. Buried log output is how the + // earlier 404s went unnoticed until they reached production. + if (process.env.GITHUB_STEP_SUMMARY) { + const summary = [ + `### Copilot SDK docs sync: ${lostUrlCount} URLs need a redirect decision`, + '', + 'These pages were removed upstream. The URLs below will 404 once this sync', + 'merges unless they are added to whichever page replaced them. Each entry lists', + 'the removed page followed by every URL that depended on it, including redirects', + 'it had inherited from earlier renames.', + '', + ...unresolved.flatMap((entry) => [ + `- \`${entry.repoPath}\``, + ...entry.urls.map((url) => ` - \`${url}\``), + ...(entry.candidate + ? [` - Possible replacement (same name only, **unverified**): \`${entry.candidate}\``] + : []), + ]), + '', + 'To fix, add the URLs to the replacement page:', + '', + '```yaml', + 'redirect_from:', + ...unresolved.flatMap((entry) => entry.urls.map((url) => ` - ${url}`)), + '```', + '', + 'A same-name match is only a suggestion. Redirecting to the wrong page is not', + 'caught by any test, so confirm each target before adding it.', + '', + ].join('\n') + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary, 'utf8') + } + + if (failOnUnresolved) process.exit(1) + } +} + +// Only run when executed directly, so the helpers above stay unit-testable. +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { + try { + main() + } catch (error) { + // Every throw in this script marks a case where continuing would silently + // drop redirects, so failing the sync is the intended outcome. + console.error(`\nRedirect preservation failed.\n\n${(error as Error).message}\n`) + process.exit(1) + } +} diff --git a/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts b/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts index ce3424e1c7ce..261cf9b5383c 100644 --- a/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts +++ b/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts @@ -38,7 +38,7 @@ const HIDDEN_CLOSE = /^\s*/i // indented at most 3 spaces, and the run of backticks or tildes may exceed 3. const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/ -interface OpenFence { +export interface OpenFence { char: string length: number } @@ -50,7 +50,7 @@ interface OpenFence { * long, and carry no info string. Tracking the length matters because a * four-backtick fence can legally contain a three-backtick line as content. */ -function nextFenceState(line: string, open: OpenFence | null): OpenFence | null { +export function nextFenceState(line: string, open: OpenFence | null): OpenFence | null { const match = FENCE.exec(line) if (!match) return open diff --git a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts new file mode 100644 index 000000000000..c2fb5b0ad9f4 --- /dev/null +++ b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts @@ -0,0 +1,577 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +import { describe, expect, test, beforeAll, afterAll } from 'vitest' + +import { + contentPathToUrl, + readRedirects, + mergeRedirects, + findSuccessor, + upsertRedirectBlock, +} from '@/workflows/sync-sdk-docs/preserve-redirects' + +const SCRIPT = path.join(process.cwd(), 'src/workflows/sync-sdk-docs/preserve-redirects.ts') +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`. + */ +const runScript = (cwd: string, args: string[] = []) => + execFileSync('npx', ['tsx', SCRIPT, ...args], { + cwd, + encoding: 'utf8', + env: { ...process.env, GITHUB_STEP_SUMMARY: path.join(cwd, STEP_SUMMARY_FILE) }, + }) + +const readStepSummary = (cwd: string) => { + const file = path.join(cwd, STEP_SUMMARY_FILE) + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '' +} + +describe('contentPathToUrl', () => { + test('strips the content prefix and .md extension', () => { + expect(contentPathToUrl(`${SDK_DIR}/features/mcp.md`)).toBe( + '/copilot/how-tos/copilot-sdk/features/mcp', + ) + }) + + test('collapses index.md to its directory', () => { + expect(contentPathToUrl(`${SDK_DIR}/auth/index.md`)).toBe('/copilot/how-tos/copilot-sdk/auth') + }) + + test('does not strip "index" from a longer filename', () => { + expect(contentPathToUrl(`${SDK_DIR}/reindex.md`)).toBe('/copilot/how-tos/copilot-sdk/reindex') + }) +}) + +describe('readRedirects', () => { + test('returns an empty list when absent', () => { + expect(readRedirects({})).toEqual([]) + }) + + test('accepts a bare string as well as an array', () => { + expect(readRedirects({ redirect_from: '/old' })).toEqual(['/old']) + expect(readRedirects({ redirect_from: ['/a', '/b'] })).toEqual(['/a', '/b']) + }) + + test('ignores non-string entries', () => { + expect(readRedirects({ redirect_from: ['/a', 42, null] })).toEqual(['/a']) + }) +}) + +describe('mergeRedirects', () => { + test('dedupes while preserving first-seen order', () => { + expect(mergeRedirects(['/a', '/b'], ['/b', '/c'])).toEqual(['/a', '/b', '/c']) + }) + + test('strips trailing slashes, which redirect-orphans rejects', () => { + expect(mergeRedirects(['/a/'])).toEqual(['/a']) + }) + + test('drops empty entries', () => { + expect(mergeRedirects(['', ' ', '/a'])).toEqual(['/a']) + }) +}) + +describe('findSuccessor', () => { + test('matches a moved page by its unique filename', () => { + const successor = findSuccessor( + `${SDK_DIR}/old/mcp.md`, + [`${SDK_DIR}/features/mcp.md`, `${SDK_DIR}/features/skills.md`], + [`${SDK_DIR}/old/mcp.md`], + ) + expect(successor?.path).toBe(`${SDK_DIR}/features/mcp.md`) + }) + + test('refuses to guess when the filename is ambiguous', () => { + expect( + findSuccessor( + `${SDK_DIR}/old/mcp.md`, + [`${SDK_DIR}/features/mcp.md`, `${SDK_DIR}/setup/mcp.md`], + [`${SDK_DIR}/old/mcp.md`], + ), + ).toBeNull() + }) + + test('refuses a many-to-one match when two removed pages share a filename', () => { + 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. + for (const removed of removedPaths) { + expect(findSuccessor(removed, currentPaths, removedPaths)).toBeNull() + } + }) + + test('refuses a many-to-one match when two removed directories share a name', () => { + const currentPaths = [`${SDK_DIR}/hooks/index.md`] + const removedPaths = [`${SDK_DIR}/a/hooks/index.md`, `${SDK_DIR}/b/hooks/index.md`] + + for (const removed of removedPaths) { + expect(findSuccessor(removed, currentPaths, removedPaths)).toBeNull() + } + }) + + test('matches index.md on its directory name, not the filename', () => { + const successor = findSuccessor( + `${SDK_DIR}/use-hooks/index.md`, + [`${SDK_DIR}/hooks/index.md`, `${SDK_DIR}/use-hooks/index.md`], + [`${SDK_DIR}/use-hooks/index.md`], + ) + expect(successor?.path).toBe(`${SDK_DIR}/use-hooks/index.md`) + }) + + test('does not pair unrelated directories through their index.md', () => { + expect( + findSuccessor( + `${SDK_DIR}/auth/index.md`, + [`${SDK_DIR}/features/index.md`, `${SDK_DIR}/setup/index.md`], + [`${SDK_DIR}/auth/index.md`], + ), + ).toBeNull() + }) + + 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. + expect( + findSuccessor( + `${SDK_DIR}/hooks/index.md`, + [`${SDK_DIR}/features/hooks.md`], + [`${SDK_DIR}/hooks/index.md`], + ), + ).toBeNull() + }) + + test('returns null when nothing matches', () => { + expect( + findSuccessor(`${SDK_DIR}/gone.md`, [`${SDK_DIR}/features/mcp.md`], [`${SDK_DIR}/gone.md`]), + ).toBeNull() + }) +}) + +describe('upsertRedirectBlock', () => { + const frontmatter = [ + 'title: Example', + 'intro: >-', + ' A long intro that YAML would rewrap if the file were re-serialized', + ' instead of edited as text.', + 'versions:', + " fpt: '*'", + 'contentType: how-tos', + ].join('\n') + + test('inserts the block directly before contentType', () => { + expect(upsertRedirectBlock(frontmatter, ['/old']).split('\n')).toEqual([ + 'title: Example', + 'intro: >-', + ' A long intro that YAML would rewrap if the file were re-serialized', + ' instead of edited as text.', + 'versions:', + " fpt: '*'", + 'redirect_from:', + ' - /old', + 'contentType: how-tos', + ]) + }) + + test('leaves every other line byte-identical', () => { + const result = upsertRedirectBlock(frontmatter, ['/old']) + for (const line of frontmatter.split('\n')) { + expect(result).toContain(line) + } + }) + + test('replaces an existing block rather than duplicating it', () => { + const withBlock = upsertRedirectBlock(frontmatter, ['/one']) + const replaced = upsertRedirectBlock(withBlock, ['/one', '/two']) + expect(replaced.match(/redirect_from:/g)).toHaveLength(1) + expect(replaced).toContain(' - /one') + expect(replaced).toContain(' - /two') + }) + + test('replaces the inline form too', () => { + const inline = 'title: Example\nredirect_from: /solo\ncontentType: how-tos' + const result = upsertRedirectBlock(inline, ['/solo', '/extra']) + expect(result.match(/redirect_from/g)).toHaveLength(1) + expect(result).toContain(' - /extra') + }) + + test('appends when there is no contentType key', () => { + const result = upsertRedirectBlock('title: Example', ['/old']) + expect(result).toBe('title: Example\nredirect_from:\n - /old') + }) + + test('removes the block when given no redirects', () => { + const withBlock = upsertRedirectBlock(frontmatter, ['/one']) + expect(upsertRedirectBlock(withBlock, [])).toBe(frontmatter) + }) + + test('consumes blank lines inside a hand-edited block', () => { + const messy = [ + 'title: Example', + 'redirect_from:', + ' - /one', + '', + ' - /two', + 'contentType: how-tos', + ].join('\n') + expect(upsertRedirectBlock(messy, ['/one', '/two'])).toBe( + ['title: Example', 'redirect_from:', ' - /one', ' - /two', 'contentType: how-tos'].join( + '\n', + ), + ) + }) + + test('keeps a blank line that separates the block from the next key', () => { + const spaced = [ + 'title: Example', + 'redirect_from:', + ' - /one', + '', + 'contentType: how-tos', + ].join('\n') + expect(upsertRedirectBlock(spaced, ['/one'])).toBe( + ['title: Example', '', 'redirect_from:', ' - /one', 'contentType: how-tos'].join('\n'), + ) + }) + + test('ignores keys that merely contain redirect_from', () => { + const decoy = 'title: Example\nold_redirect_from: /decoy\ncontentType: how-tos' + expect(upsertRedirectBlock(decoy, ['/real'])).toContain('old_redirect_from: /decoy') + }) +}) + +/** + * 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. + */ +describe('preserve-redirects end to end', () => { + let repo: string + + const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) + + const write = (relativePath: string, contents: string) => { + const full = path.join(repo, relativePath) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.writeFileSync(full, contents, 'utf8') + } + + const read = (relativePath: string) => fs.readFileSync(path.join(repo, relativePath), 'utf8') + + const page = (title: string, redirects?: string[]) => + [ + '---', + `title: ${title}`, + 'versions:', + " fpt: '*'", + ...(redirects ? ['redirect_from:', ...redirects.map((r) => ` - ${r}`)] : []), + 'contentType: how-tos', + '---', + '', + 'Body text.', + '', + ].join('\n') + + const run = (extraArgs: string[] = []) => + runScript(repo, ['--sdk-docs-dir', path.join(repo, SDK_DIR), ...extraArgs]) + + const stepSummary = () => readStepSummary(repo) + + beforeAll(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-')) + git('init', '--initial-branch=main') + 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. + 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')) + write(`${SDK_DIR}/features/stable.md`, page('Stable')) + git('add', '-A') + git('commit', '-m', 'pre-sync state') + }) + + afterAll(() => { + if (repo) fs.rmSync(repo, { recursive: true, force: true }) + }) + + 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')) + write(`${SDK_DIR}/use-hooks/index.md`, page('Hooks')) + write(`${SDK_DIR}/features/stable.md`, page('Stable')) + + 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') + }) + + test('is idempotent — a second run changes nothing', () => { + const before = [ + read(`${SDK_DIR}/features/mcp.md`), + read(`${SDK_DIR}/setup/moving.md`), + read(`${SDK_DIR}/features/stable.md`), + ] + + run() + + expect([ + read(`${SDK_DIR}/features/mcp.md`), + read(`${SDK_DIR}/setup/moving.md`), + read(`${SDK_DIR}/features/stable.md`), + ]).toEqual(before) + }) + + test('never points a page at its own URL', () => { + for (const relativePath of [`${SDK_DIR}/features/mcp.md`, `${SDK_DIR}/setup/moving.md`]) { + const selfUrl = contentPathToUrl(relativePath) + expect(read(relativePath)).not.toContain(`- ${selfUrl}\n`) + } + }) + + 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. + 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.' + + const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-reflow-')) + const igit = (...args: string[]) => + execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) + const target = path.join(isolated, SDK_DIR, 'features/stable.md') + + const build = (redirect: boolean) => + [ + '---', + 'title: Stable', + `intro: ${longIntro}`, + ...(redirect ? ['redirect_from:', ' - /copilot/how-tos/copilot-sdk/legacy-stable'] : []), + 'contentType: how-tos', + '---', + '', + 'Body.', + '', + ].join('\n') + + try { + igit('init', '--initial-branch=main') + igit('config', 'user.email', 'test@example.com') + igit('config', 'user.name', 'Test') + fs.mkdirSync(path.dirname(target), { recursive: true }) + + fs.writeFileSync(target, build(true), 'utf8') + igit('add', '-A') + 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)]) + + expect(fs.readFileSync(target, 'utf8')).toBe(expected) + expect(fs.readFileSync(target, 'utf8')).toContain(`intro: ${longIntro}`) + } finally { + fs.rmSync(isolated, { recursive: true, force: true }) + } + }) + + test('reports an ambiguous removal instead of guessing', () => { + git('add', '-A') + git('commit', '-m', 'sync result') + + // `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') + fs.rmSync(path.join(repo, `${SDK_DIR}/features/gone.md`)) + + const output = run() + expect(output).toContain('needing a redirect decision') + expect(output).toContain('/copilot/how-tos/copilot-sdk/features/gone') + }) + + test('exits non-zero on an unresolved removal when asked to', () => { + expect(() => run(['--fail-on-unresolved'])).toThrow() + }) + + 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. + const file = path.join(repo, 'step-summary.md') + fs.rmSync(file, { force: true }) + + write(`${SDK_DIR}/features/vanishing.md`, page('Vanishing', ['/copilot/older-vanishing'])) + git('add', '-A') + git('commit', '-m', 'add page that will vanish') + fs.rmSync(path.join(repo, `${SDK_DIR}/features/vanishing.md`)) + + run() + + // Guards the env redirect in `run`: without it these synthetic warnings + // would be appended to the real Actions job summary during CI. + 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. + 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. + let message = '' + try { + run(['--git-ref', 'refs/heads/no-such-ref']) + throw new Error('expected the script to fail') + } catch (error) { + message = `${(error as Error).message}${(error as { stderr?: string }).stderr ?? ''}` + } + expect(message).toContain('Could not read the baseline tree') + expect(message).not.toContain('nothing to preserve') + }) + + 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. + const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-reshape-')) + const igit = (...args: string[]) => + execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) + const flat = path.join(isolated, SDK_DIR, 'features/guide.md') + const nested = path.join(isolated, SDK_DIR, 'features/guide/index.md') + + try { + igit('init', '--initial-branch=main') + igit('config', 'user.email', 'test@example.com') + igit('config', 'user.name', 'Test') + fs.mkdirSync(path.dirname(flat), { recursive: true }) + fs.writeFileSync(flat, page('Guide', ['/copilot/ancient-guide']), 'utf8') + igit('add', '-A') + igit('commit', '-m', 'pre-sync state') + + fs.rmSync(flat) + fs.mkdirSync(path.dirname(nested), { recursive: true }) + fs.writeFileSync(nested, page('Guide'), 'utf8') + + runScript(isolated, ['--sdk-docs-dir', path.join(isolated, SDK_DIR), '--fail-on-unresolved']) + + const result = fs.readFileSync(nested, 'utf8') + expect(result).toContain('/copilot/ancient-guide') + // The URL the file already serves must not be added to its own page. + expect(result).not.toContain('- /copilot/how-tos/copilot-sdk/features/guide\n') + } finally { + fs.rmSync(isolated, { recursive: true, force: true }) + } + }) + + 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. + const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-first-')) + const igit = (...args: string[]) => + execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) + + try { + igit('init', '--initial-branch=main') + igit('config', 'user.email', 'test@example.com') + igit('config', 'user.name', 'Test') + fs.writeFileSync(path.join(isolated, 'README.md'), '# Repo\n', 'utf8') + 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') + + const output = runScript(isolated, [ + '--sdk-docs-dir', + path.join(isolated, SDK_DIR), + '--fail-on-unresolved', + ]) + expect(output).toContain('nothing to preserve') + } finally { + fs.rmSync(isolated, { recursive: true, force: true }) + } + }) + + test('fails loudly when the page receiving redirects has no frontmatter', () => { + const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-redirects-nofm-')) + const igit = (...args: string[]) => + execFileSync('git', args, { cwd: isolated, encoding: 'utf8' }) + const target = path.join(isolated, SDK_DIR, 'features/stable.md') + + try { + igit('init', '--initial-branch=main') + igit('config', 'user.email', 'test@example.com') + igit('config', 'user.name', 'Test') + fs.mkdirSync(path.dirname(target), { recursive: true }) + + fs.writeFileSync( + target, + [ + '---', + 'title: Stable', + 'redirect_from:', + ' - /copilot/legacy', + '---', + '', + 'Body.', + '', + ].join('\n'), + 'utf8', + ) + 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. + fs.writeFileSync(target, 'Body only, no frontmatter.\n', 'utf8') + + let message = '' + try { + runScript(isolated, ['--sdk-docs-dir', path.join(isolated, SDK_DIR)]) + throw new Error('expected the script to fail') + } catch (error) { + message = `${(error as Error).message}${(error as { stderr?: string }).stderr ?? ''}` + } + expect(message).toContain('no frontmatter block') + expect(message).toContain('/copilot/legacy') + } finally { + fs.rmSync(isolated, { recursive: true, force: true }) + } + }) +})