diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 382e7e674615..443aa8e45101 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,4 @@ -# To find available Node images, see https://mcr.microsoft.com/en-us/product/devcontainers/javascript-node/tags +# Update ARG and FROM together from the Node devcontainer image tags: +# https://mcr.microsoft.com/en-us/product/devcontainers/javascript-node/tags ARG VARIANT=dev-24-bookworm -FROM mcr.microsoft.com/devcontainers/javascript-node:dev-24-bookworm@sha256:c6c609fc8c4e9418991aae295debc1f4f94f000f4cc7ca5531446f1ea8258056 +FROM mcr.microsoft.com/devcontainers/javascript-node:dev-24-bookworm@sha256:c39e4aaf0c7c4da594f845aeb98d37510343bff178b320a6f7e9e2e95ca7957e diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 43c2ca6b814a..bd2d9607f813 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1,4 @@ -# Order is important. The LAST matching pattern has the MOST precedence. -# gitignore style patterns are used, not globs. -# https://docs.github.com/articles/about-codeowners -# https://git-scm.com/docs/gitignore +# Last matching pattern wins. +# Patterns use gitignore syntax, not glob syntax. -# Site Policy content/site-policy/ @github/site-policy-admins - -# Requires review of #actions-oidc-integration, docs-engineering/issues/1506 -# content/actions/deployment/security-hardening-your-deployments/** @github/oidc diff --git a/.github/actions/cache-nextjs/action.yml b/.github/actions/cache-nextjs/action.yml index 12281457f28e..6449ba374ad9 100644 --- a/.github/actions/cache-nextjs/action.yml +++ b/.github/actions/cache-nextjs/action.yml @@ -1,4 +1,5 @@ -# Based on https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching#github-actions +# Based on Next.js CI build cache guidance: +# https://nextjs.org/docs/pages/building-your-application/deploying/ci-build-caching#github-actions name: Cache Nextjs build cache @@ -11,8 +12,8 @@ runs: uses: actions/cache@v4 with: path: ${{ github.workspace }}/.next/cache - # Generate a new cache whenever packages or source files change. + # Packages and source files both invalidate the cache. key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }} - # If source files changed but packages didn't, rebuild from a prior cache. + # With matching restore-key prefixes, source-only changes restore the same-package cache. restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- diff --git a/.github/actions/create-workflow-failure-issue/action.yml b/.github/actions/create-workflow-failure-issue/action.yml index 3bbb95775751..7812930aee3f 100644 --- a/.github/actions/create-workflow-failure-issue/action.yml +++ b/.github/actions/create-workflow-failure-issue/action.yml @@ -104,8 +104,6 @@ runs: --body "$body") echo "issue_url=$url" >> "$GITHUB_OUTPUT" - # Set the type separately, and tolerate failure. This action is itself the - # failure path, so losing the whole issue because issue types are unavailable - # or `gh` is too old (--type needs gh 2.94+) would hide the original failure. + # Set type separately with gh 2.94+ --type; keep the issue visible if gh or issue types lack support. gh issue edit "$url" --type Bug \ || echo "Warning: could not set issue type on $url; leaving it unset." diff --git a/.github/actions/labeler/labeler.ts b/.github/actions/labeler/labeler.ts index 03606570cd6a..3f7589c2653c 100644 --- a/.github/actions/labeler/labeler.ts +++ b/.github/actions/labeler/labeler.ts @@ -1,5 +1,3 @@ -/* See function main in this file for documentation */ - import * as coreLib from '@actions/core' import { type Octokit } from '@octokit/rest' import { CoreInject } from '@/links/scripts/action-injections' @@ -18,7 +16,7 @@ type Options = { repo?: string } -// When this file is invoked directly from action as opposed to being imported +// Run action wiring only for direct execution, not imports from tests or other code. if (import.meta.url.endsWith(process.argv[1])) { if (!process.env.GITHUB_TOKEN) { throw new Error('You must set the GITHUB_TOKEN environment variable.') @@ -33,7 +31,7 @@ if (import.meta.url.endsWith(process.argv[1])) { ignoreIfLabeled: boolEnvVar('IGNORE_IF_LABELED'), } - // labels come in comma separated from actions + // Actions pass comma-separated labels. if (typeof ADD_LABELS === 'string') { opts.addLabels = [...ADD_LABELS.split(',')].map((l) => l.trim()) } else { @@ -60,18 +58,6 @@ if (import.meta.url.endsWith(process.argv[1])) { main(coreLib, octokit, opts) } -/* - * Applies labels to an issue or pull request. - * - * opts: - * issue_number {number} id of the issue or pull request to label - * owner {string} owner of the repository - * repo {string} repository name - * addLabels {Array} array of labels to apply - * removeLabels {Array} array of labels to remove - * ignoreIfAssigned {boolean} don't apply labels if there are assignees - * ignoreIfLabeled {boolean} don't apply labels if there are already labels added - */ export default async function main( core: typeof coreLib | CoreInject, octokit: Octokit, @@ -118,7 +104,7 @@ export default async function main( } if (opts.removeLabels?.length) { - // removing a label fails if the label isn't already applied + // Remove only applied labels because the API rejects missing labels. let appliedLabels = [] try { diff --git a/.github/actions/node-npm-setup/action.yml b/.github/actions/node-npm-setup/action.yml index 5f488d7d935e..ce5e67de33fb 100644 --- a/.github/actions/node-npm-setup/action.yml +++ b/.github/actions/node-npm-setup/action.yml @@ -9,8 +9,7 @@ runs: uses: actions/cache@v4 id: cache-node_modules env: - # Default is 10 min, per segment, but we can make it much smaller - # because it's not the end of the world if the cache restore fails. + # Cache restore failures are acceptable, so keep the segment timeout short. SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1' with: path: node_modules diff --git a/.github/actions/precompute-pageinfo/action.yml b/.github/actions/precompute-pageinfo/action.yml deleted file mode 100644 index c6e7da64fc09..000000000000 --- a/.github/actions/precompute-pageinfo/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Warmup pageinfo cache - -description: Run this to create a .pageinfo-cache.json.br file - -inputs: - restore-only: - description: Only attempt to restore, don't warm up - required: false - -runs: - using: 'composite' - steps: - # The caching technique here is to "unboundedly" add to the cache. - # By unboundedly, it means the cached item will grow and grow. - # The general idea is that we A) restore from cache, B) replace the - # file by running the script, and C) save the file back to cache. - # Optionally, you can have it just do A (and not B and C). - - - name: Cache .pageinfo-cache.json.br (restore) - uses: actions/cache/restore@v4 - with: - path: .pageinfo-cache.json.br - key: pageinfo-cache- - restore-keys: pageinfo-cache- - - # When we use this composite action from deployment workflows - # we don't have any Node installed or any of its packages. I.e. we never - # run `npm ci` in those actions. For security sake. - # So we can't do things that require Node code. - # Tests and others will omit the `restore-only` input, but - # prepping for Docker build and push, will set it to a non-empty - # string which basically means "If you can restore it, great. - # If not, that's fine, don't bother". - - name: Run script - if: ${{ inputs.restore-only == '' }} - shell: bash - run: npm run precompute-pageinfo -- --max-versions 2 - - - name: Cache .remotejson-cache (save) - if: ${{ inputs.restore-only == '' }} - uses: actions/cache/save@v4 - with: - path: .pageinfo-cache.json.br - key: pageinfo-cache-${{ github.sha }} diff --git a/.github/actions/retry-command/action.yml b/.github/actions/retry-command/action.yml index 6bbf45f9797e..f52da02f5a24 100644 --- a/.github/actions/retry-command/action.yml +++ b/.github/actions/retry-command/action.yml @@ -23,7 +23,6 @@ runs: INPUT_DELAY: ${{ inputs.delay }} INPUT_COMMAND: ${{ inputs.command }} run: | - # Generic retry function: configurable attempts and delay retry_command() { local max_attempts=${INPUT_MAX_ATTEMPTS} local delay=${INPUT_DELAY} diff --git a/.github/actions/setup-elasticsearch/action.yml b/.github/actions/setup-elasticsearch/action.yml index 813e46bc11d8..408fe9a794a2 100644 --- a/.github/actions/setup-elasticsearch/action.yml +++ b/.github/actions/setup-elasticsearch/action.yml @@ -1,4 +1,4 @@ -# For the sake of saving time, only run this step if the test-group is one that will run tests against an Elasticsearch on localhost. +# Callers skip this action for test groups that do not use local Elasticsearch. name: Set up local Elasticsearch description: Install a local Elasticsearch with version that matches prod @@ -10,13 +10,13 @@ inputs: elasticsearch_version: description: Version of Elasticsearch to install required: true - # Make sure the version matches production and is available on Docker Hub + # Version must match production and be published on Docker Hub. default: '8.12.0' runs: using: 'composite' steps: - # Cache the elasticsearch image to prevent Docker Hub rate limiting + # Cache the Elasticsearch image to prevent Docker Hub rate limits. - name: Cache Docker layers id: cache-docker-layers uses: actions/cache@v4 @@ -47,8 +47,7 @@ runs: mkdir -p /tmp/docker-cache docker save -o /tmp/docker-cache/elasticsearch.tar elasticsearch:${ES_VERSION} - # Setups the Elasticsearch container - # Derived from https://github.com/getong/elasticsearch-action + # Run a single-node container with settings copied from getong/elasticsearch-action. - name: Run Docker container shell: bash env: @@ -80,7 +79,6 @@ runs: -e discovery_type=$INPUT_DISCOVERY_TYPE \ elasticsearch:$INPUT_ELASTICSEARCH_VERSION - # Check if Elasticsearch is up and running for i in {1..120}; do if curl --silent --fail http://localhost:9200; then echo "Elasticsearch is up and running" diff --git a/.github/actions/slack-alert/action.yml b/.github/actions/slack-alert/action.yml index 57a54fe96a1c..1940f4731bf9 100644 --- a/.github/actions/slack-alert/action.yml +++ b/.github/actions/slack-alert/action.yml @@ -27,9 +27,8 @@ inputs: runs: using: composite steps: - # Build the Slack text here so the default message can be multi-line (real - # newlines) and conditionally include the issue link. A caller-supplied - # message is passed through verbatim for backward compatibility. + # Build default Slack text in shell so it can include real newlines and an issue link. + # Caller-supplied messages pass through unchanged for backward compatibility. - name: Build Slack message id: build shell: bash @@ -43,10 +42,9 @@ runs: GIT_REF: ${{ github.ref }} ACTOR: ${{ github.actor }} run: | - # Escape Slack mrkdwn control chars in interpolated context fields so a - # crafted branch/ref (e.g. containing ) can't inject mentions. + # Escape generated fields so branch/ref text like cannot inject Slack mentions. esc() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g'; } - # Unique heredoc delimiter so a custom message can't collide with it. + # Pick a unique heredoc delimiter so custom messages cannot collide with it. delim="SLACK_EOF_${RANDOM}${RANDOM}" { printf 'text<<%s\n' "$delim" diff --git a/.github/actions/warmup-remotejson-cache/action.yml b/.github/actions/warmup-remotejson-cache/action.yml index b1e7fe3b87b7..2d9220e09852 100644 --- a/.github/actions/warmup-remotejson-cache/action.yml +++ b/.github/actions/warmup-remotejson-cache/action.yml @@ -10,9 +10,7 @@ inputs: runs: using: 'composite' steps: - # The caching technique here is to unboundedly add and add to the cache. - # You "wrap" the step that appends to disk and it will possibly retrieve - # some from the cache, then save it when it's got more in it. + # Restore a stable key, add to the cache, then save by SHA so the cache can grow without bound. - name: Cache .remotejson-cache (restore) uses: actions/cache/restore@v4 with: @@ -20,14 +18,8 @@ runs: key: remotejson-cache- restore-keys: remotejson-cache- - # When we use this composite action from deployment workflows - # we don't have any Node installed or any of its packages. I.e. we never - # run `npm ci` in those actions. For security sake. - # So we can't do things that require Node code. - # Tests and others will omit the `restore-only` input, but - # prepping for Docker build and push, will set it to a non-empty - # string which basically means "If you can restore it, great. - # If not, that's fine, don't bother". + # Deployment workflows never run npm ci for security. + # restore-only can only restore an existing cache there. - name: Run script if: ${{ inputs.restore-only == '' }} shell: bash diff --git a/.github/config.yml b/.github/config.yml index 9fdfc2d93890..f561ae966429 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -1,11 +1,7 @@ -# Configuration for welcome - https://github.com/behaviorbot/welcome +# Behaviorbot welcome configuration: https://github.com/behaviorbot/welcome -# Configuration for new-issue-welcome - https://github.com/behaviorbot/new-issue-welcome -# Comment to be posted to on first time issues newIssueWelcomeComment: > Thanks for opening this issue. A GitHub docs team member should be by to give feedback soon. In the meantime, please check out the [contributing guidelines](https://docs.github.com/en/contributing). -# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome -# Comment to be posted to on PRs from first time contributors in your repository newPRWelcomeComment: > Thanks for opening this pull request! A GitHub docs team member should be by to give feedback soon. In the meantime, please check out the [contributing guidelines](https://docs.github.com/en/contributing). diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a8d17e5cbc20..b44f27721859 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 registries: - ghcr: # Define access for a private registry + ghcr: type: docker-registry url: ghcr.io username: PAT @@ -16,7 +16,7 @@ updates: cooldown: default-days: 7 ignore: - # Because this is so dependent on the remote server we use + # Keep the Elasticsearch client pinned to the server-compatible version. - dependency-name: '@elastic/elasticsearch' - dependency-name: '*' update-types: @@ -54,4 +54,4 @@ updates: patterns: - '*' ignore: - - dependency-name: 'node' # Ignore Dockerfile.openapi_decorator + - dependency-name: 'node' # Ignore Dockerfile.openapi_decorator's Node image. diff --git a/.github/workflows/keep-caches-warm.yml b/.github/workflows/keep-caches-warm.yml index ad3420362004..4d46d209e713 100644 --- a/.github/workflows/keep-caches-warm.yml +++ b/.github/workflows/keep-caches-warm.yml @@ -1,7 +1,7 @@ name: Keep caches warm -# Main-branch runs warm node_modules, Next.js, remote JSON, and pageinfo caches that -# pull request workflows and production deployments can reuse. +# Main-branch runs warm node_modules, Next.js, and remote JSON caches that pull +# request workflows can reuse. on: workflow_dispatch: @@ -29,9 +29,6 @@ jobs: - uses: ./.github/actions/warmup-remotejson-cache if: github.repository == 'github/docs-internal' - - uses: ./.github/actions/precompute-pageinfo - if: github.repository == 'github/docs-internal' - - uses: ./.github/actions/create-workflow-failure-issue id: create-failure-issue if: ${{ failure() && github.event_name != 'workflow_dispatch' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bef859bd321f..3df8e48230d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -141,12 +141,6 @@ jobs: # Only routing tests cover archived enterprise server URLs. if: ${{ matrix.name == 'redirects' }} - - uses: ./.github/actions/precompute-pageinfo - # Only pageinfo tests cover precomputed page info. - if: ${{ matrix.name == 'article-api' }} - env: - ROOT: src/fixtures/fixtures - - name: Index fixtures into the local Elasticsearch # Run indexing only for suites that query the local Elasticsearch service. if: ${{ matrix.name == 'search' || matrix.name == 'languages' }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 9972e9f09bd7..56b486e3c667 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,12 +1,12 @@ rules: - # pull_request_target is required for workflows that need write access - # on PRs from forks (e.g. labeling, commenting). We audit these manually. + # Workflows need pull_request_target for write access on forked PRs. + # We audit these manually. dangerous-triggers: disable: true - # actions/* has immutable tags, so ref-pinning is sufficient. - # github/internal-actions is a private GitHub org repo, ref-pin is fine. - # Everything else must be hash-pinned. + # actions/* tags are immutable, so ref-pinning is sufficient. + # github/internal-actions is private, so ref-pinning is sufficient. + # Hash-pin everything else. unpinned-uses: config: policies: diff --git a/.gitignore b/.gitignore index 59d1410157ae..326e03a9547a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,6 @@ # Node.js version specification .node-version -# Precomputed page info cache (brotli compressed) -.pageinfo-cache.json.br - # getRemoteJSON() disk cache for archived content .remotejson-cache/ diff --git a/Dockerfile b/Dockerfile index 94766ce91d10..8cb8e6af6a07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,172 +1,89 @@ -# This Dockerfile is used solely for production deployments to Moda -# For building this file locally, see src/deployments/production/README.md -# Most environment variables are set in the Moda configuration: -# config/moda/configuration/*/env.yaml -# V8 heap sizing is set here via NODE_OPTIONS and mirrored in -# the Moda config files for defense-in-depth. +# Build this Moda production image in CI or from src/deployments/production/README.md. +# Most environment variables come from config/moda/configuration/*/env.yaml. +# Mirror NODE_OPTIONS there and here for defense in depth. -# --------------------------------------------------------------- -# BASE STAGE: Install linux dependencies and set up the node user -# --------------------------------------------------------------- -# To update the sha: +# Update the base image digest from the gh-base-noble package page: # https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble FROM ghcr.io/github/gh-base-image/gh-base-noble:20260914-014148-gb620b63bf@sha256:fe199dcd96e01f53c42d077dee87f428e8341379aab0987721e16b32462feb05 AS base - -# Install curl for Node install and determining the early access branch -# Install git for cloning docs-early-access & translations repos -# Install Node.js latest LTS -# https://github.com/nodejs/release#release-schedule -# Ubuntu's apt-get install nodejs is _very_ outdated -# Must run as root - -# From https://thehub.github.com/epd/engineering/devops/ci/actions/setting-up-new-github-action/ -# We passed pkg-mirror-host as a secret to the build but it is not sensitive data. +# Install curl for NodeSource setup. +# Install git for early-access and translation clones. +# Ubuntu's nodejs package lags the Node LTS release line. +# Root must install OS packages before switching to node. +# pkg-mirror-host routes apt through GitHub's package mirror but is not sensitive. +# https://thehub.github.com/epd/engineering/devops/ci/actions/setting-up-new-github-action/ RUN --mount=type=secret,id=pkg-mirror-host,target=/etc/pkg_mirror_host.txt \ if [ -f /etc/pkg_mirror_host.txt ]; then cat /etc/pkg_mirror_host.txt >> /etc/apt/mirrorlist.txt; fi - RUN --mount=type=secret,id=apt-auth-conf,target=/etc/apt/auth.conf.d/apt_auth.conf \ apt-get -qq update && apt-get -qq install --no-install-recommends curl git \ && curl -sL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y nodejs \ && node --version - # Stages built FROM base inherit this ARG, so every later stage can use APP_HOME. ARG APP_HOME="/home/node/app" RUN useradd -ms /bin/bash node \ && mkdir -p $APP_HOME && chown -R node:node $APP_HOME -# ----------------------------------------------------------------- -# CLONES STAGE: Clone docs-internal, early-access, and translations -# ----------------------------------------------------------------- FROM base AS clones USER node:node WORKDIR $APP_HOME - -# We need to copy over content that will be merged with early-access +# Copy content inputs that the fetch script merges with early-access content. COPY --chown=node:node content content/ COPY --chown=node:node assets assets/ COPY --chown=node:node data data/ - -# Copy in build scripts and make them executable COPY --chown=node:node --chmod=+x \ src/deployments/production/build-scripts/*.sh build-scripts/ - -# Use the mounted --secret to: -# - 1. Fetch the docs-internal repo -# - 2. Fetch the docs-early-access repo & override docs-internal with early access content -# - 3. Fetch each translations repo to the repo/translations directory -# We use --mount-type=secret to avoid the secret being copied into the image layers for security -# The secret passed via --secret can only be used in this RUN command +# Mount the PAT secret as a BuildKit file so the secret file stays out of layers. +# Fetch docs-early-access, merge it over local content, then clone translations. +# Log the fetch time when this layer runs. RUN --mount=type=secret,id=DOCS_BOT_PAT_BASE,mode=0444 \ - # We don't cache because Docker can't know if we need to fetch new content from remote repos echo "Don't cache this step by printing date: $(date)" && \ . ./build-scripts/fetch-repos.sh -# ------------------------------------------------ -# ALL_DEPS STAGE: Install all dependencies -# ------------------------------------------------ FROM base AS all_deps USER node:node WORKDIR $APP_HOME - COPY --chown=node:node package.json package-lock.json ./ COPY --chown=node:node patches patches/ RUN npm ci --registry https://registry.npmjs.org/ -# ------------------------------------------------------------ -# PROD_DEPS STAGE: Strip dev dependencies back out -# ------------------------------------------------------------ FROM all_deps AS prod_deps - RUN npm prune --omit=dev --ignore-scripts -# ---------------------------------- -# BUILD STAGE: Build the application -# ---------------------------------- FROM base AS build USER node:node WORKDIR $APP_HOME - -# Source code COPY --chown=node:node src src/ COPY --chown=node:node package.json ./ COPY --chown=node:node next.config.ts ./ COPY --chown=node:node tsconfig.json ./ - -# From the clones stage COPY --chown=node:node --from=clones $APP_HOME/data data/ COPY --chown=node:node --from=clones $APP_HOME/assets assets/ COPY --chown=node:node --from=clones $APP_HOME/content content/ COPY --chown=node:node --from=clones $APP_HOME/translations translations/ - -# From the all_deps stage (need dev deps for build) COPY --chown=node:node --from=all_deps $APP_HOME/node_modules node_modules/ +RUN npm run build && rm -rf .next/cache/webpack -# Build the application -RUN npm run build - -# --------------------------------------------- -# WARMUP_CACHE STAGE: Warm up remote JSON cache -# --------------------------------------------- FROM build AS warmup_cache - -# Generate remote JSON cache RUN npm run warmup-remotejson -# -------------------------------------- -# PRECOMPUTE STAGE: Precompute page info -# -------------------------------------- -FROM build AS precompute_stage - -# Generate precomputed page info. Only English + free-pro-team@latest -# permalinks are cached; cache misses for older versions and translated -# pages fall through to runtime compute (which is cheap and Fastly-cached -# per pathname after the first hit). -RUN npm run precompute-pageinfo -- --max-versions 1 - -# ------------------------------------------------- -# PRODUCTION STAGE: What will run on the containers -# ------------------------------------------------- FROM base AS production USER node:node WORKDIR $APP_HOME - -# Source code COPY --chown=node:node src src/ COPY --chown=node:node package.json ./ COPY --chown=node:node next.config.ts ./ COPY --chown=node:node tsconfig.json ./ - -# From clones stage COPY --chown=node:node --from=clones $APP_HOME/data data/ COPY --chown=node:node --from=clones $APP_HOME/assets assets/ COPY --chown=node:node --from=clones $APP_HOME/content content/ COPY --chown=node:node --from=clones $APP_HOME/translations translations/ - -# From prod_deps stage (production-only node_modules) COPY --chown=node:node --from=prod_deps $APP_HOME/node_modules node_modules/ - -# From build stage COPY --chown=node:node --from=build $APP_HOME/.next .next/ - -# From warmup_cache stage COPY --chown=node:node --from=warmup_cache $APP_HOME/.remotejson-cache ./ - -# From precompute_stage -COPY --chown=node:node --from=precompute_stage $APP_HOME/.pageinfo-cache.json.br* ./ - -# This makes it possible to set `--build-arg BUILD_SHA=abc123` -# and it then becomes available as an environment variable in the docker run. +# Expose the build SHA at runtime when callers pass --build-arg BUILD_SHA=abc123. ARG BUILD_SHA ENV BUILD_SHA=$BUILD_SHA - -# V8 heap limit as a percentage of the container cgroup memory limit. -# Uses --max-old-space-size-percentage (Node 24+) so the heap adapts -# automatically when K8s memory limits change. 80% leaves ~20% headroom -# for off-heap memory (Buffers, V8 code cache, libuv) and OS overhead. -# Raised from 75% on advice from performance engineering to reduce GC -# pressure during traffic spikes. +# --max-old-space-size-percentage needs Node 24+ and tracks the cgroup memory limit. +# 80% leaves headroom for off-heap memory (Buffers, V8 code cache, libuv) and the OS. ENV NODE_OPTIONS="--max-old-space-size-percentage=80" - -# Entrypoint to start the server CMD ["node_modules/.bin/tsx", "src/frame/server.ts"] diff --git a/config/kubernetes/default/deployments/webapp.yaml b/config/kubernetes/default/deployments/webapp.yaml index c4f6d7f4223f..ee7e524cae14 100644 --- a/config/kubernetes/default/deployments/webapp.yaml +++ b/config/kubernetes/default/deployments/webapp.yaml @@ -14,8 +14,8 @@ spec: labels: app: webapp annotations: - # Our internal logs aren't structured so we use logfmt_sloppy to just log stdout and error - # See https://thehub.github.com/epd/engineering/dev-practicals/observability/logging/ for more details + # Use logfmt_sloppy because internal logs write unstructured stdout and stderr. + # https://thehub.github.com/epd/engineering/dev-practicals/observability/logging/ fluentbit.io/parser: logfmt_sloppy observability.github.com/splunk_index: docs-internal ad.datadoghq.com/webapp.logs: '[{"source":"nodejs","service":"docs-internal","tags":["env:staging"]}]' @@ -26,23 +26,20 @@ spec: containers: - name: webapp image: docs-internal - # Retune using 2 weeks of data + # Retune using 2 weeks of data: # https://app.datadoghq.com/dashboard/6vx-iun-ghs/moda-resource-recommendations?tpl_var_kube_namespace%5B0%5D=docs-internal-staging-balsam&tpl_var_kube_namespace%5B1%5D=docs-internal-staging-boxwood&tpl_var_kube_namespace%5B2%5D=docs-internal-staging-cedar&tpl_var_kube_namespace%5B3%5D=docs-internal-staging-cypress&tpl_var_kube_namespace%5B4%5D=docs-internal-staging-fir&tpl_var_kube_namespace%5B5%5D=docs-internal-staging-hemlock&tpl_var_kube_namespace%5B6%5D=docs-internal-staging-hinoki&tpl_var_kube_namespace%5B7%5D=docs-internal-staging-holly&tpl_var_kube_namespace%5B8%5D=docs-internal-staging-juniper&tpl_var_kube_namespace%5B9%5D=docs-internal-staging-laurel&tpl_var_kube_namespace%5B10%5D=docs-internal-staging-pine&tpl_var_kube_namespace%5B11%5D=docs-internal-staging-redwood&tpl_var_kube_namespace%5B12%5D=docs-internal-staging-sequoia&tpl_var_kube_namespace%5B13%5D=docs-internal-staging-spruce&tpl_var_kube_namespace%5B14%5D=docs-internal-staging-yew&from_ts=0&to_ts=1209600000&live=true - # Staging is not budget checked + # Staging is not budget checked. resources: requests: - # requests.cpu: 150m idle schedule floor - # staging idles near zero + # requests.cpu: 150m idle schedule floor because staging idles near zero. cpu: 150m - # requests.memory: highest-peak pod p99 (1882Mi) * 1.1 - # for working-set padding + # requests.memory: highest-peak pod p99 (1882Mi) * 1.1 for working-set padding. memory: 2070Mi limits: - # limits.cpu: highest-peak pod max [warmup peak] (1.82 cores) * 3 - # for start up insurance; compressible + # limits.cpu: highest warmup peak (1.82 cores) * 3 for startup headroom. cpu: 5460m - # limits.memory: highest-peak pod max (1882Mi) * 2 - # over-limit means OOMkill; non-compressible + # limits.memory: highest-peak pod max (1882Mi) * 2. + # Over the limit means OOMKill; memory is not compressible. memory: 3764Mi ports: - name: http @@ -53,8 +50,7 @@ spec: name: vault-secrets - configMapRef: name: kube-cluster-metadata - # application-config is created at deploy time from - # configuration set in config/moda/configuration/*/env.yaml + # Moda creates application-config from config/moda/configuration/*/env.yaml. - configMapRef: name: application-config env: @@ -66,14 +62,13 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - # Zero-downtime deploys + # Sleep before shutdown so Moda drains connections before old pods exit. # https://thehub.github.com/engineering/products-and-services/internal/moda/feature-documentation/pod-lifecycle/#required-prestop-hook - # https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks lifecycle: preStop: exec: command: ['sleep', '5'] - # See production/deployments/webapp.yaml for detailed comments on probe config. + # See config/kubernetes/production/deployments/webapp.yaml for probe details. startupProbe: httpGet: path: /healthcheck diff --git a/config/kubernetes/default/services/webapp.yaml b/config/kubernetes/default/services/webapp.yaml index d504fd7d9f50..e12a3d64f496 100644 --- a/config/kubernetes/default/services/webapp.yaml +++ b/config/kubernetes/default/services/webapp.yaml @@ -6,7 +6,7 @@ metadata: service: webapp annotations: moda.github.net/domain-name: 'docs-internal-%environment%.service.%region%.github.net' - # HTTP app reachable inside GitHub's network (employee website) + # Expose the employee website as an internal HTTP load balancer. moda.github.net/load-balancer-type: internal-http spec: ports: diff --git a/config/kubernetes/production/deployments/webapp.yaml b/config/kubernetes/production/deployments/webapp.yaml index c8d52bddf369..752b3e1a3624 100644 --- a/config/kubernetes/production/deployments/webapp.yaml +++ b/config/kubernetes/production/deployments/webapp.yaml @@ -10,10 +10,10 @@ spec: strategy: type: RollingUpdate rollingUpdate: - # Don't kill old pods until new ones pass readiness. - # Prevents capacity loss during deploys. Safe because we're over-provisioned. + # Keep old pods serving until replacements pass readiness. + # Overprovisioning makes the temporary extra capacity safe. maxUnavailable: 0 - # Percentage so it scales with replica count changes. + # Scale surge capacity with replica count changes. maxSurge: '100%' selector: matchLabels: @@ -23,38 +23,34 @@ spec: labels: app: webapp annotations: - # Our internal logs aren't structured so we use logfmt_sloppy to just log stdout and error - # See https://thehub.github.com/epd/engineering/dev-practicals/observability/logging/ for more details + # Use logfmt_sloppy because internal logs write unstructured stdout and stderr. + # https://thehub.github.com/epd/engineering/dev-practicals/observability/logging/ fluentbit.io/parser: logfmt_sloppy observability.github.com/splunk_index: docs-internal ad.datadoghq.com/webapp.logs: '[{"source":"nodejs","service":"docs-internal","tags":["env:production"]}]' ad.datadoghq.com/tolerate-unready: 'true' spec: dnsPolicy: Default - # Hard deadline for pod shutdown after SIGTERM (includes preStop sleep). - # Default is 30s; 60s gives plenty of room for in-flight request draining - # and OTEL SDK shutdown even if DNS is slow. + # This 60-second total covers preStop, then request drain and OTEL shutdown after SIGTERM. terminationGracePeriodSeconds: 60 containers: - name: webapp image: docs-internal - # Retune using 2 weeks of data + # Retune using 2 weeks of data: # https://app.datadoghq.com/dashboard/6vx-iun-ghs/moda-resource-recommendations?tpl_var_kube_namespace%5B0%5D=docs-internal-production&from_ts=0&to_ts=1209600000&live=true - # Moda budget is requests * replicas * clusters + # Moda budget uses requests times replicas times clusters. resources: requests: - # requests.cpu: median pod p99 (0.32 cores) * 2 - # for failover headroom + # requests.cpu: median pod p99 (0.32 cores) * 2 for failover headroom. cpu: 640m - # requests.memory: highest-peak pod p99 (4740Mi) * 1.1 - # for working-set padding + # requests.memory: highest-peak pod p99 (4740Mi) * 1.1 for working-set padding. memory: 5214Mi limits: - # limits.cpu: highest-peak pod max [warmup peak] (2.42 cores) * 3 - # for start up insurance; compressible; does not count towards budget + # limits.cpu: highest warmup peak (2.42 cores) * 3. + # This startup headroom does not count toward budget. cpu: 7260m - # limits.memory: highest-peak pod max (4813Mi) * 2 - # over-limit means OOMkill; non-compressible + # limits.memory: highest-peak pod max (4813Mi) * 2. + # Over the limit means OOMKill; memory is not compressible. memory: 9626Mi ports: - name: http @@ -65,8 +61,7 @@ spec: name: vault-secrets - configMapRef: name: kube-cluster-metadata - # application-config is created at deploy time from - # configuration set in config/moda/configuration/*/env.yaml + # Moda creates application-config from config/moda/configuration/*/env.yaml. - configMapRef: name: application-config env: @@ -78,27 +73,25 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - # Zero-downtime deploys + # Sleep before shutdown so Moda drains connections before old pods exit. # https://thehub.github.com/engineering/products-and-services/internal/moda/feature-documentation/pod-lifecycle/#required-prestop-hook - # https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks lifecycle: preStop: exec: command: ['sleep', '5'] - # warmServer() loads ~3500 content files × 9 languages × 9 versions. - # Avg startup: ~25s, worst observed: ~48s (Datadog: docs.warm_server). - # Server does not listen until warmup completes, so probes fail at - # TCP level during boot — no app-level readiness flag needed. + # warmServer() loads about 3500 content files × 9 languages × 9 versions. + # Startup averages 25 seconds and peaked at 48 seconds in Datadog docs.warm_server. + # The server starts listening after warmup, so probes fail at TCP level during boot. + # No app-level readiness flag is needed. startupProbe: httpGet: path: /healthcheck port: http - # Server can't respond until warmup finishes (~25s avg), so don't - # waste probes checking before that. + # Avoid probes during the average 25-second warmup. initialDelaySeconds: 30 periodSeconds: 5 - # Total runway: 30s + (30 × 5s) = 180s. Covers worst-case startup - # plus resource contention when multiple pods boot during a deploy. + # Total runway: 30s + 30 × 5s = 180s. + # Covers peak startup plus resource contention when multiple pods boot during a deploy. failureThreshold: 30 timeoutSeconds: 5 readinessProbe: @@ -106,13 +99,10 @@ spec: path: /healthcheck port: http periodSeconds: 10 - # 5 × 10s = 50s before pulling pod from load balancer. - # Healthcheck is always-200 (no app-level logic), so failures - # mean the process is hung or under extreme pressure. + # 5 × 10s = 50s before the pod leaves the load balancer. + # The healthcheck always returns 200, so failures mean the process is hung or under extreme pressure. failureThreshold: 5 timeoutSeconds: 5 - # No livenessProbe: healthcheck always returns 200 with no app-level - # checks, so a liveness probe would only catch a fully hung process. - # Readiness already removes hung pods from the load balancer, and we - # intentionally avoid liveness restarts — they risk killing pods - # during GC pauses or transient load spikes. + # Skip livenessProbe because healthcheck returns 200 unless the process is fully hung. + # Readiness removes hung pods from load balancing. + # Liveness restarts risk killing pods during GC pauses or transient load spikes. diff --git a/config/moda/configuration/default/env.yaml b/config/moda/configuration/default/env.yaml index 681bb075176c..cdbff2a1438e 100644 --- a/config/moda/configuration/default/env.yaml +++ b/config/moda/configuration/default/env.yaml @@ -1,19 +1,18 @@ data: MODA_APP_NAME: docs-internal NODE_ENV: production - # Matches the Dockerfile ENV. Both set the same value so that - # the heap limit is correct regardless of config-layering order. + # Match the Dockerfile heap limit so config-layering order cannot change it. NODE_OPTIONS: '--max-old-space-size-percentage=80' PORT: '4000' ENABLED_LANGUAGES: 'en,es,ja,pt,zh,ru,fr,ko,de' RATE_LIMIT_MAX: '21' - # Moda uses a non-default port for sending datadog metrics + # Moda sends Datadog metrics on a non-default port. DD_DOGSTATSD_PORT: '28125' - # NodeSDK auto-enables OTLP metrics and logs exporters when these env vars - # are unset. We only want traces, so explicitly disable the others to avoid - # spamming export errors. See https://opentelemetry.io/docs/specs/otel/protocol/exporter/ + # NodeSDK enables OTLP metrics and logs exporters when these env vars are unset. + # Only traces are wanted, so disable the others to avoid export errors. + # See https://opentelemetry.io/docs/specs/otel/protocol/exporter/ OTEL_METRICS_EXPORTER: 'none' OTEL_LOGS_EXPORTER: 'none' - # OTel traces endpoint is set per-environment (see production/env.yaml). - # Stagings don't have OTEL_EXPORTER_OTLP_TRACES_HEADERS, so they don't - # export traces — tracing.ts gates SDK startup on the endpoint env var. + # The traces endpoint is set per environment in production/env.yaml. + # Staging omits OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and trace headers. + # tracing.ts skips SDK startup without the endpoint env var. diff --git a/config/moda/configuration/production/env.yaml b/config/moda/configuration/production/env.yaml index a66e0692fb3f..05dfaad5ca14 100644 --- a/config/moda/configuration/production/env.yaml +++ b/config/moda/configuration/production/env.yaml @@ -1,21 +1,19 @@ data: MODA_APP_NAME: docs-internal NODE_ENV: production - # Matches the Dockerfile ENV. Both set the same value so that - # the heap limit is correct regardless of config-layering order. + # Match the Dockerfile heap limit so config-layering order cannot change it. NODE_OPTIONS: '--max-old-space-size-percentage=80' PORT: '4000' ENABLED_LANGUAGES: 'en,es,ja,pt,zh,ru,fr,ko,de' RATE_LIMIT_MAX: '21' - # Moda uses a non-default port for sending datadog metrics + # Moda sends Datadog metrics on a non-default port. DD_DOGSTATSD_PORT: '28125' - # Identifies the service deployment environment as production - # Equivalent to HEAVEN_DEPLOYED_ENV === 'production' + # Marks the service deployment environment as production. + # Equivalent to HEAVEN_DEPLOYED_ENV === 'production'. MODA_PROD_SERVICE_ENV: 'true' - # OTel distributed tracing — sends spans to OTel Collector via OTLP/HTTP (proto). - # Uses %site% template (not %stamp%) since docs-internal is not on the service - # mesh and not on a Proxima stamp (region: iad, profile: general). %site% - # interpolates to the cluster's site (e.g. iad), giving a hostname like - # otelcol.service.iad.github.net that resolves from production pods. - # See https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/instrumentation/ + # Send production traces to the OTel Collector through OTLP over HTTP. + # Use %site%, not %stamp%, because docs-internal lacks service-mesh and Proxima routing. + # Cluster routing is region iad and profile general. + # %site% resolves to the cluster site, such as iad, in otelcol.service.iad.github.net. + # https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/instrumentation/ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: 'https://otelcol.service.%site%.github.net/v1/traces' diff --git a/config/moda/deployment.yaml b/config/moda/deployment.yaml index a12c2f4b6497..bf8e023bdb93 100644 --- a/config/moda/deployment.yaml +++ b/config/moda/deployment.yaml @@ -1,17 +1,16 @@ -# Deploy configuration reference: https://thehub.github.com/epd/engineering/products-and-services/internal/moda/reference/deployment-yaml/ +# Moda deployment reference: +# https://thehub.github.com/epd/engineering/products-and-services/internal/moda/reference/deployment-yaml/ environments: - name: production require_pipeline: true - # Bumped from default 10m because pod scheduling occasionally pushes - # rollouts past the timeout even though the deploy itself succeeds. + # Allow extra rollout time because pod scheduling can exceed Moda's default timeout. timeout: 1200 cluster_selector: profile: general region: iad - # 15 staging environments, evergreens only - # they should all contain the same configs + # All 15 evergreen staging environments must contain the same configs. - name: staging-balsam require_pipeline: false notify_still_locked: true # Notify last person to lock this after an hour @@ -197,7 +196,7 @@ required_builds: - docs-internal-docker-image / docs-internal-docker-image - docs-internal-docker-security / docs-internal-docker-security -# Make the pipeline start automatically when a PR is enqueued +# Start the production rollout pipeline when a PR enters the merge queue. auto_start_pipeline: production_rollout pipelines: diff --git a/content/admin/monitoring-and-managing-your-instance/multiple-data-disks/configuring-multiple-data-disks.md b/content/admin/monitoring-and-managing-your-instance/multiple-data-disks/configuring-multiple-data-disks.md index b12367c1c36e..0e0aa5f9a9b7 100644 --- a/content/admin/monitoring-and-managing-your-instance/multiple-data-disks/configuring-multiple-data-disks.md +++ b/content/admin/monitoring-and-managing-your-instance/multiple-data-disks/configuring-multiple-data-disks.md @@ -34,7 +34,6 @@ category: * Setting up multi-data disks and migrating data typically requires some downtime. * You can minimize this by configuring a replica with multi-data disks, replicating data from the primary, and then failing over to the replica. * If you are adding multi-data disks directly to the primary, expect a much longer downtime. -* During the public preview, multi-data disks should be used only in non-production environments. {% ifversion ghes < 3.20 %} * It is not recommended to migrate MySQL and repositories to the same disk. * Currently, only MySQL and repositories can be migrated to additional disks. @@ -57,8 +56,7 @@ In high availability setups, it is best to use multi-data disks on both the prim * We recommend taking a recent backup of your data before getting started. * Create a test environment to try the feature. - * During the public preview, we recommend **only** using the feature in a test environment. - * Once the feature becomes generally available, we recommend testing the feature in a non-production environment before using it in production. + * We recommend testing the feature in a non-production environment before using it in production. ### Instructions diff --git a/content/code-security/concepts/secret-security/secret-scanning.md b/content/code-security/concepts/secret-security/secret-scanning.md index 4adce1a6df34..f76e1faf7f63 100644 --- a/content/code-security/concepts/secret-security/secret-scanning.md +++ b/content/code-security/concepts/secret-security/secret-scanning.md @@ -69,9 +69,7 @@ Beyond the default detection of partner and provider secrets, you can expand and ### About validity checks -Validity checks help you prioritize which secrets to remediate first by verifying whether a detected secret is still active. When you enable validity checks, {% data variables.product.prodname_secret_scanning %} may contact the secret's issuing service to determine if the credential has been revoked. - -Validity checks are separate from {% data variables.product.prodname_secret_scanning %}'s partner program. While partner secrets are automatically reported to service providers for revocation, validity checks verify the status of secrets you manage in your own alerts. For more information, see [AUTOTITLE](/code-security/concepts/secret-security/validity-checks). +Validity checks help you prioritize which secrets to remediate first by verifying whether a detected secret is still active. When you enable validity checks, {% data variables.product.prodname_secret_scanning %} may contact the secret's issuing service to determine if the credential has been revoked. For more information, see [AUTOTITLE](/code-security/concepts/secret-security/validity-checks). {% endif %} diff --git a/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md b/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md index 815b9c6e0207..cb3be49a0846 100644 --- a/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md +++ b/content/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models.md @@ -1,6 +1,6 @@ --- -title: Using your own LLM models in GitHub Copilot CLI -shortTitle: Use your own model provider +title: Adding LLM models to GitHub Copilot CLI +shortTitle: Add LLM models intro: 'Use a model from an external provider of your choice in {% data variables.product.prodname_copilot_short %} by supplying your own API key.' allowTitleToDifferFromFilename: true versions: @@ -14,7 +14,7 @@ docsTeamMetrics: - copilot-cli --- -You can configure {% data variables.copilot.copilot_cli_short %} to use your own LLM provider, also called BYOK (Bring Your Own Key), instead of {% data variables.product.github %}-hosted models. This lets you connect to OpenAI-compatible endpoints, Azure OpenAI, or Anthropic, including locally running models such as Ollama. +You can configure {% data variables.copilot.copilot_cli_short %} to include models from an LLM provider of your choice—using BYOK (Bring Your Own Key)—in addition to the {% data variables.product.github %}-hosted models. This lets you connect to OpenAI-compatible endpoints, Azure OpenAI, or Anthropic, including locally running models such as Ollama. > [!NOTE] > This article is for users who want to configure their own LLM provider API key on their local machine. To set up custom models for users in an enterprise, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/enable-custom-models). @@ -143,5 +143,5 @@ You can run {% data variables.copilot.copilot_cli_short %} in offline mode to pr ```shell export COPILOT_OFFLINE=true ``` - + 1. {% data reusables.copilot.copilot-cli.start-cli %} diff --git a/content/copilot/how-tos/github-copilot-app/use-byok-models.md b/content/copilot/how-tos/github-copilot-app/use-byok-models.md index c37cd8bbab85..4c26147fdde7 100644 --- a/content/copilot/how-tos/github-copilot-app/use-byok-models.md +++ b/content/copilot/how-tos/github-copilot-app/use-byok-models.md @@ -1,6 +1,6 @@ --- -title: Using your own LLM models in the GitHub Copilot app -shortTitle: Use your own model provider +title: Adding LLM models to the GitHub Copilot app +shortTitle: Add LLM models intro: 'Connect a model from an external provider of your choice by supplying your own API key, then use the model in agent sessions.' allowTitleToDifferFromFilename: true product: '{% data reusables.gated-features.github-app %}
Download {% data variables.copilot.github_copilot_app %} {% octicon "link-external" height:16 %}' @@ -15,7 +15,7 @@ category: > [!NOTE] > Support to use your own model provider in the {% data variables.copilot.github_copilot_app %} is in {% data variables.release-phases.public_preview %} and subject to change. -You can configure the {% data variables.copilot.github_copilot_app %} to use your own LLM provider, also called BYOK (Bring Your Own Key), instead of {% data variables.product.github %}-hosted models. You can set up your model provider when you first open the app or later in app settings. +You can configure the {% data variables.copilot.github_copilot_app %} to include models from an LLM provider of your choice—using BYOK (Bring Your Own Key)—in addition to the {% data variables.product.github %}-hosted models. You can set up your model provider when you first open the app or later in app settings. You must sign in with a {% data variables.product.github %} account to use the app, but you do not need a {% data variables.product.prodname_copilot_short %} plan if you use your own model provider. If you do have a {% data variables.product.prodname_copilot_short %} plan, you can use both your own model provider and {% data variables.product.github %}-hosted models in the same app. diff --git a/content/copilot/tutorials/image.png b/content/copilot/tutorials/image.png deleted file mode 100644 index d0a7d9539eee..000000000000 Binary files a/content/copilot/tutorials/image.png and /dev/null differ diff --git a/content/copilot/tutorials/stack-ai-generated-code-in-pull-requests.md b/content/copilot/tutorials/stack-ai-generated-code-in-pull-requests.md index 15ac41aa89a1..00426d7bc5c7 100644 --- a/content/copilot/tutorials/stack-ai-generated-code-in-pull-requests.md +++ b/content/copilot/tutorials/stack-ai-generated-code-in-pull-requests.md @@ -10,9 +10,6 @@ category: - Team collaboration --- -> [!NOTE] -> Stacked pull requests are in {% data variables.release-phases.public_preview %} and subject to change. - Large pull requests are difficult to review and create bottlenecks, especially when AI helps you generate a high volume of code in a short time. Review quality also degrades as pull request size increases. Reviewers may skim the result, miss issues, or procrastinate and leave the pull request until it grows stale and develops merge conflicts. Stacked pull requests keep large code changes reviewable. diff --git a/content/organizations/managing-programmatic-access-to-your-organization/viewing-api-insights-in-your-organization.md b/content/organizations/managing-programmatic-access-to-your-organization/viewing-api-insights-in-your-organization.md index 79de87e08303..da2f5f4e6880 100644 --- a/content/organizations/managing-programmatic-access-to-your-organization/viewing-api-insights-in-your-organization.md +++ b/content/organizations/managing-programmatic-access-to-your-organization/viewing-api-insights-in-your-organization.md @@ -16,6 +16,10 @@ As a {% data variables.product.prodname_ghe_cloud %} organization owner, you and > [!NOTE] Currently, this feature supports only the `core` category of REST API endpoints and primary rate limits. API activity for search, {% data variables.product.prodname_actions %} (using the [`GITHUB_TOKEN`](/actions/tutorials/authenticate-with-github_token) secret), and secondary rate-limiting are not supported. For information about API categories, see [AUTOTITLE](/rest/rate-limit/rate-limit). To learn more about primary and secondary rate limits, see [AUTOTITLE](/rest/using-the-rest-api/rate-limits-for-the-rest-api). +API insights only includes REST API requests sent to an API hostname, such as `api.github.com` or `api.SUBDOMAIN.ghe.com` for {% data variables.product.prodname_ghe_cloud %} with data residency. It does not include API routes served through a web hostname, such as `github.com` or `SUBDOMAIN.ghe.com`. For example, a `GET /user/repos` request sent to `SUBDOMAIN.ghe.com` is excluded from API insights. + +Requests to API routes through a web hostname can consume the same primary rate limit as requests through an API hostname. As a result, the totals shown in API insights can be lower than the usage reported by `GET /rate_limit` or the `x-ratelimit-*` response headers. Ordinary web page loads, Git operations, and raw-content requests are also outside the scope of API insights. + ## Enabling access to API insights Organization owners can create custom organization roles to allow people to view API insights for their organization. To provide users with access, select the **View organization API insights** permission when creating a custom organization role. Then assign the custom role to an organization member or team. For more information, see [AUTOTITLE](/organizations/managing-peoples-access-to-your-organization-with-roles/permissions-of-custom-organization-roles). diff --git a/content/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry.md b/content/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry.md index 53c6efbc2944..0da0d798d7d4 100644 --- a/content/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry.md +++ b/content/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry.md @@ -37,7 +37,7 @@ category: {% data reusables.package_registry.required-scopes %} -You can authenticate to {% data variables.product.prodname_registry %} with Gradle using either Gradle Groovy or Kotlin DSL by editing your _build.gradle_ file (Gradle Groovy) or _build.gradle.kts_ file (Kotlin DSL) file to include your {% data variables.product.pat_v1 %}. You can also configure Gradle Groovy and Kotlin DSL to recognize a single package or multiple packages in a repository. +You can authenticate to {% data variables.product.prodname_registry %} with Gradle using either Gradle Groovy or Kotlin DSL by editing your `build.gradle` file (Gradle Groovy) or `build.gradle.kts` file (Kotlin DSL) (`settings.gradle` or `settings.gradle.kts` if you centralize repository declarations in the settings script to use published packages) to include your {% data variables.product.pat_v1 %}. You can also configure Gradle Groovy and Kotlin DSL to recognize a single package or multiple packages in a repository. {% ifversion ghes %} Replace REGISTRY_URL with the URL for your instance's Maven registry. If your instance has subdomain isolation enabled, use `maven.HOSTNAME`. If your instance has subdomain isolation disabled, use `HOSTNAME/_registry/maven`. In either case, replace HOSTNAME with the host name of your {% data variables.product.prodname_ghe_server %} instance. @@ -60,8 +60,8 @@ publishing { name = "GitHubPackages" url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } } } @@ -87,8 +87,8 @@ subprojects { name = "GitHubPackages" url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } } } @@ -113,8 +113,8 @@ publishing { name = "GitHubPackages" url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } } } @@ -140,8 +140,8 @@ subprojects { name = "GitHubPackages" url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } } } @@ -172,7 +172,7 @@ subprojects { To use a published package from {% data variables.product.prodname_registry %}, add the package as a dependency and add the repository to your project. For more information, see [Declaring dependencies](https://docs.gradle.org/current/userguide/declaring_dependencies.html) in the Gradle documentation. {% data reusables.package_registry.authenticate-step %} -1. Add the package dependencies to your _build.gradle_ file (Gradle Groovy) or _build.gradle.kts_ file (Kotlin DSL) file. +1. Add the package dependencies to your `build.gradle` file (Gradle Groovy) or `build.gradle.kts` file (Kotlin DSL). Example using Gradle Groovy: @@ -190,7 +190,7 @@ To use a published package from {% data variables.product.prodname_registry %}, } ``` -1. Add the repository to your _build.gradle_ file (Gradle Groovy) or _build.gradle.kts_ file (Kotlin DSL) file. +1. Add the repository to your `build.gradle` file (Gradle Groovy) or `build.gradle.kts` file (Kotlin DSL), or to your `settings.gradle` file (Gradle Groovy) or `settings.gradle.kts` file (Kotlin DSL) in the `dependencyResolutionManagement` block if you centralize repository declarations in the settings script. For more information, see [Centralizing Repository Declarations](https://docs.gradle.org/current/userguide/centralizing_repositories.html) in the Gradle documentation. Example using Gradle Groovy: @@ -199,10 +199,10 @@ To use a published package from {% data variables.product.prodname_registry %}, maven { url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } - } + } } ``` @@ -213,8 +213,8 @@ To use a published package from {% data variables.product.prodname_registry %}, maven { url = uri("https://{% ifversion fpt or ghec %}maven.pkg.github.com{% else %}REGISTRY_URL{% endif %}/OWNER/REPOSITORY") credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + username = providers.gradleProperty("gpr.user").getOrNull() ?: System.getenv("USERNAME") + password = providers.gradleProperty("gpr.key").getOrNull() ?: System.getenv("TOKEN") } } } diff --git a/content/pull-requests/get-started/about-stacked-prs.md b/content/pull-requests/get-started/about-stacked-prs.md index 0404968f4c36..3001eaa76ed5 100644 --- a/content/pull-requests/get-started/about-stacked-prs.md +++ b/content/pull-requests/get-started/about-stacked-prs.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - ## About stacked pull requests Stacked pull requests are two or more pull requests in the same repository, where: diff --git a/content/pull-requests/get-started/stacked-prs-quickstart.md b/content/pull-requests/get-started/stacked-prs-quickstart.md index 9d457fc17517..260d47d79764 100644 --- a/content/pull-requests/get-started/stacked-prs-quickstart.md +++ b/content/pull-requests/get-started/stacked-prs-quickstart.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - {% data reusables.pull_requests.pr-stack-invitation %} {% data reusables.pull_requests.pr-stack-definition %} diff --git a/content/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests.md b/content/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests.md index bc7cf980122f..55db98db6d2f 100644 --- a/content/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - Create stacked pull requests with the `gh stack` extension in {% data variables.product.prodname_cli %} or on the {% data variables.product.github %} website. > [!NOTE] diff --git a/content/pull-requests/how-tos/create-pull-requests/managing-stacked-pull-requests.md b/content/pull-requests/how-tos/create-pull-requests/managing-stacked-pull-requests.md index b0f092ec8185..95c742af0cfb 100644 --- a/content/pull-requests/how-tos/create-pull-requests/managing-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/create-pull-requests/managing-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - As you iterate on a stack, you often need to make changes in a lower layer, rebase to keep a linear history, or restructure its branches. The `gh stack` extension in {% data variables.product.prodname_cli %} handles these tasks with cascading operations that update every affected branch. See [AUTOTITLE](/pull-requests/reference/stacked-prs-cli-commands). ## Making changes to a lower layer diff --git a/content/pull-requests/how-tos/create-pull-requests/requesting-a-pull-request-review.md b/content/pull-requests/how-tos/create-pull-requests/requesting-a-pull-request-review.md index 1abd31f3144c..de431466ea5d 100644 --- a/content/pull-requests/how-tos/create-pull-requests/requesting-a-pull-request-review.md +++ b/content/pull-requests/how-tos/create-pull-requests/requesting-a-pull-request-review.md @@ -24,6 +24,9 @@ To request a review, you need write access to the repository. You can request a Suggested reviewers are based on [git blame data](/repositories/working-with-files/using-files/viewing-and-understanding-files). After someone reviews your pull request and you make changes, you can request another review from the same reviewer. +> [!WARNING] +> {% data reusables.pull_requests.large-team-review-request-warning %} + {% data reusables.repositories.sidebar-pr %} 1. In the list of pull requests, click the pull request that you want a specific person or team to review. 1. To request a review from a suggested person under **Reviewers**, next to their username, click **Request**. diff --git a/content/pull-requests/how-tos/merge-and-close-pull-requests/merging-stacked-pull-requests.md b/content/pull-requests/how-tos/merge-and-close-pull-requests/merging-stacked-pull-requests.md index b78c3a68d5c1..376b094daabf 100644 --- a/content/pull-requests/how-tos/merge-and-close-pull-requests/merging-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/merge-and-close-pull-requests/merging-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Merge and close pull requests --- -{% data reusables.public-preview.public-preview %} - Stacked pull requests merge from the bottom (closest to the trunk) up. * You can merge any number of pull requests at once, as long as they form a contiguous group starting from the lowest unmerged pull request. @@ -24,7 +22,7 @@ The merge box for a stacked pull request shows the status of the entire stack, n * The stack has a linear history. * The current pull request meets all branch protection requirements for the stack base, such as `main`. -If the stack is not linear, for example, after changes were pushed to a lower branch or after the trunk moved ahead, a **Rebase stack** button will appear in the merge box and you'll need to rebase the stack before you can merge. +If the stack is not linear, for example, after changes were pushed to a lower branch or after the trunk moved ahead, a **Rebase stack** button will appear in the merge box and you'll need to rebase the stack before you can merge. Rebasing the stack will generate signed commits, and retain approvals if a diff has not changed, even if you have the **dismiss stale approvals** rule enabled. > [!NOTE] > * If you merge via the API and want to use stacked pull requests, you'll need use the asynchronous merge API for stacks. See [AUTOTITLE](/rest/pulls/pulls?apiVersion=2026-03-10#merge-a-pull-request-asynchronously). @@ -35,7 +33,7 @@ If the stack is not linear, for example, after changes were pushed to a lower br Stacks fully support merge queues. All pull requests in the stack are added to the queue in the correct order. If a pull request is removed or ejected from the queue, all pull requests above it in the stack are also removed. > [!NOTE] -> To keep a stack together, the merge queue allows the merge group to exceed its configured maximum size by up to 50 percent. If the stack is too large to fit within that buffer, it will automatically be split across consecutive merge groups. +> To keep a stack together, the merge queue allows the merge group to exceed its configured maximum size by up to 50 percent. If the stack is too large to fit within that buffer, it will automatically be put into the next merge group as a single unit. ## Merging from the bottom up diff --git a/content/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests.md b/content/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests.md index 5b501e2fb86c..468a59b91294 100644 --- a/content/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Merge and close pull requests --- -{% data reusables.public-preview.public-preview %} - Every pull request in a stack is evaluated as if it targets the base of the stack, such as `main`. This keeps quality consistent across every layer, but it also means a workflow can run many times for a single stack. This article explains how workflows run for a stack and how to reduce redundant CI usage. ## How workflows run for a stack diff --git a/content/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-stacked-pull-requests.md b/content/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-stacked-pull-requests.md index c26a9e0bbd15..2abd5e47e2c4 100644 --- a/content/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Merge and close pull requests --- -{% data reusables.public-preview.public-preview %} - This article covers common issues you may encounter when working with stacked pull requests and how to resolve them. ## A rebase reports a conflict diff --git a/content/pull-requests/how-tos/review-pull-requests/reviewing-stacked-pull-requests.md b/content/pull-requests/how-tos/review-pull-requests/reviewing-stacked-pull-requests.md index 2a0b2d8ec92f..c74e74b1df6d 100644 --- a/content/pull-requests/how-tos/review-pull-requests/reviewing-stacked-pull-requests.md +++ b/content/pull-requests/how-tos/review-pull-requests/reviewing-stacked-pull-requests.md @@ -10,8 +10,6 @@ category: - Review pull requests --- -{% data reusables.public-preview.public-preview %} - Each pull request in a stack shows only the diff for its layer. This means reviewers can request changes on any pull request independently. When a reviewer requests changes on a pull request mid-stack, you should make the fix on the branch that owns the change and rebase so the branches above it pick up your update. ## Addressing review feedback diff --git a/content/pull-requests/reference/pull-request-reviews.md b/content/pull-requests/reference/pull-request-reviews.md index 0d58905bf044..52bd0cf7bddc 100644 --- a/content/pull-requests/reference/pull-request-reviews.md +++ b/content/pull-requests/reference/pull-request-reviews.md @@ -39,6 +39,10 @@ Reviewers can also comment on specific lines, suggest exact changes, and discuss Reviews can be requested from specific people or teams when they need feedback from the right experts. To request a review, you need write access to the repository. You can request a review from a person or team with read access to the repository, and they receive a notification. + +> [!WARNING] +> {% data reusables.pull_requests.large-team-review-request-warning %} + * Pull request authors can request reviews only if they are repository owners or collaborators with write access. * Organization members with write access or triage permissions can also assign a reviewer for a pull request. * If you request a review from a team and code review assignment is enabled, specific members will be requested and the team will be removed as a reviewer. diff --git a/content/pull-requests/reference/stacked-prs-cli-commands.md b/content/pull-requests/reference/stacked-prs-cli-commands.md index 01c445d637cd..e9a8c92d816c 100644 --- a/content/pull-requests/reference/stacked-prs-cli-commands.md +++ b/content/pull-requests/reference/stacked-prs-cli-commands.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - The `gh stack` extension for {% data variables.product.prodname_cli %} creates and manages stacks of pull requests from your local repository. For an introduction to stacks, see [AUTOTITLE](/pull-requests/reference/stacked-pull-requests). ## Installation diff --git a/content/pull-requests/reference/stacked-pull-requests-apis-and-webhooks.md b/content/pull-requests/reference/stacked-pull-requests-apis-and-webhooks.md index 18a249a5b98b..70e672d5bee8 100644 --- a/content/pull-requests/reference/stacked-pull-requests-apis-and-webhooks.md +++ b/content/pull-requests/reference/stacked-pull-requests-apis-and-webhooks.md @@ -13,8 +13,6 @@ category: - Merge and close pull requests --- -{% data reusables.public-preview.public-preview %} - The {% data variables.product.github %} REST and GraphQL APIs both expose stacked pull requests. The REST API supports reading and managing stacks, while the GraphQL API supports read-only queries. Use the API to read a pull request's stack membership or to build your own automation and integrations for stacked pull requests. diff --git a/content/pull-requests/reference/stacked-pull-requests.md b/content/pull-requests/reference/stacked-pull-requests.md index 41769466084e..7bbe99ed3c37 100644 --- a/content/pull-requests/reference/stacked-pull-requests.md +++ b/content/pull-requests/reference/stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - {% data reusables.pull_requests.pr-stack-definition %} Every pull request in a stack is evaluated against rules for the **base of the stack** — typically `main` — regardless of which branch it directly targets. This means mid-stack pull requests are held to the same standard as the bottom pull request. @@ -57,6 +55,15 @@ Stack metadata, such as the stack's base branch, is available in workflow expres For the full set of metadata fields and patterns to reduce redundant CI usage, see [AUTOTITLE](/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests). +## Rebasing + +When a rebase is available or needed, the merge box will indicate this by displaying a **Rebase stack** button. For example, this may happen when changes to a pull request make the stack non-linear. When the bottom pull request is merged, a rebase happens automatically and you typically won't need to rebase manually. + +When a stack is rebased, you can expect the following: + +* Rebasing the stack generates signed commits. +* Rebasing a stack doesn't count as a new, reviewable commit if the diff does not change. Approvals are retained in this situation, even when the **Dismiss stale pull request approvals when new commits are pushed** rule is enabled. + ## Merge requirements Before a pull request in a stack can merge, all of the following must be true: @@ -67,11 +74,13 @@ Before a pull request in a stack can merge, all of the following must be true: For example, in the stack `main ← PR1 ← PR2 ← PR3`, merging PR #3 requires PR #1 and PR #2 to also pass checks, have required reviews, and satisfy all branch protection rules. +> [!NOTE] Stacked pull requests support merging with **bypass rules**, but only the bottom pull request can be merged this way. You cannot merge the whole stack with bypass rules. See [AUTOTITLE](/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository#granting-bypass-permissions-for-your-branch-or-tag-ruleset) + ## Merge methods Stacks support all three merge methods. In each case, the pull requests land as a single atomic operation: -* **Merge commit** creates one merge commit for the entire group of pull requests being merged, preserving each pull request's full commit history. +* **Merge commit** creates one merge commit for each pull requests being merged, preserving each pull request's full commit history. * **Squash** creates one clean, squashed commit per pull request. Merging `n` pull requests creates `n` squashed commits on the base branch. * **Rebase** replays the commits from each pull request onto the base branch, creating a linear history without merge commits. @@ -80,7 +89,7 @@ Stacks support all three merge methods. In each case, the pull requests land as Stacks fully support merge queues. All pull requests in the stack are added to the queue in the correct order. If a pull request is removed or ejected from the queue, all pull requests above it in the stack are also removed. > [!NOTE] -> To keep a stack together, the merge queue allows the merge group to exceed its configured maximum size by up to 50 percent. If the stack is too large to fit within that buffer, it will automatically be split across consecutive merge groups. +> To keep a stack together, the merge queue allows the merge group to exceed its configured maximum size by up to 50 percent. If the stack is too large to fit within that buffer, it will automatically be put into the next merge group as a single unit. ## Linear history diff --git a/content/pull-requests/reference/use-other-tools-with-stacked-pull-requests.md b/content/pull-requests/reference/use-other-tools-with-stacked-pull-requests.md index 5fb3d5759948..f3d203aa6f97 100644 --- a/content/pull-requests/reference/use-other-tools-with-stacked-pull-requests.md +++ b/content/pull-requests/reference/use-other-tools-with-stacked-pull-requests.md @@ -9,8 +9,6 @@ category: - Create pull requests --- -{% data reusables.public-preview.public-preview %} - Stacked pull requests are built on standard Git branches and regular pull requests, so you can choose to use tools other than the `gh stack` CLI for your local workflow. If you manage branch chains with another tool, such as Jujutsu (jj), Sapling, or git-town, you can use the `gh stack link` command to open those branches as a stack on {% data variables.product.github %}. The `gh stack link` command only calls the {% data variables.product.github %} API to create the stacked pull requests — it does not create any local tracking. If a branch already has an open pull request, `link` uses it; otherwise it creates a draft pull request with the correct base branch. diff --git a/content/pull-requests/tutorials/roll-out-stacked-prs.md b/content/pull-requests/tutorials/roll-out-stacked-prs.md index 7e331e99aa54..ecb984334068 100644 --- a/content/pull-requests/tutorials/roll-out-stacked-prs.md +++ b/content/pull-requests/tutorials/roll-out-stacked-prs.md @@ -10,9 +10,6 @@ category: - Create pull requests --- -> [!NOTE] -> Stacked pull requests are in {% data variables.release-phases.public_preview %} and subject to change. - Stacked pull requests let developers break large changes into a chain of small, focused pull requests that build on each other. This approach can help your organization maintain review quality as developers produce more code, including with {% data variables.product.prodname_copilot_short %} and other coding agents. Stacked pull requests require **no setup or enablement**. If your team already uses pull requests, they can create a stack today. The steps below help you prepare your existing controls and support a smooth rollout, not turn a feature on. diff --git a/content/pull-requests/tutorials/stack-code-changes-in-pull-requests.md b/content/pull-requests/tutorials/stack-code-changes-in-pull-requests.md index 82e76d9bef84..899ea12f4f42 100644 --- a/content/pull-requests/tutorials/stack-code-changes-in-pull-requests.md +++ b/content/pull-requests/tutorials/stack-code-changes-in-pull-requests.md @@ -10,8 +10,6 @@ category: - Team collaboration --- -{% data reusables.public-preview.public-preview %} - Large pull requests are difficult to review and create bottlenecks, especially when you generate a high volume of code in a short time. Review quality also degrades as pull request size increases. Reviewers may skim the result, miss issues, or procrastinate and leave the pull request until it grows stale and develops merge conflicts. Stacked pull requests keep large code changes reviewable. diff --git a/data/release-notes/enterprise-server/3-18/16.yml b/data/release-notes/enterprise-server/3-18/16.yml new file mode 100644 index 000000000000..fb8b177847bd --- /dev/null +++ b/data/release-notes/enterprise-server/3-18/16.yml @@ -0,0 +1,64 @@ +date: '2026-10-06' +sections: + security_fixes: + - | + **MEDIUM:** A repository collaborator with write access could use the GraphQL API to delete the current default branch and cause an attacker-controlled branch to become the new default. In repositories that required pull-request review but did not restrict branch deletion, this bypassed the review requirement and caused fresh clones and default-branch API requests to use attacker-controlled content. GitHub has requested CVE ID [CVE-2026-103620](https://www.cve.org/cverecord?id=CVE-2026-103620) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + bugs: + - | + On Microsoft Azure Eav6-series virtual machines with Accelerated Networking enabled, GitHub Enterprise Server could intermittently become unreachable after the virtual machine started. + - | + When site administrators configured custom OpenTelemetry Collector pipelines to export instance telemetry, the Management Console allowed them to save the settings without a valid configuration, causing the collector to enter a restart loop. + - | + Adding an existing user to an organization or team could take several seconds on large GitHub Enterprise Server instances due to redundant license seat calculations. + - | + Site administrators could receive a 404 error when selecting **Complete your GitHub Connect setup** after an interrupted setup attempt, preventing them from completing the connection while the pending setup session remained active. + - | + After an index repair completed, its status could remain paused. + - | + When organization owners updated the list of designated reviewers for push protection bypass requests, GitHub Enterprise Server could add, retain, or remove the wrong reviewer if different reviewer types shared the same identifier. + 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. + 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. + - | + 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 time out 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, shut down 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 to 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. + - | + 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/13.yml b/data/release-notes/enterprise-server/3-19/13.yml new file mode 100644 index 000000000000..136ebf847d85 --- /dev/null +++ b/data/release-notes/enterprise-server/3-19/13.yml @@ -0,0 +1,66 @@ +date: '2026-10-06' +sections: + security_fixes: + - | + **MEDIUM:** A repository collaborator with write access could use the GraphQL API to delete the current default branch and cause an attacker-controlled branch to become the new default. In repositories that required pull-request review but did not restrict branch deletion, this bypassed the review requirement and caused fresh clones and default-branch API requests to use attacker-controlled content. GitHub has requested CVE ID [CVE-2026-103620](https://www.cve.org/cverecord?id=CVE-2026-103620) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + bugs: + - | + In high availability configurations, administrators who had configured datacenter labels on replica nodes saw each node listed in a separate datacenter on the Management Console replication page after upgrading. The page grouped nodes by each node's internal Consul datacenter value instead of the customer-configured datacenter label. + - | + On Microsoft Azure Eav6-series virtual machines with Accelerated Networking enabled, GitHub Enterprise Server could intermittently become unreachable after the virtual machine started. + - | + On instances in a high availability or cluster configuration and with WireGuard enabled, `ghe-cluster-status-nodes --extended` could report false alarms for node connection checks. + - | + When site administrators configured custom OpenTelemetry Collector pipelines to export instance telemetry, the Management Console allowed them to save the settings without a valid configuration, causing the collector to enter a restart loop. + - | + When site administrators used Grafana to monitor storage usage and navigated using **Home**, the System & Application Insights dashboards displayed incomplete disk metric titles, omitted data from some panels, and did not display the node selector. + - | + Adding an existing user to an organization or team could take several seconds on large GitHub Enterprise Server instances due to redundant license seat calculations. + - | + Site administrators could not run the pre-upgrade stage of an upgrade outside the maintenance window. As a result, upgrades could require up to 20 additional minutes during the maintenance window. + - | + Site administrators could receive a 404 error when selecting **Complete your GitHub Connect setup** after an interrupted setup attempt, preventing them from completing the connection while the pending setup session remained active. + - | + After an index repair completed, its status could remain paused. + - | + When organization owners updated the list of designated reviewers for push protection bypass requests, GitHub Enterprise Server could add, retain, or remove the wrong reviewer if different reviewer types shared the same identifier. + 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. + 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. + - | + 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 time out 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, shut down 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. + - | + In a cluster, the host running restore requires access to 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. + - | + 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/9.yml b/data/release-notes/enterprise-server/3-20/9.yml new file mode 100644 index 000000000000..bf6254c363f4 --- /dev/null +++ b/data/release-notes/enterprise-server/3-20/9.yml @@ -0,0 +1,69 @@ +date: '2026-10-06' +sections: + features: + - | + Site administrators can configure the GitHub Enterprise Server Backup Service to use native, incremental Elasticsearch snapshots with Azure Blob storage, Amazon S3, or Google Cloud Storage. This can reduce backup time and local storage usage. Snapshots are isolated by GitHub Enterprise Server patch version. For more information, see [AUTOTITLE](/admin/backing-up-and-restoring-your-instance/configuring-elasticsearch-snapshots). + security_fixes: + - | + **HIGH**: An attacker could cause a GitHub Enterprise Server instance to send requests to attacker-chosen internal addresses by committing crafted GCP service account credentials whose token endpoint specified an internal destination. When secret scanning performed a validity check, the appliance issued a request to that destination without adequately validating it. This server-side request forgery vulnerability could potentially lead to remote code execution on the appliance. Exploitation required push access to a repository on an instance with GitHub Advanced Security and secret scanning validity checks enabled. GitHub has requested CVE ID [CVE-2026-96890](https://www.cve.org/cverecord?id=CVE-2026-96890) for this vulnerability, which was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM:** A repository collaborator with write access could use the GraphQL API to delete the current default branch and cause an attacker-controlled branch to become the new default. In repositories that required pull-request review but did not restrict branch deletion, this bypassed the review requirement and caused fresh clones and default-branch API requests to use attacker-controlled content. GitHub has requested CVE ID [CVE-2026-103620](https://www.cve.org/cverecord?id=CVE-2026-103620) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + bugs: + - | + In high availability configurations, administrators who had configured datacenter labels on replica nodes saw each node listed in a separate datacenter on the Management Console replication page after upgrading. The page grouped nodes by each node's internal Consul datacenter value instead of the customer-configured datacenter label. + - | + On Microsoft Azure Eav6-series virtual machines with Accelerated Networking enabled, GitHub Enterprise Server could intermittently become unreachable after the virtual machine started. + - | + On instances in a high availability or cluster configuration and with WireGuard enabled, `ghe-cluster-status-nodes --extended` could report false alarms for node connection checks. + - | + When site administrators used Grafana to monitor storage usage and navigated using **Home**, the System & Application Insights dashboards displayed incomplete disk metric titles, omitted data from some panels, and did not display the node selector. + - | + When site administrators configured custom OpenTelemetry Collector pipelines to export instance telemetry, the Management Console allowed them to save the settings without a valid configuration, causing the collector to enter a restart loop. + - | + Adding an existing user to an organization or team could take several seconds on large GitHub Enterprise Server instances due to redundant license seat calculations. + - | + Site administrators could not run the pre-upgrade stage of an upgrade outside the maintenance window. As a result, upgrades could require up to 20 additional minutes during the maintenance window. + - | + Site administrators could receive a 404 error when selecting **Complete your GitHub Connect setup** after an interrupted setup attempt, preventing them from completing the connection while the pending setup session remained active. + - | + After an index repair completed, its status could remain paused. + - | + For some secret scanning alerts, validity checks failed for inactive Yandex Cloud API keys or Anthropic API keys associated with disabled organizations. + - | + When organization owners updated the list of designated reviewers for push protection bypass requests, GitHub Enterprise Server could add, retain, or remove the wrong reviewer if different reviewer types shared the same identifier. + 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. + 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 time out 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, shut down 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. + - | + In a cluster, the host running restore requires access to 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 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/7.yml b/data/release-notes/enterprise-server/3-21/7.yml new file mode 100644 index 000000000000..87cc989b25f4 --- /dev/null +++ b/data/release-notes/enterprise-server/3-21/7.yml @@ -0,0 +1,77 @@ +date: '2026-10-06' +sections: + features: + - | + Site administrators can configure the GitHub Enterprise Server Backup Service to use native, incremental Elasticsearch snapshots with Azure Blob storage, Amazon S3, or Google Cloud Storage. This can reduce backup time and local storage usage. Snapshots are isolated by GitHub Enterprise Server patch version. For more information, see [AUTOTITLE](/admin/backing-up-and-restoring-your-instance/configuring-elasticsearch-snapshots). + security_fixes: + - | + **HIGH**: An attacker could cause a GitHub Enterprise Server instance to send requests to attacker-chosen internal addresses by committing crafted GCP service account credentials whose token endpoint specified an internal destination. When secret scanning performed a validity check, the appliance issued a request to that destination without adequately validating it. This server-side request forgery vulnerability could potentially lead to remote code execution on the appliance. Exploitation required push access to a repository on an instance with GitHub Advanced Security and secret scanning validity checks enabled. GitHub has requested CVE ID [CVE-2026-96890](https://www.cve.org/cverecord?id=CVE-2026-96890) for this vulnerability, which was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM:** A repository collaborator with write access could use the GraphQL API to delete the current default branch and cause an attacker-controlled branch to become the new default. In repositories that required pull-request review but did not restrict branch deletion, this bypassed the review requirement and caused fresh clones and default-branch API requests to use attacker-controlled content. GitHub has requested CVE ID [CVE-2026-103620](https://www.cve.org/cverecord?id=CVE-2026-103620) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + bugs: + - | + In high availability configurations, administrators who had configured datacenter labels on replica nodes saw each node listed in a separate datacenter on the Management Console replication page after upgrading. The page grouped nodes by each node's internal Consul datacenter value instead of the customer-configured datacenter label. + - | + On Microsoft Azure Eav6-series virtual machines with Accelerated Networking enabled, GitHub Enterprise Server could intermittently become unreachable after the virtual machine started. + - | + On instances in a high availability or cluster configuration and with WireGuard enabled, `ghe-cluster-status-nodes --extended` could report false alarms for node connection checks. + - | + When site administrators configured custom OpenTelemetry Collector pipelines to export instance telemetry, the Management Console allowed them to save the settings without a valid configuration, causing the collector to enter a restart loop. + - | + Adding an existing user to an organization or team could take several seconds on large GitHub Enterprise Server instances due to redundant license seat calculations. + - | + Site administrators could not run the pre-upgrade stage of an upgrade outside the maintenance window. As a result, upgrades could require up to 20 additional minutes during the maintenance window. + - | + Some users could not view the command-line merge instructions in pull requests. + - | + Site administrators could receive a 404 error when selecting **Complete your GitHub Connect setup** after an interrupted setup attempt, preventing them from completing the connection while the pending setup session remained active. + - | + After an index repair completed, its status could remain paused. + - | + Repository administrators who were not designated reviewers for secret scanning bypass requests could access the bypass requests list, but opening an individual request returned a 404 error. + - | + For some secret scanning alerts, validity checks failed for inactive Yandex Cloud API keys, Anthropic API keys associated with disabled organizations, or active Unkey keys with insufficient permissions. + 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. + 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 time out 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, shut down 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. + - | + In a cluster, the host running restore requires access to 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 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 a clustered GitHub Enterprise Server environment, a node that remained in the cluster after losing the `git-server`, `pages-server`, or `storage-server` role could continue to appear online and eligible for replication. This could cause replica placement failures, incorrect replica counts, or replication status to show a node without the relevant service as healthy. If you experience these symptoms, contact GitHub Support. + - | + 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/2.yml b/data/release-notes/enterprise-server/3-22/2.yml new file mode 100644 index 000000000000..d055ac8133fc --- /dev/null +++ b/data/release-notes/enterprise-server/3-22/2.yml @@ -0,0 +1,74 @@ +date: '2026-10-06' +sections: + features: + - | + Site administrators can configure the GitHub Enterprise Server Backup Service to use native, incremental Elasticsearch snapshots with Azure Blob storage, Amazon S3, or Google Cloud Storage. This can reduce backup time and local storage usage. Snapshots are isolated by GitHub Enterprise Server patch version. For more information, see [AUTOTITLE](/admin/backing-up-and-restoring-your-instance/configuring-elasticsearch-snapshots). + security_fixes: + - | + **HIGH**: An attacker could cause a GitHub Enterprise Server instance to send requests to attacker-chosen internal addresses by committing crafted GCP service account credentials whose token endpoint specified an internal destination. When secret scanning performed a validity check, the appliance issued a request to that destination without adequately validating it. This server-side request forgery vulnerability could potentially lead to remote code execution on the appliance. Exploitation required push access to a repository on an instance with GitHub Advanced Security and secret scanning validity checks enabled. GitHub has requested CVE ID [CVE-2026-96890](https://www.cve.org/cverecord?id=CVE-2026-96890) for this vulnerability, which was reported through the [GitHub Bug Bounty program](https://bounty.github.com/). + - | + **MEDIUM:** A repository collaborator with write access could use the GraphQL API to delete the current default branch and cause an attacker-controlled branch to become the new default. In repositories that required pull-request review but did not restrict branch deletion, this bypassed the review requirement and caused fresh clones and default-branch API requests to use attacker-controlled content. GitHub has requested CVE ID [CVE-2026-103620](https://www.cve.org/cverecord?id=CVE-2026-103620) for this vulnerability, which was reported via the [GitHub Bug Bounty program](https://bounty.github.com/). + bugs: + - | + On Microsoft Azure Eav6-series virtual machines with Accelerated Networking enabled, GitHub Enterprise Server could intermittently become unreachable after the virtual machine started. + - | + On instances in a high availability or cluster configuration and with WireGuard enabled, `ghe-cluster-status-nodes --extended` could report false alarms for node connection checks. + - | + When site administrators configured custom OpenTelemetry Collector pipelines to export instance telemetry, the Management Console allowed them to save the settings without a valid configuration, causing the collector to enter a restart loop. + - | + Repository administrators who were not designated reviewers for secret scanning bypass requests could access the bypass requests list, but opening an individual request returned a 404 error. + - | + Adding an existing user to an organization or team could take several seconds on large GitHub Enterprise Server instances due to redundant license seat calculations. + - | + Site administrators could not run the pre-upgrade stage of an upgrade outside the maintenance window. As a result, upgrades could require up to 20 additional minutes during the maintenance window. + - | + Some users could not view the command-line merge instructions in pull requests. + - | + Site administrators could receive a 404 error when selecting **Complete your GitHub Connect setup** after an interrupted setup attempt, preventing them from completing the connection while the pending setup session remained active. + - | + For some secret scanning alerts, validity checks failed for inactive Yandex Cloud API keys, Anthropic API keys associated with disabled organizations, active Unkey keys with insufficient permissions, or Mistral API keys whose accounts required payment. + 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 time out 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, shut down 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. + - | + In a cluster, the host running restore requires access to 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 a clustered GitHub Enterprise Server environment, a node that remained in the cluster after losing the `git-server`, `pages-server`, or `storage-server` role could continue to appear online and eligible for replication. This could cause replica placement failures, incorrect replica counts, or replication status to show a node without the relevant service as healthy. If you experience these symptoms, contact GitHub Support. + - | + 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/pull_requests/large-team-review-request-warning.md b/data/reusables/pull_requests/large-team-review-request-warning.md new file mode 100644 index 000000000000..77295c267c00 --- /dev/null +++ b/data/reusables/pull_requests/large-team-review-request-warning.md @@ -0,0 +1 @@ +Requesting a review from a large team can notify every team member. You can reduce notifications by enabling auto assignment, or by enabling **Only notify requested team members** and also requesting a specific team member. See [AUTOTITLE](/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team). diff --git a/package-lock.json b/package-lock.json index c1adc8c5087b..3e88d33ba6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "remark-remove-comments": "^1.1.1", "remark-stringify": "^11.0.0", "semver": "^7.7.4", - "sharp": "0.35.4", + "sharp": "0.35.5", "slash": "^5.1.0", "strip-ansi": "7.1.0", "swr": "^2.4.0", @@ -1535,9 +1535,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1553,13 +1553,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1575,20 +1575,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1598,9 +1598,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1614,9 +1614,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1630,9 +1630,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -1649,9 +1649,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -1668,9 +1668,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -1687,9 +1687,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -1706,9 +1706,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -1725,9 +1725,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -1744,9 +1744,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -1763,9 +1763,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -1782,9 +1782,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -1803,13 +1803,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1828,13 +1828,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -1853,13 +1853,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -1878,13 +1878,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1903,13 +1903,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1928,13 +1928,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1953,13 +1953,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1978,13 +1978,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1998,16 +1998,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2017,9 +2017,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2036,9 +2036,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2055,9 +2055,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -8735,9 +8735,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -13114,7 +13114,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -13122,6 +13124,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -14020,9 +14026,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -14036,31 +14042,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -14240,9 +14246,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 6bc63e168fe3..0b8c05944f70 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "openapi-docs": "tsx src/rest/docs.ts", "playwright-test": "playwright test --config src/fixtures/playwright.config.ts --project=\"Google Chrome\"", "lint-report": "tsx src/content-linter/scripts/lint-report.ts", - "precompute-pageinfo": "tsx src/article-api/scripts/precompute-pageinfo.ts", "prepare": "husky src/workflows/husky", "prettier": "prettier -w \"**/*.{ts,tsx,scss,yml,yaml}\"", "prettier-check": "prettier -c \"**/*.{ts,tsx,scss,yml,yaml}\"", @@ -241,7 +240,7 @@ "remark-remove-comments": "^1.1.1", "remark-stringify": "^11.0.0", "semver": "^7.7.4", - "sharp": "0.35.4", + "sharp": "0.35.5", "slash": "^5.1.0", "strip-ansi": "7.1.0", "swr": "^2.4.0", diff --git a/src/article-api/middleware/article-pageinfo.ts b/src/article-api/middleware/article-pageinfo.ts index edd700ff940f..d3b3a70564ef 100644 --- a/src/article-api/middleware/article-pageinfo.ts +++ b/src/article-api/middleware/article-pageinfo.ts @@ -7,10 +7,6 @@ import contextualize from '@/frame/middleware/context/context' import features from '@/versions/middleware/features' import breadcrumbs from '@/frame/middleware/context/breadcrumbs' import currentProductTree from '@/frame/middleware/context/current-product-tree' -import { readCompressedJsonFile } from '@/frame/lib/read-json-file' - -// scripts/precompute-pageinfo.ts imports this path; missing files fall back to live computation. -export const CACHE_FILE_PATH = '.pageinfo-cache.json.br' // Metadata rendering and breadcrumbs need this minimal middleware chain. async function makeRenderingReq(page: Page, pathname: string) { @@ -33,7 +29,7 @@ async function makeRenderingReq(page: Page, pathname: string) { type RenderingReq = Awaited> -async function computeCacheableFromReq(renderingReq: RenderingReq, page: Page) { +async function computePageInfoFromReq(renderingReq: RenderingReq, page: Page): Promise { const context = renderingReq.context const title = await page.renderProp('title', context, { textOnly: true }) @@ -67,24 +63,11 @@ async function computeBreadcrumbsFromReq(renderingReq: RenderingReq) { return renderingReq.context.breadcrumbs as Breadcrumb[] | undefined } -// Cache only title, intro, and product. -// Breadcrumbs compute cheaply on cache hits and would bloat the cache file and dictionary. -export async function getCacheablePageInfo(page: Page, pathname: string) { - const renderingReq = await makeRenderingReq(page, pathname) - return computeCacheableFromReq(renderingReq, page) -} - -// Breadcrumbs cost less than title and intro rendering, so cache hits compute them. -export async function getBreadcrumbsForPage(page: Page, pathname: string) { - const renderingReq = await makeRenderingReq(page, pathname) - return computeBreadcrumbsFromReq(renderingReq) -} - -// getPageInfo reuses one rendering request on cache misses. +// getPageInfo reuses one rendering request. // contextualize, shortVersions, and features run once for metadata and breadcrumbs. -export async function getPageInfo(page: Page, pathname: string) { +export async function getPageInfo(page: Page, pathname: string): Promise { const renderingReq = await makeRenderingReq(page, pathname) - const base = await computeCacheableFromReq(renderingReq, page) + const base = await computePageInfoFromReq(renderingReq, page) const pageBreadcrumbs = await computeBreadcrumbsFromReq(renderingReq) return { ...base, breadcrumbs: pageBreadcrumbs } } @@ -108,61 +91,16 @@ async function getProductPageInfo(page: Page, context: Context) { return _productPageCache[cacheKey] } -type CachedPageInfoEntry = { +type PageInfo = { title: string intro: string product: string } -type CachedPageInfo = { - [url: string]: CachedPageInfoEntry -} - type Breadcrumb = { href: string; title: string } -type PageInfoWithBreadcrumbs = CachedPageInfoEntry & { +type PageInfoWithBreadcrumbs = PageInfo & { breadcrumbs?: Breadcrumb[] - cacheInfo?: string -} - -// getPageInfoFromCache does not fill the in-memory cache on misses. -// Production sees each HTTP GET once per deploy because the CDN caches it until purge. -// Local review does not need this cache path for performance. -// CI warms the precomputed cache with npm run precompute-pageinfo before vitest. -let _cache: CachedPageInfo | null = null -export async function getPageInfoFromCache( - page: Page, - pathname: string, -): Promise { - let cacheInfo = '' - if (_cache === null) { - try { - _cache = readCompressedJsonFile(CACHE_FILE_PATH) as CachedPageInfo - cacheInfo = 'initial-load' - } catch (error) { - cacheInfo = 'initial-fail' - if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - _cache = {} - } - } - - const cached = _cache[pathname] - if (!cacheInfo) { - cacheInfo = cached ? 'hit' : 'miss' - } - - let meta: PageInfoWithBreadcrumbs - if (cached) { - // The precomputed cache omits breadcrumbs because cache hits can compute them cheaply. - const pageBreadcrumbs = await getBreadcrumbsForPage(page, pathname) - meta = { ...cached, breadcrumbs: pageBreadcrumbs } - } else { - meta = await getPageInfo(page, pathname) - } - meta.cacheInfo = cacheInfo - return meta } // pageValidationMiddleware follows redirects before getMetadata. @@ -189,11 +127,9 @@ export async function getMetadata(req: ExtendedRequestWithPageInfo) { throw new Error(`pathname '${pathname}' not one of the page's permalinks`) } - const fromCache = await getPageInfoFromCache(page, pathname) - const { cacheInfo, ...meta } = fromCache + const meta = await getPageInfo(page, pathname) return { meta: { ...meta, documentType, ...(redirectedFrom && { redirectedFrom }) }, - cacheInfo, } } diff --git a/src/article-api/middleware/article.ts b/src/article-api/middleware/article.ts index f011aaa98fc8..8b71f9590350 100644 --- a/src/article-api/middleware/article.ts +++ b/src/article-api/middleware/article.ts @@ -48,7 +48,7 @@ router.get( pageValidationMiddleware as RequestHandler, apiVersionValidationMiddleware as RequestHandler, catchMiddlewareError(async function (req: ExtendedRequestWithPageInfo, res: Response) { - const { meta, cacheInfo } = await getMetadata(req) + const { meta } = await getMetadata(req) let bodyContent try { bodyContent = await getArticleBody(req) @@ -56,7 +56,7 @@ router.get( return res.status(403).json({ error: (error as Error).message }) } - incrementArticleLookup(req, 'full', cacheInfo) + incrementArticleLookup(req, 'full') recordBodySize(req, bodyContent) defaultCacheControl(res) @@ -143,9 +143,9 @@ router.get( pathValidationMiddleware as RequestHandler, pageValidationMiddleware as RequestHandler, catchMiddlewareError(async function pageInfo(req: ExtendedRequestWithPageInfo, res: Response) { - const { meta, cacheInfo } = await getMetadata(req) + const { meta } = await getMetadata(req) - incrementArticleLookup(req, 'meta', cacheInfo) + incrementArticleLookup(req, 'meta') defaultCacheControl(res) setFastlySurrogateKey( @@ -160,11 +160,7 @@ router.get( // Keep Datadog metric tags consistent across Article API endpoints. // Datadog tags max at 200 characters, so path and source tags are truncated. // See https://docs.datadoghq.com/getting_started/tagging/#define-tags -function incrementArticleLookup( - req: ExtendedRequestWithPageInfo, - type: 'full' | 'body' | 'meta', - cacheInfo?: string, -) { +function incrementArticleLookup(req: ExtendedRequestWithPageInfo, type: 'full' | 'body' | 'meta') { const pathname = req.pageinfo.pathname const language = req.pageinfo.page?.languageCode || 'en' @@ -190,9 +186,6 @@ function incrementArticleLookup( `source:${source}`.slice(0, 200), ] - // Full and metadata lookups include page-info cache status. - if (cacheInfo) tags.push(`cache:${cacheInfo}`) - statsd.increment('api.article.lookup', 1, tags) } diff --git a/src/article-api/scripts/precompute-pageinfo.ts b/src/article-api/scripts/precompute-pageinfo.ts deleted file mode 100644 index 8730bf25e90b..000000000000 --- a/src/article-api/scripts/precompute-pageinfo.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Precomputes title, intro, and product for article metadata into a Brotli cache. -// This avoids rendering three properties with Liquid and Markdown on every request. -// Default English-only output keeps the cache small; Brotli keeps the Docker image smaller. -// The main workflow computes the cache; deployment only restores it. - -import fs from 'fs' -import { brotliCompressSync } from 'zlib' - -import chalk from 'chalk' -import { program, Option } from 'commander' - -import { languageKeys } from '@/languages/lib/languages-server' -import { loadPages, loadUnversionedTree } from '@/frame/lib/page-data' -import { CACHE_FILE_PATH, getCacheablePageInfo } from '../middleware/article-pageinfo' - -program - .description('Generates a JSON file with precompute pageinfo data by pathname') - .addOption( - new Option('-l, --language ', 'Which languages to focus on') - .choices(languageKeys.concat('all')) - .default(['en']), - ) - .option('-o, --output-file ', 'path to output file', CACHE_FILE_PATH) - .option('--max-versions ', 'max. number of permalink versions per page') - .parse(process.argv) - -type Options = { - outputFile: string - languages: string[] - maxVersions: number -} -const opts = program.opts() - -main({ - outputFile: opts.outputFile, - languages: opts.language, - maxVersions: isNaN(opts.maxVersions) ? 1 : Number(opts.maxVersions), -}) - -const CI = Boolean(JSON.parse(process.env.CI || 'false')) - -type PageInfo = { - title: string - intro: string - product: string -} - -async function main(options: Options) { - const { outputFile, languages, maxVersions } = options - if (outputFile !== CACHE_FILE_PATH) { - console.warn(chalk.yellow(`Writing to ${outputFile} instead of ${CACHE_FILE_PATH}`)) - } - if (languages.includes('all')) { - // loadUnversionedTree treats an empty language list as all languages. - languages.length = 0 - } - - const unversionedTree = await loadUnversionedTree(languages) - const pageList = await loadPages(unversionedTree, languages) - - let label = `Compute pageinfos for ${pageList.length.toLocaleString()} pages` - console.time(label) - const pageinfos: { - [pathname: string]: PageInfo - } = {} - for (const page of pageList) { - let countVersions = 0 - for (const permalink of page.permalinks) { - const pathname = permalink.href - try { - const computed = await getCacheablePageInfo(page, pathname) - if (computed) { - pageinfos[pathname] = computed - } - } catch (error) { - console.error(`Error computing pageinfo for ${page.fullPath} (${pathname})`) - throw error - } - if (++countVersions >= maxVersions) { - // The default keeps one permalink; 99% of first permalinks use the free-pro-team pathname. - break - } - } - } - console.timeEnd(label) - - label = `Serialize, compress, and write to ${outputFile}` - console.time(label) - const payload = CI ? JSON.stringify(pageinfos) : JSON.stringify(pageinfos, null, 2) - if (outputFile.endsWith('.json')) { - fs.writeFileSync(outputFile, payload) - } else { - const payloadBuffer = Buffer.from(payload, 'utf-8') - const payloadCompressed = brotliCompressSync(payloadBuffer as NodeJS.ArrayBufferView) - fs.writeFileSync(outputFile, payloadCompressed as NodeJS.ArrayBufferView) - } - console.timeEnd(label) - console.log( - chalk.green( - `Wrote ${Object.keys(pageinfos).length.toLocaleString()} pageinfos to ${outputFile}`, - ), - ) -} diff --git a/src/content-pipelines/config.yml b/src/content-pipelines/config.yml index 7a364ca70798..793621d6e6b8 100644 --- a/src/content-pipelines/config.yml +++ b/src/content-pipelines/config.yml @@ -52,4 +52,3 @@ gh-stack: The source is an Astro Starlight site. Convert directives to GitHub Docs alerts: ":::note" becomes "> [!NOTE]", ":::tip" becomes "> [!TIP]", ":::caution" becomes "> [!WARNING]", ":::danger" becomes "> [!CAUTION]". Drop directive titles such as ":::note[Authentication]", because GitHub Docs alerts do not take titles. The source uses "sh" code fences and title-case headings; use "shell" and sentence case instead. Write "pull request" rather than "PR". - Do not remove the public preview reusable near the top of the article; it has no counterpart in the source docs. diff --git a/src/events/components/Survey.module.scss b/src/events/components/Survey.module.scss index 2b3619b80e2f..c25af54317f1 100644 --- a/src/events/components/Survey.module.scss +++ b/src/events/components/Survey.module.scss @@ -15,11 +15,10 @@ } } -// Override Primer's form-control border color to meet WCAG 1.4.11 Non-Text Contrast -// (requires 3:1 contrast ratio against adjacent colors). -// The default Primer light-theme border (#d0d7de / #dce1e6) has only ~1.3–1.7:1 contrast -// against a white background. Using --fgColor-muted provides sufficient contrast in all themes. -// See: https://github.com/github/accessibility-audits/issues/16368 +// WCAG 1.4.11 Non-Text Contrast requires 3:1 contrast against adjacent colors. +// Primer light-theme form-control borders (#d0d7de and #dce1e6) are about 1.3:1 +// to 1.7:1 against white. --fgColor-muted meets the requirement in every theme. +// The affected survey inputs measure 6.4:1 with this border. .accessibleBorder { border-color: var(--fgColor-muted, #57606a) !important; } diff --git a/src/events/components/Survey.tsx b/src/events/components/Survey.tsx index 8b90a01e1812..bf3a9d6d2f96 100644 --- a/src/events/components/Survey.tsx +++ b/src/events/components/Survey.tsx @@ -39,16 +39,13 @@ export const Survey = () => { const [token, setToken] = useState('') useEffect(() => { - // Send the reader back to the vote prompt on every navigation, - // because a rating belongs to the page it was given on. + // Show the vote prompt on navigation because a rating belongs to the page it was given on. setState(ViewState.START) setVoteState(null) }, [asPath]) useEffect(() => { - // After the form is submitted we need to manually set the focus since we - // remove the form inputs after submit. The privacy policy link is the - // next focusable element in the footer so we focus that. + // Move focus to the footer privacy link because submit removes the form inputs. if (state === ViewState.END) { document .querySelector( @@ -65,11 +62,8 @@ export const Survey = () => { } } - // Though we set `type="email"` on the email address input which gives us browser - // validation of the field, that has accessibility issues (e.g. some screen - // readers won't read the error message) so we need to do manual validation - // ourselves. useEffect(() => { + // Browser email validation hides errors from some screen readers, so this validates manually. const emailRegex = /[^@\s.][^@\s]*@\[?[a-z0-9.-]+\]?\.\[?[a-z0-9.-]+\]?/i if (!email.trim() || emailRegex.test(email)) { setIsEmailError(false) @@ -111,7 +105,7 @@ export const Survey = () => { >

{t`able_to_find`}

- {/* Honeypot: token isn't a real field */} + {/* Bot trap: token is not a real survey field */} { function trackEvent(eventData: EventData) { return sendEvent({ type: EventType.survey, - survey_token: eventData.token || undefined, // Honeypot + survey_token: eventData.token || undefined, // Bot trap field. survey_vote: eventData.vote, survey_comment: eventData.comment || undefined, survey_email: eventData.email || undefined, diff --git a/src/events/components/dotcom-cookies.ts b/src/events/components/dotcom-cookies.ts index 201047a0cf49..75a661d5d485 100644 --- a/src/events/components/dotcom-cookies.ts +++ b/src/events/components/dotcom-cookies.ts @@ -1,7 +1,6 @@ import { isHeadless } from './is-headless' -// We cannot use Cookies.get() on the frontend for httpOnly cookies -// so we need to make a request to the server to get the cookies +// httpOnly cookies require a server request because frontend code cannot read them. type DotcomCookies = { isStaff?: boolean @@ -13,12 +12,10 @@ let inFlightPromise: Promise | null = null const GET_COOKIES_ENDPOINT = '/api/cookies' const LOCAL_STORAGE_KEY = 'dotcomCookies' -// Fetches httpOnly cookies from the server and caches the result. -// We don't want to do this every time because of the load it would place on our servers -// So on success, the data is stored in local storage and reused on subsequent loads -// On failure, returns default empty values -// If a user is staff and they didn't happen to be logged in when these cookies were saved, -// we can instruct them as needed to update the cookies and correctly set the isStaff flag. +// Cache cookie values to avoid repeated load on the cookies endpoint. +// Successful responses stay in localStorage with no expiry, so a stored isStaff=false persists after sign-in. +// Staff must clear the dotcomCookies entry before reloading to refresh experiment targeting. +// Failed requests cache isStaff=false in memory until the next page load. async function fetchCookies(): Promise { if (isHeadless()) return { isStaff: false } diff --git a/src/events/components/events.ts b/src/events/components/events.ts index 96ac18352a71..cc36319bf818 100644 --- a/src/events/components/events.ts +++ b/src/events/components/events.ts @@ -16,7 +16,7 @@ import { sendHydroAnalyticsEvent, getOctoClientId } from './hydro-analytics' const startVisitTime = Date.now() -const BATCH_INTERVAL = 5000 // 5 seconds +const BATCH_INTERVAL = 5000 // Flush queued events every 5 seconds. let initialized = false let cookieValue: string | undefined @@ -48,12 +48,11 @@ function resetPageParams() { scrollDirection = 1 scrollFlipCount = 0 maxScrollY = 0 - // Don't reset previousPath + // Keep previousPath so browser-back referrers can fall back to the prior docs path. hoveredUrls = new Set() } -// Temporary polyfill for crypto.randomUUID() -// Necessary for localhost development (doesn't have https://) +// Use crypto.randomUUID when available; fall back in contexts where the call fails. export function uuidv4(): string { try { return crypto.randomUUID() @@ -98,21 +97,19 @@ export function sendEvent({ type, context: { - // Primitives event_id: uuidv4(), user: getUserEventsId(), version, created: new Date().toISOString(), page_event_id: pageEventId, - // Content information referrer: getReferrer(document.referrer), title: document.title, - href: location.href, // full URL - hostname: location.hostname, // origin without protocol or port - path: location.pathname, // path without search or host - search: location.search, // also known as query string - hash: location.hash, // also known as anchor + href: location.href, + hostname: location.hostname, + path: location.pathname, + search: location.search, + hash: location.hash, path_language: getMetaContent('path-language'), path_version: getMetaContent('path-version'), path_product: getMetaContent('path-product'), @@ -125,8 +122,7 @@ export function sendEvent({ is_logged_in: isLoggedIn(), octo_client_id: getOctoClientId(), - // Device information - // os, os_version, browser, browser_version: + // Adds os, os_version, browser, and browser_version. ...parseUserAgent(), is_headless: isHeadless(), viewport_width: document.documentElement.clientWidth, @@ -136,11 +132,9 @@ export function sendEvent({ pixel_ratio: window.devicePixelRatio || 1, user_agent: navigator.userAgent, - // Location information timezone: new Date().getTimezoneOffset() / -60, user_language: navigator.language, - // Preference information application_preference: Cookies.get(TOOL_PREFERRED_COOKIE_NAME), color_mode_preference: getColorModePreference(), os_preference: Cookies.get(OS_PREFERRED_COOKIE_NAME), @@ -152,7 +146,6 @@ export function sendEvent({ getMetaContent('path-version'), ) || '', - // Event grouping event_group_key: eventGroupKey, event_group_id: eventGroupId, }, @@ -192,12 +185,11 @@ function queueEvent(eventBody: Record) { eventQueue.push(eventBody) } -// Sometimes using the back button means the internal referrer path is not there, -// So this fills it in with a JavaScript variable +// Browser-back navigation can omit the internal referrer path, so previousPath fills it in. function getReferrer(documentReferrer: string) { if (!previousPath) return documentReferrer try { - // new URL() throws an error if not a valid URL + // URL rejects malformed referrers, so invalid values pass through unchanged. const referrerUrl = new URL(documentReferrer) if (!referrerUrl.pathname || referrerUrl.pathname === '/') { return location.origin + previousPath @@ -207,12 +199,7 @@ function getReferrer(documentReferrer: string) { } function getColorModePreference() { - // color mode is set as attributes on , we'll use that information - // along with media query checking rather than parsing the cookie value - // set by github.com - // - // `data-color-mode` is the resolved mode; the preference attribute is what - // keeps `auto` reportable. + // HTML attributes expose the resolved mode and preserve auto preference without parsing cookies. const html = document.querySelector('html') let color_mode_preference = html?.dataset.colorModePreference || html?.dataset.colorMode @@ -243,7 +230,7 @@ function getPerformance() { } function trackScroll() { - // Throttle the calculations to no more than five per second + // Throttle scroll calculations to no more than five per second. if (pauseScrolling) return pauseScrolling = true setTimeout(() => { @@ -289,7 +276,6 @@ function sendExit() { function initPageAndExitEvent() { sendPage() - // Regular page exits window.addEventListener('scroll', trackScroll) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { @@ -299,10 +285,9 @@ function initPageAndExitEvent() { } }) - // Client-side routing Router.events.on('routeChangeStart', async (url) => { - // Don't trigger page events on query string or hash changes - previousPath = location.pathname // pathname set to "prior" url, arg "upcoming" url + // At routeChangeStart, location.pathname is prior and url is upcoming; query/hash keep one page event. + previousPath = location.pathname const newPath = url?.toString().split('?')[0].split('#')[0] const shouldSendEvents = newPath !== previousPath if (shouldSendEvents) { @@ -314,9 +299,7 @@ function initPageAndExitEvent() { }) } -// We want to wait for the DOM to mutate the tags -// as well as finish routeChangeComplete (location.pathname) -// before sending the page event in order to get accurate data +// Wait for routeChangeComplete and meta-tag mutations so page events include the new page data. async function waitForPageReady() { const route = new Promise((resolve) => { const handler = () => { @@ -377,7 +360,7 @@ function initLinkEvent() { const sameSite = link.origin === location.origin const container = target.closest(`[data-container]`) as HTMLElement | null - // We can attach `data-group-key` and `data-group-id` to any anchor element to include them in the event + // Any anchor can set data-group-key and data-group-id to include grouping fields. const eventGroupKey = link?.dataset?.groupKey || undefined const eventGroupId = link?.dataset?.groupId || undefined @@ -392,7 +375,6 @@ function initLinkEvent() { }) }) - // Add tracking for scroll to top button document.documentElement.addEventListener('click', (evt) => { const target = evt.target as HTMLElement if (!target.closest('.ghd-scroll-to-top')) return @@ -415,12 +397,11 @@ function initHoverEvent() { if (!link) return - // For hover events, we only want to record them for links inside the - // content area. + // Record hover events only for links inside the content area. const mainContent = document.querySelector('#main-content') as HTMLElement | null if (!mainContent || !mainContent.contains(link)) return - if (hoveredUrls.has(link.href)) return // Otherwise this is a flood of events + if (hoveredUrls.has(link.href)) return // Repeated hovers would flood events. if (timer) { window.clearTimeout(timer) @@ -436,9 +417,8 @@ function initHoverEvent() { }, 500) }) - // Doesn't matter which link you hovered on that triggered a timer, - // you're clearly not hovering over it anymore. document.documentElement.addEventListener('mouseout', () => { + // Any mouseout cancels the pending hover timer. if (timer) { window.clearTimeout(timer) } @@ -455,7 +435,8 @@ export function initializeEvents() { if (!ANALYTICS_ENABLED) return if (initialized) return initialized = true - initPageAndExitEvent() // must come first + // Page events must start first because sendPage creates pageEventId for later events. + initPageAndExitEvent() initLinkEvent() initHoverEvent() initClipboardEvent() diff --git a/src/events/components/experiments/ExperimentContentSwap.tsx b/src/events/components/experiments/ExperimentContentSwap.tsx index f4703b5b4f8d..d448f5c0ce3a 100644 --- a/src/events/components/experiments/ExperimentContentSwap.tsx +++ b/src/events/components/experiments/ExperimentContentSwap.tsx @@ -4,17 +4,14 @@ import { EXPERIMENTS } from '@/events/components/experiments/experiments' const EXPERIMENT_KEY = EXPERIMENTS.readability_copilot.key -// Swaps visibility of .exp-control / .exp-treatment divs within the article -// body based on the user's experiment group. Both variants are rendered -// server-side (and cached by Fastly). Treatment divs must have the `hidden` -// and `data-nosnippet` attributes in the authored HTML so control content is -// always the safe fallback and crawlers ignore the treatment variant. +// Both .exp-control and .exp-treatment divs render server-side and Fastly caches them. +// The experiment group swaps visibility after load, so authored .exp-treatment divs need +// hidden for the control fallback and data-nosnippet so crawlers ignore treatment text. export function ExperimentContentSwap({ containerRef }: { containerRef: string }) { const [hasExperimentDivs, setHasExperimentDivs] = useState(false) - // Check once on mount whether this page has any experiment markup. - // If not, skip the experiment hook entirely to avoid unnecessary work. useLayoutEffect(() => { + // Skip the experiment hook on pages without experiment markup to avoid unnecessary work. const container = document.querySelector(containerRef) if (container?.querySelector(`[data-experiment="${EXPERIMENT_KEY}"]`)) { setHasExperimentDivs(true) @@ -26,16 +23,14 @@ export function ExperimentContentSwap({ containerRef }: { containerRef: string } return } -// Separated so the experiment hook only runs when experiment divs are present. +// ExperimentSwapper isolates the experiment hook from pages without experiment divs. function ExperimentSwapper({ containerRef }: { containerRef: string }) { const { showExperiment, experimentLoading } = useShouldShowExperiment( EXPERIMENTS.readability_copilot, ) - // useLayoutEffect fires synchronously after DOM mutations but before - // the browser paints, minimizing the flash of control content for - // treatment users. useLayoutEffect(() => { + // Visibility updates before paint reduce flashes of control content for treatment users. if (experimentLoading) return const container = document.querySelector(containerRef) diff --git a/src/events/components/experiments/experiment.ts b/src/events/components/experiments/experiment.ts index f6180daa60de..1939f8a29313 100644 --- a/src/events/components/experiments/experiment.ts +++ b/src/events/components/experiments/experiment.ts @@ -20,7 +20,6 @@ export function shouldShowExperiment( isStaff: boolean, routerQuery: ParsedUrlQuery, ) { - // Accept either EXPERIMENTS. or EXPERIMENTS..key if (typeof experimentKey === 'object') { experimentKey = experimentKey.key } @@ -28,7 +27,7 @@ export function shouldShowExperiment( const experiments = getActiveExperiments('all') for (const experiment of experiments) { if (experiment.key === experimentKey) { - // Respect isActive so flipping it to false actually stops the experiment + // getActiveExperiments('all') includes inactive experiments, so isActive=false stops them. if (!experiment.isActive) return false if (controlGroupOverride[experiment.key]) { const controlGroup = getExperimentControlGroupFromSession( @@ -70,10 +69,9 @@ export function shouldShowExperiment( return false } -// Allow developers to override their experiment group for the current session export const controlGroupOverride = {} as { [key in ExperimentNames]: 'treatment' | 'control' } if (typeof window !== 'undefined') { - // @ts-expect-error globally available function + // @ts-expect-error -- window.overrideControlGroup is a global debugging hook. window.overrideControlGroup = ( experimentKey: ExperimentNames, controlGroup: 'treatment' | 'control', @@ -101,8 +99,7 @@ export function getExperimentControlGroupFromSession( } else if (process.env.NODE_ENV === 'test') { return CONTROL_VARIATION } - // We hash the user's events ID to ensure that the user is always in the same group for a given experiment - // This works because the hash is a deterministic and the user's ID is stored in a cookie for 365 days + // Hash the 365-day events cookie ID so each browser stays in one group per experiment. const id = getUserEventsId() const hash = murmur(experimentKey).hash(id).result() const modHash = hash % 100 @@ -113,8 +110,7 @@ export function getExperimentVariationForContext(locale: string, version: string const experiments = getActiveExperiments(locale, version) for (const experiment of experiments) { if (experiment.includeVariationInContext) { - // A query string containing `feature=`, or a staff - // reader when alwaysShowForStaff is set, forces the treatment variation. + // feature= or staff readers with alwaysShowForStaff force treatment. if ( (experiment.turnOnWithURLParam && window.location?.search @@ -131,7 +127,6 @@ export function getExperimentVariationForContext(locale: string, version: string } } - // When no experiment has `includeVariationInContext: true` return CONTROL_VARIATION } @@ -143,10 +138,8 @@ export function initializeExperiments( if (experimentsInitialized) return experimentsInitialized = true - // Replace any occurrence of 'enterprise-server@latest' with the actual latest version for (const [experimentKey, experiment] of Object.entries(EXPERIMENTS)) { if (experiment.limitToVersions?.includes('enterprise-server@latest')) { - // Sort the versions in descending order so that the latest enterprise-server version is first const latestEnterpriseServerVersion = Object.keys(allVersions) .filter((version) => version.startsWith('enterprise-server@')) .sort((a, b) => { @@ -184,15 +177,14 @@ export function initializeExperiments( experiment.percentOfUsersToGetExperiment, ) - // In any environment, it is useful to see if a given experiment is "on" or "off" + // Log the group in every environment so developers can confirm which variation they see. console.log( `Experiment ${experiment.key} is in the "${controlGroup === TREATMENT_VARIATION ? TREATMENT_VARIATION : CONTROL_VARIATION}" group for this browser.\nCall function window.overrideControlGroup('${experiment.key}', 'treatment' | 'control') to change your group for this session.`, ) } } -// If we have an experiment enabled that supports turnOnWithURLParam, we need to listen to -// all clicks on links to ensure we forward the `feature` query param to the new page +// Experiments with turnOnWithURLParam keep the feature query parameter across link navigation. export function initializeForwardFeatureUrlParam(router: NextRouter, currentVersion: string) { const experiments = getActiveExperiments(router.locale || 'en', currentVersion) diff --git a/src/events/components/experiments/experiments.ts b/src/events/components/experiments/experiments.ts index 813b06bd82d7..f878d9932989 100644 --- a/src/events/components/experiments/experiments.ts +++ b/src/events/components/experiments/experiments.ts @@ -4,21 +4,24 @@ export const CONTROL_VARIATION = 'control' type Experiment = { key: ExperimentNames isActive: boolean - // If percentOfUsersToGetExperiment is not provided, it will default to 50 + // Missing percentOfUsersToGetExperiment defaults to 50. percentOfUsersToGetExperiment?: number - // Only one experiment's control group (variation) can be included in the context at a time + // Only one experiment can include its variation in the event context at a time. includeVariationInContext?: boolean limitToLanguages?: string[] + // Limit to specific version keys, such as enterprise-cloud@latest. limitToVersions?: string[] + // Staff readers with the staffonly cookie always see treatment when this is true. alwaysShowForStaff: boolean + // feature= forces treatment and forwards across link navigation. turnOnWithURLParam?: string } -// Update this with the name of the experiment, e.g. | 'example_experiment' export type ExperimentNames = 'placeholder_experiment' | 'readability_copilot' +// To add an experiment, see README.md in this directory. export const EXPERIMENTS = { - // Placeholder experiment to maintain type compatibility + // The placeholder keeps ExperimentNames compatible when no active experiments exist. placeholder_experiment: { key: 'placeholder_experiment', isActive: false, @@ -39,22 +42,6 @@ export const EXPERIMENTS = { alwaysShowForStaff: true, turnOnWithURLParam: 'readability', }, - /* Add new experiments here, example: - 'example_experiment': { - key: 'example_experiment', - isActive: true, // Set to false when the experiment is over - percentOfUsersToGetExperiment: 10, // 10% of users will randomly get the experiment - includeVariationInContext: true, // All events will include the `experiment_variation` of the `example_experiment` - limitToLanguages: ['en'], // Only users with the `en` language will be included in the experiment - limitToVersions: [ - 'free-pro-team@latest', - 'enterprise-cloud@latest', - 'enterprise-server@latest', - ], // Only enable for the latest versions - alwaysShowForStaff: true, // When set to true, staff will always see the experiment (determined by the `staffonly` cookie) - turnOnWithURLParam: 'example', // When the query param `?feature=example` is set, the experiment will be enabled - } - */ } as Record export function getActiveExperiments(locale: string, version?: string): Experiment[] { diff --git a/src/events/components/experiments/useShouldShowExperiment.ts b/src/events/components/experiments/useShouldShowExperiment.ts index 3e6b6c39440d..b0cf1913d143 100644 --- a/src/events/components/experiments/useShouldShowExperiment.ts +++ b/src/events/components/experiments/useShouldShowExperiment.ts @@ -16,8 +16,8 @@ export function useShouldShowExperiment(experimentKey: ExperimentNames | { key: const mainContext = useMainContext() const [isStaff, setIsStaff] = useState(false) - // Fetch `isStaff` one time on mount so we can know if the other useEffect needs to be re-run useEffect(() => { + // Staff status refreshes experiment targeting after the cookie request resolves. let cancelled = false async function checkStaff() { const staffValue = await getIsStaff() @@ -30,7 +30,7 @@ export function useShouldShowExperiment(experimentKey: ExperimentNames | { key: }, []) useEffect(() => { - // After 1.5 seconds, if the experiment logic hasn't resolved, force it to stop loading + // Stop loading after 1.5 seconds so a stalled cookie request does not hide the page variant. const timer = setTimeout(() => { if (experimentLoading) { setExperimentLoading(false) diff --git a/src/events/components/hydro-analytics.ts b/src/events/components/hydro-analytics.ts index 55ed58ad2d02..065799d36ab1 100644 --- a/src/events/components/hydro-analytics.ts +++ b/src/events/components/hydro-analytics.ts @@ -1,5 +1,4 @@ -// Integration with @github/hydro-analytics-client for cross-subdomain tracking. -// Events go to collector.githubapp.com alongside our existing analytics. +// @github/hydro-analytics-client sends cross-subdomain events to collector.githubapp.com. // // The client auto-collects page, title, client_id, referrer, user_agent, // screen_resolution, browser_resolution, browser_languages, pixel_ratio, @@ -16,7 +15,7 @@ import { } from '@github/hydro-analytics-client' import { EventType } from '../types' -// Returns undefined if the client fails for any reason. +// getOctoClientId returns undefined if the Hydro client fails for any reason. export function getOctoClientId(): string | undefined { try { return hydroGetOrCreateClientId() @@ -31,7 +30,6 @@ const hydroClient = new AnalyticsClient({ clientId: getOctoClientId(), }) -// Fields that hydro-analytics-client already collects automatically const AUTO_COLLECTED_FIELDS = new Set([ 'referrer', 'user_agent', @@ -46,9 +44,7 @@ const AUTO_COLLECTED_FIELDS = new Set([ 'title', ]) -// Flattens a nested event body into a single-level context object, dropping -// fields the client already auto-collects and adding the ones -// analytics_v0_page_view needs. +// analytics_v0_page_view needs a flat context without fields Hydro already auto-collects. export function prepareData(body: Record): { type: string context: Record @@ -65,10 +61,9 @@ export function prepareData(body: Record): { .map(([key, value]) => [key, String(value)]), ) - // Add fields required for analytics_v0_page_view compatibility - // These are expected by the BI team's dashboards + // BI dashboards expect react_app and marketing page_type for analytics_v0_page_view. context.react_app = 'docs' - // Preserve our page_type as docs_page_type, then set page_type to 'marketing' for BI + // Preserve the docs page type because BI expects page_type to be marketing. if (context.page_type) { context.docs_page_type = context.page_type } @@ -77,7 +72,7 @@ export function prepareData(body: Record): { return { type: typeof type === 'string' ? type : 'unknown', context } } -// Page events go out as a page view, everything else as a custom event. +// Hydro treats page events as page views and all other docs events as custom events. // // Wrapped in try/catch so a broken hydro client cannot affect our primary // analytics pipeline. diff --git a/src/events/components/is-headless.ts b/src/events/components/is-headless.ts index 6f72df0a05af..0bcd41a3832a 100644 --- a/src/events/components/is-headless.ts +++ b/src/events/components/is-headless.ts @@ -1,5 +1,3 @@ -// Basic checks for if the browser is actually a headless browser robot - declare global { interface Window { GHDOCSPLAYWRIGHT: boolean | number diff --git a/src/events/components/user-agent.ts b/src/events/components/user-agent.ts index 834a092c75a0..25fd86d20539 100644 --- a/src/events/components/user-agent.ts +++ b/src/events/components/user-agent.ts @@ -1,6 +1,4 @@ -// A tiny user agent checking RegExp for analytics purposes - -// The order matters with these +// Order matters because earlier regexes win. const OS_REGEXPS = [ /(iphone os|ipad os) ([^);]+)/i, /(mac) os x ([^);]+)/i, @@ -10,10 +8,10 @@ const OS_REGEXPS = [ /(linux) ([^);]+)/i, ] -// The order matters with these +// Order matters because earlier regexes win. const BROWSER_REGEXPS = [ - /(opr)\/([^\s)]+)/i, // Opera - /(edg[e]?)\/([^\s)]+)/i, // Microsoft Edge newer ua is "edg", older is "edge" + /(opr)\/([^\s)]+)/i, // opr identifies Opera. + /(edg[e]?)\/([^\s)]+)/i, // edg and edge both identify Microsoft Edge. /(firefox)\/([^\s)]+)/i, /(chrome)\/([^\s)]+)/i, /(safari)\/([^\s)]+)/i, diff --git a/src/events/lib/analyze-comment.ts b/src/events/lib/analyze-comment.ts index f76a151f1250..87b0a1108a98 100644 --- a/src/events/lib/analyze-comment.ts +++ b/src/events/lib/analyze-comment.ts @@ -17,7 +17,7 @@ async function getLanguageInstance() { return language } -// Exported for the debugging CLI script +// analyze-comment-cli.ts imports SIGNAL_RATINGS to show each signal. export const SIGNAL_RATINGS = [ { reduction: 1.0, @@ -91,7 +91,7 @@ export async function getGuessedLanguage(comment: string) { const lang = await getLanguageInstance() const bestGuess = lang.guessBest(comment.trim(), []) - if (!bestGuess) return // Can happen if the text is just whitespace + if (!bestGuess) return // The guesser can return no result for non-empty text. return bestGuess.alpha2 || undefined } @@ -121,8 +121,7 @@ function isEmailOnly(text: string) { function isContainingEmail(text: string) { if (text.includes('@') && !isEmailOnly(text)) { - // Don't use splitWords() here because `foo@example.com` will be - // split up into ['foo', 'example.com']. + // Don't use splitWords here, because it splits octocat@github.com into octocat and github.com. return text.split(/\s+/g).some((word) => isEmailOnly(word)) } return false @@ -151,25 +150,17 @@ function isTooShort(text: string) { function isSingleWord(text: string) { const whitespaceSplit = text.trim().split(/\s+/) - // E.g. `this-has-no-whitespace` or `snap/hooks/install` + // Single path-like tokens such as snap/hooks/install count as one word. return whitespaceSplit.length === 1 } +// @horizon-rs/language-guesser can misclassify short misspelled English like `Thamk you`. +// This signal lowers confidence instead of blocking because the guess is a clue, not a fact. async function isNotLanguage(text: string, language_: string) { const lang = await getLanguageInstance() const bestGuess = lang.guessBest(text.trim(), []) - if (!bestGuess) return true // Can happen if the text is just whitespace - // @horizon-rs/language-guesser is based on tri-grams and can lead - // to false positives. For example, it thinks that 'Thamk you ❤️🙏' is - // Haitian! And that 'I wanne robux 1000' is Polish! - // But that's because they are short and there's not enough clues to - // guess what language it is. You and I might know those are actually - // attempts to be English, despite the spelling. - // But are they useful comments? Given that this is just a signal, - // and not a hard blocker, it's more of a clue than a fact. - - // We don't want to reduce the score for English comments. English - // comments, when evaluated by language, are always valid. + if (!bestGuess) return true // The guesser can return no result for non-empty text. + // Do not lower score when the guesser labels a comment as English. return bestGuess.alpha2 !== language_ && bestGuess.alpha2 !== 'en' } @@ -224,7 +215,6 @@ const surveyWords = surveyYaml.words.map((word: string) => word.toLowerCase()) function isSpammyWordList(text: string) { const words = text.toLowerCase().split(/(\s+|\\n+)/g) - // Currently, we're intentionally not checking for - // survey words that are substrings of a comment word. + // Match whole survey-word tokens only, not substrings inside longer words. return Boolean(words.some((word) => surveyWords.includes(word))) } diff --git a/src/events/lib/get-document-type.ts b/src/events/lib/get-document-type.ts index 628bfc210137..2c8a7f4d788d 100644 --- a/src/events/lib/get-document-type.ts +++ b/src/events/lib/get-document-type.ts @@ -1,11 +1,9 @@ type DocumentType = 'homepage' | 'product' | 'category' | 'subcategory' | 'article' | 'early-access' -// Derives the document type from the number of segments in the relative path, -// meaning the content path starting at the product directory. -// For example: actions/index.md or github/getting-started-with-github/quickstart.md +// Index-page depth maps to homepage, product, category, subcategory, or early-access. +// For example: index.md, actions/index.md, or early-access/index.md. export default function getDocumentType(relativePath: string): DocumentType { - // A non-index file is ALWAYS considered an article in this approach, - // even if it's at the category level (like actions/quickstart.md) + // Non-index files are articles even at category depth, such as actions/quickstart.md. if (!relativePath.endsWith('index.md')) { return 'article' } @@ -25,7 +23,7 @@ export default function getDocumentType(relativePath: string): DocumentType { 'subcategory', ] - // Anything beyond the largest depth is assumed to be a subcategory + // Depth beyond the largest known depth maps to subcategory. return isEarlyAccess ? earlyAccessDocs[Math.min(segmentLength, earlyAccessDocs.length) - 1] : publicDocs[Math.min(segmentLength, publicDocs.length) - 1] diff --git a/src/events/lib/hydro.ts b/src/events/lib/hydro.ts index 995d36b5aba8..cde06489240b 100644 --- a/src/events/lib/hydro.ts +++ b/src/events/lib/hydro.ts @@ -11,9 +11,9 @@ const logger = createLogger(import.meta.url) const TIME_OUT_TEXT = 'ms has passed since batch creation' const SERVER_DISCONNECT_TEXT = 'The server disconnected before a response was received' const X_HYDRO_APP = 'docs-production' -const CLUSTER = 'potomac' // We only have ability to publish externally to potomac cluster -const TIMEOUT = MAX_REQUEST_TIMEOUT - 1000 // Limit because Express will terminate at MAX_REQUEST_TIMEOUT -const RETRIES = 0 // We care about aggregate statistics; a few dropped events isn't a big deal +const CLUSTER = 'potomac' // Docs can publish externally only to the Potomac cluster. +const TIMEOUT = MAX_REQUEST_TIMEOUT - 1000 // Express terminates at MAX_REQUEST_TIMEOUT. +const RETRIES = 0 // Aggregate statistics can tolerate a few dropped events. const { NODE_ENV, HYDRO_SECRET, HYDRO_ENDPOINT } = process.env const inProd = NODE_ENV === 'production' @@ -41,13 +41,12 @@ async function _publish( events: events.map(({ schema, value }) => ({ cluster: CLUSTER, schema, - value: JSON.stringify(value), // We must double-encode the value property + value: JSON.stringify(value), // Hydro requires the value property to be double-encoded. })), }) const token = createHmac('sha256', secret).update(requestBody).digest('hex') - // Note: Custom HTTPS agent (keepAlive, maxSockets) not supported with native fetch - // Consider using undici.fetch() if custom agent behavior is critical + // Native fetch cannot use custom HTTPS agents; use undici.fetch for keepAlive or maxSockets. const response = await fetchWithRetry( endpoint, { @@ -70,7 +69,7 @@ async function _publish( statsd.increment('hydro.response_code.all', 1, [`response_code:${statusCode}`]) - // Track 3xx and 4xx in Sentry; 5xx is tracked separately from the Docs project + // Report eligible 3xx and 4xx responses to Failbot in production; Docs monitors 5xx separately. if ( statusCode >= 300 && statusCode < 500 && diff --git a/src/events/lib/middleware-errors.ts b/src/events/lib/middleware-errors.ts index a711c05869ef..f637b442bc58 100644 --- a/src/events/lib/middleware-errors.ts +++ b/src/events/lib/middleware-errors.ts @@ -22,7 +22,7 @@ export function formatErrors(errors: ErrorObject[], body: unknown) { created: new Date().toISOString(), raw: makeString(body), - // We convert to snake_case because dealing with case in SQL is unfortunate. + // snake_case avoids quoted mixed-case column names in SQL. ...Object.fromEntries( Object.entries(pick(error, errorKeys)).map(([key, value]) => [ snakeCase(key), diff --git a/src/events/lib/schema.ts b/src/events/lib/schema.ts index e80a1fb153aa..ba1ecc998848 100644 --- a/src/events/lib/schema.ts +++ b/src/events/lib/schema.ts @@ -12,7 +12,6 @@ const context = { additionalProperties: false, required: ['event_id', 'user', 'version', 'created', 'path'], properties: { - // Required of all events event_id: { type: 'string', description: 'The unique identifier of the event.', @@ -40,7 +39,6 @@ const context = { format: 'uuid', }, - // Content information referrer: { type: 'string', description: 'The browser value of `document.referrer`.', @@ -95,12 +93,15 @@ const context = { page_document_type: { type: 'string', description: 'The generic page document type based on URL path.', - enum: ['homepage', 'early-access', 'product', 'category', 'subcategory', 'article'], // get-document-type.ts + // Keep in sync with get-document-type.ts. + enum: ['homepage', 'early-access', 'product', 'category', 'subcategory', 'article'], }, page_type: { type: 'string', description: 'Optional page type from the content frontmatter.', - enum: ['overview', 'quick_start', 'tutorial', 'how_to', 'reference', 'rai'], // frontmatter.ts + // Keep in sync with the YAML frontmatter docs: + // content/contributing/writing-for-github-docs/using-yaml-frontmatter.md. + enum: ['overview', 'quick_start', 'tutorial', 'how_to', 'reference', 'rai'], }, content_type: { type: 'string', @@ -136,7 +137,6 @@ const context = { 'The _octo cookie client ID for cross-subdomain tracking with github.com analytics.', }, - // Device information os: { type: 'string', description: 'The type of operating system the user is working with.', @@ -194,7 +194,6 @@ const context = { description: 'The raw user agent string from the browser.', }, - // Location information timezone: { type: 'number', description: 'The timezone the user is in, as `new Date().getTimezoneOffset() / -60`.', @@ -204,7 +203,6 @@ const context = { description: 'The browser value of `navigator.language`.', }, - // Preference information os_preference: { type: 'string', enum: ['linux', 'mac', 'windows'], @@ -224,13 +222,12 @@ const context = { description: 'How the user prefers to view code examples.', }, - // Experiments experiment_variation: { type: 'string', description: 'The variation this user we bucketed in is in, such as control or treatment.', }, - // Event grouping. The combination of key + id should be unique. + // The event group key and ID pair must be unique. event_group_key: { type: 'string', description: 'A enum indentifier (e.g. "ask-ai") used to put events into a specific group.', @@ -621,22 +618,18 @@ const preference = { type: 'string', enum: [ ...new Set([ - // application ...Object.keys(allTools), - // color_mode 'dark', 'light', 'auto', 'auto:dark', 'auto:light', - // os 'linux', 'mac', 'windows', - // code_display 'beside', 'inline', - // code_language (may overlap with allTools, e.g. 'javascript') + // code_language can overlap with allTools, for example javascript. ...Object.keys(codeLanguages), ]), ], @@ -700,7 +693,7 @@ const validation = { }, } -// We are not using `oneOf` to keep the list of errors short. +// Avoid oneOf so validation returns a short error list. export const schemas = { page, exit, diff --git a/src/events/middleware.ts b/src/events/middleware.ts index be4823305315..b270ab2537af 100644 --- a/src/events/middleware.ts +++ b/src/events/middleware.ts @@ -26,10 +26,9 @@ const allowedTypes = new Set(without(Object.keys(schemas), 'validation')) const isProd = process.env.NODE_ENV === 'production' const validators = mapValues(schemas, (schema) => getJsonValidator(schema)) -// In production, fire and not wait to respond. -// _publish will send an error to failbot, -// so we don't get alerts but we still track it. -// This ends up being the same as try > await > catch > (do nothing). +// Production does not await Hydro so the request can return before publishing finishes. +// _publish reports eligible 3xx and 4xx responses to Failbot; unhandled rejections and 5xx +// responses follow separate monitoring paths. async function publish(...args: Parameters) { if (isProd) { _publish(...args) @@ -43,10 +42,9 @@ const sentValidationErrors = new QuickLRU({ maxAge: 1000 * 60, }) -// We use a LRU cache & a hash of the error message -// to prevent sending multiple validation errors that can spam requests to Hydro +// Hash validation errors in an LRU cache to avoid flooding Hydro with repeated failures. const getValidationErrorHash = (validateErrors: ErrorObject[]) => { - // limit to 10 second windows + // Hash keys include a 10-second bucket; the LRU retains buckets for up to 60 seconds. const window: number = Math.floor(new Date().getTime() / 10000) return `${window}:${(validateErrors || []) .map((error: ErrorObject) => error.message + error.instancePath + JSON.stringify(error.params)) @@ -78,12 +76,12 @@ router.post( } if (body.context) { - // JSON.stringify removes `undefined` values but not `null`, and we don't want to send `null` to Hydro + // JSON.stringify drops undefined but keeps null, and we do not send null to Hydro. body.context.dotcom_user = req.cookies?.[DOTCOM_USER_COOKIE_NAME] ? req.cookies[DOTCOM_USER_COOKIE_NAME] : undefined body.context.is_staff = Boolean(req.cookies?.[STAFFONLY_COOKIE_NAME]) - // Moda forwards the client's IP using the `fastly-client-ip` header + // Moda forwards the client's IP through the fastly-client-ip header. body.context.ip = req.headers['fastly-client-ip'] as string | undefined body.context.user_agent ??= req.headers['user-agent'] } diff --git a/src/events/scripts/analyze-comment-cli.ts b/src/events/scripts/analyze-comment-cli.ts index e9cbf1b3b375..4216a5d6aa1b 100644 --- a/src/events/scripts/analyze-comment-cli.ts +++ b/src/events/scripts/analyze-comment-cli.ts @@ -1,10 +1,5 @@ -// Debugs and tests our comment signals. -// -// npm run analyze-comment -- "I love this site\!" --verbose -// -// or, using stdin: -// -// cat naughty-comment.txt | npm run analyze-comment +// Debug comment signals with npm run analyze-comment -- "I love this site\!" --verbose. +// Pipe a file to npm run analyze-comment to read the comment from stdin. import fs from 'node:fs' import util from 'node:util' @@ -29,7 +24,7 @@ program.parse(process.argv) async function main(comment?: string, options?: Options) { if (!comment) { - const stdinBuffer = fs.readFileSync(0) // STDIN_FILENO = 0 + const stdinBuffer = fs.readFileSync(0) // File descriptor 0 reads stdin. comment = stdinBuffer.toString() } if (!comment.trim()) { diff --git a/src/events/scripts/analyze-comments-csv.ts b/src/events/scripts/analyze-comments-csv.ts index 386113cce0de..9ced524b9946 100644 --- a/src/events/scripts/analyze-comments-csv.ts +++ b/src/events/scripts/analyze-comments-csv.ts @@ -1,6 +1,4 @@ -// Analyzes posted survey comments in a CSV file. -// The CSV is expected to come from the Azure Data Explorer, after querying the -// `docs_v0_survey_event` table. +// Analyze posted survey comments in CSVs exported from Azure Data Explorer's docs_v0_survey_event. import fs from 'node:fs' import util from 'node:util' @@ -46,7 +44,7 @@ type Record = { async function analyzeFile(csvFile: string, options: Options) { const parser = fs.createReadStream(csvFile).pipe( parse({ - // Needed when parsing CSVs from the Azure Data Explorer + // Azure Data Explorer CSV exports include a byte-order mark. bom: true, }), ) diff --git a/src/events/tests/analyze-comments.ts b/src/events/tests/analyze-comments.ts index b8f5a99adbae..20e28544d1ed 100644 --- a/src/events/tests/analyze-comments.ts +++ b/src/events/tests/analyze-comments.ts @@ -156,7 +156,7 @@ describe('analyzeComment', () => { expect(rating).toBeLessThan(1.0) } { - // example of a false positive + // Short English text can trigger a not-language false positive. const { signals, rating } = await analyzeComment('english word') expect(signals.includes('not-language')).toBeTruthy() expect(rating).toBeLessThan(1.0) @@ -166,7 +166,7 @@ describe('analyzeComment', () => { const { signals } = await analyzeComment('english words longer sentence this time') expect(signals.includes('not-language')).toBeFalsy() } - // Always allow English comments even when the page language is non-English + // Always allow English comments even when the page language is non-English. { const { signals } = await analyzeComment('english words longer sentence this time', 'fr') expect(signals.includes('not-language')).toBeFalsy() @@ -178,7 +178,7 @@ describe('analyzeComment', () => { }) test('cuss-words-likely', async () => { - // The "CK" makes the final word a mix or lower and upper case. + // The first word mixes lowercase and uppercase letters. const { signals, rating } = await analyzeComment('f*CK you'.replace('*', 'u')) expect(signals.includes('cuss-words-likely')).toBeTruthy() expect(rating).toBeLessThan(1.0) @@ -224,7 +224,7 @@ describe('analyzeComment', () => { const { signals } = await analyzeComment('GitHub is great!') expect(signals.includes('spammy-words')).toBeFalsy() } - // No sub-string matches allowed + // Survey words must match whole tokens, not substrings. { const { signals } = await analyzeComment('MinecraftFacebook') expect(signals.includes('spammy-words')).toBeFalsy() @@ -241,7 +241,7 @@ describe('analyzeComment', () => { expect(guessedLanguage).toBe('en') } - // False positives due to short text + // Short text can trigger language false positives. { const guessedLanguage = await analyzeComment('Hello') expect(guessedLanguage).not.toBe('en') diff --git a/src/events/tests/hydro.ts b/src/events/tests/hydro.ts index 639afd91ee58..061142372796 100644 --- a/src/events/tests/hydro.ts +++ b/src/events/tests/hydro.ts @@ -67,11 +67,8 @@ describe('Hydro', () => { expect(scope.isDone()).toBeTruthy() }) + // Hydro 422 bodies skip Failbot when the serialized error has a disconnect or timeout marker. test('422 with JSON error', async () => { - // Hydro will return 422 errors with the body being a string of - // JSON serialized information. Some of the errors are operational - // and something we don't need to send to Failbot. - // This is one of those examples from real Failbot submissions we've seen. const hydroError = { status: 'ERROR', count: 1, diff --git a/src/events/tests/middleware-errors.ts b/src/events/tests/middleware-errors.ts index 2f139590d317..63872d3d2c08 100644 --- a/src/events/tests/middleware-errors.ts +++ b/src/events/tests/middleware-errors.ts @@ -6,7 +6,6 @@ import { schemas } from '../lib/schema' describe('formatErrors', () => { test('should produce objects that match the validation spec', () => { - // Produce an error const { errors } = validateJson({ type: 'string' }, 0) const formattedErrors = formatErrors(errors || [], '') for (const formatted of formattedErrors) { diff --git a/src/events/tests/middleware.ts b/src/events/tests/middleware.ts index dcfc71e0536d..8e1ec10f0e9b 100644 --- a/src/events/tests/middleware.ts +++ b/src/events/tests/middleware.ts @@ -23,13 +23,11 @@ describe('POST /events', () => { const pageExample = { type: 'page', context: { - // Primitives event_id: 'a35d7f88-3f48-4f36-ad89-5e3c8ebc3df7', user: '703d32a8-ed0f-45f9-8d78-a913d4dc6f19', version: '1.0.0', created: '2020-10-02T17:12:18.620Z', - // Content information path: '/github/docs/issues', hostname: 'github.com', referrer: 'https://github.com/github/docs', @@ -38,7 +36,6 @@ describe('POST /events', () => { href: 'https://github.com/github/docs/issues?q=is%3Aissue+is%3Aopen+example+', path_language: 'en', - // Device information os: 'linux', os_version: '18.04', browser: 'chrome', @@ -50,7 +47,6 @@ describe('POST /events', () => { screen_height: 1080, pixel_ratio: 2, - // Location information timezone: -7, user_language: 'en-US', ip: '192.0.2.1', @@ -62,13 +58,11 @@ describe('POST /events', () => { const exitExample = { type: 'exit', context: { - // Primitives event_id: 'a35d7f88-3f48-4f36-ad89-5e3c8ebc3df7', user: '703d32a8-ed0f-45f9-8d78-a913d4dc6f19', version: '1.0.0', created: '2020-10-02T17:12:18.620Z', - // Content information path: '/github/docs/issues', hostname: 'github.com', referrer: 'https://github.com/github/docs', @@ -77,7 +71,6 @@ describe('POST /events', () => { href: 'https://github.com/github/docs/issues?q=is%3Aissue+is%3Aopen+example+', path_language: 'en', - // Device information os: 'linux', os_version: '18.04', browser: 'chrome', @@ -89,7 +82,6 @@ describe('POST /events', () => { screen_height: 1080, pixel_ratio: 2, - // Location information timezone: -7, user_language: 'en-US', ip: '192.0.2.1', @@ -106,7 +98,7 @@ describe('POST /events', () => { test('should require a type', async () => { const { statusCode } = await checkEvent({ ...pageExample, type: undefined }) - // Events with no type are skipped, not rejected, so the batch still succeeds. + // The batch still succeeds when events have no type because the middleware skips them. expect(statusCode).toBe(200) }) diff --git a/src/events/types.ts b/src/events/types.ts index 84c423e975da..8544824d2c6a 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -70,8 +70,9 @@ export type EventProps = { export type EventPropsByType = { [EventType.aiSearchResult]: { - // Dynamic JSON string of an array of "link" objects in the form: - // [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...] + // Dynamic JSON string of an array of link objects: + // [{ "type": "reference" | "inline", "url": "https://..", + // "product": "issues" | "pages" | ... }, ...] ai_search_result_links_json: string ai_search_result_provided_answer: boolean ai_search_result_response_status: number @@ -109,12 +110,12 @@ export type EventPropsByType = { link_samepage?: boolean link_container?: string } - [EventType.page]: { type: string } // no unique properties + [EventType.page]: { type: string } // Deliberately no unique properties. [EventType.preference]: { preference_name: string preference_value: string } - [EventType.print]: { type: string } // no unique properties + [EventType.print]: { type: string } // Deliberately no unique properties. [EventType.search]: { search_query: string search_context?: string @@ -128,7 +129,7 @@ export type EventPropsByType = { search_result_url: string } [EventType.survey]: { - survey_token?: string // Honeypot, doesn't exist in schema + survey_token?: string // Bot trap field does not exist in schema. survey_vote: boolean survey_comment?: string survey_email?: string diff --git a/src/frame/middleware/context/current-product-tree.ts b/src/frame/middleware/context/current-product-tree.ts index c99b741c3b23..29899bc3511e 100644 --- a/src/frame/middleware/context/current-product-tree.ts +++ b/src/frame/middleware/context/current-product-tree.ts @@ -8,6 +8,7 @@ import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree' import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path' import { executeWithFallback } from '@/languages/lib/render-with-fallback' +// This module adds currentProductTree to the context object for use in layouts. export default async function currentProductTree( req: ExtendedRequest, res: Response, @@ -17,7 +18,7 @@ export default async function currentProductTree( if (!req.context.page) return next() if (req.context.page.documentType === 'homepage') return next() - // Keep the English tree available because localized pages can lag behind it. + // We need this so we can fall back to English if localized pages are out of sync. if (!req.context.siteTree) throw new Error('siteTree is required') if (!req.context.currentVersion) throw new Error('currentVersion is required') req.context.currentEnglishTree = req.context.siteTree.en[req.context.currentVersion] @@ -40,17 +41,22 @@ export default async function currentProductTree( currentProductPath, ) - // currentProductTreeTitles keeps href, title, shortTitle, documentType, and childPages. + // First make a slim tree of just the 'href', 'title', 'shortTitle' + // 'documentType' and 'childPages' (which is recursive). + // This gets used for subcategory and category pages. req.context.currentProductTreeTitles = await getCurrentProductTreeTitles( req.context.currentProductTree, req.context, ) - // Sidebar data excludes hidden pages. + // Now make an even slimmer version that excludes all hidden pages. + // This is used for sidebars. req.context.currentProductTreeTitlesExcludeHidden = excludeHidden( req.context.currentProductTreeTitles, ) - // Hidden pages leave sidebarTree unset because excludeHidden returns null for the root. + // Some pages, like hidden pages, don't have a tree. For example, + // the search page. That one uses the same items as the homepage + // for its sidebar. if (req.context.currentProductTreeTitlesExcludeHidden) { req.context.sidebarTree = sidebarTree(req.context.currentProductTreeTitlesExcludeHidden) } @@ -58,30 +64,26 @@ export default async function currentProductTree( return next() } +// Return a nested object that contains the bits and pieces we need +// for the tree which is used for sidebars and listing async function getCurrentProductTreeTitles(input: Tree, context: Context): Promise { const { page, href } = input const childPages = await Promise.all( (input.childPages || []).map((child) => getCurrentProductTreeTitles(child, context)), ) - // Translated pages need their English page for fallback rendering and short-title comparison. + // If the current page is a translation we're going to need the English + // equivalent for multiple things later in this function. const enPage = page.languageCode !== 'en' ? context.pages![href.replace(`/${page.languageCode}`, '/en')] : null - let rawShortTitle = page.rawShortTitle - // Swaps in rawTitle when shortTitle matches English, but the render below reads page.rawShortTitle. - if (page.languageCode !== 'en' && page.rawShortTitle) { - if (page.rawShortTitle === enPage!.shortTitle) { - rawShortTitle = page.rawTitle - } - } const renderedFullTitle = await executeWithFallback( context, () => liquid.parseAndRender(page.rawTitle, context), (enContext: Context) => liquid.parseAndRender(enPage!.rawTitle, enContext), ) let renderedShortTitle = '' - if (rawShortTitle) { + if (page.rawShortTitle) { renderedShortTitle = await executeWithFallback( context, () => liquid.parseAndRender(page.rawShortTitle!, context), @@ -89,7 +91,8 @@ async function getCurrentProductTreeTitles(input: Tree, context: Context): Promi ) } - // Empty duplicate short titles to avoid wasting sidebar space. + // If the short title was present but "useless" (same as the title), + // force it to be an empty string to not waste space. const shortTitle = renderedShortTitle && (renderedShortTitle || '') !== renderedFullTitle ? renderedShortTitle : '' @@ -124,10 +127,12 @@ function excludeHidden(tree: TitlesTree) { function sidebarTree(tree: TitlesTree) { const { href, title, shortTitle, childPages, sidebarLink } = tree - // Sidebars show only children from the current product. + // Filter out cross-product children from the sidebar const filteredChildPages = childPages.filter((child) => !child.crossProductChild) - // If siblings include a subdirectory and its articles, nest the articles under the subdirectory. + // Filter out children that are descendants of another sibling. + // When a page lists both a subdirectory and individual articles from it, + // the articles should only appear nested under the subdirectory in the sidebar. const siblingHrefs = filteredChildPages.map((c) => c.href) const dedupedChildPages = filteredChildPages.filter( (child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)), diff --git a/src/frame/tests/current-product-tree.test.ts b/src/frame/tests/current-product-tree.test.ts new file mode 100644 index 000000000000..4a90e697a3d8 --- /dev/null +++ b/src/frame/tests/current-product-tree.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test, vi } from 'vitest' +import type { Response, NextFunction } from 'express' +import type { ExtendedRequest, Page, Tree } from '@/types' +import currentProductTree from '@/frame/middleware/context/current-product-tree' + +const currentVersion = 'free-pro-team@latest' + +const createPage = (page: Partial): Page => + ({ + title: '', + rawTitle: '', + intro: '', + markdown: '', + mtime: 1, + permalinks: [], + versions: {}, + applicableVersions: [currentVersion], + render: vi.fn(), + renderProp: vi.fn(), + renderTitle: vi.fn(), + ...page, + }) as Page + +const createTree = (href: string, page: Page, childPages: Tree[] = []): Tree => ({ + href, + page, + children: undefined, + childPages, +}) + +const createRequest = (): ExtendedRequest => { + const englishProduct = createPage({ + title: 'Product', + rawTitle: 'Product', + languageCode: 'en', + documentType: 'product', + }) + const englishArticle = createPage({ + title: 'English full title', + rawTitle: 'English full title', + shortTitle: 'English short title', + rawShortTitle: 'English short title', + languageCode: 'en', + documentType: 'article', + }) + const translatedProduct = createPage({ + title: 'Producto', + rawTitle: 'Producto', + languageCode: 'es', + documentType: 'product', + }) + const translatedArticle = createPage({ + title: 'Título completo traducido', + rawTitle: 'Título completo traducido', + shortTitle: 'English short title', + rawShortTitle: 'English short title', + languageCode: 'es', + documentType: 'article', + }) + + const englishTree = createTree('/en/product', englishProduct, [ + createTree('/en/product/article', englishArticle), + ]) + const translatedTree = createTree('/es/product', translatedProduct, [ + createTree('/es/product/article', translatedArticle), + ]) + + return { + context: { + page: translatedArticle, + pages: { + '/en/product': englishProduct, + '/en/product/article': englishArticle, + '/es/product': translatedProduct, + '/es/product/article': translatedArticle, + }, + siteTree: { + en: { [currentVersion]: englishTree }, + es: { [currentVersion]: translatedTree }, + }, + currentLanguage: 'es', + currentVersion, + currentProduct: 'product', + }, + } as unknown as ExtendedRequest +} + +describe('currentProductTree middleware', () => { + test('uses the translated shortTitle even when it matches the English shortTitle', async () => { + const req = createRequest() + const next = vi.fn() as NextFunction + + await currentProductTree(req, {} as Response, next) + + expect(req.context!.currentProductTreeTitles!.childPages[0]).toMatchObject({ + title: 'Título completo traducido', + shortTitle: 'English short title', + }) + expect(req.context!.sidebarTree!.childPages[0].title).toBe('English short title') + expect(next).toHaveBeenCalled() + }) +}) diff --git a/src/graphql/data/fpt/schema-other.json b/src/graphql/data/fpt/schema-other.json index d369c78d2a12..4ff518042191 100644 --- a/src/graphql/data/fpt/schema-other.json +++ b/src/graphql/data/fpt/schema-other.json @@ -24,7 +24,7 @@ "name": "CodeCoverageParameters", "id": "codecoverageparameters", "href": "/graphql/reference/other#object-codecoverageparameters", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

", + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

", "fields": [ { "name": "maxCoverageDrop", diff --git a/src/graphql/data/fpt/schema-repos.json b/src/graphql/data/fpt/schema-repos.json index 8b445a6b2b5c..b60e6a716522 100644 --- a/src/graphql/data/fpt/schema-repos.json +++ b/src/graphql/data/fpt/schema-repos.json @@ -9383,7 +9383,7 @@ }, { "name": "CODE_COVERAGE", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

" + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

" }, { "name": "CODE_QUALITY", @@ -9990,7 +9990,7 @@ "name": "CodeCoverageParametersInput", "id": "codecoverageparametersinput", "href": "/graphql/reference/repos#input-object-codecoverageparametersinput", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

", + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

", "inputFields": [ { "name": "maxCoverageDrop", diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index b0cb7d619b67..b9b44ac81e75 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -5194,8 +5194,10 @@ The object which triggered a `ClosedEvent`. union Closer @docsCategory(name: "issues") = Commit | ProjectV2 | PullRequest """ -Enforce minimum line coverage thresholds on pull requests. When configured, -uploaded coverage data must meet the specified criteria before changes can be merged. +Enforce minimum line coverage thresholds on pull requests. This rule evaluates +uploaded coverage data but does not wait for coverage uploads. To ensure +coverage is evaluated before merging, make each status check associated with a +coverage upload a required status check. """ type CodeCoverageParameters { """ @@ -5213,8 +5215,10 @@ type CodeCoverageParameters { } """ -Enforce minimum line coverage thresholds on pull requests. When configured, -uploaded coverage data must meet the specified criteria before changes can be merged. +Enforce minimum line coverage thresholds on pull requests. This rule evaluates +uploaded coverage data but does not wait for coverage uploads. To ensure +coverage is evaluated before merging, make each status check associated with a +coverage upload a required status check. """ input CodeCoverageParametersInput { """ @@ -58147,8 +58151,10 @@ enum RepositoryRuleType { BRANCH_NAME_PATTERN """ - Enforce minimum line coverage thresholds on pull requests. When configured, - uploaded coverage data must meet the specified criteria before changes can be merged. + Enforce minimum line coverage thresholds on pull requests. This rule evaluates + uploaded coverage data but does not wait for coverage uploads. To ensure + coverage is evaluated before merging, make each status check associated with a + coverage upload a required status check. """ CODE_COVERAGE diff --git a/src/graphql/data/ghec/schema-other.json b/src/graphql/data/ghec/schema-other.json index f4ad0cc1dc9e..6d5185f37a30 100644 --- a/src/graphql/data/ghec/schema-other.json +++ b/src/graphql/data/ghec/schema-other.json @@ -24,7 +24,7 @@ "name": "CodeCoverageParameters", "id": "codecoverageparameters", "href": "/graphql/reference/other#object-codecoverageparameters", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

", + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

", "fields": [ { "name": "maxCoverageDrop", diff --git a/src/graphql/data/ghec/schema-repos.json b/src/graphql/data/ghec/schema-repos.json index 9976895732d2..3c331ae382a5 100644 --- a/src/graphql/data/ghec/schema-repos.json +++ b/src/graphql/data/ghec/schema-repos.json @@ -9383,7 +9383,7 @@ }, { "name": "CODE_COVERAGE", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

" + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

" }, { "name": "CODE_QUALITY", @@ -9990,7 +9990,7 @@ "name": "CodeCoverageParametersInput", "id": "codecoverageparametersinput", "href": "/graphql/reference/repos#input-object-codecoverageparametersinput", - "description": "

Enforce minimum line coverage thresholds on pull requests. When configured,\nuploaded coverage data must meet the specified criteria before changes can be merged.

", + "description": "

Enforce minimum line coverage thresholds on pull requests. This rule evaluates\nuploaded coverage data but does not wait for coverage uploads. To ensure\ncoverage is evaluated before merging, make each status check associated with a\ncoverage upload a required status check.

", "inputFields": [ { "name": "maxCoverageDrop", diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index b0cb7d619b67..b9b44ac81e75 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -5194,8 +5194,10 @@ The object which triggered a `ClosedEvent`. union Closer @docsCategory(name: "issues") = Commit | ProjectV2 | PullRequest """ -Enforce minimum line coverage thresholds on pull requests. When configured, -uploaded coverage data must meet the specified criteria before changes can be merged. +Enforce minimum line coverage thresholds on pull requests. This rule evaluates +uploaded coverage data but does not wait for coverage uploads. To ensure +coverage is evaluated before merging, make each status check associated with a +coverage upload a required status check. """ type CodeCoverageParameters { """ @@ -5213,8 +5215,10 @@ type CodeCoverageParameters { } """ -Enforce minimum line coverage thresholds on pull requests. When configured, -uploaded coverage data must meet the specified criteria before changes can be merged. +Enforce minimum line coverage thresholds on pull requests. This rule evaluates +uploaded coverage data but does not wait for coverage uploads. To ensure +coverage is evaluated before merging, make each status check associated with a +coverage upload a required status check. """ input CodeCoverageParametersInput { """ @@ -58147,8 +58151,10 @@ enum RepositoryRuleType { BRANCH_NAME_PATTERN """ - Enforce minimum line coverage thresholds on pull requests. When configured, - uploaded coverage data must meet the specified criteria before changes can be merged. + Enforce minimum line coverage thresholds on pull requests. This rule evaluates + uploaded coverage data but does not wait for coverage uploads. To ensure + coverage is evaluated before merging, make each status check associated with a + coverage upload a required status check. """ CODE_COVERAGE diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts index a4bd951b6335..0256ef75e064 100644 --- a/src/languages/lib/correct-translation-content.ts +++ b/src/languages/lib/correct-translation-content.ts @@ -1611,6 +1611,18 @@ export function correctTranslatedContentStrings( return match.replace(/[«»“”„]/g, '"').replace(/[‘’‚]/g, "'") }) + // French « check » becomes " check " after quote normalization. + content = content.replace( + /\{%(-?)\s*octicon\s+"\s*([\w-]+)\s*"([^%]*?)(-?)%\}/g, + (_m, o, name, rest, c) => { + const fixedRest = rest.replace( + /(^|\s)(aria-label|aria-hidden|height|width|class)="\s*([^"]*?[^"\s])\s*"/g, + '$1$2="$3"', + ) + return `{%${o} octicon "${name}"${fixedRest.replace(/\s*$/, ' ')}${c}%}` + }, + ) + content = content.replace( /\{%(-?)\s+(ifversion|elsif|if)\s+([^%]*?)\s*(-?)%\}/g, (_m, dashOpen, tag, body, dashClose) => diff --git a/src/languages/tests/correct-translation-content.ts b/src/languages/tests/correct-translation-content.ts index 308be31db93f..e9e81b3b9c9c 100644 --- a/src/languages/tests/correct-translation-content.ts +++ b/src/languages/tests/correct-translation-content.ts @@ -3379,4 +3379,30 @@ Para más información, consulta "[AUTOTITLE](/path)". ).toBe(broken) }) }) + + describe('octicon with French guillemets', () => { + test('fr: trims padding left by « check » after quote normalization', () => { + expect(fix('{% octicon « check » aria-label="Included » %}', 'fr')).toBe( + '{% octicon "check" aria-label="Included" %}', + ) + expect(fix('{% octicon « x » aria-label="Non inclus" %}', 'fr')).toBe( + '{% octicon "x" aria-label="Non inclus" %}', + ) + }) + + test('fr: leaves a correct octicon unchanged', () => { + const ok = '{% octicon "check" aria-label="Included" %}' + expect(fix(ok, 'fr')).toBe(ok) + }) + + test('leaves padded values on other attributes unchanged', () => { + const custom = '{% octicon "x" data-aria-label=" a " myclass=" b " title=" c " %}' + expect(fix(custom, 'fr')).toBe(custom) + }) + + test('leaves whitespace-only attribute values unchanged', () => { + const blank = '{% octicon "x" class=" " width="64" aria-label="Supported" %}' + expect(fix(blank, 'fr')).toBe(blank) + }) + }) }) diff --git a/src/links/components/LinkPreviewPopover.tsx b/src/links/components/LinkPreviewPopover.tsx index 792f3d1ca352..05d785782f21 100644 --- a/src/links/components/LinkPreviewPopover.tsx +++ b/src/links/components/LinkPreviewPopover.tsx @@ -25,7 +25,6 @@ type PageMetadata = { title: string intro: string anchor?: string - cacheInfo?: string } function getOrCreatePopoverGlobal() {