[release/v7.5] Add markdown link verification for PRs (#26407)

This commit is contained in:
Travis Plunk
2026-02-12 09:42:12 -08:00
committed by GitHub
parent e697a58c14
commit c1545b05ec
6 changed files with 916 additions and 0 deletions
@@ -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 ]
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