diff --git a/.github/instructions/content.instructions.md b/.github/instructions/content.instructions.md index d6a1443502d2..67847dcda393 100644 --- a/.github/instructions/content.instructions.md +++ b/.github/instructions/content.instructions.md @@ -95,17 +95,23 @@ Examples: ## Versioning -Avoid `{% ifversion fpt %}`, `{% ifversion ghec %}`, and `{% ifversion fpt or ghec %}` in content files whenever possible. Instead of suggesting or adding version-gating within an article: +Follow one of these sets of instructions, depending on how articles are versioned in the frontmatter. Articles may be versioned for FPT and GHEC, for GHES only, or for all three. Articles may also be versioned using feature-based versioning defined in `data/features`. Feature-based versioning allows centralized control of when content appears for specific GHES releases. -* Write content that applies to all versions the article is versioned for -* If content is truly version-specific, consider whether it is low-harm to show it to all readers (e.g., an enterprise-only row in a reference table) -* Only use `{% ifversion %}` as a last resort when content would be actively misleading for readers on a different version +### FPT/GHEC-only articles -**FPT and GHEC content**: When dotcom content applies to both products, version the page for `fpt` and `ghec` in the frontmatter. Do NOT use in-article Liquid versioning. Do NOT suggest adding `{% ifversion fpt or ghec %}` blocks as a fix for content that mentions a dotcom-only feature. Instead, suggest rewriting the content using the alternatives to inline versioning options listed below. +All articles that are ONLY for FPT and GHEC should be versioned for these versions in the frontmatter. -**GHES content**: If versioning is necessary for GitHub Enterprise Server content, use feature-based versioning (FBV). GHES content should rely on feature flags defined in `data/features/` rather than inline `{% ifversion ghes %}` blocks. Feature flags allow centralized control of when content appears for specific GHES releases. +For such content, DO NOT use in-article Liquid versioning such as `{% ifversion fpt %}`, `{% ifversion ghec %}`, and `{% ifversion fpt or ghec %}`. -### Alternatives to inline versioning +### GHES-only articles + +All articles that are ONLY for GitHub Enterprise Server (GHES) should be versioned in the frontmatter using feature-based versioning defined in `data/features/`. + +### FPT, GHEC, GHES articles + +All articles that are versioned for all of FPT, GHEC, and GHES in the frontmatter MAY require certain blocks of content to be versioned using in-article Liquid versioning. Before recommending this, check if this is really the case. + +#### Check in-article versioning is required Before resorting to in-article versioning, first consider whether the content is actually different across versions. Often procedures can be simplified to work at both levels. @@ -127,26 +133,6 @@ Use these strategies instead of `{% ifversion %}`, depending on the level of con * End list items with "({% data variables.product.prodname_ghe_cloud %} only)", "({% data variables.product.prodname_dotcom_the_website %} only)", etc. * Specify if the feature is not available for GHES with "NAME-OF-FEATURE is not available for {% data variables.product.prodname_ghe_server %}", "... (not available in {% data variables.product.prodname_ghe_server %})", etc. -### Example - -When documenting a feature that only applies to dotcom (not GHES): - -❌ Don't wrap content in version blocks: - -```markdown -{% ifversion fpt or ghec %} - -## Immutable subject claims - -Repositories created after July 15, 2026 now use an immutable default subject format. - -{% endif %} -``` - -✅ Do use prose to indicate availability: - -```markdown -## Immutable subject claims +#### If in-article versioning is required -Repositories created after July 15, 2026 now use an immutable default subject format. This rollout does not include {% data variables.product.prodname_ghe_server %}. -``` +In-article versioning is required if a block of content in an article is definitely ONLY relevant for GHES, but the article itself is otherwise versioned in the frontmatter for all of FPT, GHEC, and GHES. In this situation, use feature-based versioning (FBV) wherever possible, using `{% ifversion FBV %}` blocks, where FBV is defined in `data/features/`. If it's not possible to use FBV, use {% ifversion ghes %} blocks, which will version the content block for all versions of GHES. diff --git a/.github/workflows/index-general-search.yml b/.github/workflows/index-general-search.yml index ef82ce76b22b..69636ecd3217 100644 --- a/.github/workflows/index-general-search.yml +++ b/.github/workflows/index-general-search.yml @@ -231,8 +231,7 @@ jobs: env: FASTLY_TOKEN: ${{ secrets.FASTLY_TOKEN }} FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }} - FASTLY_SURROGATE_KEY: api-search:${{ matrix.language }} - run: npm run purge-fastly-edge-cache + run: npm run purge-fastly -- --surrogate-key api-search:${{ matrix.language }} - name: Upload failures artifact if: ${{ steps.check-failures.outputs.has_failures == 'true' }} diff --git a/.github/workflows/purge-fastly-all.yml b/.github/workflows/purge-fastly-all.yml deleted file mode 100644 index 4aa2745ce409..000000000000 --- a/.github/workflows/purge-fastly-all.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Purge Fastly (all) - -# **What it does**: Purges the ENTIRE Fastly cache for docs (every URL) on demand. -# **Why we have it**: So docs engineering can clear a bad cache state without asking core engineering to run a purge-all in the Fastly UI. -# **Who does it impact**: All readers. Origin sees a traffic spike while the cache refills, so only run this when a targeted purge will not do. - -on: - workflow_dispatch: - inputs: - confirm: - description: "Type 'purge everything' to confirm a full hard purge of the Fastly cache." - required: true - -permissions: - contents: read - -# Only one purge-all may run at a time. Do not cancel an in-flight purge: -# a half-finished purge leaves the cache in an unknown state. -concurrency: - group: purge-fastly-all - cancel-in-progress: false - -env: - FASTLY_TOKEN: ${{ secrets.FASTLY_TOKEN }} - FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }} - -jobs: - purge-all: - if: github.repository == 'github/docs-internal' - runs-on: ubuntu-latest - steps: - - name: Validate confirmation input - if: ${{ inputs.confirm != 'purge everything' }} - run: | - echo "::error::Confirmation text did not match. Re-run and type exactly: purge everything" - exit 1 - - - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - uses: ./.github/actions/node-npm-setup - - - name: Purge entire Fastly cache - run: npm run purge-fastly-all diff --git a/.github/workflows/purge-fastly.yml b/.github/workflows/purge-fastly.yml index f60e6f96604f..44340c93662d 100644 --- a/.github/workflows/purge-fastly.yml +++ b/.github/workflows/purge-fastly.yml @@ -1,29 +1,50 @@ name: Purge Fastly -# **What it does**: Sends a soft-purge to Fastly. -# **Why we have it**: So that, right after a production deploy, we start afresh -# **Who does it impact**: Writers and engineers. +# **What it does**: Purges Fastly after a deploy and on demand. Soft purge by +# default; can hard purge specific languages, or hard purge the ENTIRE cache. +# **Why we have it**: So that, right after a production deploy, we start afresh, +# and so docs engineering can clear a bad cache state without the Fastly UI. +# **Who does it impact**: Writers and engineers. A full purge impacts all readers, +# origin sees a traffic spike while the cache refills, so it's gated below. on: deployment_status: workflow_dispatch: inputs: languages: - description: "Comma separated languages. E.g. 'en,es,ja,pt,zh,ru,fr,ko,de' (defaults to en)" + description: "Languages: Comma separated languages, e.g. 'en,es,ja,pt,zh,ru,fr,ko,de'. Blank = all languages." required: false - default: 'en' # Temporary, only purge English on deploy. Set to empty string for all + default: 'en' + hard: + description: 'Hard purge: Evict immediately instead of the default soft purge. Use when a soft purge fails to clear stale content.' + type: boolean + required: false + default: false + everything: + description: 'Everything: Hard-purge the entire Fastly cache... every key, all readers. Ignores the languages/hard inputs. To confirm, type exactly: "purge everything". Otherwise leave blank.' + required: false + default: '' permissions: contents: read +# Serialize full-cache purges so two can't overlap and leave the cache in an +# unknown state. Every other run (per-deploy, per-language) gets a unique group +# so those never block each other. +concurrency: + group: ${{ (inputs.everything == 'purge everything' && 'purge-fastly-all') || format('purge-fastly-{0}', github.run_id) }} + cancel-in-progress: false + env: FASTLY_TOKEN: ${{ secrets.FASTLY_TOKEN }} FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }} jobs: send-purges: - # Run when workflow_dispatch is the event (manual) or when deployment_status is the event (automatic) and it's a successful production deploy. - # NOTE: This workflow triggers on all deployment_status events (including staging), but only runs for production. + # Run when workflow_dispatch is the event + # or when deployment_status is the event and it's a successful production deploy. + # NOTE: This workflow triggers on all deployment_status events, + # including staging, but only runs for production. # Non-production deploys will show as "skipped" - this is expected behavior. if: >- ${{ @@ -38,52 +59,50 @@ jobs: - uses: ./.github/actions/node-npm-setup - - name: Wait for production to match build commit SHA - if: github.event_name != 'workflow_dispatch' - # A single /_build match only proves *one* Moda instance is serving the - # new build; others can still be mid-rollout. If we purge then, Fastly's - # soft purge serves stale-while-revalidate and may revalidate against a - # lagging instance, re-caching old content as fresh for a full TTL. So we - # require several consecutive matches to confirm the rollout has settled - # across instances before purging. + - name: Validate confirmation input + # A full-cache purge only triggers on the exact string "purge everything". + # Any other non-empty value (e.g. a typo) would otherwise be silently + # ignored and fall through to a normal soft purge that finishes green, so + # an operator could think they evicted the whole cache when they didn't. + # Fail loudly instead. + env: + EVERYTHING_INPUT: ${{ inputs.everything }} run: | - needs=$(git rev-parse HEAD) - start_time=$(date +%s) - timeout_seconds=1200 - required_matches=5 - interval_seconds=10 - consecutive=0 - while [[ $consecutive -lt $required_matches ]] - do - if [[ $(($(date +%s) - $start_time)) -gt $timeout_seconds ]] - then - echo "Production did not reach $required_matches consecutive build matches within $timeout_seconds seconds" - exit 1 - fi - if [[ $needs == $(curl -s --fail --retry-connrefused --retry 5 https://docs.github.com/_build) ]] - then - consecutive=$((consecutive + 1)) - echo "Production matches the build commit ($consecutive/$required_matches)" - else - if [[ $consecutive -gt 0 ]] - then - echo "Production stopped matching the build commit; resetting consecutive count" - else - echo "Production is not up to date with the build commit" - fi - consecutive=0 - fi - if [[ $consecutive -lt $required_matches ]] - then - sleep $interval_seconds - fi - done - echo "Production is up to date with the build commit ($required_matches consecutive matches)" + if [ -n "$EVERYTHING_INPUT" ] && [ "$EVERYTHING_INPUT" != "purge everything" ]; then + echo "::error::To purge the entire cache, the 'everything' input must be exactly 'purge everything'. Got: '$EVERYTHING_INPUT'. Leave it blank for a normal purge." + exit 1 + fi - - name: Purge Fastly edge cache per language + - name: Purge Fastly + # Auto post-deploy runs wait for the build, purge English only (temporary), + # and stay soft. A manual run uses the inputs: blank languages = all, the + # `hard` toggle, or a confirmed full-cache purge. + # + # Raw inputs are passed through the environment and quoted, never spliced + # into the command string, so a value like `en' --everything` can't break + # out of its argument and inject another flag. env: - LANGUAGES: ${{ inputs.languages || 'en' }} # Temporary, only purge English on deploy. Set to empty string for all - run: npm run purge-fastly-edge-cache-per-language + EVENT_NAME: ${{ github.event_name }} + LANGUAGES_INPUT: ${{ inputs.languages }} + HARD_INPUT: ${{ inputs.hard }} + EVERYTHING_INPUT: ${{ inputs.everything }} + run: | + args=() + if [ "$EVENT_NAME" != "workflow_dispatch" ]; then + args+=(--wait-for-build) + fi + if [ "$EVENT_NAME" = "deployment_status" ]; then + args+=(--languages en) + elif [ -n "$LANGUAGES_INPUT" ]; then + args+=(--languages "$LANGUAGES_INPUT") + fi + if [ "$HARD_INPUT" = "true" ]; then + args+=(--hard) + fi + if [ "$EVERYTHING_INPUT" = "purge everything" ]; then + args+=(--everything) + fi + npm run purge-fastly -- "${args[@]}" - uses: ./.github/actions/slack-alert if: ${{ failure() && github.event_name != 'workflow_dispatch' }} diff --git a/assets/images/help/copilot/copilot-cli-context-usage.png b/assets/images/help/copilot/copilot-cli-context-usage.png index 451a7a6eb70d..1541ee8184df 100644 Binary files a/assets/images/help/copilot/copilot-cli-context-usage.png and b/assets/images/help/copilot/copilot-cli-context-usage.png differ diff --git a/content/admin/administering-your-instance/administering-your-instance-from-the-command-line/command-line-utilities.md b/content/admin/administering-your-instance/administering-your-instance-from-the-command-line/command-line-utilities.md index bbb744db633e..fb7fe38911b5 100644 --- a/content/admin/administering-your-instance/administering-your-instance-from-the-command-line/command-line-utilities.md +++ b/content/admin/administering-your-instance/administering-your-instance-from-the-command-line/command-line-utilities.md @@ -1384,12 +1384,15 @@ In this example, `ghe-repl-status -vv` sends verbose status information from a r ### ghe-check-background-upgrade-jobs -During an upgrade to a feature release, this utility displays the status of background jobs on {% data variables.location.product_location %}. If you're running back-to-back upgrades, you should use this utility to check that all background jobs are complete before proceeding with the next upgrade. +During an upgrade to a feature release, this utility displays the status of background upgrade jobs, such as Elasticsearch index migrations, on {% data variables.location.product_location %}. If you're running back-to-back upgrades, you should use this utility to check that all background jobs are complete before proceeding with the next feature upgrade. ```shell ghe-check-background-upgrade-jobs ``` +> [!NOTE] +> This utility only gates a **subsequent feature upgrade**. It is not a prerequisite for upgrading replica or other additional nodes to the same release. + ### ghe-migrations During an upgrade to a feature release, this utility displays the status of active database migrations on {% data variables.location.product_location %}. The output includes a version identifier for the migration, the migration's name, the migration's status, and the current duration of the migration. diff --git a/content/admin/upgrading-your-instance/performing-an-upgrade/upgrading-with-an-upgrade-package.md b/content/admin/upgrading-your-instance/performing-an-upgrade/upgrading-with-an-upgrade-package.md index 21750db34a98..858d7c19f4e5 100644 --- a/content/admin/upgrading-your-instance/performing-an-upgrade/upgrading-with-an-upgrade-package.md +++ b/content/admin/upgrading-your-instance/performing-an-upgrade/upgrading-with-an-upgrade-package.md @@ -53,6 +53,9 @@ While you can use a hotpatch to upgrade to the latest patch release within a fea See [AUTOTITLE](/admin/configuration/configuring-your-enterprise/command-line-utilities#ghe-check-background-upgrade-jobs). + > [!NOTE] + > Background upgrade jobs only need to complete before you begin a subsequent feature upgrade. You don't need to wait for `ghe-check-background-upgrade-jobs` before upgrading replica or other additional nodes to the same release. + To monitor progress of the configuration run, read the output in `/data/user/common/ghe-config.log`. For example, you can tail the log by running the following command: ```shell diff --git a/content/copilot/concepts/agents/copilot-cli/context-management.md b/content/copilot/concepts/agents/copilot-cli/context-management.md index 5894159630e3..f72fc19d375c 100644 --- a/content/copilot/concepts/agents/copilot-cli/context-management.md +++ b/content/copilot/concepts/agents/copilot-cli/context-management.md @@ -34,12 +34,15 @@ This means that in a very long session, {% data variables.product.prodname_copil ## Checking your context usage -You can check how much of the context window is currently in use by entering the `/context` slash command. This displays a visual breakdown of your token usage, showing: - -* **System/Tools**: The fixed overhead of system instructions and tool definitions. -* **Messages**: The space used by your conversation history. -* **Free Space**: How much room is left for new messages. -* **Buffer**: A reserved portion that triggers automatic context management. +You can use the `/context` slash command to visualize your current context window usage. The first line of the output shows the active model and the number of tokens currently in use out of the model's total context window capacity. The remainder of the output shows token usage, and context window percentage, for: + +* **System Prompt**: the base system prompt. +* **Custom Instructions**: your loaded custom instructions (shown only when present). +* **System Tools**: built-in tool definitions. +* **MCP Tools**: tool definitions contributed by MCP servers. +* **Messages**: your conversation history. +* **Free Space**: unused context still available. +* **Buffer**: capacity reserved for the model's response and headroom. ![Screenshot of the output of the '/context' CLI command.](/assets/images/help/copilot/copilot-cli-context-usage.png) diff --git a/content/copilot/how-tos/copilot-cli/use-copilot-cli/roll-back-changes.md b/content/copilot/how-tos/copilot-cli/use-copilot-cli/roll-back-changes.md index b6f272102903..023c4efb4e76 100644 --- a/content/copilot/how-tos/copilot-cli/use-copilot-cli/roll-back-changes.md +++ b/content/copilot/how-tos/copilot-cli/use-copilot-cli/roll-back-changes.md @@ -1,7 +1,7 @@ --- title: Rolling back changes made during a {% data variables.copilot.copilot_cli %} session shortTitle: Roll back changes -intro: 'Rewind your {% data variables.copilot.copilot_cli_short %} session to a previous prompt to undo changes and restore your repository to a previous state.' +intro: 'Rewind your {% data variables.copilot.copilot_cli_short %} session to a previous prompt to undo changes in conversation history, and optionally restore files.' versions: feature: copilot contentType: how-tos @@ -18,34 +18,55 @@ docsTeamMetrics: When you work in an interactive {% data variables.copilot.copilot_cli_short %} session, {% data variables.product.prodname_copilot_short %} can make changes to files, run shell commands, and modify your repository. If the result isn't what you expected, you can rewind to a previous point in the session to undo those changes. -When you enter a prompt, the first thing {% data variables.copilot.copilot_cli_short %} does is take a snapshot of your workspace state. This snapshot allows you to roll back to that point in the session if you need to. You can trigger a rewind by pressing Esc twice, or by using the `/undo` slash command. +You can trigger a rewind by pressing Esc twice, or by using the `/undo` slash command (or its alias `/rewind`). + +{% data variables.copilot.copilot_cli_short %} supports two rewind behaviors: + +* **Git-based rewind**: rolls back to a workspace snapshot taken at the start of a prompt. +* **Tools-based rewind**: lets you rewind conversation history only, or rewind conversation history and restore files that {% data variables.product.prodname_copilot_short %} changed. + +> [!NOTE] +> Tools-based rewind is currently an experimental feature and is only available if you have used the `/experimental on` slash command, or the `--experimental` command line option. + +{% data variables.copilot.copilot_cli_short %} automatically chooses one of these rewind behaviors based on your environment to provide the best possible rewind experience. + +To tell which of the rewind behaviors is active: + +* If the picker immediately shows snapshots and selecting one performs the rollback, you're using **Git-based rewind**. +* If selecting a rewind point opens an action menu with **Conversation only** and **Conversation + files**, you're using **tools-based rewind**. This article explains how to roll back changes. For more conceptual information about rewinding to an earlier point in a session, see [AUTOTITLE](/copilot/concepts/agents/copilot-cli/cancel-and-roll-back). ## Prerequisites -* **You must be working in a Git repository with at least one commit.** {% data variables.copilot.copilot_cli_short %} uses Git operations to track and restore workspace state. -* **A snapshot must exist.** Snapshots are created automatically at the start of each of your interactions with {% data variables.product.prodname_copilot_short %} in a CLI session. You can't roll back changes made before your first prompt in a session, or to the repository state for a step where snapshot creation was skipped, see [Changes that can't be rolled back](/copilot/concepts/agents/copilot-cli/cancel-and-roll-back#changes-that-cant-be-rolled-back). +* **A rewind point must exist.** You can't roll back before your first prompt in a session. +* **For Git-based rewind only:** you must be in a Git repository with at least one commit. +* **For tools-based rewind:** file restoration can be skipped for files that were changed after {% data variables.product.prodname_copilot_short %} last touched them. ## Rolling back with a double Esc keypress > [!WARNING] -> * {% data reusables.copilot.copilot-cli.cli-rewind-warning %} -> * Rewinding cannot be undone. Once you roll back to a snapshot, all snapshots and session history after that point are permanently removed. +> * Rewinding cannot be undone. Once you roll back, later session history is permanently removed. +> * In **Git-based rewind**, rolling back restores your entire workspace to the state it was in at the selected snapshot. This reverts all changes made after that point—not only changes made by {% data variables.product.prodname_copilot_short %}, but also any manual edits and changes from shell commands. Any new files created in the workspace after the snapshot was taken are deleted, regardless of their Git status. +> * In **tools-based rewind**, you can choose whether to restore files. If you choose file restoration, files changed after {% data variables.product.prodname_copilot_short %} may be left unchanged to avoid overwriting your newer edits. When {% data variables.product.prodname_copilot_short %} has finished responding to a prompt you've entered: 1. Make sure the input area is empty. If there's text in the input area, pressing Esc twice in quick succession clears the text. 1. Press Esc twice in quick succession to open the rewind picker. - The rewind picker lists the available snapshots for the current session, with the most recent first. The ten most recent snapshots are displayed. If there are more than ten snapshots available you can use the arrow key to scroll down through earlier snapshots. + The picker lists available rewind points for the current session, with the most recent first. The ten most recent points are displayed at once. If there are more than ten, use the arrow key to scroll down through earlier points. + For each rewind point, the beginning of the prompt you entered is shown, with an indication of how long ago you submitted it. - For each snapshot, the beginning of the prompt you entered is shown, with an indication of how long ago you submitted it. +1. Choose a rewind point. -1. Choose a snapshot to roll back to. This will return you to the state of the repository when you entered the associated prompt. + * In Git-based rewind, selecting a snapshot restores the workspace to the state at the start of that prompt. + * In tools-based rewind, after choosing a rewind point you can select: + * **Conversation only** (history rewound, files unchanged), or + * **Conversation + files** (history rewound and restorable files changed by {% data variables.product.prodname_copilot_short %} are restored). > [!NOTE] - > The repository is rolled back to its state immediately before {% data variables.product.prodname_copilot_short %} started working on the prompt, not immediately after it finished working on the prompt. + > In Git-based rewind, the repository is rolled back to its state immediately before {% data variables.product.prodname_copilot_short %} started working on the prompt, not immediately after it finished working on the prompt. The prompt you selected is shown in the input area, so you can edit and resubmit it, if required. diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index 5287fe5b2c6f..96892c829142 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -60,6 +60,8 @@ Used for: > [!WARNING] > When {% data variables.copilot.copilot_claude_fable_5 %} is used, Anthropic retains data, including prompts and outputs, to operate safety classifiers that detect harmful use. Other Claude models in {% data variables.product.prodname_copilot %} remain covered by {% data variables.product.github %}'s existing data retention agreements, as documented below. Enterprise and business users need to enable the {% data variables.copilot.copilot_claude_fable_5 %} model to make it available for your organization. You can read more about Anthropic's data handling practices for this model under section F of their [Service Specific Terms](https://www.anthropic.com/legal/service-specific-terms). +{% data reusables.copilot.model-fable-disabled %} + These models are hosted by Amazon Web Services, Anthropic PBC, and Google Cloud Platform. {% data variables.product.github %} has provider agreements in place to ensure data is not used for training. Additional details for each provider are included below: * Amazon Bedrock: Amazon makes the [following data commitments](https://docs.aws.amazon.com/bedrock/latest/userguide/data-protection.html): _Amazon Bedrock doesn't store or log your prompts and completions. Amazon Bedrock doesn't use your prompts and completions to train any AWS models and doesn't distribute them to third parties_. diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index c8988cc5fd33..a4530de64ac3 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -34,6 +34,8 @@ For all of the default AI models, input prompts and output completions run throu This table lists the AI models available in {% data variables.product.prodname_copilot_short %}, along with their release status. +{% data reusables.copilot.model-fable-disabled %} + {% rowheaders %} | Model name | Provider | Release status | @@ -107,6 +109,8 @@ The following table lists AI models that are retired or scheduled for retirement The following table shows which models are available in each client. +{% data reusables.copilot.model-fable-disabled %} + {% rowheaders %} | Model | {% data variables.product.prodname_dotcom_the_website %} | {% data variables.copilot.copilot_cli_short %} | {% data variables.product.prodname_vscode %} | {% data variables.product.prodname_vs %} | Eclipse | Xcode | JetBrains IDEs | diff --git a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md index 2a717c770796..5ac5fb18061e 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md @@ -352,6 +352,7 @@ Use `--model=MODEL` or the `COPILOT_MODEL` environment variable to select the AI | `gpt-5.3-codex` | Code-focused tasks | | `gemini-3.1-pro-preview` | Google Gemini reasoning | | `gemini-3.5-flash` | Fast Google Gemini responses | +| `mai-code-1-flash` | Fast, adaptive coding tasks | You can also switch models during an interactive session using the `/model` slash command. diff --git a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md index 8224bb221fc7..7b9e6f6732cf 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -3,3 +3,4 @@ * {% data variables.copilot.copilot_gemini_31_pro %} * {% data variables.copilot.copilot_gemini_35_flash %} * {% data variables.copilot.copilot_gpt_54_mini %} +* {% data variables.copilot.copilot_mai_code_1_flash %} diff --git a/data/reusables/copilot/model-fable-disabled.md b/data/reusables/copilot/model-fable-disabled.md index 3fe3c546eba8..c7feac2a8541 100644 --- a/data/reusables/copilot/model-fable-disabled.md +++ b/data/reusables/copilot/model-fable-disabled.md @@ -1 +1 @@ -> [!NOTE] Claude Fable 5 is currently unavailable. For more information, see [Anthropic's announcement](https://www.anthropic.com/news/fable-mythos-access). \ No newline at end of file +> [!NOTE] {% data variables.copilot.copilot_claude_fable_5 %} is currently unavailable. For more information, see [Anthropic's announcement](https://www.anthropic.com/news/fable-mythos-access). \ No newline at end of file diff --git a/data/tables/copilot/auto-model-selection.yml b/data/tables/copilot/auto-model-selection.yml index 1106a6c70b8a..05a89d89f044 100644 --- a/data/tables/copilot/auto-model-selection.yml +++ b/data/tables/copilot/auto-model-selection.yml @@ -53,7 +53,7 @@ - name: MAI-Code-1-Flash cloud_agent: false chat: true - cli: false + cli: true # Fine-tuned OAI models - name: Raptor mini diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 124981149d1f..fb6d267fb538 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -132,13 +132,13 @@ jetbrains: true - name: MAI-Code-1-Flash - dotcom: false - cli: false + dotcom: true + cli: true vscode: true - vs: false - eclipse: false - xcode: false - jetbrains: false + vs: true + eclipse: true + xcode: true + jetbrains: true - name: GPT-5 mini dotcom: false diff --git a/eslint.config.ts b/eslint.config.ts index 874457e2bb09..3a72c57bc708 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -231,14 +231,6 @@ export default [ }, }, - // Legacy files with @typescript-eslint/no-explicit-any violations (see github/docs-engineering#5797) - { - files: ['src/frame/components/context/MainContext.tsx'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - }, - }, - // Ignored patterns // CodeQL scripts included because cocofix is install manually by the workflow { diff --git a/package.json b/package.json index 4e4950b1d08b..eea32e0326fc 100644 --- a/package.json +++ b/package.json @@ -79,9 +79,7 @@ "prettier": "prettier -w \"**/*.{ts,tsx,scss,yml,yaml}\"", "prettier-check": "prettier -c \"**/*.{ts,tsx,scss,yml,yaml}\"", "prevent-pushes-to-main": "tsx src/workflows/prevent-pushes-to-main.ts", - "purge-fastly-edge-cache": "tsx src/workflows/purge-fastly-edge-cache.ts", - "purge-fastly-edge-cache-per-language": "tsx src/languages/scripts/purge-fastly-edge-cache-per-language.ts", - "purge-fastly-all": "tsx src/workflows/purge-fastly-all.ts", + "purge-fastly": "tsx src/workflows/purge-fastly.ts", "readability-report": "tsx src/workflows/experimental/readability-report.ts", "ready-for-docs-review": "tsx src/workflows/ready-for-docs-review.ts", "release-banner": "tsx src/ghes-releases/scripts/release-banner.ts", diff --git a/src/audit-logs/components/GroupedEvents.tsx b/src/audit-logs/components/GroupedEvents.tsx index 1240f328bcdc..ffa28309c224 100644 --- a/src/audit-logs/components/GroupedEvents.tsx +++ b/src/audit-logs/components/GroupedEvents.tsx @@ -50,7 +50,7 @@ export default function GroupedEvents({ auditLogEvents, category, categoryNote }
{auditLogEvents.map((event) => (
-
+
{event.action}
diff --git a/src/audit-logs/pages/audit-log-events.tsx b/src/audit-logs/pages/audit-log-events.tsx index 27e84296aaf7..9389b6be21e0 100644 --- a/src/audit-logs/pages/audit-log-events.tsx +++ b/src/audit-logs/pages/audit-log-events.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { addUINamespaces, @@ -60,8 +62,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => const { getAutomatedPageMiniTocItems } = await import('@/frame/lib/get-mini-toc-items') const { getCategorizedAuditLogEvents, getCategoryNotes } = await import('../lib') - const req = context.req as object - const res = context.res as object + const req = context.req as unknown as ExtendedRequest + const res = context.res as unknown as Response const currentVersion = context.query.versionId as string const url = context.req.url diff --git a/src/frame/components/context/MainContext.tsx b/src/frame/components/context/MainContext.tsx index 2316958598c0..9a48ac250781 100644 --- a/src/frame/components/context/MainContext.tsx +++ b/src/frame/components/context/MainContext.tsx @@ -1,9 +1,10 @@ import { createContext, useContext } from 'react' import pick from 'lodash/pick' +import type { Response } from 'express' import type { BreadcrumbT } from '@/frame/components/page-header/Breadcrumbs' import type { FeatureFlags } from '@/frame/components/hooks/useFeatureFlags' -import type { SidebarLink } from '@/types' +import type { ExtendedRequest, Permalink, SidebarLink } from '@/types' export type ProductT = { external: boolean @@ -106,7 +107,7 @@ export type MainContextT = { currentProduct?: ProductT currentProductName: string currentProductTree?: ProductTreeNode | null - currentLayoutName?: string + currentLayoutName?: string | null currentVersion?: string data: DataT enterpriseServerReleases: EnterpriseServerReleases @@ -127,7 +128,7 @@ export type MainContextT = { applicableVersions: string[] docsTeamMetrics: string[] | null } | null - relativePath?: string + relativePath?: string | null sidebarTree?: ProductTreeNode | null status: number xHost?: string @@ -154,8 +155,8 @@ const DEFAULT_UI_NAMESPACES = [ 'cookbook_landing', ] -export function addUINamespaces(req: any, ui: UIStrings, namespaces: string[]) { - const pool = req.context.site.data.ui +export function addUINamespaces(req: ExtendedRequest, ui: UIStrings, namespaces: string[]) { + const pool = req.context!.site!.data.ui for (const namespace of namespaces) { if (!(namespace in pool)) { throw new Error( @@ -168,22 +169,26 @@ export function addUINamespaces(req: any, ui: UIStrings, namespaces: string[]) { } } -export const getMainContext = async (req: any, res: any): Promise => { +export const getMainContext = async ( + req: ExtendedRequest, + res: Response, +): Promise => { + const context = req.context! // Our current translation process adds 'ms.*' frontmatter properties to files // it translates including when data/ui.yml is translated. We don't use these // properties and their syntax (e.g. 'ms.openlocfilehash', // 'ms.sourcegitcommit', etc.) causes problems so just delete them. - if (req.context.site.data.ui.ms) { - delete req.context.site.data.ui.ms + if (context.site!.data.ui.ms) { + delete context.site!.data.ui.ms } - const { page } = req.context + const { page } = context const documentType = page ? (page.documentType as string) : undefined const ui: UIStrings = {} addUINamespaces(req, ui, DEFAULT_UI_NAMESPACES) - if (req.context.currentJourneyTrack?.trackId) { + if (context.currentJourneyTrack?.trackId) { addUINamespaces(req, ui, ['journey_track_nav']) } @@ -197,26 +202,26 @@ export const getMainContext = async (req: any, res: any): Promise // To know whether we need this key, we need to match this // with the business logic in `DeprecationBanner.tsx` which is as follows: if ( - req.context.enterpriseServerReleases.releasesWithOldestDeprecationDate.includes( - req.context.currentRelease, + context.enterpriseServerReleases!.releasesWithOldestDeprecationDate.includes( + context.currentRelease as string, ) ) { reusables.enterprise_deprecation = { - version_was_deprecated: req.context.getDottedData( + version_was_deprecated: context.getDottedData!( 'reusables.enterprise_deprecation.version_was_deprecated', - ), - version_will_be_deprecated: req.context.getDottedData( + ) as string, + version_will_be_deprecated: context.getDottedData!( 'reusables.enterprise_deprecation.version_will_be_deprecated', - ), - deprecation_details: req.context.getDottedData( + ) as string, + deprecation_details: context.getDottedData!( 'reusables.enterprise_deprecation.deprecation_details', - ), + ) as string, } } // This is a number, like 3.13 or it's possibly null if there is no // supported release candidate at the moment. - const { releaseCandidate } = req.context.enterpriseServerReleases + const { releaseCandidate } = context.enterpriseServerReleases! // Combine the version number with the prefix so it can appear // as a full version string if the release candidate is set. const releaseCandidateVersion = releaseCandidate ? `enterprise-server@${releaseCandidate}` : null @@ -224,38 +229,38 @@ export const getMainContext = async (req: any, res: any): Promise const pageInfo = (page && { documentType, - contentType: req.context.page.contentType || null, - title: req.context.page.title, - fullTitle: req.context.page.fullTitle || null, - introPlainText: req.context.page?.introPlainText || null, - applicableVersions: req.context.page?.permalinks.map((obj: any) => obj.pageVersion) || [], - hidden: req.context.page.hidden || false, - noEarlyAccessBanner: req.context.page.noEarlyAccessBanner || false, - docsTeamMetrics: req.context.page.docsTeamMetrics || null, + contentType: page.contentType || null, + title: page.title, + fullTitle: page.fullTitle || null, + introPlainText: page.introPlainText || null, + applicableVersions: page.permalinks.map((obj: Permalink) => obj.pageVersion), + hidden: page.hidden || false, + noEarlyAccessBanner: page.noEarlyAccessBanner || false, + docsTeamMetrics: page.docsTeamMetrics || null, }) || null - const currentProduct: ProductT = req.context.productMap[req.context.currentProduct] || null - const currentProductName: string = req.context.currentProductName || '' + const currentProduct = (context.productMap?.[context.currentProduct || ''] || null) as ProductT + const currentProductName: string = context.currentProductName || '' const props: MainContextT = { - allVersions: minimalAllVersions(req.context.allVersions), - breadcrumbs: req.context.breadcrumbs || {}, - communityRedirect: req.context.page?.communityRedirect || {}, - currentCategory: req.context.currentCategory || '', - currentLayoutName: req.context.currentLayoutName || null, - currentPathWithoutLanguage: req.context.currentPathWithoutLanguage, + allVersions: minimalAllVersions(context.allVersions!), + breadcrumbs: (context.breadcrumbs || {}) as MainContextT['breadcrumbs'], + communityRedirect: (context.page?.communityRedirect || {}) as MainContextT['communityRedirect'], + currentCategory: context.currentCategory || '', + currentLayoutName: context.currentLayoutName || null, + currentPathWithoutLanguage: context.currentPathWithoutLanguage!, currentProduct, currentProductName, - // This is a slimmed down version of `req.context.currentProductTree` + // This is a slimmed down version of `context.currentProductTree` // that only has the minimal titles stuff needed for sidebars and // any page that is hidden is omitted. // However, it's not needed on most pages. For example, on article pages, // you don't need it. It's similar to the minimal product tree but, // has the full length titles and not just the short titles. currentProductTree: - (includeFullProductTree && req.context.currentProductTreeTitlesExcludeHidden) || null, - currentVersion: req.context.currentVersion, + (includeFullProductTree && context.currentProductTreeTitlesExcludeHidden) || null, + currentVersion: context.currentVersion, data: { ui, reusables, @@ -265,24 +270,24 @@ export const getMainContext = async (req: any, res: any): Promise }, }, }, - enterpriseServerReleases: pick(req.context.enterpriseServerReleases, [ + enterpriseServerReleases: pick(context.enterpriseServerReleases!, [ 'isOldestReleaseDeprecated', 'oldestSupported', 'nextDeprecationDate', 'supported', 'releasesWithOldestDeprecationDate', - ]), - enterpriseServerVersions: req.context.enterpriseServerVersions, - error: req.context.error ? req.context.error.toString() : '', + ]) as EnterpriseServerReleases, + enterpriseServerVersions: context.enterpriseServerVersions!, + error: context.error ? context.error.toString() : '', featureFlags: {}, fullUrl: `${req.protocol}://${req.hostname}${req.originalUrl}`, // does not include port for localhost - isHomepageVersion: req.context.page?.documentType === 'homepage', - nonEnterpriseDefaultVersion: req.context.nonEnterpriseDefaultVersion, - page: pageInfo, - relativePath: req.context.page?.relativePath || null, + isHomepageVersion: context.page?.documentType === 'homepage', + nonEnterpriseDefaultVersion: context.nonEnterpriseDefaultVersion!, + page: pageInfo as MainContextT['page'], + relativePath: context.page?.relativePath || null, // The minimal product tree is needed on all pages that depend on // the product sidebar or the rest sidebar. - sidebarTree: (includeSidebarTree && req.context.sidebarTree) || null, + sidebarTree: (includeSidebarTree && context.sidebarTree) || null, status: res.statusCode, xHost: req.get('x-host') || '', } diff --git a/src/github-apps/pages/endpoints-available-for-fine-grained-personal-access-tokens.tsx b/src/github-apps/pages/endpoints-available-for-fine-grained-personal-access-tokens.tsx index 4ed5f5886956..f277786d7c0c 100644 --- a/src/github-apps/pages/endpoints-available-for-fine-grained-personal-access-tokens.tsx +++ b/src/github-apps/pages/endpoints-available-for-fine-grained-personal-access-tokens.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { AutomatedPageContextT, @@ -41,7 +43,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(context.req, context.res), + mainContext: await getMainContext( + context.req as unknown as ExtendedRequest, + context.res as unknown as Response, + ), currentVersion, appsItems: appsItems as EnabledListT, automatedPageContext: getAutomatedPageContextFromRequest(context.req), diff --git a/src/github-apps/pages/endpoints-available-for-github-app-installation-access-tokens.tsx b/src/github-apps/pages/endpoints-available-for-github-app-installation-access-tokens.tsx index 3ac0fccc0f76..61cc5c8a9598 100644 --- a/src/github-apps/pages/endpoints-available-for-github-app-installation-access-tokens.tsx +++ b/src/github-apps/pages/endpoints-available-for-github-app-installation-access-tokens.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { AutomatedPageContextT, @@ -41,7 +43,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(context.req, context.res), + mainContext: await getMainContext( + context.req as unknown as ExtendedRequest, + context.res as unknown as Response, + ), currentVersion, appsItems: appsItems as EnabledListT, automatedPageContext: getAutomatedPageContextFromRequest(context.req), diff --git a/src/github-apps/pages/endpoints-available-for-github-app-user-access-tokens.tsx b/src/github-apps/pages/endpoints-available-for-github-app-user-access-tokens.tsx index 4f45615698b8..3d49f73c7af8 100644 --- a/src/github-apps/pages/endpoints-available-for-github-app-user-access-tokens.tsx +++ b/src/github-apps/pages/endpoints-available-for-github-app-user-access-tokens.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { AutomatedPageContextT, @@ -41,7 +43,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(context.req, context.res), + mainContext: await getMainContext( + context.req as unknown as ExtendedRequest, + context.res as unknown as Response, + ), currentVersion, appsItems: appsItems as EnabledListT, automatedPageContext: getAutomatedPageContextFromRequest(context.req), diff --git a/src/github-apps/pages/permissions-required-for-fine-grained-personal-access-tokens.tsx b/src/github-apps/pages/permissions-required-for-fine-grained-personal-access-tokens.tsx index cd84db30b503..8e512ba6eb1b 100644 --- a/src/github-apps/pages/permissions-required-for-fine-grained-personal-access-tokens.tsx +++ b/src/github-apps/pages/permissions-required-for-fine-grained-personal-access-tokens.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { AutomatedPageContextT, @@ -42,7 +44,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(context.req, context.res), + mainContext: await getMainContext( + context.req as unknown as ExtendedRequest, + context.res as unknown as Response, + ), currentVersion, appsItems: appsItems as PermissionListT, automatedPageContext: getAutomatedPageContextFromRequest(context.req), diff --git a/src/github-apps/pages/permissions-required-for-github-apps.tsx b/src/github-apps/pages/permissions-required-for-github-apps.tsx index 583a234b7a5a..14de389c3c52 100644 --- a/src/github-apps/pages/permissions-required-for-github-apps.tsx +++ b/src/github-apps/pages/permissions-required-for-github-apps.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { AutomatedPageContextT, getAutomatedPageContextFromRequest, @@ -42,7 +44,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(context.req, context.res), + mainContext: await getMainContext( + context.req as unknown as ExtendedRequest, + context.res as unknown as Response, + ), currentVersion, appsItems: appsItems as PermissionListT, automatedPageContext: getAutomatedPageContextFromRequest(context.req), diff --git a/src/graphql/pages/breaking-changes.tsx b/src/graphql/pages/breaking-changes.tsx index 1224d0e54e75..b39033517190 100644 --- a/src/graphql/pages/breaking-changes.tsx +++ b/src/graphql/pages/breaking-changes.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import GithubSlugger from 'github-slugger' import type { ExtendedRequest } from '@/types' import type { ServerResponse } from 'http' @@ -73,7 +74,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(req, res), + mainContext: await getMainContext(req, res as unknown as Response), automatedPageContext, schema, headings, diff --git a/src/graphql/pages/changelog-year.tsx b/src/graphql/pages/changelog-year.tsx index 79df6419c61b..99474ccf759d 100644 --- a/src/graphql/pages/changelog-year.tsx +++ b/src/graphql/pages/changelog-year.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import type { ExtendedRequest } from '@/types' import type { ServerResponse } from 'http' @@ -64,7 +65,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(req, res), + mainContext: await getMainContext(req, res as unknown as Response), automatedPageContext, schema, years, diff --git a/src/graphql/pages/changelog.tsx b/src/graphql/pages/changelog.tsx index 46bc884988a1..1aee0f1921bd 100644 --- a/src/graphql/pages/changelog.tsx +++ b/src/graphql/pages/changelog.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import type { ExtendedRequest } from '@/types' import type { ServerResponse } from 'http' @@ -59,7 +60,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { props: { - mainContext: await getMainContext(req, res), + mainContext: await getMainContext(req, res as unknown as Response), automatedPageContext, schema, years, diff --git a/src/graphql/pages/reference.tsx b/src/graphql/pages/reference.tsx index 1b8185a91fa6..bef78e785891 100644 --- a/src/graphql/pages/reference.tsx +++ b/src/graphql/pages/reference.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import { GraphqlCategoryPage, type CategorySchema } from '@/graphql/components/GraphqlCategoryPage' import { @@ -114,7 +115,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => }) } - const mainContext = await getMainContext(req, res) + const mainContext = await getMainContext(req, res as unknown as Response) addUINamespaces(req, mainContext.data.ui, ['graphql']) return { diff --git a/src/graphql/pages/schema-previews.tsx b/src/graphql/pages/schema-previews.tsx index 5a1ac1beb260..f2eda650a55f 100644 --- a/src/graphql/pages/schema-previews.tsx +++ b/src/graphql/pages/schema-previews.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import type { ExtendedRequest } from '@/types' import type { ServerResponse } from 'http' @@ -53,7 +54,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => // Update the existing context to include the miniTocItems from GraphQL automatedPageContext.miniTocItems.push(...changelogMiniTocItems) - const mainContext = await getMainContext(req, res) + const mainContext = await getMainContext(req, res as unknown as Response) addUINamespaces(req, mainContext.data.ui, ['graphql']) return { diff --git a/src/languages/scripts/purge-fastly-edge-cache-per-language.ts b/src/languages/scripts/purge-fastly-edge-cache-per-language.ts deleted file mode 100644 index 8f68c02a6403..000000000000 --- a/src/languages/scripts/purge-fastly-edge-cache-per-language.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { languageKeys } from '@/languages/lib/languages-server' - -import { makeLanguageSurrogateKey } from '@/frame/middleware/set-fastly-surrogate-key' -import purgeEdgeCache from '@/workflows/purge-edge-cache' - -/** - * In simple terms, this script sends purge commands for... - * - * 1. no-language - * 2. 'en' - * 3. 'ja' - * 4. 'pt' - * ... - * - * ...and so on for all languages. - * - * Each surrogate key is purged twice because of Fastly shielding: the first - * purge clears the edge nodes and the second clears the origin shield. - * - * Two delays shape the schedule: - * - * - To avoid a stampeding herd after each purge, and to avoid unnecessary load - * on the backend, there's a slight delay between each language's FIRST purge. - * This gives the backend a chance to finish processing all the now stale URLs, - * for that language, before tackling the next. - * - The SECOND purge of a key happens a while after its first purge, long enough - * for the now-stale content to be re-fetched and re-shielded before we clear - * the shield again. Fastly's docs recommend ~2s for surrogate-key purges, but - * in practice that's been too short and stale content survives the shielding - * race, so we use a larger margin. See the "Race conditions" subsection of - * https://www.fastly.com/documentation/guides/concepts/cache/purging#race-conditions - * (the 30s figure there is for purge-all, which we don't use). The value must - * stay a multiple of DELAY_BETWEEN_LANGUAGES to keep the slot alignment below. - * - * Rather than block on the second purge (which would serialize everything and - * make the whole run take `DELAY_BEFORE_SECOND_PURGE` per key), we schedule all - * purges against a wall-clock timeline up front. Because the second-purge delay - * is a multiple of the between-languages delay, a key's second purge lands on - * the same slot as a later key's first purge. For example, with a 10s cadence - * and a 20s second-purge delay: - * - * t=0 no-language (1st) - * t=10 en (1st) - * t=20 es (1st) + no-language (2nd) - * t=30 ja (1st) + en (2nd) - * t=40 pt (1st) + es (2nd) - * ... - */ -const DELAY_BETWEEN_LANGUAGES = 10 * 1000 -const DELAY_BEFORE_SECOND_PURGE = 20 * 1000 - -// The pipelining only lines up if the second-purge delay is a whole number of -// language slots; otherwise second purges would drift off the cadence. Enforce -// it so a future tweak to either constant can't silently break the schedule. -if (DELAY_BEFORE_SECOND_PURGE % DELAY_BETWEEN_LANGUAGES !== 0) { - throw new Error( - `DELAY_BEFORE_SECOND_PURGE (${DELAY_BEFORE_SECOND_PURGE}ms) must be a multiple of ` + - `DELAY_BETWEEN_LANGUAGES (${DELAY_BETWEEN_LANGUAGES}ms) to keep second purges ` + - `aligned with later first-purge slots`, - ) -} - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -type PurgePhase = 'first' | 'second' -type PurgeOutcome = { key: string; phase: PurgePhase; error?: unknown } - -const languages = process.env.LANGUAGES - ? languagesFromString(process.env.LANGUAGES) - : // Make sure `en` is first because contributors write mostly in English - // and they're most likely wanting to see their landed changes appear - // in production as soon as possible. - languageKeys.sort((a) => (a === 'en' ? -1 : 1)) - -// This covers things like `/api/webhooks` which isn't language specific, hence -// the no-language key (an empty `makeLanguageSurrogateKey()`) leading the list. -const surrogateKeys = [ - makeLanguageSurrogateKey(), - ...languages.map((language) => makeLanguageSurrogateKey(language)), -] - -// Schedule every purge against a single wall-clock start time so the cadence -// doesn't drift with per-purge network latency, and so each second purge aligns -// with a later first purge as described above. -const startTime = Date.now() -const purges: Promise[] = [] - -// Each call returns a promise that resolves (never rejects) to an outcome: the -// internal try/catch means a failed purge can't become an unhandled rejection -// while we wait for the rest of the schedule, which can outlast an early second -// purge. Failures are surfaced after all purges settle, below. -async function runPurge(key: string, phase: PurgePhase, targetTime: number): Promise { - await sleep(Math.max(0, targetTime - Date.now())) - try { - // `purgeEdgeCache` logs its own "Attempting Fastly purge..." line; word this - // as the scheduled phase trigger so the two purges of a key are distinguishable. - console.log(`Triggering ${phase}-phase purge for '${key}'...`) - await purgeEdgeCache(key) - return { key, phase } - } catch (error) { - return { key, phase, error } - } -} - -for (const [index, key] of surrogateKeys.entries()) { - const slotStart = startTime + index * DELAY_BETWEEN_LANGUAGES - purges.push(runPurge(key, 'first', slotStart)) - purges.push(runPurge(key, 'second', slotStart + DELAY_BEFORE_SECOND_PURGE)) -} - -const outcomes = await Promise.all(purges) -const failures = outcomes.filter((outcome) => outcome.error) -if (failures.length) { - for (const failure of failures) { - console.error(`Fastly ${failure.phase} purge failed for '${failure.key}':`, failure.error) - } - throw new Error(`${failures.length} Fastly purge(s) failed`) -} - -function languagesFromString(str: string): string[] { - const parsedLanguages = str - .split(/,/) - .map((x) => x.trim()) - .filter(Boolean) - if (!parsedLanguages.every((lang) => languageKeys.includes(lang))) { - throw new Error( - `Unrecognized language code (${parsedLanguages.find((lang) => !languageKeys.includes(lang))})`, - ) - } - return parsedLanguages -} diff --git a/src/rest/pages/category.tsx b/src/rest/pages/category.tsx index fc8912b5cd1f..652d82c86342 100644 --- a/src/rest/pages/category.tsx +++ b/src/rest/pages/category.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import type { ServerResponse } from 'http' import { Operation } from '@/rest/components/types' import type { ExtendedRequest, AllVersions } from '@/types/types' @@ -207,7 +208,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => // created. tocLandingContext.tocItems = restCategoryTocItems - const mainContext = await getMainContext(req, res) + const mainContext = await getMainContext(req, res as unknown as Response) return { props: { diff --git a/src/rest/pages/subcategory.tsx b/src/rest/pages/subcategory.tsx index b53d1d03f055..773ae3913861 100644 --- a/src/rest/pages/subcategory.tsx +++ b/src/rest/pages/subcategory.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import type { ServerResponse } from 'http' import { Operation } from '@/rest/components/types' import type { ExtendedRequest, AllVersions } from '@/types/types' @@ -87,7 +88,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => } } - const mainContext = await getMainContext(req, res) + const mainContext = await getMainContext(req, res as unknown as Response) addUINamespaces(req, mainContext.data.ui, ['parameter_table', 'rest_reference']) return { diff --git a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx index 152c7bbcd4d5..7307840715cc 100644 --- a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx +++ b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx @@ -1,4 +1,5 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' import { getMainContext, @@ -37,7 +38,7 @@ export default function SupportedSecretScanningPatterns({ export const getServerSideProps: GetServerSideProps = async (context) => { const req = context.req as unknown as ExtendedRequest - const res = context.res as object + const res = context.res as unknown as Response const mainContext = await getMainContext(req, res) addUINamespaces(req, mainContext.data.ui, ['secret_scanning']) const automatedPageContext = getAutomatedPageContextFromRequest(req) diff --git a/src/types/types.ts b/src/types/types.ts index 53a1d508457f..d1472f619b8c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,6 +4,7 @@ import type { Failbot } from '@github/failbot' import type enterpriseServerReleases from '@/versions/lib/enterprise-server-releases.d' import type { ValidOcticon } from '@/landings/types' import type { Language, Languages } from '@/languages/lib/languages-server' +import type { JourneyContext } from '@/journeys/lib/journey-path-resolver' import type { MiniTocItem } from '@/frame/lib/get-mini-toc-items' import type { UIStrings } from '@/frame/components/context/MainContext' @@ -119,6 +120,7 @@ export type Context = { // Allows dynamic properties like features & version shortnames as keys [key: string]: unknown currentCategory?: string + currentJourneyTrack?: JourneyContext | null error?: Error siteTree?: SiteTree pages?: Record @@ -341,6 +343,12 @@ export type Page = { contentType?: string docsTeamMetrics?: string[] children?: string[] + introPlainText?: string + noEarlyAccessBanner?: boolean + communityRedirect?: { + name: string + href: string + } } export type SidebarLink = { diff --git a/src/versions/lib/enterprise-server-releases.d.ts b/src/versions/lib/enterprise-server-releases.d.ts index cab010afad08..e7405ea5b3a8 100644 --- a/src/versions/lib/enterprise-server-releases.d.ts +++ b/src/versions/lib/enterprise-server-releases.d.ts @@ -81,6 +81,7 @@ const allExports = { dates, nextDeprecationDate, isOldestReleaseDeprecated, + releasesWithOldestDeprecationDate, deprecatedOnNewSite, deprecatedReleasesWithLegacyFormat, deprecatedReleasesWithNewFormat, diff --git a/src/webhooks/pages/webhook-events-and-payloads.tsx b/src/webhooks/pages/webhook-events-and-payloads.tsx index 479dee096a8d..6c9cec91bbe9 100644 --- a/src/webhooks/pages/webhook-events-and-payloads.tsx +++ b/src/webhooks/pages/webhook-events-and-payloads.tsx @@ -1,4 +1,6 @@ import { GetServerSideProps } from 'next' +import type { Response } from 'express' +import type { ExtendedRequest } from '@/types' import { useRouter } from 'next/router' import { useEffect } from 'react' @@ -78,8 +80,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => const { getInitialPageWebhooks } = await import('@/webhooks/lib') const { getAutomatedPageMiniTocItems } = await import('@/frame/lib/get-mini-toc-items') - const req = context.req as object - const res = context.res as object + const req = context.req as unknown as ExtendedRequest + const res = context.res as unknown as Response const currentVersion = context.query.versionId as string const mainContext = await getMainContext(req, res) addUINamespaces(req, mainContext.data.ui, ['parameter_table', 'webhooks']) diff --git a/src/workflows/README.md b/src/workflows/README.md index e0c1c0e2d306..dd726a0ff986 100644 --- a/src/workflows/README.md +++ b/src/workflows/README.md @@ -26,7 +26,7 @@ Scripts are registered in `package.json`: | check-content-type | `npm run check-content-type` | Validates content types | | delete-orphan-translation-files | `npm run delete-orphan-translation-files` | Removes orphaned translations | | enable-automerge | `npm run enable-automerge` | Enables PR automerge | -| purge-fastly-edge-cache | `npm run purge-fastly-edge-cache` | Purges Fastly CDN cache | +| purge-fastly | `npm run purge-fastly` | Purges Fastly CDN cache (per-language, single-key, or entire cache) | | prevent-pushes-to-main | (Husky hook) | Prevents pushing to main | ### Running tests diff --git a/src/workflows/purge-edge-cache.ts b/src/workflows/purge-edge-cache.ts deleted file mode 100644 index 4546ceaee4fb..000000000000 --- a/src/workflows/purge-edge-cache.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { fetchWithRetry } from '@/frame/lib/fetch-utils' - -async function purgeFastlyBySurrogateKey({ - apiToken, - serviceId, - surrogateKey, -}: { - apiToken: string - serviceId: string - surrogateKey: string -}) { - const safeServiceId = encodeURIComponent(serviceId) - const safeSurrogateKey = encodeURIComponent(surrogateKey) - - const headers = { - 'fastly-key': apiToken, - accept: 'application/json', - 'fastly-soft-purge': '1', - } - const requestPath = `https://api.fastly.com/service/${safeServiceId}/purge/${safeSurrogateKey}` - const response = await fetchWithRetry( - requestPath, - { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json', - }, - }, - { - retries: 0, - timeout: 30_000, - throwHttpErrors: false, - }, - ) - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - return response -} - -export default async function purgeEdgeCache(surrogateKey: string | undefined) { - if (!surrogateKey) { - throw new Error('No key set and/or no FASTLY_SURROGATE_KEY env var set') - } - console.log(`Fastly purgeEdgeCache initialized for: '${surrogateKey}'`) - - const { FASTLY_TOKEN, FASTLY_SERVICE_ID } = process.env - if (!FASTLY_TOKEN || !FASTLY_SERVICE_ID) { - throw new Error('Fastly env vars not detected; skipping purgeEdgeCache step') - } - - const purgingParams = { - apiToken: FASTLY_TOKEN, - serviceId: FASTLY_SERVICE_ID, - surrogateKey, - } - - console.log('Attempting Fastly purge...') - const result = await purgeFastlyBySurrogateKey(purgingParams) - console.log('Fastly purge result:', result.status) -} diff --git a/src/workflows/purge-fastly-all.ts b/src/workflows/purge-fastly-all.ts deleted file mode 100644 index 2d94ec0b0694..000000000000 --- a/src/workflows/purge-fastly-all.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { fetchWithRetry } from '@/frame/lib/fetch-utils' - -// Purges the ENTIRE Fastly cache for the docs service via Fastly's purge_all -// endpoint. This is the workflow equivalent of the "Purge all" button in the -// Fastly UI. It is intentionally a separate, manually triggered entry point: -// routine post-deploy purging is handled by -// `purge-fastly-edge-cache-per-language.ts`, and a gentler "refresh everything" -// can be done by running the Purge Fastly workflow with an empty languages -// input (a soft surrogate-key purge of every language). -// -// NOTE: Fastly's purge_all is always a HARD purge. The `fastly-soft-purge` -// header has no effect on this endpoint, so every object is evicted immediately -// and origin sees a traffic spike while the cache refills. Only reach for this -// when a targeted purge will not do. -// https://www.fastly.com/documentation/reference/api/purging/ - -async function purgeFastlyAll({ apiToken, serviceId }: { apiToken: string; serviceId: string }) { - const safeServiceId = encodeURIComponent(serviceId) - const requestPath = `https://api.fastly.com/service/${safeServiceId}/purge_all` - const response = await fetchWithRetry( - requestPath, - { - method: 'POST', - headers: { - 'fastly-key': apiToken, - accept: 'application/json', - 'Content-Type': 'application/json', - }, - }, - { - retries: 0, - timeout: 30_000, - throwHttpErrors: false, - }, - ) - if (!response.ok) { - // Fastly puts permission/feature-disabled details in the response body, - // which is often the only actionable signal, so surface it best-effort. - let body = '' - try { - body = await response.text() - } catch { - body = '' - } - throw new Error( - `Fastly purge_all failed: HTTP ${response.status} ${response.statusText}${body ? `: ${body}` : ''}`, - ) - } - return response -} - -const { FASTLY_TOKEN, FASTLY_SERVICE_ID } = process.env -if (!FASTLY_TOKEN || !FASTLY_SERVICE_ID) { - throw new Error('Fastly env vars not detected; refusing to run purge_all') -} - -console.log('Attempting Fastly purge_all (hard purge of the entire cache)...') -const result = await purgeFastlyAll({ apiToken: FASTLY_TOKEN, serviceId: FASTLY_SERVICE_ID }) -console.log('Fastly purge_all result:', result.status) diff --git a/src/workflows/purge-fastly-edge-cache.ts b/src/workflows/purge-fastly-edge-cache.ts deleted file mode 100755 index 54637b0cd163..000000000000 --- a/src/workflows/purge-fastly-edge-cache.ts +++ /dev/null @@ -1,28 +0,0 @@ -import purgeEdgeCache from './purge-edge-cache' - -// This will purge every response that *contains* -// `process.env.FASTLY_SURROGATE_KEY`. -// We send Surrogate-Key values like: -// -// language:en -// language:fr -// language:ja -// or -// no-language -// -// `purgeEdgeCache` throws if no key is set, so a `FASTLY_SURROGATE_KEY` must be -// provided. This is the manual/targeted purge entry point; routine per-deploy -// purging is handled by `purge-fastly-edge-cache-per-language.ts`. -// -// Because we use Fastly shielding, a single surrogate-key purge can lose a race -// with the shield re-fetching stale content, so we purge twice: the first purge -// clears the edge nodes and the second clears the origin shield. Fastly -// recommends ~2s between surrogate-key purges. See -// https://developer.fastly.com/learning/concepts/purging/#shielding -const DELAY_BETWEEN_PURGES = 2 * 1000 -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) - -const surrogateKey = process.env.FASTLY_SURROGATE_KEY -await purgeEdgeCache(surrogateKey) -await sleep(DELAY_BETWEEN_PURGES) -await purgeEdgeCache(surrogateKey) diff --git a/src/workflows/purge-fastly.ts b/src/workflows/purge-fastly.ts new file mode 100644 index 000000000000..09d2c320827e --- /dev/null +++ b/src/workflows/purge-fastly.ts @@ -0,0 +1,297 @@ +import { execFileSync } from 'node:child_process' + +import { program } from 'commander' + +import { fetchWithRetry } from '@/frame/lib/fetch-utils' +import { languageKeys } from '@/languages/lib/languages-server' +import { makeLanguageSurrogateKey } from '@/frame/middleware/set-fastly-surrogate-key' + +// Single entry point for purging Fastly. It runs in one of three modes: +// +// - --everything -> hard purge the ENTIRE cache via `purge_all`. +// - --surrogate-key -> double-purge that one surrogate key. Search uses +// this for `api-search:`. +// - otherwise -> double-purge `no-language` + each `language:` +// key, the routine post-deploy / manual purge. +// +// --wait-for-build polls production until it serves this commit before purging, +// so an automatic post-deploy purge doesn't revalidate against a still-rolling- +// out instance. --hard forces a hard purge; --everything ignores it and is +// always hard. + +const { FASTLY_TOKEN, FASTLY_SERVICE_ID } = process.env + +const DELAY_BETWEEN_KEYS = 10 * 1000 +const DELAY_BEFORE_SECOND_PURGE = 20 * 1000 + +const BUILD_WAIT_REQUIRED_MATCHES = 5 +const BUILD_WAIT_INTERVAL = 15 * 1000 +const BUILD_WAIT_TIMEOUT = 1200 * 1000 + +// The pipelining in purgeKeys only lines up if the second-purge delay is a whole +// number of key slots; otherwise second purges would drift off the cadence. +// Enforce it so a future tweak to either constant can't silently break it. +if (DELAY_BEFORE_SECOND_PURGE % DELAY_BETWEEN_KEYS !== 0) { + throw new Error( + `DELAY_BEFORE_SECOND_PURGE (${DELAY_BEFORE_SECOND_PURGE}ms) must be a multiple of ` + + `DELAY_BETWEEN_KEYS (${DELAY_BETWEEN_KEYS}ms) to keep second purges ` + + `aligned with later first-purge slots`, + ) +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +program + .description( + 'Purges Fastly after a deploy and on demand. Soft purge by default; can hard ' + + 'purge specific languages, or hard purge the entire cache.', + ) + .option('--wait-for-build', 'Wait until production serves the current commit before purging') + .option( + '--languages ', + "Comma separated languages to purge, e.g. 'en,es,ja'. Blank/omitted = all languages.", + ) + .option('--surrogate-key ', 'Purge a single explicit surrogate key. e.g. api-search:en') + .option('--hard', 'Evict immediately instead of the default soft purge') + .option('--everything', 'Hard purge the ENTIRE cache: every key. Ignores --languages/--hard.') + .parse(process.argv) + +type Options = { + waitForBuild?: boolean + languages?: string + surrogateKey?: string + hard?: boolean + everything?: boolean +} + +await main(program.opts()) + +async function main(options: Options) { + if (!FASTLY_TOKEN) { + throw new Error('FASTLY_TOKEN not detected; refusing to purge') + } + if (!FASTLY_SERVICE_ID) { + throw new Error('FASTLY_SERVICE_ID not detected; refusing to purge') + } + + if (options.waitForBuild) { + await waitForBuild() + } + + if (options.everything) { + // NOTE: Fastly's purge_all is always a HARD purge. The `fastly-soft-purge` + // header has no effect here, so every object is evicted immediately and + // origin sees a traffic spike while the cache refills. It clears every + // surrogate key: content, no-language, manual-purge assets, search, and so + // on. A targeted surrogate-key purge cannot reach those, so only reach for + // this when a targeted purge will not do. + // https://www.fastly.com/documentation/reference/api/purging/ + console.log('Attempting hard purge of the entire cache...') + const result = await fastlyPurge('purge_all') + console.log('Fastly purge_all result:', result.status) + return + } + + const soft = !options.hard + const surrogateKeys = options.surrogateKey + ? [options.surrogateKey] + : languageSurrogateKeys(options.languages) + await purgeKeys(surrogateKeys, soft) +} + +// Poll production until it serves the build commit. A single /_build match only +// proves one Moda instance has the new build; others may still be mid-rollout, +// and a soft purge could then revalidate against a lagging instance and re-cache +// old content for a full TTL. So require several consecutive matches first. +async function waitForBuild() { + const needs = execFileSync('git', ['rev-parse', 'HEAD']).toString().trim() + const startTime = Date.now() + let consecutive = 0 + + while (consecutive < BUILD_WAIT_REQUIRED_MATCHES) { + if (Date.now() - startTime > BUILD_WAIT_TIMEOUT) { + throw new Error( + `Production did not reach ${BUILD_WAIT_REQUIRED_MATCHES} consecutive build matches within ${BUILD_WAIT_TIMEOUT / 1000} seconds`, + ) + } + + let current = '' + try { + const response = await fetchWithRetry( + 'https://docs.github.com/_build', + {}, + { retries: 5, timeout: 30_000, throwHttpErrors: false }, + ) + if (response.ok) { + current = (await response.text()).trim() + } + } catch { + // Treat a fetch failure like a non-match: reset and keep polling. + current = '' + } + + if (current && current === needs) { + consecutive += 1 + console.log( + `Production matches the build commit (${consecutive}/${BUILD_WAIT_REQUIRED_MATCHES})`, + ) + } else { + if (consecutive > 0) { + console.log('Production stopped matching the build commit; resetting consecutive count') + } else { + console.log('Production is not up to date with the build commit') + } + consecutive = 0 + } + + if (consecutive < BUILD_WAIT_REQUIRED_MATCHES) { + await sleep(BUILD_WAIT_INTERVAL) + } + } + console.log( + `Production is up to date with the build commit.`, + `${BUILD_WAIT_REQUIRED_MATCHES} consecutive matches.`, + ) +} + +function languageSurrogateKeys(languagesInput?: string): string[] { + // Put `en` first because contributors write mostly in English and are most + // likely waiting to see their landed changes appear in production. Build a + // new array rather than sorting `languageKeys` in place, which is shared + // state. + const trimmed = languagesInput?.trim() + const languages = trimmed + ? languagesFromString(trimmed) + : ['en', ...languageKeys.filter((lang) => lang !== 'en')] + + // The leading no-language key, an empty `makeLanguageSurrogateKey()`, covers + // things like `/api/webhooks` which aren't language specific. + return [ + makeLanguageSurrogateKey(), + ...languages.map((language) => makeLanguageSurrogateKey(language)), + ] +} + +function languagesFromString(str: string): string[] { + const parsedLanguages = str + .split(/,/) + .map((x) => x.trim()) + .filter(Boolean) + if (!parsedLanguages.every((lang) => languageKeys.includes(lang))) { + throw new Error( + `Unrecognized language code (${parsedLanguages.find((lang) => !languageKeys.includes(lang))})`, + ) + } + return parsedLanguages +} + +type PurgePhase = 'first' | 'second' +type PurgeOutcome = { key: string; phase: PurgePhase; error?: unknown } + +/** + * Double-purge a set of surrogate keys. Per-deploy / manual purges pass + * `no-language` plus each `language:` key; the search reindex passes a + * single `api-search:` key. + * + * Each key is purged twice because of Fastly shielding: the first purge clears + * the edge nodes, the second clears the origin shield. Two delays shape the run. + * DELAY_BETWEEN_KEYS spaces out each key's first purge to avoid a stampeding + * herd on the backend. DELAY_BEFORE_SECOND_PURGE waits long enough for the now- + * stale content to be re-fetched and re-shielded before the second purge clears + * it. Fastly suggests ~2s between surrogate-key purges, but that's been too short + * in practice, so we use a larger margin. See the "Race conditions" section of + * https://www.fastly.com/documentation/guides/concepts/cache/purging#race-conditions + * Its 30s figure is for purge-all, which we don't use. + * + * To avoid serializing the run, we schedule every purge against one wall-clock + * timeline up front instead of blocking on each second purge. Because the second- + * purge delay is a multiple of the between-keys delay, a key's second purge + * shares a slot with a later key's first purge: + * + * t=0 no-language (1st) + * t=10 en (1st) + * t=20 es (1st) + no-language (2nd) + * t=30 ja (1st) + en (2nd) + * t=40 pt (1st) + es (2nd) + * ... + * + * A single-key purge is the degenerate case: first at t=0, second at t=20s. + */ +async function purgeKeys(surrogateKeys: string[], soft: boolean) { + // One wall-clock start time so the cadence doesn't drift with per-purge network + // latency and each second purge aligns with a later first purge, as above. + const startTime = Date.now() + const purges: Promise[] = [] + + // Each call resolves to an outcome and never rejects: the try/catch keeps a + // failed purge from becoming an unhandled rejection while later scheduled + // purges are still pending. Failures are surfaced after all purges settle. + async function runPurge( + key: string, + phase: PurgePhase, + targetTime: number, + ): Promise { + await sleep(Math.max(0, targetTime - Date.now())) + try { + console.log(`Triggering ${phase}-phase ${soft ? 'soft' : 'hard'} purge for '${key}'...`) + const result = await fastlyPurge(`purge/${encodeURIComponent(key)}`, { soft }) + console.log(`Fastly purge result for '${key}':`, result.status) + return { key, phase } + } catch (error) { + return { key, phase, error } + } + } + + for (const [index, key] of surrogateKeys.entries()) { + const slotStart = startTime + index * DELAY_BETWEEN_KEYS + purges.push(runPurge(key, 'first', slotStart)) + purges.push(runPurge(key, 'second', slotStart + DELAY_BEFORE_SECOND_PURGE)) + } + + const outcomes = await Promise.all(purges) + const failures = outcomes.filter((outcome) => outcome.error) + if (failures.length) { + for (const failure of failures) { + console.error(`Fastly ${failure.phase} purge failed for '${failure.key}':`, failure.error) + } + throw new Error(`${failures.length} Fastly purge(s) failed`) + } +} + +// Low-level Fastly purge. `endpoint` is appended to the service path, e.g. +// `purge/` or `purge_all`. Returns the response; on a non-2xx throws with +// the body best-effort, since Fastly puts permission/feature details there. +// +// Soft purge marks the object stale and serves stale-while-revalidate; hard +// purge evicts it outright. Soft can fail to clear content whose origin returns +// `304 Not Modified` on revalidation, since a 304 just extends the stale object, +// so use hard then. `purge_all` ignores the soft header and is always hard. +async function fastlyPurge(endpoint: string, { soft = false }: { soft?: boolean } = {}) { + const headers: Record = { + 'fastly-key': FASTLY_TOKEN as string, + accept: 'application/json', + 'Content-Type': 'application/json', + } + if (soft) { + headers['fastly-soft-purge'] = '1' + } + + const url = `https://api.fastly.com/service/${encodeURIComponent(FASTLY_SERVICE_ID as string)}/${endpoint}` + const response = await fetchWithRetry( + url, + { method: 'POST', headers }, + { retries: 0, timeout: 30_000, throwHttpErrors: false }, + ) + if (!response.ok) { + let body = '' + try { + body = await response.text() + } catch { + body = '' + } + throw new Error( + `Fastly purge failed: HTTP ${response.status} ${response.statusText}${body ? `: ${body}` : ''}`, + ) + } + return response +}