From 98da48c96b59d3ba9b9b7442575a7b1dbb49379b Mon Sep 17 00:00:00 2001 From: Tina Heidinger Date: Tue, 14 Oct 2025 20:10:08 +0200 Subject: [PATCH 1/4] Immutable Releases GA docs update (#57912) Co-authored-by: mc <42146119+mchammer01@users.noreply.github.com> --- .../manage-custom-actions.md | 2 +- .../release-and-maintain-actions.md | 2 +- ...nd-tags-to-manage-your-actions-releases.md | 5 ++-- .../securing-builds.md | 2 +- .../immutable-releases.md | 29 +++++++++++++++---- .../preventing-changes-to-your-releases.md | 5 ++-- .../verifying-the-integrity-of-a-release.md | 5 ++-- .../managing-releases-in-a-repository.md | 11 ++++++- data/reusables/releases/finish-release.md | 2 +- .../immutable-releases-preview-note.md | 2 -- 10 files changed, 44 insertions(+), 21 deletions(-) delete mode 100644 data/reusables/releases/immutable-releases-preview-note.md diff --git a/content/actions/how-tos/create-and-publish-actions/manage-custom-actions.md b/content/actions/how-tos/create-and-publish-actions/manage-custom-actions.md index f8ed0adac7f1..416486f811a5 100644 --- a/content/actions/how-tos/create-and-publish-actions/manage-custom-actions.md +++ b/content/actions/how-tos/create-and-publish-actions/manage-custom-actions.md @@ -50,7 +50,7 @@ To use a specific action version, users can configure their {% data variables.pr ### Using tags for release management -{% ifversion immutable-releases-preview %} +{% ifversion fpt or ghec %} > [!NOTE] If you have enabled immutable releases to help prevent supply chain attacks and accidental changes to your releases, instead see [AUTOTITLE](/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases). {% endif %} diff --git a/content/actions/how-tos/create-and-publish-actions/release-and-maintain-actions.md b/content/actions/how-tos/create-and-publish-actions/release-and-maintain-actions.md index 737eb1325606..b81034397a78 100644 --- a/content/actions/how-tos/create-and-publish-actions/release-and-maintain-actions.md +++ b/content/actions/how-tos/create-and-publish-actions/release-and-maintain-actions.md @@ -55,7 +55,7 @@ To support the developer process in the next section, add two {% data variables. 1. Add a workflow that triggers when a commit is pushed to a feature branch or to `main` or when a pull request is created. Configure the workflow to run your unit and integration tests. For an example, see [this workflow](https://github.com/actions/javascript-action/blob/main/.github/workflows/ci.yml). 1. Add a workflow that triggers when a release is published or edited. Configure the workflow to ensure semantic tags are in place. You can use an action like [JasonEtco/build-and-tag-action](https://github.com/JasonEtco/build-and-tag-action) to compile and bundle the JavaScript and metadata file and force push semantic major, minor, and patch tags. For more information about semantic tags, see [About semantic versioning](https://docs.npmjs.com/about-semantic-versioning). - {% ifversion immutable-releases-preview %} + {% ifversion fpt or ghec %} > [!NOTE] > If you enable immutable releases for your repository, you cannot use this action to force push tags tied to releases on {% data variables.product.github %}. To learn how to manage your releases with immutable releases, see [AUTOTITLE](/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases). {% endif %} diff --git a/content/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases.md b/content/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases.md index b613aed2d4ae..855600505a85 100644 --- a/content/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases.md +++ b/content/actions/how-tos/create-and-publish-actions/using-immutable-releases-and-tags-to-manage-your-actions-releases.md @@ -3,7 +3,8 @@ title: Using immutable releases and tags to manage your action's releases shortTitle: Use immutable releases intro: 'Learn how you can use a combination of immutable releases on {% data variables.product.github %} and Git tags to manage your action''s releases.' versions: - feature: immutable-releases-preview + fpt: '*' + ghec: '*' topics: - Actions - Code Security @@ -11,8 +12,6 @@ topics: - Dependencies --- -{% data reusables.releases.immutable-releases-preview-note %} - If you enable immutable releases on your action's repository, you can manage your action's releases as follows: 1. To start the release cycle, develop and validate a potential release for your action on a release branch. diff --git a/content/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds.md b/content/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds.md index 7415cbdd0f66..2c83c9acb40e 100644 --- a/content/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds.md +++ b/content/code-security/supply-chain-security/end-to-end-supply-chain/securing-builds.md @@ -59,7 +59,7 @@ How exactly you sign your build will depend on what sort of code you're writing, For more information, see [AUTOTITLE](/actions/security-guides/encrypted-secrets){% ifversion fpt or ghec %}, [AUTOTITLE](/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect),{% endif %} and [AUTOTITLE](/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners). -{% ifversion immutable-releases-preview %} +{% ifversion fpt or ghec %} ## Use immutable releases diff --git a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases.md b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases.md index e0c5a5c25516..9237a2f5bff0 100644 --- a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases.md +++ b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases.md @@ -2,7 +2,8 @@ title: Immutable releases intro: 'Learn about immutable releases and how they can help you maintain the integrity of your software supply chain.' versions: - feature: immutable-releases-preview + fpt: '*' + ghec: '*' type: overview topics: - Code Security @@ -10,16 +11,34 @@ topics: - Dependencies --- -{% data reusables.releases.immutable-releases-preview-note %} +**Immutable releases** are releases where the assets and associated Git tag cannot be changed after publication. The use of this type of release increases security by blocking supply chain attacks. Attackers cannot: +* Inject vulnerabilities or malware into current project releases. +* Make changes to assets and tags that may break developer workflows. -**Immutable releases** are releases where the assets and associated Git tag cannot be changed after publication. They increase security by blocking: -* Supply chain attacks where attackers inject vulnerabilities or malware into current project releases -* Accidental changes to assets and tags that may break developer workflows +## What immutable releases protect + +When you enable immutable releases, the following protections are enforced: + +* **Git tags cannot be moved or deleted**: Once an immutable release is published, its associated Git tag is locked to a specific commit and cannot be changed or removed. +* **Release assets cannot be modified or deleted**: All files attached to the release (such as binaries and archives) are protected from modification or deletion. Additionally, creating an immutable release automatically generates a **release attestation**, which is a cryptographically verifiable record of a release containing the release tag, commit SHA, and release assets. Consumers can use this attestation to make sure the releases and artifacts they are using exactly match the published {% data variables.product.github %} releases. +> [!NOTE] +> Immutable releases include protection against repository resurrection attacks. Even if you delete a repository and create a new one with the same name, you cannot reuse tags that were associated with immutable releases in the original repository. + If a release is immutable, you will see "{% octicon "lock" aria-hidden="true" %} Immutable" below the title on the release page. +## Best practices for publishing immutable releases + +We recommend you use the following workflow for publishing an immutable release. + +1. Create the release as a draft. +1. Attach all associated assets to the draft release. +1. Publish the draft release. + +This ensures that all assets are in place before the release becomes immutable, preventing the need to work around immutability restrictions. + ## Next steps To learn how to enable immutable releases for your repository or organization, see [AUTOTITLE](/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases). diff --git a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases.md b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases.md index 0e9df8a3a395..8d056b886621 100644 --- a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases.md +++ b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases.md @@ -3,7 +3,8 @@ title: Preventing changes to your releases shortTitle: Prevent release changes intro: 'You can enforce immutable releases for a repository or organization to prevent potential vulnerabilities.' versions: - feature: immutable-releases-preview + fpt: '*' + ghec: '*' type: overview topics: - Code Security @@ -11,8 +12,6 @@ topics: - Dependencies --- -{% data reusables.releases.immutable-releases-preview-note %} - ## Enforcing immutable releases for your repository {% data reusables.repositories.navigate-to-repo %} diff --git a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/verifying-the-integrity-of-a-release.md b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/verifying-the-integrity-of-a-release.md index 2062defa2ca2..5887986a03c8 100644 --- a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/verifying-the-integrity-of-a-release.md +++ b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/verifying-the-integrity-of-a-release.md @@ -3,7 +3,8 @@ title: Verifying the integrity of a release shortTitle: Verify release integrity intro: 'You can avoid tampering and accidental changes by ensuring the releases you use have not been modified after publication.' versions: - feature: immutable-releases-preview + fpt: '*' + ghec: '*' type: overview topics: - Code Security @@ -12,8 +13,6 @@ topics: defaultTool: cli --- -{% data reusables.releases.immutable-releases-preview-note %} - {% cli %} ## Prerequisites diff --git a/content/repositories/releasing-projects-on-github/managing-releases-in-a-repository.md b/content/repositories/releasing-projects-on-github/managing-releases-in-a-repository.md index d623c388f624..0095e3c82bdf 100644 --- a/content/repositories/releasing-projects-on-github/managing-releases-in-a-repository.md +++ b/content/repositories/releasing-projects-on-github/managing-releases-in-a-repository.md @@ -31,6 +31,13 @@ You can choose whether {% data variables.large_files.product_name_long %} ({% da ## Creating a release +{% ifversion fpt or ghec %} + +> [!TIP] +> If you have enabled immutable releases for your repository, it's recommended to create releases as drafts first, attach all assets, and then publish. This ensures all assets are in place before the release becomes immutable. For more information, see [AUTOTITLE](/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases). + +{% endif %} + {% webui %} {% data reusables.repositories.navigate-to-repo %} @@ -67,9 +74,11 @@ If you @mention any {% data variables.product.github %} users in the notes, the ## Editing a release -{% ifversion immutable-releases-preview %} +{% ifversion fpt or ghec %} + > [!NOTE] > If you have enabled immutable releases for your repository, you can only edit the title and release notes after a release is published. See [AUTOTITLE](/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases). + {% endif %} {% webui %} diff --git a/data/reusables/releases/finish-release.md b/data/reusables/releases/finish-release.md index ca997be59d6a..57d3cad5046a 100644 --- a/data/reusables/releases/finish-release.md +++ b/data/reusables/releases/finish-release.md @@ -4,7 +4,7 @@ 1. Optionally, if {% data variables.product.prodname_discussions %} is enabled for the repository, create a discussion for the release. * Select **Create a discussion for this release**. * Select the **Category** dropdown menu, then click a category for the release discussion. -1. If you're ready to publicize your release, click **Publish release**. To work on the release later, click **Save draft**. +1. If you're ready to publicize your release, click **Publish release**. To work on the release later, click **Save draft**.{% ifversion fpt or ghec %} If you have enabled immutable releases for the repository, creating a draft first allows you to attach all assets before the release becomes immutable.{% endif %} {%- ifversion fpt or ghec %} You can then view your published or draft releases in the releases feed for your repository. For more information, see [AUTOTITLE](/repositories/releasing-projects-on-github/viewing-your-repositorys-releases-and-tags). diff --git a/data/reusables/releases/immutable-releases-preview-note.md b/data/reusables/releases/immutable-releases-preview-note.md deleted file mode 100644 index 9b5712d66434..000000000000 --- a/data/reusables/releases/immutable-releases-preview-note.md +++ /dev/null @@ -1,2 +0,0 @@ -> [!NOTE] -> Immutable releases are currently in {% data variables.release-phases.public_preview %} and subject to change. From f109c3c32b4fecbae5021a7362406506383a0f3e Mon Sep 17 00:00:00 2001 From: Nhu Do Date: Tue, 14 Oct 2025 11:54:40 -0700 Subject: [PATCH 2/4] Update Auto model list to include Sonnet 4.5 rather than Sonnet 4 (#57942) --- content/copilot/concepts/auto-model-selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/copilot/concepts/auto-model-selection.md b/content/copilot/concepts/auto-model-selection.md index ff2732a8a25d..21074c60fe12 100644 --- a/content/copilot/concepts/auto-model-selection.md +++ b/content/copilot/concepts/auto-model-selection.md @@ -17,7 +17,7 @@ contentType: concepts Experience less rate limiting and reduce the mental load of choosing a model by letting {% data variables.copilot.copilot_auto_model_selection %} automatically choose the best available model. -In {% data variables.product.prodname_vscode_shortname %}, {% data variables.copilot.copilot_auto_model_selection %} chooses from {% data variables.copilot.copilot_gpt_41 %}, {% data variables.copilot.copilot_gpt_5_mini %}, {% data variables.copilot.copilot_gpt_5 %}, {% data variables.copilot.copilot_claude_sonnet_35 %}, and {% data variables.copilot.copilot_claude_sonnet_40 %}, based on availability and to help reduce rate limiting. Included models may change over time. +In {% data variables.product.prodname_vscode_shortname %}, {% data variables.copilot.copilot_auto_model_selection %} chooses from {% data variables.copilot.copilot_gpt_41 %}, {% data variables.copilot.copilot_gpt_5_mini %}, {% data variables.copilot.copilot_gpt_5 %}, {% data variables.copilot.copilot_claude_sonnet_35 %}, and {% data variables.copilot.copilot_claude_sonnet_45 %}, based on availability and to help reduce rate limiting. Included models may change over time. Automatically selected models **won't** include these models: * Models with premium request multipliers greater than one. See [AUTOTITLE](/copilot/reference/ai-models/supported-models#model-multipliers). From 0cf4aabd126c1b6a7066e3bac7ad38ba02ec0a49 Mon Sep 17 00:00:00 2001 From: Robert Sese <734194+rsese@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:39:35 -0500 Subject: [PATCH 3/4] fix test-changed-content workflow (#57970) --- .github/workflows/test-changed-content.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-changed-content.yml b/.github/workflows/test-changed-content.yml index 2e2548c7b77a..a1f2e4589407 100644 --- a/.github/workflows/test-changed-content.yml +++ b/.github/workflows/test-changed-content.yml @@ -51,4 +51,4 @@ jobs: env: CHANGED_FILES: ${{ steps.changed_files.outputs.filtered_changed_files }} DELETED_FILES: ${{ steps.changed_files.outputs.filtered_deleted_files }} - run: npm test -- src/content-render/tests/render-changed-and-deleted-files.js + run: npm test -- src/content-render/tests/render-changed-and-deleted-files.ts From a79730e26839a676ee40b51c08d216bf82907559 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Tue, 14 Oct 2025 13:50:09 -0700 Subject: [PATCH 4/4] Migrate 10 JavaScript files to TypeScript (#57971) --- ...alidation.js => frontmatter-validation.ts} | 76 ++++++++++++++----- ...int-results.js => pretty-print-results.ts} | 40 +++++++--- .../tests/{annotate.js => annotate.ts} | 6 +- ...haned-features.js => orphaned-features.ts} | 0 .../lib/{create-tree.js => create-tree.ts} | 70 ++++++++++------- src/frame/tests/{pages.js => pages.ts} | 35 +++++---- ...ead-frontmatter.js => read-frontmatter.ts} | 23 +++--- .../utils/{operation.js => operation.ts} | 69 ++++++++++++----- .../{cli-examples.js => cli-examples.ts} | 29 ++++--- src/rest/tests/{rendering.js => rendering.ts} | 2 +- 10 files changed, 238 insertions(+), 112 deletions(-) rename src/content-linter/lib/linting-rules/{frontmatter-validation.js => frontmatter-validation.ts} (67%) rename src/content-linter/scripts/{pretty-print-results.js => pretty-print-results.ts} (81%) rename src/content-render/tests/{annotate.js => annotate.ts} (95%) rename src/data-directory/tests/{orphaned-features.js => orphaned-features.ts} (100%) rename src/frame/lib/{create-tree.js => create-tree.ts} (70%) rename src/frame/tests/{pages.js => pages.ts} (79%) rename src/frame/tests/{read-frontmatter.js => read-frontmatter.ts} (88%) rename src/rest/scripts/utils/{operation.js => operation.ts} (73%) rename src/rest/tests/{cli-examples.js => cli-examples.ts} (77%) rename src/rest/tests/{rendering.js => rendering.ts} (99%) diff --git a/src/content-linter/lib/linting-rules/frontmatter-validation.js b/src/content-linter/lib/linting-rules/frontmatter-validation.ts similarity index 67% rename from src/content-linter/lib/linting-rules/frontmatter-validation.js rename to src/content-linter/lib/linting-rules/frontmatter-validation.ts index 60fa08efb908..ff91c4b24605 100644 --- a/src/content-linter/lib/linting-rules/frontmatter-validation.js +++ b/src/content-linter/lib/linting-rules/frontmatter-validation.ts @@ -1,9 +1,27 @@ +// @ts-ignore - no types available for markdownlint-rule-helpers import { addError } from 'markdownlint-rule-helpers' import { getFrontmatter } from '@/content-linter/lib/helpers/utils' +import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' + +interface PropertyLimits { + max: number + recommended: number + required?: boolean +} + +interface ContentRules { + title: PropertyLimits + shortTitle: PropertyLimits + intro: PropertyLimits + requiredProperties: string[] +} + +type ContentType = 'category' | 'mapTopic' | 'article' | null + // Strip liquid tags from text for character counting purposes -function stripLiquidTags(text) { - if (typeof text !== 'string') return text +function stripLiquidTags(text: unknown): string { + if (typeof text !== 'string') return text as string // Remove both {% %} and {{ }} liquid tags return text.replace(/\{%.*?%\}/g, '').replace(/\{\{.*?\}\}/g, '') } @@ -13,15 +31,15 @@ export const frontmatterValidation = { description: 'Frontmatter properties must meet character limits and required property requirements', tags: ['frontmatter', 'character-limits', 'required-properties'], - function: (params, onError) => { - const fm = getFrontmatter(params.lines) + function: (params: RuleParams, onError: RuleErrorCallback) => { + const fm = getFrontmatter(params.lines as string[]) if (!fm) return // Detect content type based on frontmatter properties and file path const contentType = detectContentType(fm, params.name) // Define character limits and requirements for different content types - const contentRules = { + const contentRules: Record = { category: { title: { max: 70, recommended: 67 }, shortTitle: { max: 30, recommended: 27 }, @@ -42,7 +60,7 @@ export const frontmatterValidation = { }, } - const rules = contentRules[contentType] + const rules = contentType ? contentRules[contentType] : null if (!rules) return // Check required properties @@ -61,14 +79,21 @@ export const frontmatterValidation = { // Check title length if (fm.title) { - validatePropertyLength(onError, params.lines, 'title', fm.title, rules.title, 'Title') + validatePropertyLength( + onError, + params.lines as string[], + 'title', + fm.title, + rules.title, + 'Title', + ) } // Check shortTitle length if (fm.shortTitle) { validatePropertyLength( onError, - params.lines, + params.lines as string[], 'shortTitle', fm.shortTitle, rules.shortTitle, @@ -78,17 +103,24 @@ export const frontmatterValidation = { // Check intro length if it exists if (fm.intro && rules.intro) { - validatePropertyLength(onError, params.lines, 'intro', fm.intro, rules.intro, 'Intro') + validatePropertyLength( + onError, + params.lines as string[], + 'intro', + fm.intro, + rules.intro, + 'Intro', + ) } // Cross-property validation: if title is longer than shortTitle limit, shortTitle must exist const strippedTitle = stripLiquidTags(fm.title) - if (fm.title && strippedTitle.length > rules.shortTitle.max && !fm.shortTitle) { - const titleLine = findPropertyLine(params.lines, 'title') + if (fm.title && (strippedTitle as string).length > rules.shortTitle.max && !fm.shortTitle) { + const titleLine = findPropertyLine(params.lines as string[], 'title') addError( onError, titleLine, - `Title is ${strippedTitle.length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`, + `Title is ${(strippedTitle as string).length} characters, which exceeds the shortTitle limit of ${rules.shortTitle.max} characters. A shortTitle must be provided.`, fm.title, null, null, @@ -98,10 +130,10 @@ export const frontmatterValidation = { // Special validation for articles: should have at least one topic if (contentType === 'article' && fm.topics) { if (!Array.isArray(fm.topics)) { - const topicsLine = findPropertyLine(params.lines, 'topics') + const topicsLine = findPropertyLine(params.lines as string[], 'topics') addError(onError, topicsLine, 'Topics must be an array', String(fm.topics), null, null) } else if (fm.topics.length === 0) { - const topicsLine = findPropertyLine(params.lines, 'topics') + const topicsLine = findPropertyLine(params.lines as string[], 'topics') addError( onError, topicsLine, @@ -115,9 +147,16 @@ export const frontmatterValidation = { }, } -function validatePropertyLength(onError, lines, propertyName, propertyValue, limits, displayName) { +function validatePropertyLength( + onError: RuleErrorCallback, + lines: string[], + propertyName: string, + propertyValue: string, + limits: PropertyLimits, + displayName: string, +): void { const strippedValue = stripLiquidTags(propertyValue) - const propertyLength = strippedValue.length + const propertyLength = (strippedValue as string).length const propertyLine = findPropertyLine(lines, propertyName) // Only report the most severe error - maximum takes precedence over recommended @@ -142,7 +181,8 @@ function validatePropertyLength(onError, lines, propertyName, propertyValue, lim } } -function detectContentType(frontmatter, filePath) { +// frontmatter object structure varies based on YAML content, using any for flexibility +function detectContentType(frontmatter: any, filePath: string): ContentType { // Only apply validation to markdown files if (!filePath || !filePath.endsWith('.md')) { return null @@ -168,7 +208,7 @@ function detectContentType(frontmatter, filePath) { return 'article' } -function findPropertyLine(lines, property) { +function findPropertyLine(lines: string[], property: string): number { const line = lines.find((line) => line.trim().startsWith(`${property}:`)) return line ? lines.indexOf(line) + 1 : 1 } diff --git a/src/content-linter/scripts/pretty-print-results.js b/src/content-linter/scripts/pretty-print-results.ts similarity index 81% rename from src/content-linter/scripts/pretty-print-results.js rename to src/content-linter/scripts/pretty-print-results.ts index fb938cfcea6c..615fb812ef89 100644 --- a/src/content-linter/scripts/pretty-print-results.js +++ b/src/content-linter/scripts/pretty-print-results.ts @@ -1,19 +1,36 @@ import chalk from 'chalk' -function isNumber(value) { +interface LintResult { + ruleDescription: string + ruleNames: string[] + lineNumber: number + columnNumber?: number + severity: string + errorDetail?: string + errorContext?: string + context?: string + fixable?: boolean +} + +type LintResults = Record + +function isNumber(value: unknown): value is number { return typeof value === 'number' && !isNaN(value) } -function shorten(text, length = 70) { +function shorten(text: string, length = 70): string { if (text.length <= length) return text return `${text.slice(0, length - 3)}…` } -export function prettyPrintResults(results, { fixed = false } = {}) { +export function prettyPrintResults( + results: LintResults, + { fixed = false }: { fixed?: boolean } = {}, +): void { const PREFIX_PADDING = ' '.repeat(4) const columnPadding = 'Description'.length // The longest column header word - function label(text, padding = columnPadding) { + function label(text: string, padding = columnPadding): string { if (padding < text.length) throw new Error('Padding must be greater than text length') return `${PREFIX_PADDING}${chalk.dim(text.padEnd(padding))}` } @@ -114,7 +131,8 @@ export function prettyPrintResults(results, { fixed = false } = {}) { } } -function chalkFunColors(text) { +function chalkFunColors(text: string): string { + // Valid chalk color method names for terminal output const colors = [ 'red', 'yellow', @@ -126,19 +144,21 @@ function chalkFunColors(text) { 'greenBright', 'magentaBright', 'cyanBright', - ].sort(() => Math.random() - 0.5) + ] as const + const shuffledColors = [...colors].sort(() => Math.random() - 0.5) let colorIndex = 0 return text .split('') .map((char) => { - const color = colors[colorIndex] - colorIndex = (colorIndex + 1) % colors.length - return chalk[color](char) + const color = shuffledColors[colorIndex] + colorIndex = (colorIndex + 1) % shuffledColors.length + // Chalk's TypeScript types don't support dynamic property access, but these are valid color methods + return (chalk as any)[color](char) }) .join('') } -function indentWrappedString(str, startingIndent) { +function indentWrappedString(str: string, startingIndent: number): string { const NEW_LINE_PADDING = ' '.repeat(16) const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available let indentedString = '' diff --git a/src/content-render/tests/annotate.js b/src/content-render/tests/annotate.ts similarity index 95% rename from src/content-render/tests/annotate.js rename to src/content-render/tests/annotate.ts index b1fd79020e6e..bd1b3be98279 100644 --- a/src/content-render/tests/annotate.js +++ b/src/content-render/tests/annotate.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import cheerio from 'cheerio' import { renderContent } from '@/content-render/index' +import type { Context } from '@/types' const example = ` \`\`\`yaml annotate @@ -131,7 +132,7 @@ on: [push] ` // Create a mock context with pages for AUTOTITLE resolution - const mockPages = { + const mockPages: Record = { '/get-started/start-your-journey/hello-world': { href: '/get-started/start-your-journey/hello-world', rawTitle: 'Hello World', @@ -147,7 +148,8 @@ on: [push] currentVersion: 'free-pro-team@latest', pages: mockPages, redirects: {}, - } + // Mock test object doesn't need all Context properties, using 'as unknown as' to bypass strict type checking + } as unknown as Context const res = await renderContent(example, mockContext) const $ = cheerio.load(res) diff --git a/src/data-directory/tests/orphaned-features.js b/src/data-directory/tests/orphaned-features.ts similarity index 100% rename from src/data-directory/tests/orphaned-features.js rename to src/data-directory/tests/orphaned-features.ts diff --git a/src/frame/lib/create-tree.js b/src/frame/lib/create-tree.ts similarity index 70% rename from src/frame/lib/create-tree.js rename to src/frame/lib/create-tree.ts index f90c778e03c5..8f47312a9d35 100644 --- a/src/frame/lib/create-tree.js +++ b/src/frame/lib/create-tree.ts @@ -1,16 +1,21 @@ import path from 'path' import fs from 'fs/promises' -import Page from './page' +import PageClass from './page' +import type { UnversionedTree, Page } from '@/types' -export default async function createTree(originalPath, rootPath, previousTree) { +export default async function createTree( + originalPath: string, + rootPath?: string, + previousTree?: UnversionedTree, +): Promise { const basePath = rootPath || originalPath // On recursive runs, this is processing page.children items in `/` format. // If the path exists as is, assume this is a directory with a child index.md. // Otherwise, assume it's a child .md file and add `.md` to the path. - let filepath - let mtime + let filepath: string + let mtime: number // This kills two birds with one stone. We (attempt to) read it as a file, // to find out if it's a directory or a file and whence we know that // we also collect it's modification time. @@ -18,7 +23,7 @@ export default async function createTree(originalPath, rootPath, previousTree) { filepath = `${originalPath}.md` mtime = await getMtime(filepath) } catch (error) { - if (error.code !== 'ENOENT') { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error } filepath = `${originalPath}/index.md` @@ -30,7 +35,7 @@ export default async function createTree(originalPath, rootPath, previousTree) { try { mtime = await getMtime(filepath) } catch (error) { - if (error.code !== 'ENOENT') { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error } // Throw an error if we can't find a content file associated with the children: entry. @@ -51,7 +56,7 @@ export default async function createTree(originalPath, rootPath, previousTree) { // Reading in a file from disk is slow and best avoided if we can be // certain it isn't necessary. If the previous tree is known and that // tree's page node's `mtime` hasn't changed, we can use that instead. - let page + let page: Page if (previousTree && previousTree.page.mtime === mtime) { // A save! We can use the same exact Page instance from the previous // tree because the assumption is that since the `.md` file it was @@ -61,20 +66,22 @@ export default async function createTree(originalPath, rootPath, previousTree) { } else { // Either the previous tree doesn't exist yet or the modification time // of the file on disk has changed. - page = await Page.init({ + const newPage = await PageClass.init({ basePath, relativePath, languageCode: 'en', mtime, - }) - } - - if (!page) { - throw Error(`Cannot initialize page for ${filepath}`) + // PageInitOptions doesn't include mtime in its type definition, but PageReadResult uses `& any` + // which allows additional properties to be passed through to the Page constructor + } as any) + if (!newPage) { + throw Error(`Cannot initialize page for ${filepath}`) + } + page = newPage as unknown as Page } // Create the root tree object on the first run, and create children recursively. - const item = { + const item: UnversionedTree = { page, // This is only here for the sake of reloading the tree later which // only happens in development mode. @@ -86,18 +93,23 @@ export default async function createTree(originalPath, rootPath, previousTree) { // this value now will be different from what it was before. // It's not enough to rely on *length* of the array before and after // because the change could have been to remove one and add another. - children: page.children, + // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition + children: (page as any).children || [], + childPages: [], } // Process frontmatter children recursively. - if (item.page.children) { - assertUniqueChildren(item.page) + // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition + if ((page as any).children) { + assertUniqueChildren(page as any) item.childPages = ( await Promise.all( - item.page.children.map(async (child, i) => { - let childPreviousTree + // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition + ((page as any).children as string[]).map(async (child: string, i: number) => { + let childPreviousTree: UnversionedTree | undefined if (previousTree && previousTree.childPages) { - if (equalArray(item.page.children, previousTree.children)) { + // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition + if (equalArray((page as any).children, previousTree.children)) { // We can only safely rely on picking the same "n'th" item // from the array if we're confident the names are the same // as they were before. @@ -119,22 +131,25 @@ export default async function createTree(originalPath, rootPath, previousTree) { // (early exit instead of returning a tree). So let's // mutate the `page.children` so we can benefit from the // ability to reload the site tree on consecutive requests. - item.page.children = item.page.children.filter((c) => c !== child) + // Page class has dynamic frontmatter properties like 'children' that aren't in the type definition + ;(page as any).children = ((page as any).children as string[]).filter( + (c: string) => c !== child, + ) } return subTree }), ) - ).filter(Boolean) + ).filter((tree): tree is UnversionedTree => tree !== undefined) } return item } -function equalArray(arr1, arr2) { +function equalArray(arr1: string[], arr2: string[]): boolean { return arr1.length === arr2.length && arr1.every((value, i) => value === arr2[i]) } -async function getMtime(filePath) { +async function getMtime(filePath: string): Promise { // Use mtimeMs, which is a regular floating point number, instead of the // mtime which is a Date based on that same number. // Otherwise, if we use the Date instances, we have to compare @@ -150,10 +165,11 @@ async function getMtime(filePath) { return Math.round(mtimeMs) } -function assertUniqueChildren(page) { +// Page class has dynamic frontmatter properties that aren't in the type definition +function assertUniqueChildren(page: any): void { if (page.children.length !== new Set(page.children).size) { - const count = {} - page.children.forEach((entry) => (count[entry] = 1 + (count[entry] || 0))) + const count: Record = {} + page.children.forEach((entry: string) => (count[entry] = 1 + (count[entry] || 0))) let msg = `${page.relativePath} has duplicates in the 'children' key.` for (const [entry, times] of Object.entries(count)) { if (times > 1) msg += ` '${entry}' is repeated ${times} times. ` diff --git a/src/frame/tests/pages.js b/src/frame/tests/pages.ts similarity index 79% rename from src/frame/tests/pages.js rename to src/frame/tests/pages.ts index 1f3eee1cf54c..76ed444e4b79 100644 --- a/src/frame/tests/pages.js +++ b/src/frame/tests/pages.ts @@ -10,6 +10,7 @@ import libLanguages from '@/languages/lib/languages' import { liquid } from '@/content-render/index' import patterns from '@/frame/lib/patterns' import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path' +import type { Page } from '@/types' const languageCodes = Object.keys(libLanguages) const slugger = new GithubSlugger() @@ -17,7 +18,7 @@ const slugger = new GithubSlugger() describe('pages module', () => { vi.setConfig({ testTimeout: 60 * 1000 }) - let pages + let pages: Page[] beforeAll(async () => { pages = await loadPages() @@ -50,29 +51,30 @@ describe('pages module', () => { const englishPages = chain(pages) .filter(['languageCode', 'en']) .filter('redirect_from') - .map((pages) => pick(pages, ['redirect_from', 'applicableVersions', 'fullPath'])) + .map((page) => pick(page, ['redirect_from', 'applicableVersions', 'fullPath'])) .value() // Map from redirect path to Set of file paths - const redirectToFiles = new Map() - const versionedRedirects = [] + const redirectToFiles = new Map>() + const versionedRedirects: Array<{ path: string; file: string }> = [] - englishPages.forEach((page) => { - page.redirect_from.forEach((redirect) => { - page.applicableVersions.forEach((version) => { + // Page objects have dynamic properties from chain/lodash that aren't fully typed + englishPages.forEach((page: any) => { + page.redirect_from.forEach((redirect: string) => { + page.applicableVersions.forEach((version: string) => { const versioned = removeFPTFromPath(path.posix.join('/', version, redirect)) versionedRedirects.push({ path: versioned, file: page.fullPath }) if (!redirectToFiles.has(versioned)) { - redirectToFiles.set(versioned, new Set()) + redirectToFiles.set(versioned, new Set()) } - redirectToFiles.get(versioned).add(page.fullPath) + redirectToFiles.get(versioned)!.add(page.fullPath) }) }) }) // Only consider as duplicate if more than one unique file defines the same redirect const duplicates = Array.from(redirectToFiles.entries()) - .filter(([_, files]) => files.size > 1) + .filter(([, files]) => files.size > 1) .map(([path]) => path) // Build a detailed message with sources for each duplicate @@ -96,7 +98,8 @@ describe('pages module', () => { return ( page.languageCode === 'en' && // only check English !page.relativePath.includes('index.md') && // ignore TOCs - !page.allowTitleToDifferFromFilename && // ignore docs with override + // Page class has dynamic frontmatter properties like 'allowTitleToDifferFromFilename' not in type definition + !(page as any).allowTitleToDifferFromFilename && // ignore docs with override slugger.slug(decode(page.title)) !== path.basename(page.relativePath, '.md') && slugger.slug(decode(page.shortTitle || '')) !== path.basename(page.relativePath, '.md') ) @@ -127,7 +130,8 @@ describe('pages module', () => { test('every page has valid frontmatter', async () => { const frontmatterErrors = chain(pages) // .filter(page => page.languageCode === 'en') - .map((page) => page.frontmatterErrors) + // Page class has dynamic error properties like 'frontmatterErrors' not in type definition + .map((page) => (page as any).frontmatterErrors) .filter(Boolean) .flatten() .value() @@ -141,17 +145,18 @@ describe('pages module', () => { }) test('every page has valid Liquid templating', async () => { - const liquidErrors = [] + const liquidErrors: Array<{ filename: string; error: string }> = [] for (const page of pages) { - const markdown = page.raw + // Page class has dynamic properties like 'raw' markdown not in type definition + const markdown = (page as any).raw if (!patterns.hasLiquid.test(markdown)) continue try { await liquid.parse(markdown) } catch (error) { liquidErrors.push({ filename: page.fullPath, - error: error.message, + error: (error as Error).message, }) } } diff --git a/src/frame/tests/read-frontmatter.js b/src/frame/tests/read-frontmatter.ts similarity index 88% rename from src/frame/tests/read-frontmatter.js rename to src/frame/tests/read-frontmatter.ts index da0d0adb9596..feda8abe9639 100644 --- a/src/frame/tests/read-frontmatter.js +++ b/src/frame/tests/read-frontmatter.ts @@ -22,9 +22,9 @@ versions: describe('frontmatter', () => { test('parses frontmatter and content in a given string (no options required)', () => { const { data, content, errors } = parse(fixture1) - expect(data.title).toBe('Hello, World') - expect(data.meaning_of_life).toBe(42) - expect(content.trim()).toBe('I am content.') + expect(data!.title).toBe('Hello, World') + expect(data!.meaning_of_life).toBe(42) + expect(content!.trim()).toBe('I am content.') expect(errors.length).toBe(0) }) @@ -85,9 +85,9 @@ I am content. } const { data, content, errors } = parse(fixture1, { schema }) - expect(data.title).toBe('Hello, World') - expect(data.meaning_of_life).toBe(42) - expect(content.trim()).toBe('I am content.') + expect(data!.title).toBe('Hello, World') + expect(data!.meaning_of_life).toBe(42) + expect(content!.trim()).toBe('I am content.') expect(errors.length).toBe(0) }) @@ -102,9 +102,9 @@ I am content. } const { data, content, errors } = parse(fixture1, { schema }) - expect(data.title).toBe('Hello, World') - expect(data.meaning_of_life).toBe(42) - expect(content.trim()).toBe('I am content.') + expect(data!.title).toBe('Hello, World') + expect(data!.meaning_of_life).toBe(42) + expect(content!.trim()).toBe('I am content.') expect(errors.length).toBe(1) const expectedError = { instancePath: '/meaning_of_life', @@ -121,7 +121,10 @@ I am content. test('creates errors if versions frontmatter does not match semver format', () => { const schema = { type: 'object', required: ['versions'], properties: {} } - schema.properties.versions = Object.assign({}, frontmatterSchema.properties.versions) + ;(schema.properties as any).versions = Object.assign( + {}, + (frontmatterSchema.properties as any).versions, + ) const { errors } = parse(fixture2, { schema }) const expectedError = { diff --git a/src/rest/scripts/utils/operation.js b/src/rest/scripts/utils/operation.ts similarity index 73% rename from src/rest/scripts/utils/operation.js rename to src/rest/scripts/utils/operation.ts index 1f493b5001ee..2dcc78b1aadd 100644 --- a/src/rest/scripts/utils/operation.js +++ b/src/rest/scripts/utils/operation.ts @@ -1,6 +1,8 @@ +// @ts-ignore - no types available import httpStatusCodes from 'http-status-code' import { get, isPlainObject } from 'lodash-es' import { parseTemplate } from 'url-template' +// @ts-ignore - no types available import mergeAllOf from 'json-schema-merge-allof' import { renderContent } from './render-content' @@ -10,19 +12,41 @@ import { validateJson } from '@/tests/lib/validate-json-schema' import { getBodyParams } from './get-body-params' export default class Operation { - #operation - constructor(verb, requestPath, operation, globalServers) { + // OpenAPI operation object - schema is dynamic and varies by endpoint + #operation: any + serverUrl: string + verb: string + requestPath: string + title: string + category: string + subcategory: string + // OpenAPI parameters vary by endpoint, no fixed schema available + parameters: any[] + // Body parameters are dynamically generated from OpenAPI schema + bodyParameters: any[] + descriptionHTML?: string + // Code examples structure varies by language and endpoint + codeExamples?: any[] + // Status codes are dynamically generated from OpenAPI responses + statusCodes?: any[] + previews?: any[] + // Programmatic access data structure varies by operation + progAccess?: any + + // OpenAPI operation and globalServers objects have dynamic schema + constructor(verb: string, requestPath: string, operation: any, globalServers?: any[]) { this.#operation = operation // The global server object sets metadata including the base url for // all operations in a version. Individual operations can override // the global server url at the operation level. - this.serverUrl = operation.servers ? operation.servers[0].url : globalServers[0].url + this.serverUrl = operation.servers ? operation.servers[0].url : globalServers?.[0]?.url const serverVariables = operation.servers ? operation.servers[0].variables - : globalServers[0].variables + : globalServers?.[0]?.variables if (serverVariables) { - const templateVariables = {} + // Template variables structure comes from OpenAPI server variables + const templateVariables: Record = {} Object.keys(serverVariables).forEach( (key) => (templateVariables[key] = serverVariables[key].default), ) @@ -47,9 +71,10 @@ export default class Operation { return this } - async process(progAccessData) { + // Programmatic access data structure varies by operation and is not strongly typed + async process(progAccessData: any): Promise { await Promise.all([ - this.codeExamples(), + this.renderCodeExamples(), this.renderDescription(), this.renderStatusCodes(), this.renderParameterDescriptions(), @@ -65,7 +90,7 @@ export default class Operation { } } - async renderDescription() { + async renderDescription(): Promise { try { this.descriptionHTML = await renderContent(this.#operation.description) return this @@ -75,25 +100,27 @@ export default class Operation { } } - async codeExamples() { - this.codeExamples = await getCodeSamples(this.#operation) + async renderCodeExamples(): Promise { + const codeExamples = await getCodeSamples(this.#operation) try { - return await Promise.all( - this.codeExamples.map(async (codeExample) => { + this.codeExamples = await Promise.all( + // Code example structure varies by endpoint and language + codeExamples.map(async (codeExample: any) => { codeExample.response.description = await renderContent(codeExample.response.description) return codeExample }), ) + return this.codeExamples } catch (error) { console.error(error) throw new Error(`Error generating code examples for ${this.verb} ${this.requestPath}`) } } - async renderStatusCodes() { + async renderStatusCodes(): Promise { const responses = this.#operation.responses const responseKeys = Object.keys(responses) - if (responseKeys.length === 0) return [] + if (responseKeys.length === 0) return try { this.statusCodes = await Promise.all( @@ -121,7 +148,7 @@ export default class Operation { } } - async renderParameterDescriptions() { + async renderParameterDescriptions(): Promise { try { return Promise.all( this.parameters.map(async (param) => { @@ -135,8 +162,8 @@ export default class Operation { } } - async renderBodyParameterDescriptions() { - if (!this.#operation.requestBody) return [] + async renderBodyParameterDescriptions(): Promise { + if (!this.#operation.requestBody) return // There is currently only one operation with more than one content type // and the request body parameter types are the same for both. @@ -161,11 +188,12 @@ export default class Operation { } } - async renderPreviewNotes() { + async renderPreviewNotes(): Promise { const previews = get(this.#operation, 'x-github.previews', []) try { this.previews = await Promise.all( - previews.map(async (preview) => { + // Preview note structure from OpenAPI x-github extension is dynamic + previews.map(async (preview: any) => { const note = preview.note // remove extra leading and trailing newlines .replace(/```\n\n\n/gm, '```\n') @@ -186,7 +214,8 @@ export default class Operation { } } - programmaticAccess(progAccessData) { + // Programmatic access data structure varies by operation and is not strongly typed + programmaticAccess(progAccessData: any): void { this.progAccess = progAccessData[this.#operation.operationId] } } diff --git a/src/rest/tests/cli-examples.js b/src/rest/tests/cli-examples.ts similarity index 77% rename from src/rest/tests/cli-examples.js rename to src/rest/tests/cli-examples.ts index 94336f564af9..2f3b99f844a7 100644 --- a/src/rest/tests/cli-examples.js +++ b/src/rest/tests/cli-examples.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'vitest' import { getGHExample, getShellExample } from '../components/get-rest-code-samples' +import type { CodeSample, Operation } from '@/rest/components/types' +import type { VersionItem } from '@/frame/components/context/MainContext' describe('CLI examples generation', () => { const mockOperation = { @@ -9,14 +11,16 @@ describe('CLI examples generation', () => { serverUrl: 'https://api.github.com', subcategory: 'code-scanning', parameters: [], - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as Operation const mockVersions = { 'free-pro-team@latest': { apiVersions: ['2022-11-28'], latestApiVersion: '2022-11-28', }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as Record test('GitHub CLI example properly escapes contractions in string values', () => { const codeSample = { @@ -33,7 +37,8 @@ describe('CLI examples generation', () => { "This alert is not actually correct, because there's a sanitizer included in the library.", }, }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as CodeSample const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) @@ -61,7 +66,8 @@ describe('CLI examples generation', () => { }, contentType: 'application/json', }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as CodeSample const result = getShellExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) @@ -83,7 +89,8 @@ describe('CLI examples generation', () => { body: "It's not working because there's an issue and we can't fix it", }, }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as CodeSample const mockSimpleOperation = { verb: 'post', @@ -91,7 +98,8 @@ describe('CLI examples generation', () => { serverUrl: 'https://api.github.com', subcategory: 'issues', parameters: [], - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as Operation const result = getGHExample( mockSimpleOperation, @@ -121,7 +129,8 @@ describe('CLI examples generation', () => { 'This alert is not actually correct because there is a sanitizer included in the library.', }, }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as CodeSample const result = getGHExample(mockOperation, codeSample, 'free-pro-team@latest', mockVersions) @@ -143,7 +152,8 @@ describe('CLI examples generation', () => { }, contentType: 'application/x-www-form-urlencoded', }, - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as CodeSample const mockSimpleOperation = { verb: 'post', @@ -151,7 +161,8 @@ describe('CLI examples generation', () => { serverUrl: 'https://api.github.com', subcategory: 'pulls', parameters: [], - } + // Partial mock object for testing - 'as unknown as' bypasses strict type checking for missing properties + } as unknown as Operation const result = getShellExample( mockSimpleOperation, diff --git a/src/rest/tests/rendering.js b/src/rest/tests/rendering.ts similarity index 99% rename from src/rest/tests/rendering.js rename to src/rest/tests/rendering.ts index 6c078d6d608f..56c9e85006ef 100644 --- a/src/rest/tests/rendering.js +++ b/src/rest/tests/rendering.ts @@ -145,7 +145,7 @@ describe('REST references docs', () => { }) }) -function formatErrors(differences) { +function formatErrors(differences: Record): string { let errorMessage = 'There are differences in Categories/Subcategories in:\n' for (const schema in differences) { errorMessage += 'Version: ' + schema + '\n'