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/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/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/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/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/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/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/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/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() {