mirror of
https://github.com/PowerShell/PowerShell
synced 2026-06-08 12:12:50 +00:00
[release/v7.6] Add markdown link verification for PRs (#26445)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: xtqqczze <45661989+xtqqczze@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
xtqqczze
parent
ae7f849949
commit
dee5188f75
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 += @"
|
||||
|
||||
<details>
|
||||
<summary>Click to see all failed link locations</summary>
|
||||
|
||||
"@
|
||||
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</details>`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"
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Verify Markdown Links
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, release/v7.6 ]
|
||||
paths:
|
||||
- '**/*.md'
|
||||
- '.github/workflows/verify-markdown-links.yml'
|
||||
- '.github/actions/infrastructure/markdownlinks/**'
|
||||
pull_request:
|
||||
branches: [ main, master, release/v7.6 ]
|
||||
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
|
||||
Reference in New Issue
Block a user