diff --git a/.github/linters/.codespellrc b/.github/linters/.codespellrc index 512b4cb..f4c7ea5 100644 --- a/.github/linters/.codespellrc +++ b/.github/linters/.codespellrc @@ -1,3 +1,6 @@ [codespell] skip = ./.github/linters ignore-words-list = afterall,skelton +# A Unicode category escape ('\p{Nd}', '\p{Lu}') is a regex token, not prose - codespell +# reads the category name as a misspelling ('Nd' for 'And') and there is nothing to fix. +ignore-regex = \\p\{[A-Za-z]+\} diff --git a/.github/scripts/Test-CrossRepositoryLink.ps1 b/.github/scripts/Test-CrossRepositoryLink.ps1 new file mode 100644 index 0000000..7f1d315 --- /dev/null +++ b/.github/scripts/Test-CrossRepositoryLink.ps1 @@ -0,0 +1,827 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Validate that every cross-repository Markdown link into an MSX organization resolves. + +.DESCRIPTION + The Markdown standard tells authors to use a canonical published URL for a + cross-repository reference, and 'Test-DocumentationLink.ps1' ignores external + links on purpose. This is the other half: the links that point at a repository + somewhere else, which moves on its own schedule without anyone here being told. + + Scope is decided by ownership rather than by scheme. Checking every external URL + on the internet is slow, flaky, and hostage to other people's outages; links into + the organizations MSX controls are a bounded set and are where the breakage comes + from - the target moved because we moved it. Only 'github.com' and + 'raw.githubusercontent.com' links owned by '-Owner' are resolved. + + These URL shapes are resolved: + + - 'https://github.com/OWNER/REPO' the repository exists + - 'https://github.com/OWNER/REPO#anchor' an anchor in its README + - 'https://github.com/OWNER/REPO?tab=readme-ov-file#anchor' the same, GitHub's own form + - 'https://github.com/OWNER/REPO/blob/REF/PATH#anchor' a file, and its anchor + - 'https://github.com/OWNER/REPO/tree/REF/PATH' a directory + - 'https://raw.githubusercontent.com/OWNER/REPO/REF/PATH' a file + + Everything else under an in-scope repository - '/issues/', '/pull/', + '/discussions/', '/releases/', '/actions/', '/wiki/', '/compare/', '/commit/' - is + ignored. Those are API objects rather than paths, and they do not move when a + repository is restructured. + + An anchor cannot be checked with a HEAD request: the fragment is never sent to the + server, so '.../file.md#heading' answers 200 whether or not that heading exists. + The content is fetched and its headings are slugged with GitHub's rules - not with + the 'ConvertTo-Slug' in 'Test-DocumentationLink.ps1', which mirrors python-markdown + for the published site. A cross-repository link resolves against GitHub's rendering, + so reusing the site slugger here would check the wrong algorithm and quietly agree + with itself. + + A link into the repository the script runs in is resolved against the checkout + rather than over the network. Resolved against the default branch it would confirm + the state a pull request is about to invalidate: a change that moves the file would + pass here and break the moment it merged. + + Three outcomes, not two. A link is *broken* when the target repository is readable + and the path or the anchor is not there. A link is *unresolvable* when the check + could not answer at all - a network failure, an exhausted API rate limit, or a + target no anonymous reader can reach. Both are reported, under separate headings, + so a red run says which of the two happened. + + The oracle is deliberately what an anonymous reader sees. GITHUB_TOKEN, when + present, is used only to lift the rate limit from 60 to 1000 requests an hour; it + grants no access to a private repository elsewhere. That is the right bar for a + public documentation site: a page here linking into a repository a reader cannot + open is broken for that reader. + + It also exits 1 when it resolved no cross-repository link at all. Every link + resolving is trivially true when none were found, so an empty run is reported as a + failure rather than a pass. + + The script changes nothing. It exits 0 when every in-scope link resolves and exits + 1 otherwise, so it can gate a pull request in CI. + +.EXAMPLE + ./Test-CrossRepositoryLink.ps1 + Validates every cross-repository link in the repository's Markdown. + +.EXAMPLE + ./Test-CrossRepositoryLink.ps1 -Path src/docs/Coding-Standards/Markdown.md + Validates a single file. + +.EXAMPLE + ./Test-CrossRepositoryLink.ps1 -Owner MSXOrg, PSModule, Storhaug-ting, Contoso + Adds another organization to the set whose links are resolved. + +.INPUTS + None + + You can't pipe objects to Test-CrossRepositoryLink.ps1. + +.OUTPUTS + None + + The script reports through the console log, a workflow annotation, and its exit code. + +.NOTES + 'ConvertTo-GitHubSlug' and 'Get-RenderedHeadingText' are taken from + 'scripts/Test-MarkdownLink.ps1' in Storhaug-ting/Kilden, where the character class + was compared against github-slugger across every code point. Keeping one + implementation of GitHub's slug rules, rather than writing a third, is deliberate. + +.LINK + https://msxorg.github.io/docs/Coding-Standards/Markdown/ + +.LINK + https://github.com/Flet/github-slugger +#> +[CmdletBinding()] +param( + # Markdown files to validate, relative to the repository root. Defaults to every + # Markdown file in the repository outside a dot-directory and the build output. + [Parameter()] + [string[]] $Path, + + # Repository owners whose links are resolved. Everything else is left alone: the + # bounded set we control is where a moved target originates. + [Parameter()] + [string[]] $Owner = @('MSXOrg', 'PSModule', 'Storhaug-ting'), + + # Base URI of the GitHub REST API. Follows GITHUB_API_URL so the script works + # unchanged on GitHub Enterprise Server. + [Parameter()] + [string] $ApiBaseUri = $(if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' }), + + # 'owner/repository' of the repository being checked, whose links resolve against + # the checkout instead of the network. Discovered from the environment or the git + # remote when not given. + [Parameter()] + [string] $SelfRepository = $env:GITHUB_REPOSITORY, + + # How many times a failing request is attempted before the link is called + # unresolvable. A transient failure must not read as a broken link. + [Parameter()] + [ValidateRange(1, 10)] + [int] $MaximumAttempt = 3 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + +function ConvertTo-GitHubSlug { + <# + .SYNOPSIS + Convert heading text to the anchor GitHub gives it. + + .DESCRIPTION + Mirror github-slugger, the library GitHub uses: lowercase the text, drop every + character that is not a letter, mark, decimal or letter number, or connector + punctuation - keeping hyphens and spaces - then turn spaces into hyphens. + + The character class is .NET's Unicode categories rather than the generated + table github-slugger ships. The two were compared across every code point and + agree exactly over Basic Latin, Latin-1, Latin Extended-A and B, Greek, + Cyrillic, General Punctuation, currency, letterlike and number forms, and the + emoji planes. They disagree on 52 code points in the arrows and symbols blocks + and 3 in CJK, where the two Unicode versions classify a character differently. + + .EXAMPLE + ConvertTo-GitHubSlug -Text 'Prefer .NET for the actual work' + Returns 'prefer-net-for-the-actual-work'. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The rendered heading text to convert. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + return ($Text.ToLowerInvariant() -replace '[^\p{L}\p{M}\p{Nd}\p{Nl}\p{Pc}\- ]', '') -replace ' ', '-' +} + +function Get-RenderedHeadingText { + <# + .SYNOPSIS + Get the text a heading renders to, before it is turned into an anchor. + + .DESCRIPTION + GitHub builds the anchor from the rendered heading, so the Markdown that only + affects presentation is resolved first: a link keeps its text and loses its + target, inline code keeps its content and loses its backticks, HTML tags and + emphasis markers are dropped, and a trailing closing '#' run is removed. + + .EXAMPLE + Get-RenderedHeadingText -Heading 'See the [guide](x.md) for `npm ci`' + Returns 'See the guide for npm ci'. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The raw heading text, without its leading '#' characters. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Heading + ) + $text = $Heading -replace '!?\[([^\]]*)\]\([^)]*\)', '$1' + $text = $text -replace '<[^>]*>', '' + $text = $text -replace '[`*_~]', '' + return ($text -replace '\s+#+\s*$', '').Trim() +} + +function Get-MarkdownAnchor { + <# + .SYNOPSIS + Get the anchors a Markdown document exposes. + + .DESCRIPTION + Return every anchor in the document: one per heading, plus any explicit id on a + raw HTML element, which GitHub honours as an anchor of its own. A repeated + heading gets the '-1', '-2' suffix github-slugger appends, and the suffixed form + is claimed too, so a heading colliding with an already generated suffix still + gets a unique anchor. Fenced code blocks are skipped. + + Takes the document text rather than a path, because a cross-repository target + arrives as an API response and never touches disk. + + .EXAMPLE + Get-MarkdownAnchor -Markdown (Get-Content ./README.md -Raw) + Returns the anchors README.md exposes. + + .OUTPUTS + [System.Collections.Generic.HashSet[string]] + #> + [CmdletBinding()] + [OutputType([System.Collections.Generic.HashSet[string]])] + param( + # The Markdown document to scan. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Markdown + ) + $anchors = [System.Collections.Generic.HashSet[string]]::new() + $occurrences = @{} + $fence = $null + foreach ($line in ($Markdown -split '\r?\n')) { + if ($line -match '^\s{0,3}(`{3,}|~{3,})') { + if ($null -eq $fence) { + $fence = $matches[1] + } elseif ($line -match "^\s{0,3}$([regex]::Escape($fence[0])){$($fence.Length),}\s*$") { + $fence = $null + } + continue + } + if ($fence) { continue } + foreach ($element in [regex]::Matches($line, '<[a-z][^>]*\sid\s*=\s*"([^"]+)"')) { + $null = $anchors.Add($element.Groups[1].Value) + } + if ($line -notmatch '^\s{0,3}#{1,6}(\s+.*)?$') { continue } + $slug = ConvertTo-GitHubSlug -Text (Get-RenderedHeadingText -Heading ($line -replace '^\s{0,3}#{1,6}\s*', '')) + $unique = $slug + while ($occurrences.ContainsKey($unique)) { + $occurrences[$slug]++ + $unique = "$slug-$($occurrences[$slug])" + } + $occurrences[$unique] = 0 + $null = $anchors.Add($unique) + } + return $anchors +} + +function Get-CrossRepositoryTarget { + <# + .SYNOPSIS + Describe what an in-scope cross-repository URL points at. + + .DESCRIPTION + Parse a link target into the repository, git reference, path, and fragment it + addresses, or return nothing when the URL is not an in-scope file reference - + a different host, an owner outside the configured set, or a route such as + '/issues/' that names an API object rather than a path. + + .EXAMPLE + Get-CrossRepositoryTarget -Url 'https://github.com/PSModule/Demo/blob/main/README.md#usage' -Owner PSModule + Returns a descriptor for README.md at 'main' with the fragment 'usage'. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The raw link target as written in the Markdown. + [Parameter(Mandatory)] + [string] $Url, + + # Repository owners that are in scope. + [Parameter(Mandatory)] + [string[]] $Owner + ) + [uri] $parsed = $null + if (-not [uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref] $parsed)) { return } + if ($parsed.Scheme -notin 'http', 'https') { return } + + $hostName = $parsed.Host.ToLowerInvariant() -replace '^www\.', '' + if ($hostName -notin 'github.com', 'raw.githubusercontent.com') { return } + + $segments = @($parsed.AbsolutePath.Trim('/') -split '/' | Where-Object { $_ } | ForEach-Object { [uri]::UnescapeDataString($_) }) + if ($segments.Count -lt 2) { return } + if ($segments[0] -notin $Owner) { return } + + $fragment = if ($parsed.Fragment) { [uri]::UnescapeDataString($parsed.Fragment.TrimStart('#')) } else { '' } + $target = [pscustomobject]@{ + Owner = $segments[0] + Repository = $segments[1] + Reference = '' + ItemPath = '' + Fragment = $fragment + } + + if ($hostName -eq 'raw.githubusercontent.com') { + if ($segments.Count -lt 4) { return } + $target.Reference = $segments[2] + $target.ItemPath = ($segments[3..($segments.Count - 1)] -join '/') + return $target + } + + if ($segments.Count -eq 2) { + # A repository landing page renders its README, so an anchor on it is an + # anchor in that file. Without a fragment only the repository is checked. + if ($fragment) { $target.ItemPath = 'README.md' } + return $target + } + + if ($segments[2] -notin 'blob', 'tree', 'raw') { return } + if ($segments.Count -lt 4) { return } + $target.Reference = $segments[3] + if ($segments.Count -gt 4) { $target.ItemPath = ($segments[4..($segments.Count - 1)] -join '/') } + return $target +} + +function Invoke-GitHubRequest { + <# + .SYNOPSIS + Send one GitHub REST request and classify how it went. + + .DESCRIPTION + Return the status code and body, or a failure description when the check could + not get an answer. A transient failure - a connection error or a 5xx - is + retried with a growing delay before it is given up on, so a blip does not read + as a broken link. An exhausted rate limit is recognised from the response + headers and not retried, because waiting will not help within a run. + + .EXAMPLE + Invoke-GitHubRequest -Uri 'https://api.github.com/repos/PSModule/Demo' -MaximumAttempt 3 + Returns the status code and body, or the reason no answer was obtained. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # Absolute URI to request. + [Parameter(Mandatory)] + [string] $Uri, + + # How many times to attempt the request before giving up. + [Parameter(Mandatory)] + [int] $MaximumAttempt + ) + $headers = @{ + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + 'User-Agent' = 'MSXOrg-docs-cross-repository-link-check' + } + # Only for rate-limit headroom. It unlocks no repository a reader could not open, + # which is exactly the visibility this check is supposed to measure. + if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" } + + $lastProblem = 'no attempt was made' + for ($attempt = 1; $attempt -le $MaximumAttempt; $attempt++) { + try { + $response = Invoke-WebRequest -Uri $Uri -Headers $headers -SkipHttpErrorCheck -MaximumRedirection 5 -TimeoutSec 30 + } catch { + $lastProblem = $_.Exception.Message + if ($attempt -lt $MaximumAttempt) { + Start-Sleep -Seconds $attempt + continue + } + return [pscustomobject]@{ StatusCode = 0; Content = ''; Failure = "the request failed after $MaximumAttempt attempt(s): $lastProblem" } + } + + $status = [int] $response.StatusCode + if ($status -in 403, 429) { + $remaining = if ($response.Headers.ContainsKey('x-ratelimit-remaining')) { @($response.Headers['x-ratelimit-remaining'])[0] } else { '' } + if ($remaining -eq '0') { + $resetAt = 'an unknown time' + if ($response.Headers.ContainsKey('x-ratelimit-reset')) { + $resetAt = [System.DateTimeOffset]::FromUnixTimeSeconds([long] @($response.Headers['x-ratelimit-reset'])[0]).ToString('u') + } + # Every later request would answer 403 too, so stop asking: the run is + # already going to fail, and hammering a closed quota only slows it down. + $script:rateLimitReached = if ($env:GITHUB_TOKEN) { + "the GitHub API rate limit is exhausted, resetting at $resetAt" + } else { + "the GitHub API rate limit is exhausted, resetting at $resetAt - no GITHUB_TOKEN was set, so the anonymous limit of 60 requests an hour applied" + } + return [pscustomobject]@{ StatusCode = $status; Content = ''; Failure = $script:rateLimitReached } + } + return [pscustomobject]@{ StatusCode = $status; Content = ''; Failure = "the request was refused with HTTP $status after $attempt attempt(s)" } + } + if ($status -ge 500) { + $lastProblem = "HTTP $status" + if ($attempt -lt $MaximumAttempt) { + Start-Sleep -Seconds $attempt + continue + } + return [pscustomobject]@{ StatusCode = $status; Content = ''; Failure = "the request failed after $MaximumAttempt attempt(s): $lastProblem" } + } + if ($status -eq 401) { + return [pscustomobject]@{ StatusCode = $status; Content = ''; Failure = 'the credentials the check runs with were rejected' } + } + return [pscustomobject]@{ StatusCode = $status; Content = [string] $response.Content; Failure = $null } + } + return [pscustomobject]@{ StatusCode = 0; Content = ''; Failure = "the request failed after $MaximumAttempt attempt(s): $lastProblem" } +} + +$script:responseCache = @{} +$script:rateLimitReached = $null +function Get-CachedGitHubRequest { + <# + .SYNOPSIS + Send a GitHub REST request, sending each distinct URI only once. + + .DESCRIPTION + Memoise Invoke-GitHubRequest, so a target linked from ten pages costs one + request rather than ten. Documentation repeats its links, and the rate limit + is the scarce resource here. + + Once the quota is gone it stops asking altogether. Every later request would + answer 403 the same way, and the run is already going to fail; continuing to + ask only makes it slower and the reason harder to read. + + .EXAMPLE + Get-CachedGitHubRequest -Uri 'https://api.github.com/repos/PSModule/Demo' -MaximumAttempt 3 + Returns the response, reaching the network only on the first call. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # Absolute URI to request. + [Parameter(Mandatory)] + [string] $Uri, + + # How many times to attempt the request before giving up. + [Parameter(Mandatory)] + [int] $MaximumAttempt + ) + if ($script:rateLimitReached) { + return [pscustomobject]@{ StatusCode = 0; Content = ''; Failure = $script:rateLimitReached } + } + if (-not $script:responseCache.ContainsKey($Uri)) { + $script:responseCache[$Uri] = Invoke-GitHubRequest -Uri $Uri -MaximumAttempt $MaximumAttempt + } + return $script:responseCache[$Uri] +} + +function Get-ContentUri { + <# + .SYNOPSIS + Build the contents-API URI for a target. + + .DESCRIPTION + Compose the repository contents endpoint for the target's path, pinned to the + target's git reference when the link carried one. A link without a reference + resolves against the repository's default branch, which is what a reader + following it gets. + + A target with a reference but no path - 'github.com/OWNER/REPO/tree/REF' - + asks for the repository root at that reference, which is what makes an invalid + branch or tag answer 404 rather than passing on the repository existing. + + .EXAMPLE + Get-ContentUri -Target $target -ApiBaseUri 'https://api.github.com' + Returns the endpoint that answers whether the target's path exists. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The target descriptor from Get-CrossRepositoryTarget. + [Parameter(Mandatory)] + [pscustomobject] $Target, + + # Base URI of the GitHub REST API. + [Parameter(Mandatory)] + [string] $ApiBaseUri + ) + $encoded = ($Target.ItemPath -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/' + $uri = "$($ApiBaseUri.TrimEnd('/'))/repos/$($Target.Owner)/$($Target.Repository)/contents/$encoded".TrimEnd('/') + if ($Target.Reference) { $uri += "?ref=$([uri]::EscapeDataString($Target.Reference))" } + return $uri +} + +function Get-DocumentText { + <# + .SYNOPSIS + Get the text of a file from a contents-API response. + + .DESCRIPTION + Decode the base64 body the contents endpoint inlines. A file above the inline + size limit arrives with an empty body and a download URL instead, so that is + followed rather than treated as an empty document - an empty document exposes + no anchors, and every anchor into it would be reported broken. + + .EXAMPLE + Get-DocumentText -Payload $payload -MaximumAttempt 3 + Returns the file's text. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The parsed contents-API response for a file. + [Parameter(Mandatory)] + [psobject] $Payload, + + # How many times to attempt a follow-up request before giving up. + [Parameter(Mandatory)] + [int] $MaximumAttempt + ) + $names = $Payload.PSObject.Properties.Name + if (($names -contains 'content') -and $Payload.content) { + return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(($Payload.content -replace '\s', ''))) + } + if (($names -contains 'download_url') -and $Payload.download_url) { + $download = Get-CachedGitHubRequest -Uri $Payload.download_url -MaximumAttempt $MaximumAttempt + if (-not $download.Failure -and $download.StatusCode -eq 200) { return $download.Content } + } + return '' +} + +function Get-LocalTargetOutcome { + <# + .SYNOPSIS + Resolve a link into the repository being checked against the checkout. + + .DESCRIPTION + A link back into this repository is answered from the working tree rather than + from the default branch over the API. Resolved remotely it would confirm the + state the change is about to invalidate: a pull request that moves the file + would pass, and the link would break as it merged. + + .EXAMPLE + Get-LocalTargetOutcome -Target $target -Root /repo + Returns whether the path and anchor exist in the checkout. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The target descriptor from Get-CrossRepositoryTarget. + [Parameter(Mandatory)] + [pscustomobject] $Target, + + # Repository root the target's path is resolved against. + [Parameter(Mandatory)] + [string] $Root + ) + if (-not $Target.ItemPath) { + return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } + } + $resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($Root, ($Target.ItemPath -replace '/', [System.IO.Path]::DirectorySeparatorChar))) + if ([System.IO.Directory]::Exists($resolved)) { + if (-not $Target.Fragment) { return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } } + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the anchor '#$($Target.Fragment)' points into a directory" } + } + if (-not [System.IO.File]::Exists($resolved)) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the target does not exist in this repository's checkout" } + } + if (-not $Target.Fragment) { return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } } + if (-not $Target.ItemPath.EndsWith('.md', [System.StringComparison]::OrdinalIgnoreCase)) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the anchor '#$($Target.Fragment)' points into a file that is not Markdown" } + } + $anchors = Get-MarkdownAnchor -Markdown ([System.IO.File]::ReadAllText($resolved)) + if ($Target.Fragment -cnotin $anchors) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "no heading in the target file produces the anchor '#$($Target.Fragment)'" } + } + return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } +} + +function Get-RemoteTargetOutcome { + <# + .SYNOPSIS + Resolve a link into another repository against that repository. + + .DESCRIPTION + Ask the contents endpoint whether the path exists and, for Markdown with an + anchor, whether a heading produces it. A link naming a git reference but no + path - 'github.com/OWNER/REPO/tree/REF' - asks for the repository root at that + reference, so an invalid branch or tag is caught rather than passing because + the repository exists. + + A 404 is not conclusive on its own - it answers the same for a deleted file + and for a repository no anonymous reader can open - so the repository itself + is probed before the link is called broken, and an unreadable repository is + reported as unresolvable instead. + + .EXAMPLE + Get-RemoteTargetOutcome -Target $target -ApiBaseUri 'https://api.github.com' -MaximumAttempt 3 + Returns whether the path and anchor exist in the target repository. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The target descriptor from Get-CrossRepositoryTarget. + [Parameter(Mandatory)] + [pscustomobject] $Target, + + # Base URI of the GitHub REST API. + [Parameter(Mandatory)] + [string] $ApiBaseUri, + + # How many times to attempt a request before giving up. + [Parameter(Mandatory)] + [int] $MaximumAttempt + ) + $repository = "$($Target.Owner)/$($Target.Repository)" + $repositoryUri = "$($ApiBaseUri.TrimEnd('/'))/repos/$repository" + + if (-not $Target.ItemPath -and -not $Target.Reference) { + $probe = Get-CachedGitHubRequest -Uri $repositoryUri -MaximumAttempt $MaximumAttempt + if ($probe.Failure) { return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = $probe.Failure } } + if ($probe.StatusCode -eq 200) { return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } } + return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = "$repository is not publicly readable, so a reader cannot follow this link either" } + } + + $response = Get-CachedGitHubRequest -Uri (Get-ContentUri -Target $Target -ApiBaseUri $ApiBaseUri) -MaximumAttempt $MaximumAttempt + if ($response.Failure) { return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = $response.Failure } } + + if ($response.StatusCode -eq 404) { + $probe = Get-CachedGitHubRequest -Uri $repositoryUri -MaximumAttempt $MaximumAttempt + if ($probe.Failure) { return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = $probe.Failure } } + if ($probe.StatusCode -ne 200) { + return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = "$repository is not publicly readable, so a reader cannot follow this link either" } + } + $at = if ($Target.Reference) { " at '$($Target.Reference)'" } else { ' on the default branch' } + if (-not $Target.ItemPath) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "$repository has no branch, tag, or commit named '$($Target.Reference)'" } + } + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the target does not exist in $repository$at" } + } + if ($response.StatusCode -ne 200) { + return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = "the request answered HTTP $($response.StatusCode)" } + } + + # An array is a directory listing; an object is a single file. + if ($response.Content.TrimStart().StartsWith('[')) { + if (-not $Target.Fragment) { return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } } + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the anchor '#$($Target.Fragment)' points into a directory" } + } + if (-not $Target.Fragment) { return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } } + if (-not $Target.ItemPath.EndsWith('.md', [System.StringComparison]::OrdinalIgnoreCase)) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "the anchor '#$($Target.Fragment)' points into a file that is not Markdown" } + } + + $document = Get-DocumentText -Payload ($response.Content | ConvertFrom-Json) -MaximumAttempt $MaximumAttempt + if (-not $document) { + return [pscustomobject]@{ Outcome = 'Unresolvable'; Reason = 'the target file was fetched but arrived empty, so its anchors are unknown' } + } + if ($Target.Fragment -cnotin (Get-MarkdownAnchor -Markdown $document)) { + return [pscustomobject]@{ Outcome = 'Broken'; Reason = "no heading in the target file produces the anchor '#$($Target.Fragment)'" } + } + return [pscustomobject]@{ Outcome = 'Resolved'; Reason = '' } +} + +function Write-WorkflowAnnotation { + <# + .SYNOPSIS + Emit a GitHub Actions annotation that renders above the collapsed log. + + .DESCRIPTION + Write a '::notice::' or '::error::' workflow command with the dynamic parts + percent-encoded, so a value carrying '%', a newline, a colon, or a comma cannot + corrupt or break out of the single-line command. Outside Actions it writes + nothing, so a local run is not littered with workflow commands. + + .EXAMPLE + Write-WorkflowAnnotation -Type notice -Title 'Cross-repository links' -Message '21 link(s) resolve' + Renders a notice on the run summary and in the Checks view. + + .OUTPUTS + None + #> + [CmdletBinding()] + param( + # Annotation severity, which decides how GitHub renders it. + [Parameter(Mandatory)] + [ValidateSet('notice', 'warning', 'error')] + [string] $Type, + + # Short headline shown in bold on the annotation. + [Parameter(Mandatory)] + [string] $Title, + + # The annotation body. + [Parameter(Mandatory)] + [string] $Message + ) + if (-not $env:GITHUB_ACTIONS) { return } + $encodedMessage = $Message -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A' + $encodedTitle = $Title -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A' -replace ':', '%3A' -replace ',', '%2C' + Write-Output "::${Type} title=${encodedTitle}::${encodedMessage}" +} + +if (-not $SelfRepository) { + # A copy of this script in another repository needs no edit: the repository it is + # running in comes from the environment, or from the remote it was cloned from. + try { + $remote = & git -C $Root remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and $remote -match '[:/]([^/:]+)/([^/]+?)(\.git)?$') { + $SelfRepository = "$($matches[1])/$($matches[2])" + } + } catch { + Write-Verbose "Could not resolve the current repository from git: $($_.Exception.Message)" + } +} + +# Inline links '[text](target)', reference-style definitions '[label]: target', and +# autolinks ''. The inline target may carry a title ("...", '...', or +# (...)); the nested-paren alternative keeps a parenthesised title from truncating it. +# A label starting with '^' is a footnote definition, whose body is prose rather than +# a destination. +$inlineLinkPattern = '\[[^\]]*\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)' +$referenceDefinitionPattern = '^\s{0,3}\[(?!\^)[^\]]+\]:\s+(<[^>]+>|\S+)' +$autolinkPattern = '<(https?://[^>\s]+)>' + +$files = @(if ($Path) { + $Path | ForEach-Object { Get-Item -LiteralPath ([System.IO.Path]::Combine($Root, $_)) } + } else { + # The exclusion is tested against the path below the root, not the full path: a + # clone can itself sit under a dotted directory, and matching on the full path + # would then exclude every file in the repository and report a vacuous pass. + Get-ChildItem -LiteralPath $Root -Recurse -File -Filter *.md | + Where-Object { + $relative = $_.FullName.Substring($Root.Length) + $relative -notmatch '[\\/](\.[^\\/]+|node_modules)[\\/]' -and $relative -notmatch '^[\\/]src[\\/]site[\\/]' + } | + Sort-Object FullName + }) + +$broken = [System.Collections.Generic.List[string]]::new() +$unresolvable = [System.Collections.Generic.List[string]]::new() +$checked = 0 + +foreach ($file in $files) { + $display = $file.FullName.Substring($Root.Length).TrimStart('\', '/').Replace('\', '/') + $lines = [System.IO.File]::ReadAllLines($file.FullName) + $fence = $null + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = $lines[$index] + if ($line -match '^\s{0,3}(`{3,}|~{3,})') { + if ($null -eq $fence) { + $fence = $matches[1] + } elseif ($line -match "^\s{0,3}$([regex]::Escape($fence[0])){$($fence.Length),}\s*$") { + $fence = $null + } + continue + } + if ($fence) { continue } + + # Inline code spans hold examples, not links that have to resolve. + $scrubbed = $line -replace '`[^`]*`', '' + $lineNumber = $index + 1 + $targets = @([regex]::Matches($scrubbed, $inlineLinkPattern) | ForEach-Object { $_.Groups[1].Value }) + $targets += @([regex]::Matches($scrubbed, $autolinkPattern) | ForEach-Object { $_.Groups[1].Value }) + if ($scrubbed -match $referenceDefinitionPattern) { $targets += $matches[1] } + + foreach ($raw in $targets) { + $url = ($raw.Trim() -replace '\s+("[^"]*"|''[^'']*''|\([^)]*\))$', '') -replace '^<', '' -replace '>$', '' + $target = Get-CrossRepositoryTarget -Url $url -Owner $Owner + if (-not $target) { continue } + + $checked++ + $outcome = if ("$($target.Owner)/$($target.Repository)" -eq $SelfRepository) { + Get-LocalTargetOutcome -Target $target -Root $Root + } else { + Get-RemoteTargetOutcome -Target $target -ApiBaseUri $ApiBaseUri -MaximumAttempt $MaximumAttempt + } + + switch ($outcome.Outcome) { + 'Broken' { $broken.Add("${display}:${lineNumber}: '$url' - $($outcome.Reason)") } + 'Unresolvable' { $unresolvable.Add("${display}:${lineNumber}: '$url' - $($outcome.Reason)") } + } + } + } +} + +if ($checked -eq 0) { + Write-Output "No cross-repository link into $($Owner -join ', ') was found in $($files.Count) file(s) under $Root - nothing was validated." + Write-Output 'A check that checked nothing is a failure, not a pass.' + Write-WorkflowAnnotation -Type error -Title 'Cross-repository links' -Message "No link was found in $($files.Count) file(s), so this run proved nothing." + exit 1 +} + +$summary = "$checked cross-repository link(s) checked in $($files.Count) file(s)." +if ($broken.Count -eq 0 -and $unresolvable.Count -eq 0) { + Write-Output "$summary Every one of them resolves." + Write-WorkflowAnnotation -Type notice -Title 'Cross-repository links' -Message "$checked link(s) resolve." + exit 0 +} + +Write-Output $summary +if ($broken.Count -gt 0) { + Write-Output '' + Write-Output "Broken cross-repository links ($($broken.Count)) - the target repository was read and the target was not there:" + $broken | Sort-Object | ForEach-Object { Write-Output " - $_" } +} +if ($unresolvable.Count -gt 0) { + Write-Output '' + Write-Output "Cross-repository links that could not be resolved ($($unresolvable.Count)) - the check got no answer, which is not the same as a broken link:" + $unresolvable | Sort-Object | ForEach-Object { Write-Output " - $_" } +} +Write-WorkflowAnnotation -Type error -Title 'Cross-repository links' -Message "$($broken.Count) link(s) point at something that is not there; $($unresolvable.Count) could not be checked at all." +exit 1 diff --git a/.github/workflows/Cross-Repository-Links.yml b/.github/workflows/Cross-Repository-Links.yml new file mode 100644 index 0000000..cba18d3 --- /dev/null +++ b/.github/workflows/Cross-Repository-Links.yml @@ -0,0 +1,51 @@ +name: Cross-Repository Links + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + # A target repository moves content on its own schedule, long after a pull request + # here has merged, and nothing in the pull-request trigger will ever notice. Weekly + # is often enough to catch it while the change is still recent, and rare enough that + # a rate limit is never in play. + schedule: + - cron: '17 6 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Default-deny floor: the job below grants only what its steps need, so a job added +# later that omits its own permissions block inherits nothing and fails closed. See the +# GitHub Actions coding standard, "Grant least-privilege permissions". +permissions: {} + +jobs: + # Its own workflow rather than a job in Docs.yml, for two reasons. The name of a red + # check then says the network check failed rather than the documentation being wrong. + # And Docs.yml's publish job has 'needs: [build, lint, links, test]', so a job there + # would let a GitHub outage block a Pages deploy. + cross-links: + name: Cross-repository links + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Validate cross-repository links + shell: pwsh + env: + # Only for rate-limit headroom: 60 requests an hour anonymously against 1000 + # authenticated. This token grants no access to a private repository + # elsewhere, so the check still measures what an anonymous reader can reach — + # which is the right bar for a public documentation site. + GITHUB_TOKEN: ${{ github.token }} + run: ./.github/scripts/Test-CrossRepositoryLink.ps1 diff --git a/.github/workflows/Docs.yml b/.github/workflows/Docs.yml index 0f07359..49c6827 100644 --- a/.github/workflows/Docs.yml +++ b/.github/workflows/Docs.yml @@ -2,10 +2,10 @@ name: Docs on: workflow_dispatch: - # No paths filter on purpose: this is the only workflow, so every push and pull - # request must run it to lint and build the full documented surface — including - # root files such as README.md, .github/dependabot.yml, and .prettierrc.json. A - # paths allow-list would silently skip CI for changes outside it. + # No paths filter on purpose: every push and pull request must run this to lint and + # build the full documented surface — including root files such as README.md, + # .github/dependabot.yml, and .prettierrc.json. A paths allow-list would silently + # skip CI for changes outside it. push: branches: - main @@ -92,6 +92,9 @@ jobs: with: path: src/site + # Relative links and same-repository anchors only, resolved from the checkout with no + # network access. The cross-repository half lives in Cross-Repository-Links.yml, so a + # GitHub outage cannot turn this check red or block the Pages deploy below. links: name: Links runs-on: ubuntu-24.04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcb516e..23c45ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,10 +21,13 @@ owns it. pwsh .github/scripts/Update-DocumentationIndex.ps1 ``` -4. Validate links before opening a pull request: +4. Validate links before opening a pull request. The first resolves relative links and + anchors from the checkout; the second resolves links into other MSX repositories + over the network: ```pwsh pwsh .github/scripts/Test-DocumentationLink.ps1 + pwsh .github/scripts/Test-CrossRepositoryLink.ps1 ``` 5. Run the Pester suites — the same job CI runs, so a failure shows up before the @@ -76,8 +79,11 @@ index and drill down to the right page. Three conventions make that work. and fails if an index is out of date. Links are validated the same way: `.github/scripts/Test-DocumentationLink.ps1` checks that -every relative link and heading anchor across the docs resolves, in CI on every pull request -and on every push to `main`. +every relative link and heading anchor across the docs resolves, and +`.github/scripts/Test-CrossRepositoryLink.ps1` resolves every link into another MSX +repository against that repository — file and anchor both. Both run in CI on every pull +request and on every push to `main`, the second also weekly, since a target repository +moves on its own schedule. Write to the [Markdown standard](https://msxorg.github.io/docs/Coding-Standards/Markdown/) and the [Documentation Model](https://msxorg.github.io/docs/Ways-of-Working/Documentation-Model/); diff --git a/src/docs/Capabilities/process-psmodule/index.md b/src/docs/Capabilities/process-psmodule/index.md index d47451f..62cae24 100644 --- a/src/docs/Capabilities/process-psmodule/index.md +++ b/src/docs/Capabilities/process-psmodule/index.md @@ -60,7 +60,7 @@ Depending on the labels in the pull requests, the [workflow will result in diffe The spec and design own the what and how. The pages below are reference documentation for those who implement, configure, and operate the workflow. -Process-PSModule composes its work from reusable workflows, actions, a container image, PowerShell modules, and Python packages. For the full dependency tree, including diagrams and a reference of every dependency, see [DEPENDENCIES.md](https://github.com/PSModule/Process-PSModule/blob/main/DEPENDENCIES.md). +Process-PSModule composes its work from reusable workflows, actions, a container image, PowerShell modules, and Python packages. The composition itself is the reference: see the [reusable workflows](https://github.com/PSModule/Process-PSModule/tree/main/.github/workflows) and the [actions they call](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions). diff --git a/src/docs/Capabilities/process-psmodule/pipeline-stages.md b/src/docs/Capabilities/process-psmodule/pipeline-stages.md index f72010c..2b102b5 100644 --- a/src/docs/Capabilities/process-psmodule/pipeline-stages.md +++ b/src/docs/Capabilities/process-psmodule/pipeline-stages.md @@ -62,10 +62,10 @@ suite matrices are computed under each owning test phase, and resolved version m [workflow](https://github.com/PSModule/Process-PSModule/blob/main/.github/workflows/Test-SourceCode.yml) - Tests the source code in parallel (matrix) using: - - [PSModule framework settings for style and standards for source code](https://github.com/PSModule/Test-PSModule?tab=readme-ov-file#sourcecode-tests) + - [PSModule framework settings for style and standards for source code](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions/Test-PSModule/src/tests/SourceCode) - This produces a JSON-based report that is used by [Get-PesterTestResults](#get-test-results) evaluate the results of the tests. -The [PSModule - SourceCode tests](https://github.com/PSModule/Process-PSModule/blob/main/scripts/tests/SourceCode/PSModule/PSModule.Tests.ps1) verifies the following coding practices that the framework enforces: +The [PSModule - SourceCode tests](https://github.com/PSModule/Process-PSModule/blob/main/.github/actions/Test-PSModule/src/tests/SourceCode/PSModule/PSModule.Tests.ps1) verify the following coding practices that the framework enforces: | ID | Category | Description | |---------------------|---------------------|--------------------------------------------------------------------------------------------| @@ -93,7 +93,7 @@ The [PSModule - SourceCode tests](https://github.com/PSModule/Process-PSModule/b [workflow](https://github.com/PSModule/Process-PSModule/blob/main/.github/workflows/Test-Module.yml) - Tests and lints the module in parallel (matrix) using: - - [PSModule framework settings for style and standards for modules](https://github.com/PSModule/Test-PSModule?tab=readme-ov-file#module-tests) + - [PSModule framework settings for style and standards for modules](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions/Test-PSModule/src/tests/Module) - [PSScriptAnalyzer rules](https://github.com/PSModule/Invoke-ScriptAnalyzer) - This produces a JSON-based report that is used by [Get-PesterTestResults](#get-test-results) evaluate the results of the tests. - **Code coverage for framework-generated code**: This step collects code coverage for framework-generated @@ -241,7 +241,7 @@ the name to avoid collisions (for example, `Test-{OS}-{ContextID}-{RunID}`). ### Module tests -The [PSModule - Module tests](https://github.com/PSModule/Process-PSModule/blob/main/scripts/tests/Module/PSModule/PSModule.Tests.ps1) verifies the following coding practices that the framework enforces: +The [PSModule - Module tests](https://github.com/PSModule/Process-PSModule/blob/main/.github/actions/Test-PSModule/src/tests/Module/PSModule/PSModule.Tests.ps1) verify the following coding practices that the framework enforces: | Name | Description | | ------ | ----------- | @@ -294,4 +294,4 @@ The [PSModule - Module tests](https://github.com/PSModule/Process-PSModule/blob/ ## Publish Docs -[workflow](https://github.com/PSModule/Process-PSModule/blob/main/.github/workflows/Publish-Docs.yml) +[workflow](https://github.com/PSModule/Process-PSModule/blob/main/.github/workflows/Publish-Site.yml) diff --git a/src/docs/Coding-Standards/Markdown.md b/src/docs/Coding-Standards/Markdown.md index bb12aad..8488192 100644 --- a/src/docs/Coding-Standards/Markdown.md +++ b/src/docs/Coding-Standards/Markdown.md @@ -52,12 +52,40 @@ These rules are disabled or widened so they do not flag valid documentation — - **Write one H1, then never skip heading levels** — an H3 only appears under an H2. - **Use sentence-style headings.** - **Surround headings, lists, and fenced blocks with a blank line** for readability, even though the linter no longer enforces it. -- **Prefer relative links** within a repository; use the canonical published URL for cross-repository references. +- **Prefer relative links** within a repository; use the canonical published URL for cross-repository references. Relative links, and cross-repository links on `github.com`, are checked in CI — see [Links are checked](#links-are-checked). - **Give a repeated or long link a reference-style definition** (`[text][ref]`, with `[ref]: url` listed below) so the prose stays readable and one edit updates every use. - **Tag every code fence with a language** (` ```bash `, ` ```yaml `) so it is highlighted and converts cleanly when published. - **Wrap code, commands, filenames, and identifiers in backticks** rather than bold or italic, so they read as code and do not lean on the emphasis the linter now allows freely. - **Give every image descriptive alt text** — `![what the image shows](diagram.png)` — so it serves screen readers and still says something when the image fails to load; use a relative path for images kept in the repository. +## Links are checked + +A link the standard asks for is a link something verifies. Two checks run on every pull request and on every push to `main`, and each answers a different question. + +**Inside a repository** — `Test-DocumentationLink.ps1` resolves every relative target and every heading anchor against the checkout. It needs no network, and it fails the moment a moved page is not accompanied by the links that pointed at it. + +**Into another repository** — `Test-CrossRepositoryLink.ps1` resolves cross-repository links on `github.com` against the repository they point at. It runs as its own job, so a red check says the network check failed rather than the documentation being wrong, and again weekly, because a target repository moves content long after a pull request here has merged. + +What it covers: + +- **Links into the organizations MSX controls** — `MSXOrg`, `PSModule`, and `Storhaug-ting`, on `github.com` and `raw.githubusercontent.com`. Scope is ownership, not scheme: checking every URL on the internet is slow and hostage to other people's outages, while the repositories we govern are a bounded set and are where the breakage starts — the target moved because we moved it. +- **The file and the anchor.** A `#fragment` is never sent to the server, so a HEAD request answers 200 whether or not the heading exists. The content is fetched and its headings are slugged with **GitHub's** rules, which are not the rules the published site uses — `## Hello — world` is `#hello--world` on GitHub and `#hello-world` on the site, and a repeated heading is `-1` there and `_1` here. Write the anchor GitHub gives you, which is the one the browser scrolls to. +- **Repository roots, `blob`, `tree`, `raw`, and `?tab=readme-ov-file#anchor`.** A link naming a branch or tag is resolved at that reference, so a renamed branch fails too. Routes that name an API object rather than a path — `/issues/`, `/pull/`, `/discussions/`, `/releases/`, `/actions/`, `/wiki/`, `/compare/`, `/commit/` — are left alone. They do not move when a repository is restructured. + +What it does **not** cover yet: a published-site URL such as `https://msxorg.github.io/docs/…`, which is the canonical form for a repository that publishes to GitHub Pages. Nothing verifies those today — see [MSXOrg/docs#150](https://github.com/MSXOrg/docs/issues/150). Inside a repository, prefer a relative link anyway; the check that already resolves those is the stricter of the two. + +Two things follow for authors: + +- **Do not link a public page into a repository a reader cannot open.** The check reads targets as an anonymous reader does, so a private target is reported — not as a broken link, but as one nobody outside can follow. If the link has to stay, say in the prose that the target is private. +- **A run that resolved no cross-repository link fails.** *Every link resolves* is trivially true when none were found, so an empty result is reported as a failure rather than a pass. See [Nothing checked is not a pass](Testing.md#nothing-checked-is-not-a-pass). + +Run both before opening a pull request: + +```powershell +./.github/scripts/Test-DocumentationLink.ps1 +./.github/scripts/Test-CrossRepositoryLink.ps1 +``` + ## PowerShell code samples Documentation is full of PowerShell, so present it the way the [PowerShell standard](PowerShell/index.md) writes it: diff --git a/src/docs/Ways-of-Working/Git-Worktrees.md b/src/docs/Ways-of-Working/Git-Worktrees.md index 0387a9c..e915d3a 100644 --- a/src/docs/Ways-of-Working/Git-Worktrees.md +++ b/src/docs/Ways-of-Working/Git-Worktrees.md @@ -97,7 +97,7 @@ git --git-dir="$repo.git" config "branch.$defaultBranch.remote" origin git --git-dir="$repo.git" config "branch.$defaultBranch.merge" "refs/heads/$defaultBranch" ``` -> The [Checkout-GitHubRepo](https://github.com/MariusStorhaug/.dev/blob/main/.github/Checkout-GitHubRepo.ps1) script automates this for all repositories. +> The [Checkout-GitHubRepo](https://github.com/MariusStorhaug/.dev/blob/main/.github/Checkout-GitHubRepo.ps1) script — in a private repository, so the link resolves only for the maintainer — automates this for all repositories. ## Working on a delivery leaf diff --git a/tests/Test-CrossRepositoryLink.Tests.ps1 b/tests/Test-CrossRepositoryLink.Tests.ps1 new file mode 100644 index 0000000..041e8d6 --- /dev/null +++ b/tests/Test-CrossRepositoryLink.Tests.ps1 @@ -0,0 +1,548 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +Describe 'Test-CrossRepositoryLink' { + BeforeAll { + $script:sourceScript = Join-Path $PSScriptRoot '../.github/scripts/Test-CrossRepositoryLink.ps1' + $script:pwsh = (Get-Process -Id $PID).Path + $script:utf8 = [System.Text.UTF8Encoding]::new($false) + + # The stub API runs in-process, in its own runspace, so a test never touches + # github.com. It answers the two endpoints the script calls and nothing else. + $script:serve = { + param($Listener, $Store) + + function Write-StubResponse { + param($Context, [int] $Status, [string] $Body, [switch] $RateLimited) + $Context.Response.StatusCode = $Status + $Context.Response.ContentType = 'application/json' + if ($RateLimited) { + $Context.Response.Headers.Add('x-ratelimit-remaining', '0') + $Context.Response.Headers.Add('x-ratelimit-reset', '1700000000') + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Body) + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.Close() + } + + while ($Listener.IsListening) { + try { + $context = $Listener.GetContext() + } catch { + break + } + try { + $requestPath = $context.Request.Url.AbsolutePath + Add-Content -LiteralPath (Join-Path $Store '_requests.log') -Value "$requestPath$($context.Request.Url.Query)" + + $statusFile = Join-Path $Store '_status.txt' + if (Test-Path -LiteralPath $statusFile) { + $forced = [int]((Get-Content -LiteralPath $statusFile -Raw).Trim()) + Write-StubResponse -Context $context -Status $forced -Body '{"message":"forced"}' -RateLimited:($forced -eq 403) + continue + } + + if ($requestPath -match '^/repos/([^/]+)/([^/]+)/contents(?:/(.*))?$') { + $repositoryStore = Join-Path $Store "$($matches[1])/$($matches[2])" + $itemPath = if ($matches.Count -gt 3 -and $matches[3]) { [uri]::UnescapeDataString($matches[3]) } else { '' } + $reference = if ($context.Request.Url.Query -match 'ref=([^&]+)') { $matches[1] } else { 'main' } + if (-not (Test-Path -LiteralPath $repositoryStore)) { + Write-StubResponse -Context $context -Status 404 -Body '{"message":"Not Found"}' + continue + } + $item = if ($itemPath) { Join-Path $repositoryStore $reference $itemPath } else { Join-Path $repositoryStore $reference } + if (Test-Path -LiteralPath $item -PathType Leaf) { + $encoded = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($item)) + $payload = [pscustomobject]@{ type = 'file'; encoding = 'base64'; content = $encoded } | ConvertTo-Json -Compress + Write-StubResponse -Context $context -Status 200 -Body $payload + } elseif (Test-Path -LiteralPath $item -PathType Container) { + Write-StubResponse -Context $context -Status 200 -Body '[]' + } else { + Write-StubResponse -Context $context -Status 404 -Body '{"message":"Not Found"}' + } + continue + } + + if ($requestPath -match '^/repos/([^/]+)/([^/]+)/?$') { + $repositoryStore = Join-Path $Store "$($matches[1])/$($matches[2])" + if (Test-Path -LiteralPath $repositoryStore) { + Write-StubResponse -Context $context -Status 200 -Body "{`"full_name`":`"$($matches[1])/$($matches[2])`"}" + } else { + Write-StubResponse -Context $context -Status 404 -Body '{"message":"Not Found"}' + } + continue + } + + Write-StubResponse -Context $context -Status 404 -Body '{"message":"Not Found"}' + } catch { + # A stub that dies takes the whole suite with it; keep serving. + Write-Verbose "Stub API request failed: $($_.Exception.Message)" + } + } + } + + function New-CrossLinkFixture { + <# + .SYNOPSIS + Create a throwaway repository and a stub GitHub API to resolve against. + + .DESCRIPTION + Lay out what Test-CrossRepositoryLink.ps1 expects - a copy of the script + under '.github/scripts' and content under 'src/docs' - beside a store the + stub API serves target repositories from. 'src/docs/Real.md' always exists + as a local target for links into this repository itself. + + .EXAMPLE + New-CrossLinkFixture -Content '# Page' -Target @{ 'PSModule/Demo/main/docs/Guide.md' = '# Guide' } + Returns the fixture paths and the stub API's base URI. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The Markdown body written to 'src/docs/Page.md'. + [Parameter(Mandatory)] + [string] $Content, + + # Target files the stub API serves, keyed by '///'. + [Parameter()] + [hashtable] $Target = @{}, + + # HTTP status the stub returns for every request, instead of resolving it. + [Parameter()] + [int] $ForcedStatus + ) + + $base = Join-Path ([System.IO.Path]::GetTempPath()) "xrepo-link-$([guid]::NewGuid().ToString('N'))" + if (-not $PSCmdlet.ShouldProcess($base, 'Create cross-repository link fixture')) { + return + } + $scripts = New-Item -ItemType Directory -Path (Join-Path $base 'repo/.github/scripts') + $docs = New-Item -ItemType Directory -Path (Join-Path $base 'repo/src/docs') + $store = New-Item -ItemType Directory -Path (Join-Path $base 'api') + Copy-Item -LiteralPath $script:sourceScript -Destination $scripts.FullName + + [System.IO.File]::WriteAllText((Join-Path $docs.FullName 'Real.md'), "# Real`n", $script:utf8) + [System.IO.File]::WriteAllText((Join-Path $docs.FullName 'Page.md'), $Content, $script:utf8) + + foreach ($key in $Target.Keys) { + $item = Join-Path $store.FullName $key + $null = New-Item -ItemType Directory -Path (Split-Path -Parent $item) -Force + [System.IO.File]::WriteAllText($item, $Target[$key], $script:utf8) + } + if ($PSBoundParameters.ContainsKey('ForcedStatus')) { + [System.IO.File]::WriteAllText((Join-Path $store.FullName '_status.txt'), "$ForcedStatus", $script:utf8) + } + + $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $port = $probe.LocalEndpoint.Port + $probe.Stop() + + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://localhost:$port/") + $listener.Start() + + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $shell = [powershell]::Create() + $shell.Runspace = $runspace + $null = $shell.AddScript($script:serve).AddArgument($listener).AddArgument($store.FullName) + $null = $shell.BeginInvoke() + + return [pscustomobject]@{ + Base = $base + ScriptPath = Join-Path $scripts.FullName 'Test-CrossRepositoryLink.ps1' + Store = $store.FullName + RequestLog = Join-Path $store.FullName '_requests.log' + ApiBaseUri = "http://localhost:$port" + Listener = $listener + Shell = $shell + Runspace = $runspace + } + } + + function Invoke-CrossLinkFixture { + <# + .SYNOPSIS + Run the fixture's copy of the cross-repository link check. + + .DESCRIPTION + Invoke the script in a separate PowerShell process so the assertion is made + on the real exit code and console output CI sees, not on internal state. + + .EXAMPLE + Invoke-CrossLinkFixture -Fixture $fixture + Returns the script's exit code and combined output. + + .OUTPUTS + [pscustomobject] + #> + [CmdletBinding()] + param( + # The fixture returned by New-CrossLinkFixture. + [Parameter(Mandatory)] + [psobject] $Fixture + ) + + $output = & $script:pwsh -NoProfile -File $Fixture.ScriptPath -ApiBaseUri $Fixture.ApiBaseUri -SelfRepository 'MSXOrg/docs' 2>&1 | Out-String + + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = $output + } + } + + function Stop-CrossLinkFixture { + <# + .SYNOPSIS + Shut down a fixture's stub API and delete its files. + + .DESCRIPTION + Stop the listener, dispose the runspace serving it, and remove the temporary + tree, so a suite leaves neither a listening port nor a directory behind. + + .EXAMPLE + Stop-CrossLinkFixture -Fixture $fixture + Releases the port and deletes the fixture. + + .OUTPUTS + None + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The fixture returned by New-CrossLinkFixture. + [Parameter(Mandatory)] + [psobject] $Fixture + ) + if (-not $PSCmdlet.ShouldProcess($Fixture.Base, 'Remove cross-repository link fixture')) { + return + } + $Fixture.Listener.Stop() + $Fixture.Listener.Close() + $Fixture.Shell.Dispose() + $Fixture.Runspace.Dispose() + if (Test-Path -LiteralPath $Fixture.Base) { + Remove-Item -LiteralPath $Fixture.Base -Recurse -Force + } + } + } + + AfterEach { + if ($fixture) { + Stop-CrossLinkFixture -Fixture $fixture + $fixture = $null + } + } + + Context 'Broken targets' { + It 'fails and names the link when the target file does not exist' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Missing.md). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Broken cross-repository links' + $result.Output | Should -Match 'docs/Missing\.md' + $result.Output | Should -Match 'does not exist' + $result.Output | Should -Match 'src/docs/Page\.md:3' + } + + It 'fails and names the anchor when no heading in the target produces it' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#missing-anchor). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## Real section`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Broken cross-repository links' + $result.Output | Should -Match 'missing-anchor' + $result.Output | Should -Match 'no heading' + } + + It 'fails when a link into this repository points at a file the checkout does not have' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [helper](https://github.com/MSXOrg/docs/blob/main/src/docs/Gone.md). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'does not exist' + (Test-Path -LiteralPath $fixture.RequestLog) | Should -BeFalse + } + It 'fails when a link names a branch the target repository does not have' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [branch](https://github.com/PSModule/Demo/tree/no-such-branch). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Broken cross-repository links' + $result.Output | Should -Match 'no branch, tag, or commit named' + $result.Output | Should -Match 'no-such-branch' + } + } + + Context 'Resolving targets' { + It 'passes when the target file and its anchor both exist' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#real-section). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## Real section`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match 'Every one of them resolves' + } + + It 'resolves a link to a branch the target repository does have' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [branch](https://github.com/PSModule/Demo/tree/main). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match 'Every one of them resolves' + } + + It 'resolves a link into this repository against the checkout instead of the network' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See [Real](https://github.com/MSXOrg/docs/blob/main/src/docs/Real.md#real). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + (Test-Path -LiteralPath $fixture.RequestLog) | Should -BeFalse + } + + It 'resolves an anchor on a repository landing page against its README' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [tests](https://github.com/PSModule/Demo?tab=readme-ov-file#module-tests). +'@ -Target @{ 'PSModule/Demo/main/README.md' = "# Demo`n`n## Module tests`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match 'Every one of them resolves' + } + + It 'fetches a target linked from several places exactly once' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [first section](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#one) and the +[second section](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#two). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## One`n`n## Two`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + @(Get-Content -LiteralPath $fixture.RequestLog | Where-Object { $_ -match 'contents' }).Count | Should -Be 1 + } + } + + Context 'Anchors follow GitHub slug rules, not the site slug rules' { + It "accepts GitHub's slug for a heading the site would slug differently" { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [section](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#hello--world). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## Hello $([char]0x2014) world`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + } + + It "rejects the site's slug for that same heading" { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [section](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#hello-world). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## Hello $([char]0x2014) world`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'no heading' + } + + It "suffixes a repeated heading the way GitHub does, not the way the site does" { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [second](https://github.com/PSModule/Demo/blob/main/docs/Guide.md#duplicate-1). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n`n## Duplicate`n`n## Duplicate`n" } + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 0 + } + } + + Context 'A run that resolved nothing' { + It 'fails when no cross-repository link was found at all' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See [Real](Real.md). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'nothing was validated' + $result.Output | Should -Match 'A check that checked nothing is a failure, not a pass' + } + + It 'ignores a link to an owner outside the configured scope' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See [super-linter](https://github.com/super-linter/super-linter/blob/main/Nope.md). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'nothing was validated' + } + + It 'ignores a link to an issue, which is not a file that moves' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See [issue 142](https://github.com/MSXOrg/docs/issues/142). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'nothing was validated' + } + } + + Context 'A link the check could not resolve is not a broken link' { + It 'reports an exhausted rate limit as its own failure' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Guide.md). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } -ForcedStatus 403 + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'could not be resolved' + $result.Output | Should -Match 'rate limit' + $result.Output | Should -Not -Match 'Broken cross-repository links' + } + + It 'stops asking once the quota is gone' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Guide.md) and the +[other guide](https://github.com/PSModule/Demo/blob/main/docs/Other.md). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } -ForcedStatus 403 + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + @(Get-Content -LiteralPath $fixture.RequestLog).Count | Should -Be 1 + } + + It 'reports a failing request as its own failure' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Demo/blob/main/docs/Guide.md). +'@ -Target @{ 'PSModule/Demo/main/docs/Guide.md' = "# Guide`n" } -ForcedStatus 500 + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'could not be resolved' + $result.Output | Should -Match 'attempt' + $result.Output | Should -Not -Match 'Broken cross-repository links' + } + + It 'reports a target repository a reader cannot read as its own failure' { + $fixture = New-CrossLinkFixture -Content @' +# Page + +See the [guide](https://github.com/PSModule/Private/blob/main/docs/Guide.md). +'@ + + $result = Invoke-CrossLinkFixture -Fixture $fixture + + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'could not be resolved' + $result.Output | Should -Match 'not publicly readable' + $result.Output | Should -Not -Match 'Broken cross-repository links' + } + } +} + +Describe 'ConvertTo-GitHubSlug' { + BeforeAll { + # Load the function without running the script: a script that did work merely by + # being dot-sourced would violate the Scripts standard, and parsing keeps the test + # honest about that. + $scriptPath = (Resolve-Path (Join-Path $PSScriptRoot '../.github/scripts/Test-CrossRepositoryLink.ps1')).ProviderPath + $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref] $null, [ref] $null) + $definition = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConvertTo-GitHubSlug' + }, $true) + . ([scriptblock]::Create($definition.Extent.Text)) + } + + # The expected values are a recorded fixture, not a second derivation: each one was + # produced by running github-slugger 2.0.0 - the library GitHub's own anchors come + # from - over the heading on the left. The cases are written inline because '-ForEach' + # is expanded at discovery, before any 'BeforeAll' has run. Regenerate with: + # npm install github-slugger + # node -e "import('github-slugger').then(m=>{const s=new m.default();console.log(s.slug('Hello, world!'))})" + It "slugs '' as ''" -ForEach @( + @{ Heading = 'Hello, world!'; Slug = 'hello-world' } + @{ Heading = 'Prefer .NET for the actual work'; Slug = 'prefer-net-for-the-actual-work' } + @{ Heading = "Don't mock what you don't own"; Slug = 'dont-mock-what-you-dont-own' } + @{ Heading = "Hello $([char]0x2014) world"; Slug = 'hello--world' } + @{ Heading = "Gr$([char]0x00FC)nanlage"; Slug = "gr$([char]0x00FC)nanlage" } + @{ Heading = 'CI/CD pipeline'; Slug = 'cicd-pipeline' } + @{ Heading = "Bruksordning for veg - $([char]0x00A7) 3-8"; Slug = 'bruksordning-for-veg----3-8' } + @{ Heading = 'A heading with double spaces'; Slug = 'a-heading-with--double--spaces' } + @{ Heading = 'snake_case and kebab-case'; Slug = 'snake_case-and-kebab-case' } + @{ Heading = '100% coverage?'; Slug = '100-coverage' } + @{ Heading = "$([char]0x041F)$([char]0x0440)$([char]0x0438)$([char]0x0432)$([char]0x0435)$([char]0x0442) non-latin $([char]0x4F60)$([char]0x597D)"; Slug = "$([char]0x043F)$([char]0x0440)$([char]0x0438)$([char]0x0432)$([char]0x0435)$([char]0x0442)-non-latin-$([char]0x4F60)$([char]0x597D)" } + @{ Heading = 'env vars & secrets'; Slug = 'env-vars--secrets' } + @{ Heading = 'parens (like this)'; Slug = 'parens-like-this' } + @{ Heading = "emoji $([char]::ConvertFromUtf32(0x1F680)) heading"; Slug = 'emoji--heading' } + ) { + ConvertTo-GitHubSlug -Text $Heading | Should -BeExactly $Slug + } +}