diff --git a/.github/actions/infrastructure/markdownlinks/Parse-MarkdownLink.ps1 b/.github/actions/infrastructure/markdownlinks/Parse-MarkdownLink.ps1
new file mode 100644
index 00000000000..a56d696eb6e
--- /dev/null
+++ b/.github/actions/infrastructure/markdownlinks/Parse-MarkdownLink.ps1
@@ -0,0 +1,182 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+#requires -version 7
+# Markdig is always available in PowerShell 7
+<#
+.SYNOPSIS
+ Parse CHANGELOG files using Markdig to extract links.
+
+.DESCRIPTION
+ This script uses Markdig.Markdown.Parse to parse all markdown files in the CHANGELOG directory
+ and extract different types of links (inline links, reference links, etc.).
+
+.PARAMETER ChangelogPath
+ Path to the CHANGELOG directory. Defaults to ./CHANGELOG
+
+.PARAMETER LinkType
+ Filter by link type: All, Inline, Reference, AutoLink. Defaults to All.
+
+.EXAMPLE
+ .\Parse-MarkdownLink.ps1
+
+.EXAMPLE
+ .\Parse-MarkdownLink.ps1 -LinkType Reference
+#>
+
+param(
+ [string]$ChangelogPath = "./CHANGELOG",
+ [ValidateSet("All", "Inline", "Reference", "AutoLink")]
+ [string]$LinkType = "All"
+)
+
+Write-Verbose "Using built-in Markdig functionality to parse markdown files"
+
+function Get-LinksFromMarkdownAst {
+ param(
+ [Parameter(Mandatory)]
+ [object]$Node,
+ [Parameter(Mandatory)]
+ [string]$FileName,
+ [System.Collections.ArrayList]$Links
+ )
+
+ if ($null -eq $Links) {
+ return
+ }
+
+ # Check if current node is a link
+ if ($Node -is [Markdig.Syntax.Inlines.LinkInline]) {
+ $linkInfo = [PSCustomObject]@{
+ Path = $FileName
+ Line = $Node.Line + 1 # Convert to 1-based line numbering
+ Column = $Node.Column + 1 # Convert to 1-based column numbering
+ Url = $Node.Url ?? ""
+ Text = $Node.FirstChild?.ToString() ?? ""
+ Type = "Inline"
+ IsImage = $Node.IsImage
+ }
+ [void]$Links.Add($linkInfo)
+ }
+ elseif ($Node -is [Markdig.Syntax.Inlines.AutolinkInline]) {
+ $linkInfo = [PSCustomObject]@{
+ Path = $FileName
+ Line = $Node.Line + 1
+ Column = $Node.Column + 1
+ Url = $Node.Url ?? ""
+ Text = $Node.Url ?? ""
+ Type = "AutoLink"
+ IsImage = $false
+ }
+ [void]$Links.Add($linkInfo)
+ }
+ elseif ($Node -is [Markdig.Syntax.LinkReferenceDefinitionGroup]) {
+ foreach ($refDef in $Node) {
+ $linkInfo = [PSCustomObject]@{
+ Path = $FileName
+ Line = $refDef.Line + 1
+ Column = $refDef.Column + 1
+ Url = $refDef.Url ?? ""
+ Text = $refDef.Label ?? ""
+ Type = "Reference"
+ IsImage = $false
+ }
+ [void]$Links.Add($linkInfo)
+ }
+ }
+ elseif ($Node -is [Markdig.Syntax.LinkReferenceDefinition]) {
+ $linkInfo = [PSCustomObject]@{
+ Path = $FileName
+ Line = $Node.Line + 1
+ Column = $Node.Column + 1
+ Url = $Node.Url ?? ""
+ Text = $Node.Label ?? ""
+ Type = "Reference"
+ IsImage = $false
+ }
+ [void]$Links.Add($linkInfo)
+ }
+
+ # For MarkdownDocument (root), iterate through all blocks
+ if ($Node -is [Markdig.Syntax.MarkdownDocument]) {
+ foreach ($block in $Node) {
+ Get-LinksFromMarkdownAst -Node $block -FileName $FileName -Links $Links
+ }
+ }
+ # For block containers, iterate through children
+ elseif ($Node -is [Markdig.Syntax.ContainerBlock]) {
+ foreach ($child in $Node) {
+ Get-LinksFromMarkdownAst -Node $child -FileName $FileName -Links $Links
+ }
+ }
+ # For leaf blocks with inlines, process the inline content
+ elseif ($Node -is [Markdig.Syntax.LeafBlock] -and $Node.Inline) {
+ Get-LinksFromMarkdownAst -Node $Node.Inline -FileName $FileName -Links $Links
+ }
+ # For inline containers, process all child inlines
+ elseif ($Node -is [Markdig.Syntax.Inlines.ContainerInline]) {
+ $child = $Node.FirstChild
+ while ($child) {
+ Get-LinksFromMarkdownAst -Node $child -FileName $FileName -Links $Links
+ $child = $child.NextSibling
+ }
+ }
+ # For other inline elements that might have children
+ elseif ($Node.PSObject.Properties.Name -contains "FirstChild" -and $Node.FirstChild) {
+ $child = $Node.FirstChild
+ while ($child) {
+ Get-LinksFromMarkdownAst -Node $child -FileName $FileName -Links $Links
+ $child = $child.NextSibling
+ }
+ }
+}
+
+function Parse-ChangelogFiles {
+ param(
+ [string]$Path
+ )
+
+ if (-not (Test-Path $Path)) {
+ Write-Error "CHANGELOG directory not found: $Path"
+ return
+ }
+
+ $markdownFiles = Get-ChildItem -Path $Path -Filter "*.md" -File
+
+ if ($markdownFiles.Count -eq 0) {
+ Write-Warning "No markdown files found in $Path"
+ return
+ }
+
+ $allLinks = [System.Collections.ArrayList]::new()
+
+ foreach ($file in $markdownFiles) {
+ Write-Verbose "Processing file: $($file.Name)"
+
+ try {
+ $content = Get-Content -Path $file.FullName -Raw -Encoding UTF8
+
+ # Parse the markdown content using Markdig
+ $document = [Markdig.Markdown]::Parse($content, [Markdig.MarkdownPipelineBuilder]::new())
+
+ # Extract links from the AST
+ Get-LinksFromMarkdownAst -Node $document -FileName $file.FullName -Links $allLinks
+
+ } catch {
+ Write-Warning "Error processing file $($file.Name): $($_.Exception.Message)"
+ }
+ }
+
+ # Filter by link type if specified
+ if ($LinkType -ne "All") {
+ $allLinks = $allLinks | Where-Object { $_.Type -eq $LinkType }
+ }
+
+ return $allLinks
+}
+
+# Main execution
+$links = Parse-ChangelogFiles -Path $ChangelogPath
+
+# Output PowerShell objects
+$links
diff --git a/.github/actions/infrastructure/markdownlinks/README.md b/.github/actions/infrastructure/markdownlinks/README.md
new file mode 100644
index 00000000000..e566ec2bcc3
--- /dev/null
+++ b/.github/actions/infrastructure/markdownlinks/README.md
@@ -0,0 +1,177 @@
+# Verify Markdown Links Action
+
+A GitHub composite action that verifies all links in markdown files using PowerShell and Markdig.
+
+## Features
+
+- ✅ Parses markdown files using Markdig (built into PowerShell 7)
+- ✅ Extracts all link types: inline links, reference links, and autolinks
+- ✅ Verifies HTTP/HTTPS links with configurable timeouts and retries
+- ✅ Validates local file references
+- ✅ Supports excluding specific URL patterns
+- ✅ Provides detailed error reporting with file locations
+- ✅ Outputs metrics for CI/CD integration
+
+## Usage
+
+### Basic Usage
+
+```yaml
+- name: Verify Markdown Links
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ path: './CHANGELOG'
+```
+
+### Advanced Usage
+
+```yaml
+- name: Verify Markdown Links
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ path: './docs'
+ fail-on-error: 'true'
+ timeout: 30
+ max-retries: 2
+ exclude-patterns: '*.example.com/*,*://localhost/*'
+```
+
+### With Outputs
+
+```yaml
+- name: Verify Markdown Links
+ id: verify-links
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ path: './CHANGELOG'
+ fail-on-error: 'false'
+
+- name: Display Results
+ run: |
+ echo "Total links: ${{ steps.verify-links.outputs.total-links }}"
+ echo "Passed: ${{ steps.verify-links.outputs.passed-links }}"
+ echo "Failed: ${{ steps.verify-links.outputs.failed-links }}"
+ echo "Skipped: ${{ steps.verify-links.outputs.skipped-links }}"
+```
+
+## Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `path` | Path to the directory containing markdown files to verify | No | `./CHANGELOG` |
+| `exclude-patterns` | Comma-separated list of URL patterns to exclude from verification | No | `''` |
+| `fail-on-error` | Whether to fail the action if any links are broken | No | `true` |
+| `timeout` | Timeout in seconds for HTTP requests | No | `30` |
+| `max-retries` | Maximum number of retries for failed requests | No | `2` |
+
+## Outputs
+
+| Output | Description |
+|--------|-------------|
+| `total-links` | Total number of unique links checked |
+| `passed-links` | Number of links that passed verification |
+| `failed-links` | Number of links that failed verification |
+| `skipped-links` | Number of links that were skipped |
+
+## Excluded Link Types
+
+The action automatically skips the following link types:
+
+- **Anchor links** (`#section-name`) - Would require full markdown parsing
+- **Email links** (`mailto:user@example.com`) - Cannot be verified without sending email
+
+## GitHub Workflow Test
+
+This section provides a workflow example and instructions for testing the link verification action.
+
+### Testing the Workflow
+
+To test that the workflow properly detects broken links:
+
+1. Make change to this file (e.g., this README.md file already contains one in the [Broken Link Test](#broken-link-test) section)
+1. The workflow will run and should fail, reporting the broken link(s)
+1. Revert your change to this file
+1. Push again to verify the workflow passes
+
+### Example Workflow Configuration
+
+```yaml
+name: Verify Links
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - '**/*.md'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - '**/*.md'
+ schedule:
+ # Run weekly to catch external link rot
+ - cron: '0 0 * * 0'
+
+jobs:
+ verify-links:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Verify CHANGELOG Links
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ path: './CHANGELOG'
+ fail-on-error: 'true'
+
+ - name: Verify Documentation Links
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ path: './docs'
+ fail-on-error: 'false'
+ exclude-patterns: '*.internal.example.com/*'
+```
+
+## How It Works
+
+1. **Parse Markdown**: Uses `Parse-MarkdownLink.ps1` to extract all links from markdown files using Markdig
+2. **Deduplicate**: Groups links by URL to avoid checking the same link multiple times
+3. **Verify Links**:
+ - HTTP/HTTPS links: Makes HEAD/GET requests with configurable timeout and retries
+ - Local file references: Checks if the file exists relative to the markdown file
+ - Excluded patterns: Skips links matching the exclude patterns
+4. **Report Results**: Displays detailed results with file locations for failed links
+5. **Set Outputs**: Provides metrics for downstream steps
+
+## Error Output Example
+
+```
+✗ FAILED: https://example.com/broken-link - HTTP 404
+ Found in: /path/to/file.md:42:15
+ Found in: /path/to/other.md:100:20
+
+Link Verification Summary
+============================================================
+Total URLs checked: 150
+Passed: 145
+Failed: 2
+Skipped: 3
+
+Failed Links:
+ • https://example.com/broken-link
+ Error: HTTP 404
+ Occurrences: 2
+```
+
+## Requirements
+
+- PowerShell 7+ (includes Markdig)
+- Runs on: `ubuntu-latest`, `windows-latest`, `macos-latest`
+
+## Broken Link Test
+
+- [Broken Link](https://github.com/PowerShell/PowerShell/wiki/NonExistentPage404)
+
+## License
+
+Same as the PowerShell repository.
diff --git a/.github/actions/infrastructure/markdownlinks/Verify-MarkdownLinks.ps1 b/.github/actions/infrastructure/markdownlinks/Verify-MarkdownLinks.ps1
new file mode 100644
index 00000000000..f50ab1590b9
--- /dev/null
+++ b/.github/actions/infrastructure/markdownlinks/Verify-MarkdownLinks.ps1
@@ -0,0 +1,317 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+#Requires -Version 7.0
+
+<#
+.SYNOPSIS
+ Verify all links in markdown files.
+
+.DESCRIPTION
+ This script parses markdown files to extract links and verifies their accessibility.
+ It supports HTTP/HTTPS links and local file references.
+
+.PARAMETER Path
+ Path to the directory containing markdown files. Defaults to current directory.
+
+.PARAMETER File
+ Array of specific markdown files to verify. If provided, Path parameter is ignored.
+
+.PARAMETER TimeoutSec
+ Timeout in seconds for HTTP requests. Defaults to 30.
+
+.PARAMETER MaximumRetryCount
+ Maximum number of retries for failed requests. Defaults to 2.
+
+.PARAMETER RetryIntervalSec
+ Interval in seconds between retry attempts. Defaults to 2.
+
+.EXAMPLE
+ .\Verify-MarkdownLinks.ps1 -Path ./CHANGELOG
+
+.EXAMPLE
+ .\Verify-MarkdownLinks.ps1 -Path ./docs -FailOnError
+
+.EXAMPLE
+ .\Verify-MarkdownLinks.ps1 -File @('CHANGELOG/7.5.md', 'README.md')
+#>
+
+param(
+ [Parameter(ParameterSetName = 'ByPath', Mandatory)]
+ [string]$Path = "Q:\src\git\powershell\docs\git",
+ [Parameter(ParameterSetName = 'ByFile', Mandatory)]
+ [string[]]$File = @(),
+ [int]$TimeoutSec = 30,
+ [int]$MaximumRetryCount = 2,
+ [int]$RetryIntervalSec = 2
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Get the script directory
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+# Determine what to process: specific files or directory
+if ($File.Count -gt 0) {
+ Write-Host "Extracting links from $($File.Count) specified markdown file(s)" -ForegroundColor Cyan
+
+ # Process each file individually
+ $allLinks = @()
+ $parseScriptPath = Join-Path $scriptDir "Parse-MarkdownLink.ps1"
+
+ foreach ($filePath in $File) {
+ if (Test-Path $filePath) {
+ Write-Verbose "Processing: $filePath"
+ $fileLinks = & $parseScriptPath -ChangelogPath $filePath
+ $allLinks += $fileLinks
+ }
+ else {
+ Write-Warning "File not found: $filePath"
+ }
+ }
+}
+else {
+ Write-Host "Extracting links from markdown files in: $Path" -ForegroundColor Cyan
+
+ # Get all links from markdown files using the Parse-ChangelogLinks script
+ $parseScriptPath = Join-Path $scriptDir "Parse-MarkdownLink.ps1"
+ $allLinks = & $parseScriptPath -ChangelogPath $Path
+}
+
+if ($allLinks.Count -eq 0) {
+ Write-Host "No links found in markdown files." -ForegroundColor Yellow
+ exit 0
+}
+
+Write-Host "Found $($allLinks.Count) links to verify" -ForegroundColor Green
+
+# Group links by URL to avoid duplicate checks
+$uniqueLinks = $allLinks | Group-Object -Property Url
+
+Write-Host "Unique URLs to verify: $($uniqueLinks.Count)" -ForegroundColor Cyan
+
+$results = @{
+ Total = $uniqueLinks.Count
+ Passed = 0
+ Failed = 0
+ Skipped = 0
+ Errors = [System.Collections.ArrayList]::new()
+}
+
+function Test-HttpLink {
+ param(
+ [string]$Url
+ )
+
+ try {
+ # Try HEAD request first (faster, doesn't download content)
+ $response = Invoke-WebRequest -Uri $Url `
+ -Method Head `
+ -TimeoutSec $TimeoutSec `
+ -MaximumRetryCount $MaximumRetryCount `
+ -RetryIntervalSec $RetryIntervalSec `
+ -UserAgent "Mozilla/5.0 (compatible; GitHubActions/1.0; +https://github.com/PowerShell/PowerShell)" `
+ -SkipHttpErrorCheck
+
+ # If HEAD fails with 404 or 405, retry with GET (some servers don't support HEAD)
+ if ($response.StatusCode -eq 404 -or $response.StatusCode -eq 405) {
+ Write-Verbose "HEAD request failed with $($response.StatusCode), retrying with GET for: $Url"
+ $response = Invoke-WebRequest -Uri $Url `
+ -Method Get `
+ -TimeoutSec $TimeoutSec `
+ -MaximumRetryCount $MaximumRetryCount `
+ -RetryIntervalSec $RetryIntervalSec `
+ -UserAgent "Mozilla/5.0 (compatible; GitHubActions/1.0; +https://github.com)" `
+ -SkipHttpErrorCheck
+ }
+
+ if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) {
+ return @{ Success = $true; StatusCode = $response.StatusCode }
+ }
+ else {
+ return @{ Success = $false; StatusCode = $response.StatusCode; Error = "HTTP $($response.StatusCode)" }
+ }
+ }
+ catch {
+ return @{ Success = $false; StatusCode = 0; Error = $_.Exception.Message }
+ }
+}
+
+function Test-LocalLink {
+ param(
+ [string]$Url,
+ [string]$BasePath
+ )
+
+ # Strip query parameters (e.g., ?sanitize=true) and anchors (e.g., #section)
+ $cleanUrl = $Url -replace '\?.*$', '' -replace '#.*$', ''
+
+ # Handle relative paths
+ $targetPath = Join-Path $BasePath $cleanUrl
+
+ if (Test-Path $targetPath) {
+ return @{ Success = $true }
+ }
+ else {
+ return @{ Success = $false; Error = "File not found: $targetPath" }
+ }
+}
+
+# Verify each unique link
+$progressCount = 0
+foreach ($linkGroup in $uniqueLinks) {
+ $progressCount++
+ $url = $linkGroup.Name
+ $occurrences = $linkGroup.Group
+ Write-Verbose -Verbose "[$progressCount/$($uniqueLinks.Count)] Checking: $url"
+
+ # Determine link type and verify
+ $verifyResult = $null
+ if ($url -match '^https?://') {
+ $verifyResult = Test-HttpLink -Url $url
+ }
+ elseif ($url -match '^#') {
+ Write-Verbose -Verbose "Skipping anchor link: $url"
+ $results.Skipped++
+ continue
+ }
+ elseif ($url -match '^mailto:') {
+ Write-Verbose -Verbose "Skipping mailto link: $url"
+ $results.Skipped++
+ continue
+ }
+ else {
+ $basePath = Split-Path -Parent $occurrences[0].Path
+ $verifyResult = Test-LocalLink -Url $url -BasePath $basePath
+ }
+ if ($verifyResult.Success) {
+ Write-Host "✓ OK: $url" -ForegroundColor Green
+ $results.Passed++
+ }
+ else {
+ $errorMsg = if ($verifyResult.StatusCode) {
+ "HTTP $($verifyResult.StatusCode)"
+ }
+ else {
+ $verifyResult.Error
+ }
+
+ # Determine if this status code should be ignored or treated as failure
+ # Ignore: 401 (Unauthorized), 403 (Forbidden), 429 (Too Many Requests - already retried)
+ # Fail: 404 (Not Found), 410 (Gone), 406 (Not Acceptable) - these indicate broken links
+ $shouldIgnore = $false
+ $ignoreReason = ""
+
+ switch ($verifyResult.StatusCode) {
+ 401 {
+ $shouldIgnore = $true
+ $ignoreReason = "authentication required"
+ }
+ 403 {
+ $shouldIgnore = $true
+ $ignoreReason = "access forbidden"
+ }
+ 429 {
+ $shouldIgnore = $true
+ $ignoreReason = "rate limited (already retried)"
+ }
+ }
+
+ if ($shouldIgnore) {
+ Write-Host "⊘ IGNORED: $url - $errorMsg ($ignoreReason)" -ForegroundColor Yellow
+ Write-Verbose -Verbose "Ignored error details for $url - Status: $($verifyResult.StatusCode) - $ignoreReason"
+ foreach ($occurrence in $occurrences) {
+ Write-Verbose -Verbose " Found in: $($occurrence.Path):$($occurrence.Line):$($occurrence.Column)"
+ }
+ $results.Skipped++
+ }
+ else {
+ Write-Host "✗ FAILED: $url - $errorMsg" -ForegroundColor Red
+ foreach ($occurrence in $occurrences) {
+ Write-Host " Found in: $($occurrence.Path):$($occurrence.Line):$($occurrence.Column)" -ForegroundColor DarkGray
+ }
+ $results.Failed++
+ [void]$results.Errors.Add(@{
+ Url = $url
+ Error = $errorMsg
+ Occurrences = $occurrences
+ })
+ }
+ }
+ }
+
+# Print summary
+Write-Host "`n" + ("=" * 60) -ForegroundColor Cyan
+Write-Host "Link Verification Summary" -ForegroundColor Cyan
+Write-Host ("=" * 60) -ForegroundColor Cyan
+Write-Host "Total URLs checked: $($results.Total)" -ForegroundColor White
+Write-Host "Passed: $($results.Passed)" -ForegroundColor Green
+Write-Host "Failed: $($results.Failed)" -ForegroundColor $(if ($results.Failed -gt 0) { "Red" } else { "Green" })
+Write-Host "Skipped: $($results.Skipped)" -ForegroundColor Gray
+
+if ($results.Failed -gt 0) {
+ Write-Host "`nFailed Links:" -ForegroundColor Red
+ foreach ($failedLink in $results.Errors) {
+ Write-Host " • $($failedLink.Url)" -ForegroundColor Red
+ Write-Host " Error: $($failedLink.Error)" -ForegroundColor DarkGray
+ Write-Host " Occurrences: $($failedLink.Occurrences.Count)" -ForegroundColor DarkGray
+ }
+
+ Write-Host "`n❌ Link verification failed!" -ForegroundColor Red
+ exit 1
+}
+else {
+ Write-Host "`n✅ All links verified successfully!" -ForegroundColor Green
+}
+
+# Write to GitHub Actions step summary if running in a workflow
+if ($env:GITHUB_STEP_SUMMARY) {
+ $summaryContent = @"
+
+# Markdown Link Verification Results
+
+## Summary
+- **Total URLs checked:** $($results.Total)
+- **Passed:** ✅ $($results.Passed)
+- **Failed:** $(if ($results.Failed -gt 0) { "❌" } else { "✅" }) $($results.Failed)
+- **Skipped:** $($results.Skipped)
+
+"@
+
+ if ($results.Failed -gt 0) {
+ $summaryContent += @"
+
+## Failed Links
+
+| URL | Error | Occurrences |
+|-----|-------|-------------|
+
+"@
+ foreach ($failedLink in $results.Errors) {
+ $summaryContent += "| $($failedLink.Url) | $($failedLink.Error) | $($failedLink.Occurrences.Count) |`n"
+ }
+
+ $summaryContent += @"
+
+
+Click to see all failed link locations
+
+"@
+ foreach ($failedLink in $results.Errors) {
+ $summaryContent += "`n### $($failedLink.Url)`n"
+ $summaryContent += "**Error:** $($failedLink.Error)`n`n"
+ foreach ($occurrence in $failedLink.Occurrences) {
+ $summaryContent += "- `$($occurrence.Path):$($occurrence.Line):$($occurrence.Column)`n"
+ }
+ }
+ $summaryContent += "`n `n"
+ }
+ else {
+ $summaryContent += "`n## ✅ All links verified successfully!`n"
+ }
+
+ Write-Verbose -Verbose "Writing `n $summaryContent `n to ${env:GITHUB_STEP_SUMMARY}"
+ $summaryContent | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
+ Write-Verbose -Verbose "Summary written to GitHub Actions step summary"
+}
+
diff --git a/.github/actions/infrastructure/markdownlinks/action.yml b/.github/actions/infrastructure/markdownlinks/action.yml
new file mode 100644
index 00000000000..1d6d0864784
--- /dev/null
+++ b/.github/actions/infrastructure/markdownlinks/action.yml
@@ -0,0 +1,139 @@
+name: 'Verify Markdown Links'
+description: 'Verify all links in markdown files using PowerShell and Markdig'
+author: 'PowerShell Team'
+
+inputs:
+ timeout-sec:
+ description: 'Timeout in seconds for HTTP requests'
+ required: false
+ default: '30'
+ maximum-retry-count:
+ description: 'Maximum number of retries for failed requests'
+ required: false
+ default: '2'
+
+outputs:
+ total-links:
+ description: 'Total number of unique links checked'
+ value: ${{ steps.verify.outputs.total }}
+ passed-links:
+ description: 'Number of links that passed verification'
+ value: ${{ steps.verify.outputs.passed }}
+ failed-links:
+ description: 'Number of links that failed verification'
+ value: ${{ steps.verify.outputs.failed }}
+ skipped-links:
+ description: 'Number of links that were skipped'
+ value: ${{ steps.verify.outputs.skipped }}
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Get changed markdown files
+ id: changed-files
+ uses: actions/github-script@v7
+ with:
+ script: |
+ let changedMarkdownFiles = [];
+
+ if (context.eventName === 'pull_request') {
+ const { data: files } = await github.rest.pulls.listFiles({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.payload.pull_request.number,
+ });
+
+ changedMarkdownFiles = files
+ .filter(file => file.filename.endsWith('.md'))
+ .map(file => file.filename);
+ } else if (context.eventName === 'push') {
+ const { data: comparison } = await github.rest.repos.compareCommits({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ base: context.payload.before,
+ head: context.payload.after,
+ });
+
+ changedMarkdownFiles = comparison.files
+ .filter(file => file.filename.endsWith('.md'))
+ .map(file => file.filename);
+ } else {
+ core.setFailed(`Unsupported event type: ${context.eventName}. This action only supports 'pull_request' and 'push' events.`);
+ return;
+ }
+
+ console.log('Changed markdown files:', changedMarkdownFiles);
+ core.setOutput('files', JSON.stringify(changedMarkdownFiles));
+ core.setOutput('count', changedMarkdownFiles.length);
+ return changedMarkdownFiles;
+
+ - name: Verify markdown links
+ id: verify
+ shell: pwsh
+ run: |
+ Write-Host "Starting markdown link verification..." -ForegroundColor Cyan
+
+ # Get changed markdown files from previous step
+ $changedFilesJson = '${{ steps.changed-files.outputs.files }}'
+ $changedFiles = $changedFilesJson | ConvertFrom-Json
+
+ if ($changedFiles.Count -eq 0) {
+ Write-Host "No markdown files changed, skipping verification" -ForegroundColor Yellow
+ "total=0" >> $env:GITHUB_OUTPUT
+ "passed=0" >> $env:GITHUB_OUTPUT
+ "failed=0" >> $env:GITHUB_OUTPUT
+ "skipped=0" >> $env:GITHUB_OUTPUT
+ exit 0
+ }
+
+ Write-Host "Changed markdown files: $($changedFiles.Count)" -ForegroundColor Cyan
+ $changedFiles | ForEach-Object { Write-Host " - $_" -ForegroundColor Gray }
+
+ # Build parameters for each file
+ $params = @{
+ File = $changedFiles
+ TimeoutSec = [int]'${{ inputs.timeout-sec }}'
+ MaximumRetryCount = [int]'${{ inputs.maximum-retry-count }}'
+ }
+
+ # Run the verification script
+ $scriptPath = Join-Path '${{ github.action_path }}' 'Verify-MarkdownLinks.ps1'
+
+ # Capture output and parse results
+ $output = & $scriptPath @params 2>&1 | Tee-Object -Variable capturedOutput
+
+ # Try to extract metrics from output
+ $totalLinks = 0
+ $passedLinks = 0
+ $failedLinks = 0
+ $skippedLinks = 0
+
+ foreach ($line in $capturedOutput) {
+ if ($line -match 'Total URLs checked: (\d+)') {
+ $totalLinks = $Matches[1]
+ }
+ elseif ($line -match 'Passed: (\d+)') {
+ $passedLinks = $Matches[1]
+ }
+ elseif ($line -match 'Failed: (\d+)') {
+ $failedLinks = $Matches[1]
+ }
+ elseif ($line -match 'Skipped: (\d+)') {
+ $skippedLinks = $Matches[1]
+ }
+ }
+
+ # Set outputs
+ "total=$totalLinks" >> $env:GITHUB_OUTPUT
+ "passed=$passedLinks" >> $env:GITHUB_OUTPUT
+ "failed=$failedLinks" >> $env:GITHUB_OUTPUT
+ "skipped=$skippedLinks" >> $env:GITHUB_OUTPUT
+
+ Write-Host "Action completed" -ForegroundColor Cyan
+
+ # Exit with the same code as the verification script
+ exit $LASTEXITCODE
+
+branding:
+ icon: 'link'
+ color: 'blue'
diff --git a/.github/instructions/powershell-parameter-naming.instructions.md b/.github/instructions/powershell-parameter-naming.instructions.md
new file mode 100644
index 00000000000..155fd1a85c3
--- /dev/null
+++ b/.github/instructions/powershell-parameter-naming.instructions.md
@@ -0,0 +1,69 @@
+---
+applyTo: '**/*.ps1, **/*.psm1'
+description: Naming conventions for PowerShell parameters
+---
+
+# PowerShell Parameter Naming Conventions
+
+## Purpose
+
+This instruction defines the naming conventions for parameters in PowerShell scripts and modules. Consistent parameter naming improves code readability, maintainability, and usability for users of PowerShell cmdlets and functions.
+
+## Parameter Naming Rules
+
+### General Conventions
+- **Singular Nouns**: Use singular nouns for parameter names even if the parameter is expected to handle multiple values (e.g., `File` instead of `Files`).
+- **Use PascalCase**: Parameter names must use PascalCase (e.g., `ParameterName`).
+- **Descriptive Names**: Parameter names should be descriptive and convey their purpose clearly (e.g., `FilePath`, `UserName`).
+- **Avoid Abbreviations**: Avoid using abbreviations unless they are widely recognized (e.g., `ID` for Identifier).
+- **Avoid Reserved Words**: Do not use PowerShell reserved words as parameter names (e.g., `if`, `else`, `function`).
+
+### Units and Precision
+- **Include Units in Parameter Names**: When a parameter represents a value with units, include the unit in the parameter name for clarity:
+ - `TimeoutSec` instead of `Timeout`
+ - `RetryIntervalSec` instead of `RetryInterval`
+ - `MaxSizeBytes` instead of `MaxSize`
+- **Use Full Words for Clarity**: Spell out common terms to match PowerShell conventions:
+ - `MaximumRetryCount` instead of `MaxRetries`
+ - `MinimumLength` instead of `MinLength`
+
+### Alignment with Built-in Cmdlets
+- **Follow Existing PowerShell Conventions**: When your parameter serves a similar purpose to a built-in cmdlet parameter, use the same or similar naming:
+ - Match `Invoke-WebRequest` parameters when making HTTP requests: `TimeoutSec`, `MaximumRetryCount`, `RetryIntervalSec`
+ - Follow common parameter patterns like `Path`, `Force`, `Recurse`, `WhatIf`, `Confirm`
+- **Consistency Within Scripts**: If multiple parameters relate to the same concept, use consistent naming patterns (e.g., `TimeoutSec`, `RetryIntervalSec` both use `Sec` suffix).
+
+## Examples
+
+### Good Parameter Names
+```powershell
+param(
+ [string[]]$File, # Singular, even though it accepts arrays
+ [int]$TimeoutSec = 30, # Unit included
+ [int]$MaximumRetryCount = 2, # Full word "Maximum"
+ [int]$RetryIntervalSec = 2, # Consistent with TimeoutSec
+ [string]$Path, # Standard PowerShell convention
+ [switch]$Force # Common PowerShell parameter
+)
+```
+
+### Names to Avoid
+```powershell
+param(
+ [string[]]$Files, # Should be singular: File
+ [int]$Timeout = 30, # Missing unit: TimeoutSec
+ [int]$MaxRetries = 2, # Should be: MaximumRetryCount
+ [int]$RetryInterval = 2, # Missing unit: RetryIntervalSec
+ [string]$FileLoc, # Avoid abbreviations: FilePath
+ [int]$Max # Ambiguous: MaximumWhat?
+)
+```
+
+## Exceptions
+- **Common Terms**: Some common terms may be used in plural form if they are widely accepted in the context (e.g., `Credentials`, `Permissions`).
+- **Legacy Code**: Existing code that does not follow these conventions may be exempted to avoid breaking changes, but new code should adhere to these guidelines.
+- **Well Established Naming Patterns**: If a naming pattern is well established in the PowerShell community, it may be used even if it does not strictly adhere to these guidelines.
+
+## References
+- [PowerShell Cmdlet Design Guidelines](https://learn.microsoft.com/powershell/scripting/developer/cmdlet/strongly-encouraged-development-guidelines)
+- [About Parameters - PowerShell Documentation](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_parameters)
diff --git a/.github/workflows/verify-markdown-links.yml b/.github/workflows/verify-markdown-links.yml
new file mode 100644
index 00000000000..db9fb7e416a
--- /dev/null
+++ b/.github/workflows/verify-markdown-links.yml
@@ -0,0 +1,32 @@
+name: Verify Markdown Links
+
+on:
+ push:
+ branches: [ main, master ]
+ paths:
+ - '**/*.md'
+ - '.github/workflows/verify-markdown-links.yml'
+ - '.github/actions/infrastructure/markdownlinks/**'
+ pull_request:
+ branches: [ main, master ]
+ paths:
+ - '**/*.md'
+ schedule:
+ # Run weekly on Sundays at midnight UTC to catch external link rot
+ - cron: '0 0 * * 0'
+ workflow_dispatch:
+
+jobs:
+ verify-markdown-links:
+ name: Verify Markdown Links
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Verify markdown links
+ id: verify
+ uses: ./.github/actions/infrastructure/markdownlinks
+ with:
+ timeout-sec: 30
+ maximum-retry-count: 2
diff --git a/AGENT_FEEDBACK.md b/AGENT_FEEDBACK.md
new file mode 100644
index 00000000000..753143893b7
--- /dev/null
+++ b/AGENT_FEEDBACK.md
@@ -0,0 +1,189 @@
+# Suggested Improvements to backport-agent.md
+
+Based on this backport experience, here are suggested improvements to `.github/agents/backport-agent.md`:
+
+## 1. Clarify PR Description Generation Requirement
+
+**Issue**: The agent workflow shows how to prepare a PR body in Step 4, but doesn't explicitly state that this should be saved and used by report_progress.
+
+**Current state** (Step 4):
+```powershell
+# Save PR body to file for report-progress action
+$prBody | Out-File -FilePath "pr-body.txt" -Encoding utf8
+```
+
+**Suggested improvement**: Add explicit instruction that the PR description must be included in the `prDescription` parameter of `report_progress`:
+
+```markdown
+### Step 4: Prepare PR Description
+
+**IMPORTANT**: The PR description must be passed to the `report_progress` tool using the `prDescription` parameter. The report-progress action will use this as the PR body.
+
+1. Build the PR body following the template from `pr-template.instructions.md`
+2. When calling `report_progress`, include the full PR body in the `prDescription` parameter
+3. Do NOT just save to a file - the tool needs the content directly
+
+Example:
+```powershell
+# Build PR body (see pr-template.instructions.md for details)
+$prBody = @"
+Backport of #$($pr.number) to release/v$version
+...
+"@
+
+# Use report_progress with the PR body
+report_progress(
+ commitMessage: "Backport PR #$prNumber to release/v$version",
+ prDescription: $prBody
+)
+```
+```
+
+## 2. Add PR Template Validation Checklist
+
+**Issue**: The agent instructions reference the PR template but don't include a checklist to verify all required sections are present.
+
+**Suggested addition** to Step 4, after the template example:
+
+```markdown
+### PR Description Checklist
+
+Before calling `report_progress`, verify your PR description includes:
+
+- [ ] First line: `Backport of # to `
+- [ ] Auto-generated comment with `$$$originalprnumber:$$$`
+- [ ] "Triggered by" line with current user and original author
+- [ ] Original CL label reference
+- [ ] CC to @PowerShell/powershell-maintainers
+- [ ] **Impact section** (Tooling or Customer, with description)
+- [ ] **Regression section** (Yes/No with explanation)
+- [ ] **Testing section** (How verified, tests added)
+- [ ] **Risk section** (High/Medium/Low with justification)
+- [ ] **Agent Feedback Request section** (for continuous improvement)
+- [ ] **Merge Conflicts section** (if conflicts occurred)
+
+Missing any section will result in an incomplete PR that doesn't follow the repository's backport standards.
+```
+
+## 3. Improve Branch Name Verification Error Message
+
+**Issue**: Step 1 verifies branch name but the error handling could be more specific about what's expected.
+
+**Current state**:
+```powershell
+if ($currentBranch -notmatch "7[.-]5") {
+ Write-Error "CRITICAL: Branch name '$currentBranch' does not indicate target version 7.5"
+ throw "Branch name mismatch"
+}
+```
+
+**Suggested improvement**:
+```powershell
+if ($currentBranch -notmatch "7[.-]5") {
+ Write-Error @"
+CRITICAL: Branch name mismatch!
+
+Current branch: $currentBranch
+Target version: 7.5
+
+The branch name must contain '7.5' or '7-5' to indicate the target release.
+Expected patterns:
+ - copilot/backport-*-7-5
+ - backport-*-release-7.5
+ - release-7.5-*
+
+The report-progress action infers the PR base branch from your branch name.
+If this verification fails, the PR will target the wrong release branch.
+"@
+ throw "Branch name verification failed"
+}
+```
+
+## 4. Clarify How to Find Default Branch for Reading Instructions
+
+**Issue**: The agent instructions assume the default branch is "master" when reading instruction files, but this may not always be correct. The agent should determine the default branch dynamically.
+
+**Current state** (in "Required Reading" section):
+```powershell
+# Read instruction files from default branch
+git show upstream/master:.github/instructions/backports/pr-template.instructions.md
+```
+
+**Problem**:
+- Assumes default branch is named "master"
+- Assumes upstream remote exists and is named "upstream"
+- Instructions may not exist on master if they're in a different branch
+
+**Suggested improvement**: Add a section that explains how to find the correct branch for reading instructions:
+
+```markdown
+## Reading Instruction Files
+
+**IMPORTANT**: Instruction files must be read from the branch where they exist, which may not be the default branch (master/main).
+
+### Step 1: Determine where instruction files exist
+
+First, check if the instruction files exist in various branches:
+
+```bash
+# Check if instructions exist in upstream master
+git ls-tree -r --name-only upstream/master .github/instructions/backports/ 2>/dev/null
+
+# Check if instructions exist in upstream main
+git ls-tree -r --name-only upstream/main .github/instructions/backports/ 2>/dev/null
+
+# Check in origin branches if upstream doesn't have them
+git ls-tree -r --name-only origin/travisez13-main .github/instructions/backports/ 2>/dev/null
+git ls-tree -r --name-only origin/master .github/instructions/backports/ 2>/dev/null
+git ls-tree -r --name-only origin/main .github/instructions/backports/ 2>/dev/null
+```
+
+### Step 2: Read from the branch that has them
+
+Once you've identified which branch contains the instruction files, use that branch:
+
+```bash
+# Example: If instructions are in origin/travisez13-main
+git show origin/travisez13-main:.github/instructions/backports/pr-template.instructions.md
+
+# Example: If instructions are in upstream/main
+git show upstream/main:.github/instructions/backports/pr-template.instructions.md
+```
+
+### Step 3: Similarly for agent instructions
+
+```bash
+# Check where agent file exists
+git ls-tree -r --name-only origin/travisez13-main .github/agents/ 2>/dev/null
+git ls-tree -r --name-only upstream/master .github/agents/ 2>/dev/null
+
+# Read from the correct location
+git show origin/travisez13-main:.github/agents/backport-agent.md
+```
+
+### Why this matters
+
+Instruction files and agent prompts may be:
+- In a feature/development branch before being merged to default branch
+- In a fork's main branch (like `origin/travisez13-main`)
+- Have different content between default branch and development branches
+- Not exist at all in older release branches
+
+**Don't assume**:
+- Default branch is named "master" (could be "main")
+- Instructions exist in the default branch
+- Remote is named "upstream" (might be "origin")
+
+**Always verify** where the files exist before trying to read them.
+```
+
+## Summary
+
+These improvements focus on:
+
+1. **Explicit PR description handling**: Making it clear that the PR body must be passed to `report_progress`, not just saved to a file
+2. **Template validation**: Adding a checklist to ensure all required sections are included
+3. **Better error messages**: Providing clearer guidance when branch name verification fails
+4. **Dynamic branch discovery**: Teaching the agent to find instruction files rather than assuming they're in "upstream/master"
+
+These changes would help ensure backport PRs follow the correct template on the first attempt and reduce confusion about how to properly format and submit the PR description, while also making the agent more robust when instruction files are in non-standard locations.
diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md
new file mode 100644
index 00000000000..3d0302254f6
--- /dev/null
+++ b/PR_DESCRIPTION.md
@@ -0,0 +1,75 @@
+Backport of #26219 to release/v7.5
+
+
+
+Triggered by @TravisEz13 on behalf of @TravisEz13
+
+Original CL Label: CL-Build
+
+/cc @PowerShell/powershell-maintainers
+
+## Impact
+
+### Tooling Impact
+
+- [x] Required tooling change
+
+This backports the markdown link verification GitHub Action to the release/v7.5 branch. This ensures that documentation quality checks are consistent across all active release branches, preventing broken links from being merged into release branches.
+
+## Regression
+
+- [ ] No
+
+This is a new feature addition, not a regression fix.
+
+## Testing
+
+Original PR added:
+- GitHub Actions workflow that runs on markdown file changes
+- PowerShell scripts to parse and verify links using Markdig
+- Configurable timeout and retry parameters
+
+Backport verified by:
+1. Clean cherry-pick with no conflicts
+2. All 6 files added successfully (916 lines)
+3. Workflow YAML validated for syntax correctness
+4. Scripts follow PowerShell parameter naming conventions per `.github/instructions/powershell-parameter-naming.instructions.md`
+
+The workflow will be tested automatically when it runs on the release branch after merge.
+
+## Risk
+
+- [ ] High
+- [x] Medium
+- [ ] Low
+
+Medium risk: This is infrastructure tooling that adds a new GitHub Actions workflow. While it doesn't affect PowerShell runtime or customer-facing features, CI/CD changes can impact the development workflow. However:
+
+- The change has been validated in master (merged Oct 21, 2025)
+- Clean cherry-pick indicates compatibility with release/v7.5
+- Workflow only runs on markdown file changes, limiting scope
+- Adds quality checks without modifying existing processes
+- Can be easily disabled if issues arise
+
+The benefit of maintaining documentation quality on the release branch outweighs the risk of adding this tooling.
+
+---
+
+## 🤖 Agent Feedback Request
+
+This backport was performed by the backport-agent running in a report-progress action environment.
+
+**@copilot** Please review the agent instruction files and suggest 1-2 improvements, focusing on:
+
+1. **Environment-specific limitations**: Are there constraints or limitations of the report-progress action environment that should be better documented in the agent instructions?
+2. **Workflow clarity**: Were there any steps that could have been clearer or more explicit about what the agent can/cannot do?
+
+Relevant instruction files:
+- `.github/agents/backport-agent.md` - Main agent workflow
+- `.github/instructions/backports/pr-template.instructions.md` - PR formatting
+- `.github/instructions/backports/conflict-resolution.instructions.md` - Conflict handling
+
+Focus on actionable improvements that would help future backport operations succeed on the first attempt.