diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md
new file mode 100644
index 0000000..460bee6
--- /dev/null
+++ b/.github/VALIDATION.md
@@ -0,0 +1,111 @@
+---
+title: Validate community-default source changes
+description: Check committed source files without requiring a PR template.
+---
+
+# Validate community-default source changes
+
+`Organization defaults validation` checks pull requests to `main` in the
+special public `MSXOrg/.github` source repository.
+
+## Before starting
+
+Use PowerShell 7.4+, Git, and Node.js/npm. Fetch the intended base and commit
+the changes being checked; the validator rejects staged, unstaged, or untracked
+differences rather than certifying working-tree corrections as the committed head.
+The existing runner restores pinned Pester 6.1.0 and verifies its module GUID.
+Markdown validation restores the existing markdownlint-cli2 tool at 0.24.0.
+
+PR descriptions follow the [canonical PR-format guidance][pr-format].
+PR templates are intentionally absent: this check neither requires nor copies,
+generates, or compares a template scaffold. Other community defaults retain
+their ordinary GitHub inheritance behavior. Consumer repositories do not install
+this workflow or copy policy/contribution files to satisfy it.
+
+The [Repository Standard][file-policy] owns the no-file policy, not PR Format.
+Template absence is not record-quality proof: review the actual title and full
+description against PR Format's boundary, ordered results, complete consumer
+actions, and supporting evidence. Release decisions apply only to actual
+release-managed routes. This file validator does not replace those record checks.
+
+## Steps
+
+1. Fetch the intended base with `git fetch origin main`.
+2. Run the anonymous regression suites:
+
+ ```powershell
+ pwsh -NoProfile -File .github\scripts\Invoke-PesterSuite.ps1 -RequiredVersion 6.1.0
+ ```
+
+3. Validate the actual committed inputs, shared sources, and changed Markdown:
+
+ ```powershell
+ pwsh -NoProfile -File .github\scripts\Test-OrganizationDefaults.ps1 -BaseRevision origin/main
+ ```
+
+## Verify
+
+The result names the actual head and merge base, existing changed files,
+Markdown files actually linted, checked deletions, and verified shared
+configuration sources. Empty or unsupported-only diffs fail. Intentional
+template deletion is checked as absence, not a missing-file error or a claim
+that deleted content was linted. Regression tests are reported separately and
+cannot disguise a scan that found no applicable input.
+Deleted and surviving paths come from the committed diff with case-sensitive
+identity, so a case-only rename cannot hide its removed source path.
+The native test runner also fails if no discovered test passes, including
+skipped-only suites. Discovery counts do not substitute for executed regressions.
+The job-summary verdict and exit decision use the same success condition.
+
+Every selected Markdown file is read literally and sent on stdin to
+markdownlint-cli2 with the [unchanged shared rules][markdown].
+An isolated temporary configuration sets `noInlineConfig: true`, so inline
+disable/configuration directives cannot turn malformed Markdown into a pass.
+Repository-local ignore/configuration files and glob-like filenames cannot
+skip an input. Directive examples remain valid Markdown; they are not new rules.
+
+The [shared PowerShell][powershell], [Markdown][markdown], and [zizmor][zizmor]
+snapshots and [YAML baseline][yaml] are verified against the recorded immutable
+Git blobs in `validation.psd1`. They are tooling sources, not copied PR-format
+templates or producer-runtime dependencies. The [Pester runner][pester] comes
+from the same immutable shared tooling revision, with its local default adapted
+to `.github/tests` and a no-passed-test guard. Invocation without `-Path`
+discovers this repository's suite; skipped-only discovery cannot pass it.
+
+The workflow uses `pull_request` activities `opened`, `synchronize`, `reopened`,
+and `edited`, so retargeting a PR to `main` starts validation without a new head
+commit. It checks out and verifies that actual head. Only `contents: read` is
+granted; persisted credentials, per-language statuses, and PR-summary writes are
+disabled. Other language checks use the pinned shared linter's exact
+repository-relative filter. Control/backslash names, symbolic links, and
+relevant inputs in its pruned directories fail explicitly.
+Surviving PowerShell paths containing wildcard characters are also rejected:
+the shared analyzer's path option could otherwise inspect a different file.
+
+For deletion-only diffs there is no surviving content to lint. The report counts
+the checked deletions and configuration sources, and the other-language lint
+step does not run. This is not an empty input or a Markdown pass.
+
+The exact hosted check is `Organization defaults validation`, produced by
+GitHub Actions. A completed result at the actual PR head is required evidence;
+local tests or a manually dispatched/skipped job cannot replace it.
+Making that check required for `main` is a separately approved governance action,
+not a rule or access change performed by this code.
+
+## If it fails
+
+| Symptom | Cause | Resolution |
+| --- | --- | --- |
+| No applicable changed input | Empty diff or unsupported-only files | Confirm the intended committed base/head; do not substitute regression-only evidence. |
+| Markdown lint failure | Shared-rule violation, even if an inline directive disables it | Correct the content; do not suppress or skip validation. |
+| Source mismatch | A shared configuration differs from its recorded source | Select and verify an immutable source refresh, then rerun the regressions. |
+| Head/working-tree mismatch | Wrong checkout, incomplete history, or uncommitted content | Fetch the intended base/head and commit the intended changes. |
+| Check does not hold merge | No required-check rule | Ask the governance owner to separately approve requiring the exact check/source. |
+
+[pr-format]: https://msx.no/docs/Ways-of-Working/PR-Format/
+[file-policy]: https://github.com/MSXOrg/docs/blob/bc03a5992615f65d1d5346dc48e7506e80b8b6ca/src/docs/Ways-of-Working/Repository-Standard.md#no-pr-template-files
+[markdown]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/.markdown-lint.yml
+[powershell]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/.powershell-psscriptanalyzer.psd1
+[zizmor]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/zizmor.yaml
+[yaml]: https://github.com/super-linter/super-linter/blob/4ce20838b8ab83717e78138c5b3a1407148e0918/TEMPLATES/.yaml-lint.yml
+[pester]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/scripts/Invoke-PesterSuite.ps1
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..9001457
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,8 @@
+---
+version: 2
+
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml
new file mode 100644
index 0000000..4c0f98c
--- /dev/null
+++ b/.github/linters/.markdown-lint.yml
@@ -0,0 +1,29 @@
+###########################
+## Markdown Linter rules ##
+###########################
+
+# Linter rules doc:
+# - https://github.com/DavidAnson/markdownlint
+
+###############
+# Rules by id #
+###############
+MD004: false # Unordered list style
+MD007:
+ indent: 2 # Unordered list indentation
+MD013:
+ line_length: 3000 # Line length
+MD025:
+ front_matter_title: "" # Allow a body H1 alongside the front matter title
+MD026:
+ punctuation: ".,;:!。,;:" # List of not allowed
+MD029: false # Ordered list item prefix
+MD033: false # Allow inline HTML
+MD036: false # Emphasis used instead of a heading
+MD041: false # First line in file should be a top level heading, PULL_REQUEST_TEMPLATE.md is an exception
+MD060: false # Table column style — content mixes compact and aligned tables
+
+#################
+# Rules by tags #
+#################
+blank_lines: false # Error on blank lines
diff --git a/.github/linters/.powershell-psscriptanalyzer.psd1 b/.github/linters/.powershell-psscriptanalyzer.psd1
new file mode 100644
index 0000000..52f0ee5
--- /dev/null
+++ b/.github/linters/.powershell-psscriptanalyzer.psd1
@@ -0,0 +1,55 @@
+# PSScriptAnalyzer settings — the enforcement mechanism for the PowerShell coding
+# standard (src/docs/Coding-Standards/PowerShell/index.md). The standard is the
+# source of truth; every rule configured here is derived from it. Change a rule by
+# changing the standard first, then reflect it here — never the other way around.
+#
+# super-linter discovers this file automatically as POWERSHELL_CONFIG_FILE
+# (.powershell-psscriptanalyzer.psd1) under .github/linters.
+@{
+ # Run the full default rule set — approved verbs, singular nouns, alias bans,
+ # $null-on-the-left comparisons, Invoke-Expression, unused variables, and the
+ # rest — then tighten the formatting rules below to match the standard.
+ IncludeDefaultRules = $true
+
+ # Gate on errors and warnings; both map to rules the standard requires.
+ Severity = @('Error', 'Warning')
+
+ Rules = @{
+ # One True Brace Style: opening brace on the statement line, closing brace
+ # on its own line with no blank line before it. NewLineAfter stays false on
+ # the close brace so else / elseif / catch / finally sit on the same line as
+ # the preceding brace ("} else {"), as the standard requires.
+ PSPlaceOpenBrace = @{
+ Enable = $true
+ OnSameLine = $true
+ NewLineAfter = $true
+ IgnoreOneLineBlock = $true
+ }
+ PSPlaceCloseBrace = @{
+ Enable = $true
+ NewLineAfter = $false
+ IgnoreOneLineBlock = $true
+ NoEmptyLineBefore = $true
+ }
+
+ # Indent with four spaces, never tabs.
+ PSUseConsistentIndentation = @{
+ Enable = $true
+ Kind = 'space'
+ IndentationSize = 4
+ PipelineIndentation = 'IncreaseIndentationForFirstPipeline'
+ }
+
+ # One space around operators and after commas, and consistent brace and
+ # parenthesis spacing.
+ PSUseConsistentWhitespace = @{
+ Enable = $true
+ CheckInnerBrace = $true
+ CheckOpenBrace = $true
+ CheckOpenParen = $true
+ CheckOperator = $true
+ CheckPipe = $true
+ CheckSeparator = $true
+ }
+ }
+}
diff --git a/.github/linters/.yaml-lint.yml b/.github/linters/.yaml-lint.yml
new file mode 100644
index 0000000..2aa49bd
--- /dev/null
+++ b/.github/linters/.yaml-lint.yml
@@ -0,0 +1,59 @@
+---
+###########################################
+# These are the rules used for #
+# linting all the yaml files in the stack #
+# NOTE: #
+# You can disable line with: #
+# # yamllint disable-line #
+###########################################
+rules:
+ braces:
+ level: warning
+ min-spaces-inside: 0
+ max-spaces-inside: 0
+ min-spaces-inside-empty: 1
+ max-spaces-inside-empty: 5
+ brackets:
+ level: warning
+ min-spaces-inside: 0
+ max-spaces-inside: 0
+ min-spaces-inside-empty: 1
+ max-spaces-inside-empty: 5
+ colons:
+ level: warning
+ max-spaces-before: 0
+ max-spaces-after: 1
+ commas:
+ level: warning
+ max-spaces-before: 0
+ min-spaces-after: 1
+ max-spaces-after: 1
+ comments: disable
+ comments-indentation: disable
+ document-end: disable
+ document-start:
+ level: warning
+ present: true
+ empty-lines:
+ level: warning
+ max: 2
+ max-start: 0
+ max-end: 0
+ hyphens:
+ level: warning
+ max-spaces-after: 1
+ indentation:
+ level: warning
+ spaces: consistent
+ indent-sequences: true
+ check-multi-line-strings: false
+ key-duplicates: enable
+ line-length:
+ level: warning
+ max: 80
+ allow-non-breakable-words: true
+ allow-non-breakable-inline-mappings: true
+ new-line-at-end-of-file: disable
+ new-lines:
+ type: unix
+ trailing-spaces: disable
diff --git a/.github/linters/zizmor.yml b/.github/linters/zizmor.yml
new file mode 100644
index 0000000..6b55013
--- /dev/null
+++ b/.github/linters/zizmor.yml
@@ -0,0 +1,4 @@
+---
+rules:
+ dependabot-cooldown:
+ disable: true
diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1
new file mode 100644
index 0000000..f7c17af
--- /dev/null
+++ b/.github/scripts/Invoke-PesterSuite.ps1
@@ -0,0 +1,259 @@
+#!/usr/bin/env pwsh
+#Requires -Version 7.0
+
+<#
+.SYNOPSIS
+ Run every Pester suite in the repository and gate the pull request on the result.
+
+.DESCRIPTION
+ Resolves the pinned Pester version, discovers every '*.Tests.ps1' file beneath the
+ suite directory, runs them all in one Pester session, and reports the result three
+ ways: a grouped console log, per-test error annotations from Pester's GitHub Actions
+ CI format, and a Markdown table on the job summary.
+
+ The script exits 1 when a test fails, when a suite fails to run at all, or when
+ discovery finds no test file, or no test passes (including skipped-only runs).
+ A run that silently executes nothing is not a pass.
+
+ Pester is pinned to an exact version and verified by module GUID. A CI pipeline is an
+ end artifact, so it pins to an exact resolved version for reproducibility rather than
+ to the range the suites themselves declare; see the Dependencies coding standard.
+
+.EXAMPLE
+ ./Invoke-PesterSuite.ps1
+ Runs every suite under .github/tests/ and exits non-zero if any test fails.
+
+.EXAMPLE
+ ./Invoke-PesterSuite.ps1 -Path ./tests -RequiredVersion 6.0.1
+ Runs the suites in an explicit directory with an explicit Pester version.
+
+.INPUTS
+ None
+
+ You can't pipe objects to Invoke-PesterSuite.ps1.
+
+.OUTPUTS
+ None
+
+ The script reports through the console log, annotations, the job summary, and its
+ exit code.
+
+.LINK
+ https://msxorg.github.io/docs/Coding-Standards/GitHub-Actions/
+#>
+[CmdletBinding()]
+param(
+ # Directory holding the Pester suites. Every '*.Tests.ps1' beneath it is run.
+ [Parameter()]
+ [string] $Path = (Join-Path (Split-Path -Parent $PSScriptRoot) 'tests'),
+
+ # Exact Pester version to run with, so a new release cannot turn an untouched pull
+ # request red. Keep it inside the range the suites' '#Requires' lines declare.
+ [Parameter()]
+ [string] $RequiredVersion = '6.0.1',
+
+ # Pester's module GUID — the identity half of the pin, checked after import so a
+ # name-squatted module cannot satisfy the version.
+ [Parameter()]
+ [guid] $ModuleGuid = 'a699dea5-2c73-4616-a270-1f7abb777e71'
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Write-WorkflowAnnotation {
+ <#
+ .SYNOPSIS
+ Emit a GitHub Actions annotation that renders above the collapsed log.
+
+ .DESCRIPTION
+ Writes a '::notice::', '::warning::', 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.
+
+ .EXAMPLE
+ Write-WorkflowAnnotation -Type notice -Title 'Test' -Message '45 tests passed'
+ Renders a notice on the run summary and in the Checks view.
+
+ .OUTPUTS
+ [string]
+ #>
+ [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
+ )
+ $encodedMessage = $Message -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A'
+ # A value in a command property needs ':' and ',' encoded too — they delimit the list.
+ $encodedTitle = $Title -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A' -replace ':', '%3A' -replace ',', '%2C'
+ Write-Output "::${Type} title=${encodedTitle}::${encodedMessage}"
+}
+
+function Test-PesterResult {
+ <#
+ .SYNOPSIS
+ Decide whether a Pester run meets the test success gate.
+
+ .DESCRIPTION
+ Require passed tests, a completed passing result, and no failed containers.
+ Use this same decision for the summary verdict and process exit.
+
+ .EXAMPLE
+ Test-PesterResult -Result $result
+ Returns whether the run genuinely passed.
+
+ .INPUTS
+ None
+
+ You can't pipe objects to Test-PesterResult.
+
+ .OUTPUTS
+ System.Boolean
+
+ Whether the result satisfies the success gate.
+ #>
+ [OutputType([bool])]
+ [CmdletBinding()]
+ param(
+ # Actual result returned by Invoke-Pester.
+ [Parameter(Mandatory)]
+ [ValidateNotNull()]
+ [psobject] $Result
+ )
+
+ $Result.TotalCount -gt 0 -and $Result.PassedCount -gt 0 -and
+ $Result.Result -eq 'Passed' -and $Result.FailedContainersCount -eq 0
+}
+
+function Get-PesterSummaryMarkdown {
+ <#
+ .SYNOPSIS
+ Render a Pester run as the Markdown written to the job summary.
+
+ .DESCRIPTION
+ Builds a verdict heading and one table row per suite, so a reader sees which
+ suites ran and how many tests each contributed without expanding the raw log.
+ Failed tests are listed underneath in a block that stays closed until opened.
+
+ .EXAMPLE
+ Get-PesterSummaryMarkdown -Result $result -SuiteRoot ./tests
+ Returns the Markdown for the job summary.
+
+ .OUTPUTS
+ [string]
+ #>
+ [CmdletBinding()]
+ param(
+ # The object returned by Invoke-Pester with PassThru enabled.
+ [Parameter(Mandatory)]
+ [psobject] $Result,
+
+ # Directory the suite paths are shown relative to.
+ [Parameter(Mandatory)]
+ [string] $SuiteRoot
+ )
+ $verdict = if (Test-PesterResult -Result $Result) { '✅' } else { '❌' }
+ $lines = [System.Collections.Generic.List[string]]::new()
+ $lines.Add("## $verdict Pester — $($Result.PassedCount) passed, $($Result.FailedCount) failed, $($Result.SkippedCount) skipped")
+ $lines.Add('')
+ $lines.Add("Pester $($Result.Version) ran $($Result.Containers.Count) suite(s) in $($Result.Duration.TotalSeconds.ToString('0.0'))s.")
+ $lines.Add('')
+ $lines.Add('| Suite | Total | Passed | Failed | Skipped | Duration |')
+ $lines.Add('| --- | ---: | ---: | ---: | ---: | ---: |')
+ foreach ($container in $Result.Containers) {
+ $name = if ($container.Item -is [System.IO.FileInfo]) {
+ [IO.Path]::GetRelativePath($SuiteRoot, $container.Item.FullName)
+ } else {
+ [string] $container.Name
+ }
+ $lines.Add("| ``$name`` | $($container.TotalCount) | $($container.PassedCount) | $($container.FailedCount) | $($container.SkippedCount) | $($container.Duration.TotalSeconds.ToString('0.0'))s |")
+ }
+ $lines.Add("| **Total** | **$($Result.TotalCount)** | **$($Result.PassedCount)** | **$($Result.FailedCount)** | **$($Result.SkippedCount)** | **$($Result.Duration.TotalSeconds.ToString('0.0'))s** |")
+
+ if ($Result.FailedCount -gt 0) {
+ $lines.Add('')
+ $lines.Add("Failed tests ($($Result.FailedCount))
")
+ $lines.Add('')
+ foreach ($test in $Result.Failed) {
+ $lines.Add("- **$($test.ExpandedPath)**")
+ $lines.Add('')
+ $lines.Add(' ```text')
+ foreach ($line in ("$($test.ErrorRecord)" -split '\r?\n')) {
+ $lines.Add(" $line")
+ }
+ $lines.Add(' ```')
+ $lines.Add('')
+ }
+ $lines.Add(' ')
+ }
+
+ return ($lines -join [Environment]::NewLine)
+}
+
+Write-Output "::group::Resolve Pester $RequiredVersion"
+$available = @(Get-Module -ListAvailable -Name Pester |
+ Where-Object { $_.Version.ToString() -eq $RequiredVersion -and $_.Guid -eq $ModuleGuid })
+if ($available.Count -eq 0) {
+ Write-Output "Pester $RequiredVersion is not installed; installing it for the current user."
+ Install-Module -Name Pester -RequiredVersion $RequiredVersion -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck
+} else {
+ Write-Output "Pester $RequiredVersion is already installed."
+}
+Import-Module -Name Pester -RequiredVersion $RequiredVersion -Force
+$pester = Get-Module -Name Pester
+if ($pester.Guid -ne $ModuleGuid) {
+ Write-WorkflowAnnotation -Type error -Title 'Test' -Message "The imported Pester module has GUID $($pester.Guid), expected $ModuleGuid."
+ exit 1
+}
+Write-Output "Imported Pester $($pester.Version) ($($pester.Guid)) from $($pester.ModuleBase)."
+Write-Output '::endgroup::'
+
+$suiteRoot = (Resolve-Path -LiteralPath $Path).ProviderPath
+$suite = @(Get-ChildItem -LiteralPath $suiteRoot -Filter '*.Tests.ps1' -File -Recurse | Sort-Object -Property FullName)
+if ($suite.Count -eq 0) {
+ Write-WorkflowAnnotation -Type error -Title 'Test' -Message "No '*.Tests.ps1' file found under $suiteRoot — the run would have been green without testing anything."
+ exit 1
+}
+Write-Output "Discovered $($suite.Count) suite(s) under ${suiteRoot}:"
+$suite | ForEach-Object { Write-Output " - $([IO.Path]::GetRelativePath($suiteRoot, $_.FullName))" }
+
+$configuration = New-PesterConfiguration
+$configuration.Run.Path = $suite.FullName
+$configuration.Run.PassThru = $true
+$configuration.Output.Verbosity = 'Detailed'
+$configuration.Output.CIFormat = 'GithubActions'
+
+Write-Output "::group::Run $($suite.Count) Pester suite(s)"
+$result = Invoke-Pester -Configuration $configuration
+Write-Output '::endgroup::'
+
+if ($env:GITHUB_STEP_SUMMARY) {
+ Get-PesterSummaryMarkdown -Result $result -SuiteRoot $suiteRoot |
+ Out-File -LiteralPath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
+}
+
+$failedSuite = $result.FailedContainersCount
+if ($result.TotalCount -eq 0) {
+ Write-WorkflowAnnotation -Type error -Title 'Test' -Message "The $($suite.Count) discovered suite(s) contained no test — the run proved nothing."
+ exit 1
+}
+if ($result.PassedCount -eq 0) {
+ Write-WorkflowAnnotation -Type error -Title 'Test' -Message "No test passed in $($suite.Count) discovered suite(s); $($result.SkippedCount) were skipped."
+ exit 1
+}
+if (Test-PesterResult -Result $result) {
+ Write-WorkflowAnnotation -Type notice -Title 'Test' -Message "$($result.PassedCount) test(s) passed in $($suite.Count) suite(s) ($($result.Duration.TotalSeconds.ToString('0.0'))s)."
+ exit 0
+}
+Write-WorkflowAnnotation -Type error -Title 'Test' -Message "$($result.FailedCount) of $($result.TotalCount) test(s) failed, and $failedSuite suite(s) failed to run."
+exit 1
diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1
new file mode 100644
index 0000000..6eb1952
--- /dev/null
+++ b/.github/scripts/PullRequestValidation.psm1
@@ -0,0 +1,274 @@
+#Requires -Version 7.4
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+function Test-ValidationSource {
+ <#
+ .SYNOPSIS
+ Verify the shared validation configuration snapshots.
+
+ .DESCRIPTION
+ Compare Git-normalized configuration blobs with their recorded immutable
+ sources. This verifies tooling configuration, not a copied PR template.
+
+ .EXAMPLE
+ Test-ValidationSource -RepositoryPath $repository
+ Return the number of verified configurations and the source revision.
+
+ .INPUTS
+ None
+
+ You can't pipe objects to Test-ValidationSource.
+
+ .OUTPUTS
+ System.Management.Automation.PSCustomObject
+
+ The verified configuration count and immutable source revision.
+ #>
+ [OutputType([pscustomobject])]
+ [CmdletBinding()]
+ param(
+ # Root of the clean source checkout being validated.
+ [Parameter(Mandatory)]
+ [ValidateNotNullOrEmpty()]
+ [string] $RepositoryPath
+ )
+
+ $PSNativeCommandUseErrorActionPreference = $false
+ $repository = (Resolve-Path -LiteralPath $RepositoryPath).ProviderPath
+ $source = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $repository -ChildPath '.github\validation.psd1')
+ if ($source.ConfigFiles.Count -eq 0) {
+ throw 'No shared validation configuration was recorded; source correspondence checked nothing.'
+ }
+ foreach ($entry in $source.ConfigFiles.GetEnumerator()) {
+ $path = [IO.Path]::GetFullPath((Join-Path -Path $repository -ChildPath $entry.Key))
+ $relative = [IO.Path]::GetRelativePath($repository, $path)
+ if ([IO.Path]::IsPathRooted($relative) -or $relative -match '^\.\.(?:[\\/]|$)') {
+ throw 'A validation configuration path escapes the source checkout.'
+ }
+ $blob = git -C $repository hash-object "--path=$($entry.Key)" -- $path
+ if ($LASTEXITCODE -ne 0 -or $blob -cne $entry.Value) {
+ throw "Shared validation configuration '$($entry.Key)' differs from its recorded immutable source."
+ }
+ }
+
+ [pscustomobject]@{
+ CheckedFiles = $source.ConfigFiles.Count
+ SourceCommit = $source.SourceCommit
+ }
+}
+
+function Test-MarkdownFile {
+ <#
+ .SYNOPSIS
+ Lint every supplied Markdown file with the shared rules.
+
+ .DESCRIPTION
+ Send literal file contents to the existing Markdown tool on stdin from an
+ isolated configuration directory. Inline overrides and repository-local
+ ignore/configuration files cannot hide violations or skip selected files.
+
+ .EXAMPLE
+ Test-MarkdownFile -Path $files -ConfigurationPath $configuration
+ Return the checked file count or throw with the failing file's diagnostic.
+
+ .INPUTS
+ None
+
+ You can't pipe objects to Test-MarkdownFile.
+
+ .OUTPUTS
+ System.Management.Automation.PSCustomObject
+
+ The number of files actually linted and the pinned tool version.
+ #>
+ [OutputType([pscustomobject])]
+ [CmdletBinding()]
+ param(
+ # Literal files selected from the validated committed diff.
+ [Parameter(Mandatory)]
+ [AllowEmptyCollection()]
+ [string[]] $Path,
+
+ # Absolute shared Markdown rule configuration.
+ [Parameter(Mandatory)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ConfigurationPath
+ )
+
+ if ($Path.Count -eq 0) {
+ throw 'No Markdown input was supplied; a zero-file lint is not a pass.'
+ }
+ $files = @($Path | ForEach-Object { (Resolve-Path -LiteralPath $_).ProviderPath })
+ $configuration = (Resolve-Path -LiteralPath $ConfigurationPath).ProviderPath
+ $source = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\validation.psd1')
+ if ($source.MarkdownlintVersion -notmatch '^\d+\.\d+\.\d+$') {
+ throw 'The Markdown tool version is missing or invalid.'
+ }
+ $npx = 'npx'
+ if ($IsWindows) {
+ $npx = 'npx.cmd'
+ }
+ $temporaryDirectory = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ([guid]::NewGuid().ToString())
+ $null = New-Item -ItemType Directory -Path $temporaryDirectory
+ $temporaryConfiguration = Join-Path -Path $temporaryDirectory -ChildPath 'validation.markdownlint-cli2.jsonc'
+ $options = @{
+ config = @{ extends = $configuration }
+ noInlineConfig = $true
+ } | ConvertTo-Json -Depth 3
+ [IO.File]::WriteAllText($temporaryConfiguration, $options)
+
+ Push-Location -LiteralPath $temporaryDirectory
+ try {
+ $PSNativeCommandUseErrorActionPreference = $false
+ foreach ($file in $files) {
+ $content = [IO.File]::ReadAllText($file)
+ if ([string]::IsNullOrWhiteSpace($content)) {
+ throw "Markdown lint failed for '$file': the file is empty."
+ }
+ Write-Information -MessageData "Checking Markdown: $file" -InformationAction Continue
+ $diagnostics = @(
+ $content | & $npx --yes -- "markdownlint-cli2@$($source.MarkdownlintVersion)" --config $temporaryConfiguration -
+ )
+ foreach ($diagnostic in $diagnostics) {
+ Write-Information -MessageData $diagnostic -InformationAction Continue
+ }
+ if ($LASTEXITCODE -ne 0) {
+ throw "Markdown lint failed for '$file' (exit $LASTEXITCODE)."
+ }
+ }
+ } finally {
+ Pop-Location
+ Remove-Item -LiteralPath $temporaryConfiguration
+ Remove-Item -LiteralPath $temporaryDirectory
+ }
+ [pscustomobject]@{
+ CheckedFiles = $files.Count
+ ToolVersion = $source.MarkdownlintVersion
+ }
+}
+
+function Get-ValidationInput {
+ <#
+ .SYNOPSIS
+ Identify the actual committed inputs changed by a pull request.
+
+ .DESCRIPTION
+ Compare the merge base with HEAD, report intentional deletions separately,
+ and distinguish changed Markdown from unchanged files and fixture self-tests.
+ An empty or unsupported-only change cannot become a validation pass.
+
+ .EXAMPLE
+ Get-ValidationInput -RepositoryPath $repository -BaseRevision 'origin/main'
+ Return actual head, merge base, and applicable changed paths.
+
+ .INPUTS
+ None
+
+ You can't pipe objects to Get-ValidationInput.
+
+ .OUTPUTS
+ System.Management.Automation.PSCustomObject
+
+ Commit identities and existing, deleted, and Markdown input paths.
+ #>
+ [OutputType([pscustomobject])]
+ [CmdletBinding()]
+ param(
+ # Absolute root of the target checkout.
+ [Parameter(Mandatory)]
+ [ValidateNotNullOrEmpty()]
+ [string] $RepositoryPath,
+
+ # Base commit or reference used to find the pull request's merge base.
+ [Parameter(Mandatory)]
+ [ValidateNotNullOrEmpty()]
+ [string] $BaseRevision
+ )
+
+ $PSNativeCommandUseErrorActionPreference = $false
+ $repository = (Resolve-Path -LiteralPath $RepositoryPath).ProviderPath
+ $baseCommit = git -C $repository rev-parse --verify --end-of-options "$BaseRevision^{commit}"
+ if ($LASTEXITCODE -ne 0) {
+ throw "Cannot resolve the base revision '$BaseRevision'. Fetch the intended base before validation."
+ }
+ $headCommit = git -C $repository rev-parse --verify HEAD
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot resolve the actual pull request head.'
+ }
+ $mergeBase = git -C $repository merge-base $baseCommit $headCommit
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot find the merge base. Fetch the complete base and head history.'
+ }
+ # Retain both paths so a rename cannot hide a checked deletion.
+ $changed = git -C $repository diff --no-renames --name-only --diff-filter=ACDMRT -z $mergeBase $headCommit --
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot enumerate the actual changed inputs.'
+ }
+ $paths = @(($changed -join "`n") -split "`0" | Where-Object { $_ })
+ if (@($paths | Where-Object { $_ -match '[\x00-\x1f\x7f]' }).Count -gt 0) {
+ throw 'Changed filenames containing control characters are unsupported; rename those files before validation.'
+ }
+ if (@($paths | Where-Object { $_.Contains('\') }).Count -gt 0) {
+ throw 'Changed Git paths containing a literal backslash are unsupported; rename those files before validation.'
+ }
+ $relevant = @($paths | Where-Object { $_ -match '\.(md|ps1|psm1|psd1|yml|yaml)$' })
+ # These directories are pruned by the pinned shared linter finder.
+ $prunedPath = '(^|/)(\.git|\.pytest_cache|\.rbenv|\.terragrunt-cache|\.venv|__pycache__|node_modules)/'
+ if (@($relevant | Where-Object { $_ -cmatch $prunedPath }).Count -gt 0) {
+ throw 'An applicable changed input is excluded by shared linter discovery; move it to a linted path before validation.'
+ }
+ if ($relevant.Count -gt 0) {
+ $literalPaths = @($relevant | ForEach-Object { ':(literal)' + $_ })
+ $modes = git -C $repository ls-tree -r -z --format='%(objectmode)' $headCommit -- @literalPaths
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot inspect committed validation input file types.'
+ }
+ if ((($modes -join "`n") -split "`0") -contains '120000') {
+ throw 'A committed validation input is a symbolic link; replace it with a regular file before validation.'
+ }
+ }
+ $status = git -C $repository status --porcelain=v1 --untracked-files=all -z
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot verify the validation working tree.'
+ }
+ if (-not [string]::IsNullOrEmpty(($status -join "`n"))) {
+ throw 'The working tree differs from HEAD. Commit the intended changes before certifying that head.'
+ }
+ $deletedChanges = git -C $repository diff --no-renames --name-only --diff-filter=D -z $mergeBase $headCommit --
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Cannot enumerate the committed deleted inputs.'
+ }
+ $deleted = @(($deletedChanges -join "`n") -split "`0" | Where-Object { $_ -cin $relevant })
+ $existing = @($relevant | Where-Object { $_ -cnotin $deleted })
+ foreach ($path in $existing) {
+ if (-not (Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $path) -PathType Leaf)) {
+ throw "Committed changed input '$path' has no regular file in the checkout."
+ }
+ }
+ if (@($existing | Where-Object {
+ $_ -match '\.(ps1|psm1|psd1)$' -and
+ [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($_)
+ }).Count -gt 0) {
+ throw 'A changed PowerShell path contains wildcard characters unsupported by the shared linter; rename it before validation.'
+ }
+ if ($relevant.Count -eq 0) {
+ throw 'No applicable changed input was found. Fixture self-tests are not a complete pull request validation pass.'
+ }
+
+ $escapedPaths = @($existing | ForEach-Object { [regex]::Escape($_) })
+ $filter = '^(' + ($escapedPaths -join '|') + ')$'
+
+ [pscustomobject]@{
+ HeadCommit = $headCommit
+ MergeBase = $mergeBase
+ ChangedFiles = $relevant
+ ExistingFiles = $existing
+ MarkdownFiles = @($existing | Where-Object { $_ -match '\.md$' })
+ DeletedFiles = $deleted
+ LintFilter = $filter
+ }
+}
+
+Export-ModuleMember -Function Test-ValidationSource, Test-MarkdownFile, Get-ValidationInput
diff --git a/.github/scripts/Test-OrganizationDefaults.ps1 b/.github/scripts/Test-OrganizationDefaults.ps1
new file mode 100644
index 0000000..6828050
--- /dev/null
+++ b/.github/scripts/Test-OrganizationDefaults.ps1
@@ -0,0 +1,97 @@
+#Requires -Version 7.4
+
+<#
+ .SYNOPSIS
+ Validate the source repository's actual changed inputs.
+
+ .DESCRIPTION
+ Check the actual head and shared configuration sources without requiring a
+ PR template. Lint changed Markdown and report intentional deletions separately.
+ Pass an exact changed-file filter to the shared linter without write permissions.
+
+ .EXAMPLE
+ ./Test-OrganizationDefaults.ps1 -BaseRevision 'origin/main'
+ Validate committed changes against their merge base with origin/main.
+
+ .INPUTS
+ None
+
+ You can't pipe objects to Test-OrganizationDefaults.ps1.
+
+ .OUTPUTS
+ System.Management.Automation.PSCustomObject
+
+ The actual head and counts of checked content, deletions, and configuration sources.
+#>
+[OutputType([pscustomobject])]
+[CmdletBinding()]
+param(
+ # Base commit or reference; local use compares committed changes only.
+ [Parameter()]
+ [ValidateNotNullOrEmpty()]
+ [string] $BaseRevision = 'origin/main',
+
+ # Expected pull request head supplied by the hosted event.
+ [Parameter()]
+ [ValidatePattern('^$|^[0-9a-fA-F]{40}$')]
+ [string] $ExpectedHead = ''
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$repository = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath
+Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'PullRequestValidation.psm1') -Force
+$inputs = Get-ValidationInput -RepositoryPath $repository -BaseRevision $BaseRevision
+if ($ExpectedHead -and $inputs.HeadCommit -cne $ExpectedHead) {
+ throw 'The checked-out commit differs from the actual pull request head; this result cannot certify that head.'
+}
+
+Write-Information -MessageData "Actual head: $($inputs.HeadCommit); merge base: $($inputs.MergeBase)" -InformationAction Continue
+foreach ($path in $inputs.ChangedFiles) {
+ Write-Information -MessageData "Applicable changed input: $path" -InformationAction Continue
+}
+$source = Test-ValidationSource -RepositoryPath $repository
+$checkedMarkdown = 0
+if ($inputs.MarkdownFiles.Count -eq 0) {
+ Write-Information -MessageData 'No existing changed Markdown: deletions are checked as absent, not counted as linted content.' -InformationAction Continue
+} else {
+ $markdownPaths = @($inputs.MarkdownFiles | ForEach-Object { Join-Path -Path $repository -ChildPath $_ })
+ $markdown = Test-MarkdownFile -Path $markdownPaths -ConfigurationPath (
+ Join-Path -Path $repository -ChildPath '.github\linters\.markdown-lint.yml'
+ )
+ $checkedMarkdown = $markdown.CheckedFiles
+}
+foreach ($path in $inputs.DeletedFiles) {
+ Write-Information -MessageData "Checked deletion: $path" -InformationAction Continue
+}
+
+$filter = $inputs.LintFilter
+if ($env:GITHUB_OUTPUT) {
+ [IO.File]::AppendAllText($env:GITHUB_OUTPUT, "files_regex=$filter`nexisting_files=$($inputs.ExistingFiles.Count)`n")
+}
+$report = [pscustomobject]@{
+ HeadCommit = $inputs.HeadCommit
+ ChangedInputs = $inputs.ExistingFiles.Count
+ CheckedMarkdown = $checkedMarkdown
+ CheckedDeletions = $inputs.DeletedFiles.Count
+ ConfigurationSources = $source.CheckedFiles
+ SourceCommit = $source.SourceCommit
+}
+if ($env:GITHUB_STEP_SUMMARY) {
+ $summary = @(
+ '## Organization-default validation inputs'
+ ''
+ "| Actual head | ``$($report.HeadCommit)`` |"
+ '| --- | --- |'
+ "| Applicable changed files sent to linters | $($report.ChangedInputs) |"
+ "| Changed Markdown actually linted | $($report.CheckedMarkdown) |"
+ "| Checked deletions, not counted as linted content | $($report.CheckedDeletions) |"
+ "| Shared configuration sources verified | $($report.ConfigurationSources) |"
+ "| Immutable configuration source | ``$($report.SourceCommit)`` |"
+ ''
+ 'PR templates are not required or copied. Regression tests and other language lint follow separately.'
+ )
+ [IO.File]::AppendAllText($env:GITHUB_STEP_SUMMARY, ($summary -join "`n") + "`n")
+}
+$report
diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1
new file mode 100644
index 0000000..4033ed8
--- /dev/null
+++ b/.github/tests/PullRequestValidation.Tests.ps1
@@ -0,0 +1,337 @@
+#Requires -Version 7.4
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseDeclaredVarsMoreThanAssignments', '',
+ Justification = 'Pester setup and test blocks execute in separate scopes.'
+)]
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+
+BeforeDiscovery {
+ $unsupportedNames = @(
+ @{ Name = 'tab'; FileName = "changed`tfile.md"; Expected = '*control characters*' }
+ @{ Name = 'form feed'; FileName = "changed$([char] 12)file.md"; Expected = '*control characters*' }
+ @{ Name = 'DEL'; FileName = "changed$([char] 127)file.md"; Expected = '*control characters*' }
+ @{ Name = 'backslash'; FileName = 'changed\file.md'; Expected = '*backslash*' }
+ )
+ $prunedDirectories = @('.pytest_cache', '.rbenv', '.terragrunt-cache', '.venv', '__pycache__', 'node_modules')
+ $markdownCases = @(
+ @{ Name = 'plain malformed heading'; Content = "# Broken!`n"; Passes = $false }
+ @{ Name = 'whole-file disable'; Content = "`n# Broken!`n"; Passes = $false }
+ @{ Name = 'rule-specific disable'; Content = "`n# Broken!`n"; Passes = $false }
+ @{ Name = 'next-line disable'; Content = "`n# Broken!`n"; Passes = $false }
+ @{ Name = 'inline configuration'; Content = "`n# Broken!`n"; Passes = $false }
+ @{ Name = 'valid no-template Markdown'; Content = "# Valid`n"; Passes = $true }
+ @{ Name = 'documented directive example'; Content = "# Example`n`n``````html`n`n```````n"; Passes = $true }
+ )
+}
+
+BeforeAll {
+ $sourceRoot = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath
+ Import-Module -Name (Join-Path -Path $sourceRoot -ChildPath '.github\scripts\PullRequestValidation.psm1') -Force
+ $markdownConfig = Join-Path -Path $sourceRoot -ChildPath '.github\linters\.markdown-lint.yml'
+}
+
+Describe 'Actual committed inputs without a PR template' {
+ BeforeEach {
+ $PSNativeCommandUseErrorActionPreference = $true
+ $repository = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString())
+ $null = New-Item -ItemType Directory -Path $repository
+ git -C $repository init --quiet --initial-branch=main
+ git -C $repository config user.name 'Validation fixture'
+ git -C $repository config user.email 'fixture@example.invalid'
+ git -C $repository config core.autocrlf false
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'base.md'), "# Base`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record anonymous no-template base'
+ $base = git -C $repository rev-parse HEAD
+ }
+
+ It 'selects valid changed Markdown without requiring a template or copied scaffold' {
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'changed.md'), "# Changed`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Change Markdown without a template'
+ $result = Get-ValidationInput -RepositoryPath $repository -BaseRevision $base
+ $result.MarkdownFiles | Should -Contain 'changed.md'
+ $result.DeletedFiles | Should -HaveCount 0
+ Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath '.github\pull_request_template.md') |
+ Should -BeFalse
+ }
+
+ It 'retains intentional central-template removal as a checked deletion, not a missing-file failure' {
+ $directory = Join-Path -Path $repository -ChildPath '.github'
+ $null = New-Item -ItemType Directory -Path $directory
+ $template = Join-Path -Path $directory -ChildPath 'pull_request_template.md'
+ [IO.File]::WriteAllText($template, "# Legacy`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record anonymous legacy template'
+ $base = git -C $repository rev-parse HEAD
+ Remove-Item -LiteralPath $template
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Use canonical guidance without a template'
+ $result = Get-ValidationInput -RepositoryPath $repository -BaseRevision $base
+ $result.DeletedFiles | Should -Contain '.github/pull_request_template.md'
+ $result.ExistingFiles | Should -HaveCount 0
+ $result.MarkdownFiles | Should -HaveCount 0
+ }
+
+ It 'distinguishes a deleted path from its case-only renamed replacement' {
+ $original = Join-Path -Path $repository -ChildPath 'Case.md'
+ $intermediate = Join-Path -Path $repository -ChildPath 'intermediate.md'
+ $replacement = Join-Path -Path $repository -ChildPath 'case.md'
+ [IO.File]::WriteAllText($original, "# Case`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record original path casing'
+ $base = git -C $repository rev-parse HEAD
+ git -C $repository mv -- $original $intermediate
+ git -C $repository mv -- $intermediate $replacement
+ git -C $repository commit --quiet -m 'Record a case-only path rename'
+ $result = Get-ValidationInput -RepositoryPath $repository -BaseRevision $base
+ $result.DeletedFiles | Should -HaveCount 1
+ $result.DeletedFiles[0] | Should -BeExactly 'Case.md'
+ $result.ExistingFiles | Should -HaveCount 1
+ $result.ExistingFiles[0] | Should -BeExactly 'case.md'
+ }
+
+ It 'does not call an empty diff a complete validation pass' {
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*No applicable changed input*'
+ }
+
+ It 'does not count unrelated media as applicable Markdown' {
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'image.png'), 'anonymous')
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Add anonymous unsupported input'
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*No applicable changed input*'
+ }
+
+ It 'matches exact relative paths, including spaces and literal glob characters' {
+ $path = Join-Path -Path $repository -ChildPath 'changed [one].md'
+ [IO.File]::WriteAllText($path, "# Changed`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record literal Markdown filename'
+ $result = Get-ValidationInput -RepositoryPath $repository -BaseRevision $base
+ 'changed [one].md' | Should -Match $result.LintFilter
+ 'docs/changed [one].md' | Should -Not -Match $result.LintFilter
+ 'changed o.md' | Should -Not -Match $result.LintFilter
+ }
+
+ It 'rejects wildcard-bearing PowerShell input instead of linting an unchanged matching file' {
+ $unchanged = Join-Path -Path $repository -ChildPath 'changed o.ps1'
+ [IO.File]::WriteAllText($unchanged, "Write-Output 'anonymous fixture'`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record unchanged ordinary PowerShell input'
+ $base = git -C $repository rev-parse HEAD
+ $changed = Join-Path -Path $repository -ChildPath 'changed [one].ps1'
+ [IO.File]::WriteAllText($changed, "Invoke-Expression -Command `"'anonymous fixture'`"`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record a wildcard-bearing PowerShell filename'
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*PowerShell path contains wildcard characters*'
+ }
+
+ It 'rejects a filename before path or filter normalization' -ForEach $unsupportedNames {
+ $blob = git -C $repository rev-parse HEAD:base.md
+ # Index-only Unix paths avoid creating invalid Windows files.
+ git -C $repository -c core.protectNTFS=false update-index --add --cacheinfo "100644,$blob,$FileName"
+ git -C $repository commit --quiet -m 'Record nonportable filename'
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage $Expected
+ }
+
+ It 'rejects changed content in finder-pruned directory <_>' -ForEach $prunedDirectories {
+ $directory = Join-Path -Path $repository -ChildPath $_
+ $null = New-Item -ItemType Directory -Path $directory
+ [IO.File]::WriteAllText((Join-Path -Path $directory -ChildPath 'broken.md'), "# Broken!`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record an excluded input'
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*linter discovery*'
+ }
+
+ It 'rejects committed symbolic links before reading their targets' {
+ $path = Join-Path -Path $repository -ChildPath 'linked.md'
+ [IO.File]::WriteAllText($path, "# Linked`n")
+ $blob = git -C $repository hash-object -w $path
+ git -C $repository config core.symlinks false
+ git -C $repository update-index --add --cacheinfo "120000,$blob,linked.md"
+ git -C $repository commit --quiet -m 'Record a symbolic-link input'
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*symbolic link*'
+ }
+
+ It 'rejects uncommitted content instead of certifying the reported HEAD' {
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'base.md'), "# Uncommitted`n")
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*working tree*'
+ }
+
+ It 'rejects staged changes instead of certifying an older HEAD' {
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'base.md'), "# Staged`n")
+ git -C $repository add --all
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision $base } |
+ Should -Throw -ExpectedMessage '*working tree*'
+ }
+
+ It 'fails explicitly when the intended base is unavailable' {
+ { Get-ValidationInput -RepositoryPath $repository -BaseRevision 'missing-reference' } |
+ Should -Throw -ExpectedMessage '*base revision*'
+ }
+}
+
+Describe 'Changed Markdown uses the immutable shared rules without inline bypasses' {
+ It 'checks ' -ForEach $markdownCases {
+ $path = Join-Path -Path $TestDrive -ChildPath 'anonymous.md'
+ [IO.File]::WriteAllText($path, $Content)
+ if ($Passes) {
+ $result = Test-MarkdownFile -Path @($path) -ConfigurationPath $markdownConfig
+ $result | Should -BeOfType ([pscustomobject])
+ $result.CheckedFiles | Should -Be 1
+ } else {
+ { Test-MarkdownFile -Path @($path) -ConfigurationPath $markdownConfig } |
+ Should -Throw -ExpectedMessage '*Markdown lint failed*'
+ }
+ }
+
+ It 'does not count a zero-file Markdown scan as success' {
+ { Test-MarkdownFile -Path @() -ConfigurationPath $markdownConfig } |
+ Should -Throw -ExpectedMessage '*No Markdown input*'
+ }
+
+ It 'uses literal filenames rather than dropping glob-like input' {
+ $path = Join-Path -Path $TestDrive -ChildPath 'changed [one].md'
+ [IO.File]::WriteAllText($path, "# Broken!`n")
+ { Test-MarkdownFile -Path @($path) -ConfigurationPath $markdownConfig } |
+ Should -Throw -ExpectedMessage '*Markdown lint failed*'
+ }
+}
+
+Describe 'Immutable validation configuration source' {
+ It 'checks the shared configuration snapshots rather than a copied PR scaffold' {
+ $result = Test-ValidationSource -RepositoryPath $sourceRoot
+ $result.CheckedFiles | Should -Be 4
+ $result.SourceCommit | Should -BeExactly '36da9154aa55d2052345fb2f28b812b29daf8bc9'
+ }
+
+ It 'rejects a source entry that escapes the checkout' {
+ $repository = Join-Path -Path $TestDrive -ChildPath 'source-containment'
+ $directory = Join-Path -Path $repository -ChildPath '.github'
+ $null = New-Item -ItemType Directory -Path $directory -Force
+ [IO.File]::WriteAllText(
+ (Join-Path -Path $directory -ChildPath 'validation.psd1'),
+ "@{ ConfigFiles = @{ '../outside.yml' = 'not-a-source-blob' } }"
+ )
+ { Test-ValidationSource -RepositoryPath $repository } |
+ Should -Throw -ExpectedMessage '*escapes the source checkout*'
+ }
+
+ It 'rejects a zero-configuration source scan' {
+ $repository = Join-Path -Path $TestDrive -ChildPath 'empty-source'
+ $directory = Join-Path -Path $repository -ChildPath '.github'
+ $null = New-Item -ItemType Directory -Path $directory -Force
+ [IO.File]::WriteAllText(
+ (Join-Path -Path $directory -ChildPath 'validation.psd1'),
+ '@{ ConfigFiles = @{} }'
+ )
+ { Test-ValidationSource -RepositoryPath $repository } |
+ Should -Throw -ExpectedMessage '*source correspondence checked nothing*'
+ }
+}
+
+Describe 'Source controller accepts canonical-guidance-only inputs' {
+ BeforeEach {
+ $PSNativeCommandUseErrorActionPreference = $false
+ $repository = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString())
+ $null = New-Item -ItemType Directory -Path $repository
+ $paths = @(
+ '.github\scripts\PullRequestValidation.psm1'
+ '.github\scripts\Test-OrganizationDefaults.ps1'
+ '.github\validation.psd1'
+ '.github\linters\.markdown-lint.yml'
+ '.github\linters\.powershell-psscriptanalyzer.psd1'
+ '.github\linters\.yaml-lint.yml'
+ '.github\linters\zizmor.yml'
+ )
+ foreach ($path in $paths) {
+ $destination = Join-Path -Path $repository -ChildPath $path
+ $null = New-Item -ItemType Directory -Path (Split-Path -Path $destination -Parent) -Force
+ Copy-Item -LiteralPath (Join-Path -Path $sourceRoot -ChildPath $path) -Destination $destination
+ }
+ git -C $repository init --quiet --initial-branch=main
+ git -C $repository config user.name 'Validation fixture'
+ git -C $repository config user.email 'fixture@example.invalid'
+ git -C $repository config core.autocrlf false
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record anonymous source tooling without templates'
+ $base = git -C $repository rev-parse HEAD
+ $controller = Join-Path -Path $repository -ChildPath '.github\scripts\Test-OrganizationDefaults.ps1'
+ }
+
+ It 'validates no-template source Markdown without looking for a fixture scaffold' {
+ [IO.File]::WriteAllText((Join-Path -Path $repository -ChildPath 'changed.md'), "# Valid`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Change source Markdown without template files'
+ pwsh -NoProfile -File $controller -BaseRevision $base
+ $LASTEXITCODE | Should -Be 0
+ }
+
+ It 'checks intentional removal without pretending zero Markdown files were linted' {
+ $template = Join-Path -Path $repository -ChildPath '.github\pull_request_template.md'
+ [IO.File]::WriteAllText($template, "# Legacy`n")
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Record anonymous legacy distribution path'
+ $base = git -C $repository rev-parse HEAD
+ Remove-Item -LiteralPath $template
+ git -C $repository add --all
+ git -C $repository commit --quiet -m 'Remove central template distribution'
+ pwsh -NoProfile -File $controller -BaseRevision $base
+ $LASTEXITCODE | Should -Be 0
+ }
+}
+
+Describe 'Repository-native test runner defaults' {
+ BeforeEach {
+ $repository = Join-Path -Path $TestDrive -ChildPath 'runner-default'
+ $scripts = Join-Path -Path $repository -ChildPath '.github\scripts'
+ $tests = Join-Path -Path $repository -ChildPath '.github\tests'
+ $null = New-Item -ItemType Directory -Path $scripts, $tests -Force
+ $runner = Join-Path -Path $scripts -ChildPath 'Invoke-PesterSuite.ps1'
+ Copy-Item -LiteralPath (Join-Path -Path $sourceRoot -ChildPath '.github\scripts\Invoke-PesterSuite.ps1') -Destination $runner
+ $testPath = Join-Path -Path $tests -ChildPath 'Anonymous.Tests.ps1'
+ }
+
+ It 'discovers the source test directory without an explicit Path override' {
+ [IO.File]::WriteAllText(
+ $testPath,
+ "Describe 'Anonymous source suite' { It 'runs one test' { 1 | Should -Be 1 } }"
+ )
+ $output = pwsh -NoProfile -File $runner -RequiredVersion 6.1.0
+ $LASTEXITCODE | Should -Be 0
+ ($output -join "`n") | Should -Match '1 test\(s\) passed'
+ }
+
+ It 'fails when discovered tests are all skipped and no regression executes' {
+ [IO.File]::WriteAllText(
+ $testPath,
+ "Describe 'Anonymous skipped suite' { It 'does not run' -Skip { throw 'must not run' } }"
+ )
+ $summaryPath = Join-Path -Path $repository -ChildPath 'anonymous-summary.md'
+ $previousSummary = [Environment]::GetEnvironmentVariable('GITHUB_STEP_SUMMARY')
+ try {
+ [Environment]::SetEnvironmentVariable('GITHUB_STEP_SUMMARY', $summaryPath, 'Process')
+ $output = pwsh -NoProfile -File $runner -RequiredVersion 6.1.0
+ $actualExit = $LASTEXITCODE
+ } finally {
+ [Environment]::SetEnvironmentVariable('GITHUB_STEP_SUMMARY', $previousSummary, 'Process')
+ }
+ $actualExit | Should -Be 1
+ ($output -join "`n") | Should -Match 'No test passed'
+ $summary = [IO.File]::ReadAllText($summaryPath)
+ $summary | Should -Match ('(?m)^## ' + [char] 0x274c + ' Pester')
+ $summary | Should -Not -Match ('(?m)^## ' + [char] 0x2705 + ' Pester')
+ }
+}
diff --git a/.github/tests/WorkflowTriggers.Tests.ps1 b/.github/tests/WorkflowTriggers.Tests.ps1
new file mode 100644
index 0000000..a59af31
--- /dev/null
+++ b/.github/tests/WorkflowTriggers.Tests.ps1
@@ -0,0 +1,58 @@
+#Requires -Version 7.4
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseDeclaredVarsMoreThanAssignments', '',
+ Justification = 'Pester setup and test blocks execute in separate scopes.'
+)]
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+
+BeforeDiscovery {
+ $events = @(
+ @{ Action = 'opened'; Base = 'main'; Expected = $true }
+ @{ Action = 'synchronize'; Base = 'main'; Expected = $true }
+ @{ Action = 'reopened'; Base = 'main'; Expected = $true }
+ @{ Action = 'edited'; Base = 'main'; Expected = $true }
+ @{ Action = 'edited'; Base = 'other'; Expected = $false }
+ )
+}
+
+Describe 'Pull request validation trigger coverage' {
+ BeforeAll {
+ $path = Join-Path -Path $PSScriptRoot -ChildPath '..\workflows\Organization-Defaults-Validation.yml'
+ $workflow = [IO.File]::ReadAllText($path) -replace '\r\n?', "`n"
+ $trigger = [regex]::Match($workflow, "(?ms)^'on':\n pull_request:\n(?.*?)(?=^\S)").Groups['settings'].Value
+ if ([string]::IsNullOrWhiteSpace($trigger)) {
+ throw 'The expected pull_request trigger is missing.'
+ }
+ $types = [regex]::Match($trigger, '(?m)^ types: \[(?[^\]]+)\]$')
+ $actions = @('opened', 'synchronize', 'reopened')
+ if ($types.Success) {
+ $actions = @($types.Groups['types'].Value -split ',\s*')
+ }
+ $branches = @([regex]::Matches($trigger, '(?m)^ - (?\S+)$') |
+ ForEach-Object { $_.Groups['branch'].Value })
+ }
+
+ It 'covers anonymous activity targeting ' -ForEach $events {
+ # GitHub documents retargeting as edited; default activities exclude it.
+ (($actions -contains $Action) -and ($branches -contains $Base)) | Should -Be $Expected
+ }
+
+ It 'retains the named read-only job and the actual-head checkout' {
+ $workflow | Should -Match '(?m)^ name: Organization defaults validation$'
+ $workflow | Should -Match '(?m)^ contents: read$'
+ $workflow | Should -Match '(?m)^ persist-credentials: false$'
+ $workflow | Should -Match 'ref: \$\{\{ github.event.pull_request.head.sha \}\}'
+ $workflow | Should -Not -Match 'pull_request_target|workflow_dispatch'
+ }
+
+ It 'uses the released linter include-only contract after Markdown is checked separately' {
+ $workflow | Should -Not -Match '(?m)^ VALIDATE_[A-Z_]+: false$'
+ $workflow | Should -Match '(?m)^ VALIDATE_POWERSHELL: true$'
+ $workflow | Should -Match 'Test-OrganizationDefaults.ps1'
+ }
+}
diff --git a/.github/validation.psd1 b/.github/validation.psd1
new file mode 100644
index 0000000..88ef792
--- /dev/null
+++ b/.github/validation.psd1
@@ -0,0 +1,12 @@
+@{
+ SourceRepository = 'MSXOrg/docs'
+ SourceCommit = '36da9154aa55d2052345fb2f28b812b29daf8bc9'
+ SuperLinterCommit = '4ce20838b8ab83717e78138c5b3a1407148e0918'
+ MarkdownlintVersion = '0.24.0'
+ ConfigFiles = @{
+ '.github/linters/.markdown-lint.yml' = '4c0f98ccfb5050b2af5c6ad6e9389c55385a3103'
+ '.github/linters/.powershell-psscriptanalyzer.psd1' = '52f0ee58bdab1d24f06763c81ebc8f29f6f0a1e2'
+ '.github/linters/.yaml-lint.yml' = '2aa49bd1867e24acdea4172191922df6135b69c6'
+ '.github/linters/zizmor.yml' = '6b55013a1d4241e5257c76bd84fb0278482cbe0d'
+ }
+}
diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml
new file mode 100644
index 0000000..dfafbd5
--- /dev/null
+++ b/.github/workflows/Organization-Defaults-Validation.yml
@@ -0,0 +1,63 @@
+---
+name: Organization defaults validation
+
+'on':
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+ branches:
+ - main
+
+permissions: { }
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ validation:
+ name: Organization defaults validation
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ - name: Check out the actual pull request head
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Validate actual changed inputs and source correspondence
+ id: inputs
+ shell: pwsh
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: >-
+ ./.github/scripts/Test-OrganizationDefaults.ps1
+ -BaseRevision $env:BASE_SHA -ExpectedHead $env:PR_HEAD_SHA
+
+ - name: Test no-template inputs and validation regressions
+ shell: pwsh
+ run: >-
+ ./.github/scripts/Invoke-PesterSuite.ps1
+ -RequiredVersion 6.1.0
+
+ - name: Lint the applicable changed files
+ if: steps.inputs.outputs.existing_files != '0'
+ # The complete identity pin and readable version exceed 80 columns.
+ # yamllint disable-line rule:line-length
+ uses: super-linter/super-linter@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
+ env:
+ MULTI_STATUS: false
+ ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false
+ USE_FIND_ALGORITHM: true
+ STRIP_DEFAULT_WORKSPACE_FOR_REGEX: true
+ FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }}
+ # The controller already checked Markdown; only these routes run.
+ VALIDATE_POWERSHELL: true
+ VALIDATE_YAML: true
+ VALIDATE_GITHUB_ACTIONS: true
+ VALIDATE_GITHUB_ACTIONS_ZIZMOR: true
+ GITHUB_ACTIONS_ZIZMOR_CONFIG_FILE: zizmor.yml